mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat: add cached string width function for performance optimization (#7850)
Co-authored-by: lifeloating <imshuazi@126.com>
This commit is contained in:
@@ -1393,6 +1393,69 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
|||||||
expect(getBufferState(result).text).toBe('Pasted Text');
|
expect(getBufferState(result).text).toBe('Pasted Text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should sanitize large text (>5000 chars) and strip unsafe characters', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
|
const unsafeChars = '\x07\x08\x0B\x0C';
|
||||||
|
const largeTextWithUnsafe =
|
||||||
|
'safe text'.repeat(600) + unsafeChars + 'more safe text';
|
||||||
|
|
||||||
|
expect(largeTextWithUnsafe.length).toBeGreaterThan(5000);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.handleInput({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: largeTextWithUnsafe,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultText = getBufferState(result).text;
|
||||||
|
expect(resultText).not.toContain('\x07');
|
||||||
|
expect(resultText).not.toContain('\x08');
|
||||||
|
expect(resultText).not.toContain('\x0B');
|
||||||
|
expect(resultText).not.toContain('\x0C');
|
||||||
|
expect(resultText).toContain('safe text');
|
||||||
|
expect(resultText).toContain('more safe text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize large ANSI text (>5000 chars) and strip escape codes', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
|
const largeTextWithAnsi =
|
||||||
|
'\x1B[31m' +
|
||||||
|
'red text'.repeat(800) +
|
||||||
|
'\x1B[0m' +
|
||||||
|
'\x1B[32m' +
|
||||||
|
'green text'.repeat(200) +
|
||||||
|
'\x1B[0m';
|
||||||
|
|
||||||
|
expect(largeTextWithAnsi.length).toBeGreaterThan(5000);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.handleInput({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: largeTextWithAnsi,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultText = getBufferState(result).text;
|
||||||
|
expect(resultText).not.toContain('\x1B[31m');
|
||||||
|
expect(resultText).not.toContain('\x1B[32m');
|
||||||
|
expect(resultText).not.toContain('\x1B[0m');
|
||||||
|
expect(resultText).toContain('red text');
|
||||||
|
expect(resultText).toContain('green text');
|
||||||
|
});
|
||||||
|
|
||||||
it('should not strip popular emojis', () => {
|
it('should not strip popular emojis', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import pathMod from 'node:path';
|
import pathMod from 'node:path';
|
||||||
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||||
import stringWidth from 'string-width';
|
|
||||||
import { unescapePath } from '@google/gemini-cli-core';
|
import { unescapePath } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
toCodePoints,
|
toCodePoints,
|
||||||
cpLen,
|
cpLen,
|
||||||
cpSlice,
|
cpSlice,
|
||||||
stripUnsafeCharacters,
|
stripUnsafeCharacters,
|
||||||
|
getCachedStringWidth,
|
||||||
} from '../../utils/textUtils.js';
|
} from '../../utils/textUtils.js';
|
||||||
import type { VimAction } from './vim-buffer-actions.js';
|
import type { VimAction } from './vim-buffer-actions.js';
|
||||||
import { handleVimAction } from './vim-buffer-actions.js';
|
import { handleVimAction } from './vim-buffer-actions.js';
|
||||||
@@ -629,21 +629,23 @@ export function logicalPosToOffset(
|
|||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to calculate visual lines and map cursor positions
|
export interface VisualLayout {
|
||||||
function calculateVisualLayout(
|
|
||||||
logicalLines: string[],
|
|
||||||
logicalCursor: [number, number],
|
|
||||||
viewportWidth: number,
|
|
||||||
): {
|
|
||||||
visualLines: string[];
|
visualLines: string[];
|
||||||
visualCursor: [number, number];
|
// For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||||
logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
|
logicalToVisualMap: Array<Array<[number, number]>>;
|
||||||
visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
|
// For each visual line, its [logicalLineIndex, startColInLogical]
|
||||||
} {
|
visualToLogicalMap: Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
|
||||||
|
// This is an expensive operation and should be memoized.
|
||||||
|
function calculateLayout(
|
||||||
|
logicalLines: string[],
|
||||||
|
viewportWidth: number,
|
||||||
|
): VisualLayout {
|
||||||
const visualLines: string[] = [];
|
const visualLines: string[] = [];
|
||||||
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
||||||
const visualToLogicalMap: Array<[number, number]> = [];
|
const visualToLogicalMap: Array<[number, number]> = [];
|
||||||
let currentVisualCursor: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
logicalLines.forEach((logLine, logIndex) => {
|
logicalLines.forEach((logLine, logIndex) => {
|
||||||
logicalToVisualMap[logIndex] = [];
|
logicalToVisualMap[logIndex] = [];
|
||||||
@@ -652,9 +654,6 @@ function calculateVisualLayout(
|
|||||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||||
visualToLogicalMap.push([logIndex, 0]);
|
visualToLogicalMap.push([logIndex, 0]);
|
||||||
visualLines.push('');
|
visualLines.push('');
|
||||||
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
|
|
||||||
currentVisualCursor = [visualLines.length - 1, 0];
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Non-empty logical line
|
// Non-empty logical line
|
||||||
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
||||||
@@ -670,7 +669,7 @@ function calculateVisualLayout(
|
|||||||
// Iterate through code points to build the current visual line (chunk)
|
// Iterate through code points to build the current visual line (chunk)
|
||||||
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
|
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
|
||||||
const char = codePointsInLogLine[i];
|
const char = codePointsInLogLine[i];
|
||||||
const charVisualWidth = stringWidth(char);
|
const charVisualWidth = getCachedStringWidth(char);
|
||||||
|
|
||||||
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
|
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
|
||||||
// Character would exceed viewport width
|
// Character would exceed viewport width
|
||||||
@@ -754,30 +753,6 @@ function calculateVisualLayout(
|
|||||||
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
||||||
visualLines.push(currentChunk);
|
visualLines.push(currentChunk);
|
||||||
|
|
||||||
// Cursor mapping logic
|
|
||||||
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
|
|
||||||
if (logIndex === logicalCursor[0]) {
|
|
||||||
const cursorLogCol = logicalCursor[1]; // This is a code point index
|
|
||||||
if (
|
|
||||||
cursorLogCol >= currentPosInLogLine &&
|
|
||||||
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
|
|
||||||
) {
|
|
||||||
currentVisualCursor = [
|
|
||||||
visualLines.length - 1,
|
|
||||||
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
|
|
||||||
];
|
|
||||||
} else if (
|
|
||||||
cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
|
|
||||||
numCodePointsInChunk > 0
|
|
||||||
) {
|
|
||||||
// Cursor is exactly at the end of this non-empty chunk
|
|
||||||
currentVisualCursor = [
|
|
||||||
visualLines.length - 1,
|
|
||||||
numCodePointsInChunk,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||||
currentPosInLogLine += numCodePointsInChunk;
|
currentPosInLogLine += numCodePointsInChunk;
|
||||||
|
|
||||||
@@ -793,23 +768,6 @@ function calculateVisualLayout(
|
|||||||
currentPosInLogLine++;
|
currentPosInLogLine++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// After all chunks of a non-empty logical line are processed,
|
|
||||||
// if the cursor is at the very end of this logical line, update visual cursor.
|
|
||||||
if (
|
|
||||||
logIndex === logicalCursor[0] &&
|
|
||||||
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
|
|
||||||
) {
|
|
||||||
const lastVisualLineIdx = visualLines.length - 1;
|
|
||||||
if (
|
|
||||||
lastVisualLineIdx >= 0 &&
|
|
||||||
visualLines[lastVisualLineIdx] !== undefined
|
|
||||||
) {
|
|
||||||
currentVisualCursor = [
|
|
||||||
lastVisualLineIdx,
|
|
||||||
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -824,27 +782,67 @@ function calculateVisualLayout(
|
|||||||
logicalToVisualMap[0].push([0, 0]);
|
logicalToVisualMap[0].push([0, 0]);
|
||||||
visualToLogicalMap.push([0, 0]);
|
visualToLogicalMap.push([0, 0]);
|
||||||
}
|
}
|
||||||
currentVisualCursor = [0, 0];
|
|
||||||
}
|
|
||||||
// Handle cursor at the very end of the text (after all processing)
|
|
||||||
// This case might be covered by the loop end condition now, but kept for safety.
|
|
||||||
else if (
|
|
||||||
logicalCursor[0] === logicalLines.length - 1 &&
|
|
||||||
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
|
|
||||||
visualLines.length > 0
|
|
||||||
) {
|
|
||||||
const lastVisLineIdx = visualLines.length - 1;
|
|
||||||
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visualLines,
|
visualLines,
|
||||||
visualCursor: currentVisualCursor,
|
|
||||||
logicalToVisualMap,
|
logicalToVisualMap,
|
||||||
visualToLogicalMap,
|
visualToLogicalMap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculates the visual cursor position based on a pre-calculated layout.
|
||||||
|
// This is a lightweight operation.
|
||||||
|
function calculateVisualCursorFromLayout(
|
||||||
|
layout: VisualLayout,
|
||||||
|
logicalCursor: [number, number],
|
||||||
|
): [number, number] {
|
||||||
|
const { logicalToVisualMap, visualLines } = layout;
|
||||||
|
const [logicalRow, logicalCol] = logicalCursor;
|
||||||
|
|
||||||
|
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
|
||||||
|
|
||||||
|
if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) {
|
||||||
|
// This can happen for an empty document.
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the segment where the logical column fits.
|
||||||
|
// The segments are sorted by startColInLogical.
|
||||||
|
let targetSegmentIndex = segmentsForLogicalLine.findIndex(
|
||||||
|
([, startColInLogical], index) => {
|
||||||
|
const nextStartColInLogical =
|
||||||
|
index + 1 < segmentsForLogicalLine.length
|
||||||
|
? segmentsForLogicalLine[index + 1][1]
|
||||||
|
: Infinity;
|
||||||
|
return (
|
||||||
|
logicalCol >= startColInLogical && logicalCol < nextStartColInLogical
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// If not found, it means the cursor is at the end of the logical line.
|
||||||
|
if (targetSegmentIndex === -1) {
|
||||||
|
if (logicalCol === 0) {
|
||||||
|
targetSegmentIndex = 0;
|
||||||
|
} else {
|
||||||
|
targetSegmentIndex = segmentsForLogicalLine.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [visualRow, startColInLogical] =
|
||||||
|
segmentsForLogicalLine[targetSegmentIndex];
|
||||||
|
const visualCol = logicalCol - startColInLogical;
|
||||||
|
|
||||||
|
// The visual column should not exceed the length of the visual line.
|
||||||
|
const clampedVisualCol = Math.min(
|
||||||
|
visualCol,
|
||||||
|
cpLen(visualLines[visualRow] ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [visualRow, clampedVisualCol];
|
||||||
|
}
|
||||||
|
|
||||||
// --- Start of reducer logic ---
|
// --- Start of reducer logic ---
|
||||||
|
|
||||||
export interface TextBufferState {
|
export interface TextBufferState {
|
||||||
@@ -857,6 +855,8 @@ export interface TextBufferState {
|
|||||||
clipboard: string | null;
|
clipboard: string | null;
|
||||||
selectionAnchor: [number, number] | null;
|
selectionAnchor: [number, number] | null;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
visualLayout: VisualLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyLimit = 100;
|
const historyLimit = 100;
|
||||||
@@ -884,6 +884,14 @@ export type TextBufferAction =
|
|||||||
dir: Direction;
|
dir: Direction;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'set_cursor';
|
||||||
|
payload: {
|
||||||
|
cursorRow: number;
|
||||||
|
cursorCol: number;
|
||||||
|
preferredCol: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { type: 'delete' }
|
| { type: 'delete' }
|
||||||
| { type: 'delete_word_left' }
|
| { type: 'delete_word_left' }
|
||||||
| { type: 'delete_word_right' }
|
| { type: 'delete_word_right' }
|
||||||
@@ -903,7 +911,7 @@ export type TextBufferAction =
|
|||||||
}
|
}
|
||||||
| { type: 'move_to_offset'; payload: { offset: number } }
|
| { type: 'move_to_offset'; payload: { offset: number } }
|
||||||
| { type: 'create_undo_snapshot' }
|
| { type: 'create_undo_snapshot' }
|
||||||
| { type: 'set_viewport_width'; payload: number }
|
| { type: 'set_viewport'; payload: { width: number; height: number } }
|
||||||
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
||||||
@@ -941,7 +949,7 @@ export type TextBufferAction =
|
|||||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||||
| { type: 'vim_escape_insert_mode' };
|
| { type: 'vim_escape_insert_mode' };
|
||||||
|
|
||||||
export function textBufferReducer(
|
function textBufferReducerLogic(
|
||||||
state: TextBufferState,
|
state: TextBufferState,
|
||||||
action: TextBufferAction,
|
action: TextBufferAction,
|
||||||
): TextBufferState {
|
): TextBufferState {
|
||||||
@@ -1047,80 +1055,120 @@ export function textBufferReducer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'set_viewport_width': {
|
case 'set_viewport': {
|
||||||
if (action.payload === state.viewportWidth) {
|
const { width, height } = action.payload;
|
||||||
|
if (width === state.viewportWidth && height === state.viewportHeight) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return { ...state, viewportWidth: action.payload };
|
return {
|
||||||
|
...state,
|
||||||
|
viewportWidth: width,
|
||||||
|
viewportHeight: height,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'move': {
|
case 'move': {
|
||||||
const { dir } = action.payload;
|
const { dir } = action.payload;
|
||||||
const { lines, cursorRow, cursorCol, viewportWidth } = state;
|
const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state;
|
||||||
const visualLayout = calculateVisualLayout(
|
|
||||||
lines,
|
|
||||||
[cursorRow, cursorCol],
|
|
||||||
viewportWidth,
|
|
||||||
);
|
|
||||||
const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
|
|
||||||
|
|
||||||
let newVisualRow = visualCursor[0];
|
// Visual movements
|
||||||
let newVisualCol = visualCursor[1];
|
if (
|
||||||
let newPreferredCol = state.preferredCol;
|
dir === 'left' ||
|
||||||
|
dir === 'right' ||
|
||||||
|
dir === 'up' ||
|
||||||
|
dir === 'down' ||
|
||||||
|
dir === 'home' ||
|
||||||
|
dir === 'end'
|
||||||
|
) {
|
||||||
|
const visualCursor = calculateVisualCursorFromLayout(visualLayout, [
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
]);
|
||||||
|
const { visualLines, visualToLogicalMap } = visualLayout;
|
||||||
|
|
||||||
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
let newVisualRow = visualCursor[0];
|
||||||
|
let newVisualCol = visualCursor[1];
|
||||||
|
let newPreferredCol = preferredCol;
|
||||||
|
|
||||||
switch (dir) {
|
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
||||||
case 'left':
|
|
||||||
newPreferredCol = null;
|
switch (dir) {
|
||||||
if (newVisualCol > 0) {
|
case 'left':
|
||||||
newVisualCol--;
|
newPreferredCol = null;
|
||||||
} else if (newVisualRow > 0) {
|
if (newVisualCol > 0) {
|
||||||
newVisualRow--;
|
newVisualCol--;
|
||||||
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
} else if (newVisualRow > 0) {
|
||||||
}
|
newVisualRow--;
|
||||||
break;
|
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
||||||
case 'right':
|
}
|
||||||
newPreferredCol = null;
|
break;
|
||||||
if (newVisualCol < currentVisLineLen) {
|
case 'right':
|
||||||
newVisualCol++;
|
newPreferredCol = null;
|
||||||
} else if (newVisualRow < visualLines.length - 1) {
|
if (newVisualCol < currentVisLineLen) {
|
||||||
newVisualRow++;
|
newVisualCol++;
|
||||||
|
} else if (newVisualRow < visualLines.length - 1) {
|
||||||
|
newVisualRow++;
|
||||||
|
newVisualCol = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'up':
|
||||||
|
if (newVisualRow > 0) {
|
||||||
|
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||||
|
newVisualRow--;
|
||||||
|
newVisualCol = clamp(
|
||||||
|
newPreferredCol,
|
||||||
|
0,
|
||||||
|
cpLen(visualLines[newVisualRow] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'down':
|
||||||
|
if (newVisualRow < visualLines.length - 1) {
|
||||||
|
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||||
|
newVisualRow++;
|
||||||
|
newVisualCol = clamp(
|
||||||
|
newPreferredCol,
|
||||||
|
0,
|
||||||
|
cpLen(visualLines[newVisualRow] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'home':
|
||||||
|
newPreferredCol = null;
|
||||||
newVisualCol = 0;
|
newVisualCol = 0;
|
||||||
}
|
break;
|
||||||
break;
|
case 'end':
|
||||||
case 'up':
|
newPreferredCol = null;
|
||||||
if (newVisualRow > 0) {
|
newVisualCol = currentVisLineLen;
|
||||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
break;
|
||||||
newVisualRow--;
|
default: {
|
||||||
newVisualCol = clamp(
|
const exhaustiveCheck: never = dir;
|
||||||
newPreferredCol,
|
console.error(
|
||||||
0,
|
`Unknown visual movement direction: ${exhaustiveCheck}`,
|
||||||
cpLen(visualLines[newVisualRow] ?? ''),
|
|
||||||
);
|
);
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
break;
|
}
|
||||||
case 'down':
|
|
||||||
if (newVisualRow < visualLines.length - 1) {
|
if (visualToLogicalMap[newVisualRow]) {
|
||||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
||||||
newVisualRow++;
|
return {
|
||||||
newVisualCol = clamp(
|
...state,
|
||||||
newPreferredCol,
|
cursorRow: logRow,
|
||||||
|
cursorCol: clamp(
|
||||||
|
logStartCol + newVisualCol,
|
||||||
0,
|
0,
|
||||||
cpLen(visualLines[newVisualRow] ?? ''),
|
cpLen(lines[logRow] ?? ''),
|
||||||
);
|
),
|
||||||
}
|
preferredCol: newPreferredCol,
|
||||||
break;
|
};
|
||||||
case 'home':
|
}
|
||||||
newPreferredCol = null;
|
return state;
|
||||||
newVisualCol = 0;
|
}
|
||||||
break;
|
|
||||||
case 'end':
|
// Logical movements
|
||||||
newPreferredCol = null;
|
switch (dir) {
|
||||||
newVisualCol = currentVisLineLen;
|
|
||||||
break;
|
|
||||||
case 'wordLeft': {
|
case 'wordLeft': {
|
||||||
const { cursorRow, cursorCol, lines } = state;
|
|
||||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||||
|
|
||||||
let newCursorRow = cursorRow;
|
let newCursorRow = cursorRow;
|
||||||
@@ -1156,7 +1204,6 @@ export function textBufferReducer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'wordRight': {
|
case 'wordRight': {
|
||||||
const { cursorRow, cursorCol, lines } = state;
|
|
||||||
if (
|
if (
|
||||||
cursorRow === lines.length - 1 &&
|
cursorRow === lines.length - 1 &&
|
||||||
cursorCol === cpLen(lines[cursorRow] ?? '')
|
cursorCol === cpLen(lines[cursorRow] ?? '')
|
||||||
@@ -1186,23 +1233,15 @@ export function textBufferReducer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
return state;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (visualToLogicalMap[newVisualRow]) {
|
case 'set_cursor': {
|
||||||
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
return {
|
||||||
return {
|
...state,
|
||||||
...state,
|
...action.payload,
|
||||||
cursorRow: logRow,
|
};
|
||||||
cursorCol: clamp(
|
|
||||||
logStartCol + newVisualCol,
|
|
||||||
0,
|
|
||||||
cpLen(state.lines[logRow] ?? ''),
|
|
||||||
),
|
|
||||||
preferredCol: newPreferredCol,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
@@ -1214,14 +1253,22 @@ export function textBufferReducer(
|
|||||||
newLines[cursorRow] =
|
newLines[cursorRow] =
|
||||||
cpSlice(lineContent, 0, cursorCol) +
|
cpSlice(lineContent, 0, cursorCol) +
|
||||||
cpSlice(lineContent, cursorCol + 1);
|
cpSlice(lineContent, cursorCol + 1);
|
||||||
return { ...nextState, lines: newLines, preferredCol: null };
|
return {
|
||||||
|
...nextState,
|
||||||
|
lines: newLines,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
} else if (cursorRow < lines.length - 1) {
|
} else if (cursorRow < lines.length - 1) {
|
||||||
const nextState = pushUndoLocal(state);
|
const nextState = pushUndoLocal(state);
|
||||||
const nextLineContent = currentLine(cursorRow + 1);
|
const nextLineContent = currentLine(cursorRow + 1);
|
||||||
const newLines = [...nextState.lines];
|
const newLines = [...nextState.lines];
|
||||||
newLines[cursorRow] = lineContent + nextLineContent;
|
newLines[cursorRow] = lineContent + nextLineContent;
|
||||||
newLines.splice(cursorRow + 1, 1);
|
newLines.splice(cursorRow + 1, 1);
|
||||||
return { ...nextState, lines: newLines, preferredCol: null };
|
return {
|
||||||
|
...nextState,
|
||||||
|
lines: newLines,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -1303,7 +1350,10 @@ export function textBufferReducer(
|
|||||||
const nextState = pushUndoLocal(state);
|
const nextState = pushUndoLocal(state);
|
||||||
const newLines = [...nextState.lines];
|
const newLines = [...nextState.lines];
|
||||||
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
||||||
return { ...nextState, lines: newLines };
|
return {
|
||||||
|
...nextState,
|
||||||
|
lines: newLines,
|
||||||
|
};
|
||||||
} else if (cursorRow < lines.length - 1) {
|
} else if (cursorRow < lines.length - 1) {
|
||||||
// Act as a delete
|
// Act as a delete
|
||||||
const nextState = pushUndoLocal(state);
|
const nextState = pushUndoLocal(state);
|
||||||
@@ -1311,7 +1361,11 @@ export function textBufferReducer(
|
|||||||
const newLines = [...nextState.lines];
|
const newLines = [...nextState.lines];
|
||||||
newLines[cursorRow] = lineContent + nextLineContent;
|
newLines[cursorRow] = lineContent + nextLineContent;
|
||||||
newLines.splice(cursorRow + 1, 1);
|
newLines.splice(cursorRow + 1, 1);
|
||||||
return { ...nextState, lines: newLines, preferredCol: null };
|
return {
|
||||||
|
...nextState,
|
||||||
|
lines: newLines,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -1441,6 +1495,25 @@ export function textBufferReducer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function textBufferReducer(
|
||||||
|
state: TextBufferState,
|
||||||
|
action: TextBufferAction,
|
||||||
|
): TextBufferState {
|
||||||
|
const newState = textBufferReducerLogic(state, action);
|
||||||
|
|
||||||
|
if (
|
||||||
|
newState.lines !== state.lines ||
|
||||||
|
newState.viewportWidth !== state.viewportWidth
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...newState,
|
||||||
|
visualLayout: calculateLayout(newState.lines, newState.viewportWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
// --- End of reducer logic ---
|
// --- End of reducer logic ---
|
||||||
|
|
||||||
export function useTextBuffer({
|
export function useTextBuffer({
|
||||||
@@ -1459,6 +1532,10 @@ export function useTextBuffer({
|
|||||||
lines.length === 0 ? [''] : lines,
|
lines.length === 0 ? [''] : lines,
|
||||||
initialCursorOffset,
|
initialCursorOffset,
|
||||||
);
|
);
|
||||||
|
const visualLayout = calculateLayout(
|
||||||
|
lines.length === 0 ? [''] : lines,
|
||||||
|
viewport.width,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
lines: lines.length === 0 ? [''] : lines,
|
lines: lines.length === 0 ? [''] : lines,
|
||||||
cursorRow: initialCursorRow,
|
cursorRow: initialCursorRow,
|
||||||
@@ -1469,21 +1546,29 @@ export function useTextBuffer({
|
|||||||
clipboard: null,
|
clipboard: null,
|
||||||
selectionAnchor: null,
|
selectionAnchor: null,
|
||||||
viewportWidth: viewport.width,
|
viewportWidth: viewport.width,
|
||||||
|
viewportHeight: viewport.height,
|
||||||
|
visualLayout,
|
||||||
};
|
};
|
||||||
}, [initialText, initialCursorOffset, viewport.width]);
|
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(textBufferReducer, initialState);
|
const [state, dispatch] = useReducer(textBufferReducer, initialState);
|
||||||
const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
|
const {
|
||||||
|
lines,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
preferredCol,
|
||||||
|
selectionAnchor,
|
||||||
|
visualLayout,
|
||||||
|
} = state;
|
||||||
|
|
||||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||||
|
|
||||||
const visualLayout = useMemo(
|
const visualCursor = useMemo(
|
||||||
() =>
|
() => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]),
|
||||||
calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth),
|
[visualLayout, cursorRow, cursorCol],
|
||||||
[lines, cursorRow, cursorCol, state.viewportWidth],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { visualLines, visualCursor } = visualLayout;
|
const { visualLines } = visualLayout;
|
||||||
|
|
||||||
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
||||||
|
|
||||||
@@ -1494,8 +1579,11 @@ export function useTextBuffer({
|
|||||||
}, [text, onChange]);
|
}, [text, onChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: 'set_viewport_width', payload: viewport.width });
|
dispatch({
|
||||||
}, [viewport.width]);
|
type: 'set_viewport',
|
||||||
|
payload: { width: viewport.width, height: viewport.height },
|
||||||
|
});
|
||||||
|
}, [viewport.width, viewport.height]);
|
||||||
|
|
||||||
// Update visual scroll (vertical)
|
// Update visual scroll (vertical)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1568,9 +1656,12 @@ export function useTextBuffer({
|
|||||||
dispatch({ type: 'delete' });
|
dispatch({ type: 'delete' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const move = useCallback((dir: Direction): void => {
|
const move = useCallback(
|
||||||
dispatch({ type: 'move', payload: { dir } });
|
(dir: Direction): void => {
|
||||||
}, []);
|
dispatch({ type: 'move', payload: { dir } });
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
const undo = useCallback((): void => {
|
const undo = useCallback((): void => {
|
||||||
dispatch({ type: 'undo' });
|
dispatch({ type: 'undo' });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import { stripVTControlCharacters } from 'node:util';
|
import { stripVTControlCharacters } from 'node:util';
|
||||||
|
import stringWidth from 'string-width';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the maximum width of a multi-line ASCII art string.
|
* Calculates the maximum width of a multi-line ASCII art string.
|
||||||
@@ -26,10 +27,39 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
|
|||||||
* code units so that surrogate‑pair emoji count as one "column".)
|
* code units so that surrogate‑pair emoji count as one "column".)
|
||||||
* ---------------------------------------------------------------------- */
|
* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// Cache for code points to reduce GC pressure
|
||||||
|
const codePointsCache = new Map<string, string[]>();
|
||||||
|
const MAX_STRING_LENGTH_TO_CACHE = 1000;
|
||||||
|
|
||||||
export function toCodePoints(str: string): string[] {
|
export function toCodePoints(str: string): string[] {
|
||||||
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
// ASCII fast path - check if all chars are ASCII (0-127)
|
||||||
// surrogate pairs correctly.
|
let isAscii = true;
|
||||||
return Array.from(str);
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str.charCodeAt(i) > 127) {
|
||||||
|
isAscii = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isAscii) {
|
||||||
|
return str.split('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache short strings
|
||||||
|
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
|
||||||
|
const cached = codePointsCache.get(str);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array.from(str);
|
||||||
|
|
||||||
|
// Cache result (unlimited like Ink)
|
||||||
|
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
|
||||||
|
codePointsCache.set(str, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cpLen(str: string): number {
|
export function cpLen(str: string): number {
|
||||||
@@ -86,3 +116,33 @@ export function stripUnsafeCharacters(str: string): string {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String width caching for performance optimization
|
||||||
|
const stringWidthCache = new Map<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached version of stringWidth function for better performance
|
||||||
|
* Follows Ink's approach with unlimited cache (no eviction)
|
||||||
|
*/
|
||||||
|
export const getCachedStringWidth = (str: string): number => {
|
||||||
|
// ASCII printable chars have width 1
|
||||||
|
if (/^[\x20-\x7E]*$/.test(str)) {
|
||||||
|
return str.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringWidthCache.has(str)) {
|
||||||
|
return stringWidthCache.get(str)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = stringWidth(str);
|
||||||
|
stringWidthCache.set(str, width);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the string width cache
|
||||||
|
*/
|
||||||
|
export const clearStringWidthCache = (): void => {
|
||||||
|
stringWidthCache.clear();
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user