summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Richman <[email protected]>2025-05-16 11:58:37 -0700
committerGitHub <[email protected]>2025-05-16 11:58:37 -0700
commitc692a0c583f343225c4435ad14fb1b5a8d0e1b01 (patch)
tree33ac89c1e67939f55f6da59bc7d8776db1b17448
parent968e09f0b50d17f7c591baa977666b991a1e59b7 (diff)
Support auto wrapping of in the multiline editor. (#383)
-rw-r--r--.vscode/launch.json20
-rw-r--r--package-lock.json1
-rw-r--r--packages/cli/package.json3
-rw-r--r--packages/cli/src/ui/components/shared/multiline-editor.tsx76
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts507
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts665
6 files changed, 1099 insertions, 173 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json
index b4cdfd70..8e8e56b6 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -22,6 +22,26 @@
"skipFiles": ["<node_internals>/**"],
"program": "${file}",
"outFiles": ["${workspaceFolder}/**/*.js"]
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Debug CLI Test: text-buffer",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": [
+ "run",
+ "test",
+ "-w",
+ "packages/cli",
+ "--",
+ "--inspect-brk=9229",
+ "--no-file-parallelism",
+ "${workspaceFolder}/packages/cli/src/ui/components/shared/text-buffer.test.ts"
+ ],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal",
+ "internalConsoleOptions": "neverOpen",
+ "skipFiles": ["<node_internals>/**"]
}
]
}
diff --git a/package-lock.json b/package-lock.json
index 1255beab..6cf5d3f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8161,6 +8161,7 @@
"react": "^18.3.1",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.2",
+ "string-width": "^7.1.0",
"yargs": "^17.7.2"
},
"bin": {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index bafb2526..727f16e3 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -43,7 +43,8 @@
"react": "^18.3.1",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.2",
- "yargs": "^17.7.2"
+ "yargs": "^17.7.2",
+ "string-width": "^7.1.0"
},
"devDependencies": {
"@testing-library/react": "^14.0.0",
diff --git a/packages/cli/src/ui/components/shared/multiline-editor.tsx b/packages/cli/src/ui/components/shared/multiline-editor.tsx
index e1e21fff..a89acfd1 100644
--- a/packages/cli/src/ui/components/shared/multiline-editor.tsx
+++ b/packages/cli/src/ui/components/shared/multiline-editor.tsx
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useTextBuffer } from './text-buffer.js';
+import { useTextBuffer, cpSlice, cpLen } from './text-buffer.js';
import chalk from 'chalk';
import { Box, Text, useInput, useStdin, Key } from 'ink';
import React from 'react';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Colors } from '../../colors.js';
+import stringWidth from 'string-width';
export interface MultilineTextEditorProps {
// Initial contents.
@@ -184,14 +185,21 @@ export const MultilineTextEditor = ({
}
if (key.upArrow) {
- if (buffer.cursor[0] === 0 && navigateUp) {
+ if (
+ buffer.visualCursor[0] === 0 &&
+ buffer.visualScrollRow === 0 &&
+ navigateUp
+ ) {
navigateUp();
return;
}
}
if (key.downArrow) {
- if (buffer.cursor[0] === buffer.lines.length - 1 && navigateDown) {
+ if (
+ buffer.visualCursor[0] === buffer.allVisualLines.length - 1 &&
+ navigateDown
+ ) {
navigateDown();
return;
}
@@ -202,39 +210,57 @@ export const MultilineTextEditor = ({
{ isActive: focus },
);
- const visibleLines = buffer.visibleLines;
- const [cursorRow, cursorCol] = buffer.cursor;
- const [scrollRow, scrollCol] = buffer.scroll;
+ const linesToRender = buffer.viewportVisualLines; // This is the subset of visual lines for display
+ const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
+ buffer.visualCursor; // This is relative to *all* visual lines
+ const scrollVisualRow = buffer.visualScrollRow;
+ // scrollHorizontalCol removed as it's always 0 due to word wrap
return (
<Box flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
<Text color={Colors.SubtleComment}>{placeholder}</Text>
) : (
- visibleLines.map((lineText, idx) => {
- const absoluteRow = scrollRow + idx;
- let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
- if (display.length < effectiveWidth) {
- display = display.padEnd(effectiveWidth, ' ');
+ linesToRender.map((lineText, visualIdxInRenderedSet) => {
+ // cursorVisualRow is the cursor's row index within the currently *rendered* set of visual lines
+ const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
+
+ let display = cpSlice(
+ lineText,
+ 0, // Start from 0 as horizontal scroll is disabled
+ effectiveWidth, // This is still code point based for slicing
+ );
+ // Pad based on visual width
+ const currentVisualWidth = stringWidth(display);
+ if (currentVisualWidth < effectiveWidth) {
+ display = display + ' '.repeat(effectiveWidth - currentVisualWidth);
}
- if (absoluteRow === cursorRow) {
- const relativeCol = cursorCol - scrollCol;
- const highlightCol = relativeCol;
+ if (visualIdxInRenderedSet === cursorVisualRow) {
+ const relativeVisualColForHighlight = cursorVisualColAbsolute; // Directly use absolute as horizontal scroll is 0
- if (highlightCol >= 0 && highlightCol < effectiveWidth) {
- const charToHighlight = display[highlightCol] || ' ';
- const highlighted = chalk.inverse(charToHighlight);
- display =
- display.slice(0, highlightCol) +
- highlighted +
- display.slice(highlightCol + 1);
- } else if (relativeCol === effectiveWidth) {
- display =
- display.slice(0, effectiveWidth - 1) + chalk.inverse(' ');
+ if (relativeVisualColForHighlight >= 0) {
+ if (relativeVisualColForHighlight < cpLen(display)) {
+ const charToHighlight =
+ cpSlice(
+ display,
+ relativeVisualColForHighlight,
+ relativeVisualColForHighlight + 1,
+ ) || ' ';
+ const highlighted = chalk.inverse(charToHighlight);
+ display =
+ cpSlice(display, 0, relativeVisualColForHighlight) +
+ highlighted +
+ cpSlice(display, relativeVisualColForHighlight + 1);
+ } else if (
+ relativeVisualColForHighlight === cpLen(display) &&
+ cpLen(display) === effectiveWidth
+ ) {
+ display = display + chalk.inverse(' ');
+ }
}
}
- return <Text key={idx}>{display}</Text>;
+ return <Text key={visualIdxInRenderedSet}>{display}</Text>;
})
)}
</Box>
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
new file mode 100644
index 00000000..8e35e3e9
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -0,0 +1,507 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useTextBuffer, Viewport, TextBuffer } from './text-buffer.js';
+
+// Helper to get the state from the hook
+const getBufferState = (result: { current: TextBuffer }) => ({
+ text: result.current.text,
+ lines: [...result.current.lines], // Clone for safety
+ cursor: [...result.current.cursor] as [number, number],
+ allVisualLines: [...result.current.allVisualLines],
+ viewportVisualLines: [...result.current.viewportVisualLines],
+ visualCursor: [...result.current.visualCursor] as [number, number],
+ visualScrollRow: result.current.visualScrollRow,
+ preferredCol: result.current.preferredCol,
+});
+
+describe('useTextBuffer', () => {
+ let viewport: Viewport;
+
+ beforeEach(() => {
+ viewport = { width: 10, height: 3 }; // Default viewport for tests
+ });
+
+ describe('Initialization', () => {
+ it('should initialize with empty text and cursor at (0,0) by default', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ const state = getBufferState(result);
+ expect(state.text).toBe('');
+ expect(state.lines).toEqual(['']);
+ expect(state.cursor).toEqual([0, 0]);
+ expect(state.allVisualLines).toEqual(['']);
+ expect(state.viewportVisualLines).toEqual(['']);
+ expect(state.visualCursor).toEqual([0, 0]);
+ expect(state.visualScrollRow).toBe(0);
+ });
+
+ it('should initialize with provided initialText', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'hello', viewport }),
+ );
+ const state = getBufferState(result);
+ expect(state.text).toBe('hello');
+ expect(state.lines).toEqual(['hello']);
+ expect(state.cursor).toEqual([0, 0]); // Default cursor if offset not given
+ expect(state.allVisualLines).toEqual(['hello']);
+ expect(state.viewportVisualLines).toEqual(['hello']);
+ expect(state.visualCursor).toEqual([0, 0]);
+ });
+
+ it('should initialize with initialText and initialCursorOffset', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'hello\nworld',
+ initialCursorOffset: 7, // Should be at 'o' in 'world'
+ viewport,
+ }),
+ );
+ const state = getBufferState(result);
+ expect(state.text).toBe('hello\nworld');
+ expect(state.lines).toEqual(['hello', 'world']);
+ expect(state.cursor).toEqual([1, 1]); // Logical cursor at 'o' in "world"
+ expect(state.allVisualLines).toEqual(['hello', 'world']);
+ expect(state.viewportVisualLines).toEqual(['hello', 'world']);
+ expect(state.visualCursor[0]).toBe(1); // On the second visual line
+ expect(state.visualCursor[1]).toBe(1); // At 'o' in "world"
+ });
+
+ it('should wrap visual lines', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'The quick brown fox jumps over the lazy dog.',
+ initialCursorOffset: 2, // After '好'
+ viewport: { width: 15, height: 4 },
+ }),
+ );
+ const state = getBufferState(result);
+ expect(state.allVisualLines).toEqual([
+ 'The quick',
+ 'brown fox',
+ 'jumps over the',
+ 'lazy dog.',
+ ]);
+ });
+
+ it('should wrap visual lines with multiple spaces', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'The quick brown fox jumps over the lazy dog.',
+ viewport: { width: 15, height: 4 },
+ }),
+ );
+ const state = getBufferState(result);
+ // Including multiple spaces at the end of the lines like this is
+ // consistent with Google docs behavior and makes it intuitive to edit
+ // the spaces as needed.
+ expect(state.allVisualLines).toEqual([
+ 'The quick ',
+ 'brown fox ',
+ 'jumps over the',
+ 'lazy dog.',
+ ]);
+ });
+
+ it('should wrap visual lines even without spaces', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: '123456789012345ABCDEFG', // 4 chars, 12 bytes
+ viewport: { width: 15, height: 2 },
+ }),
+ );
+ const state = getBufferState(result);
+ // Including multiple spaces at the end of the lines like this is
+ // consistent with Google docs behavior and makes it intuitive to edit
+ // the spaces as needed.
+ expect(state.allVisualLines).toEqual(['123456789012345', 'ABCDEFG']);
+ });
+
+ it('should initialize with multi-byte unicode characters and correct cursor offset', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: '你好世界', // 4 chars, 12 bytes
+ initialCursorOffset: 2, // After '好'
+ viewport: { width: 5, height: 2 },
+ }),
+ );
+ const state = getBufferState(result);
+ expect(state.text).toBe('你好世界');
+ expect(state.lines).toEqual(['你好世界']);
+ expect(state.cursor).toEqual([0, 2]);
+ // Visual: "你好" (width 4), "世"界" (width 4) with viewport width 5
+ expect(state.allVisualLines).toEqual(['你好', '世界']);
+ expect(state.visualCursor).toEqual([1, 0]);
+ });
+ });
+
+ describe('Basic Editing', () => {
+ it('insert: should insert a character and update cursor', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ act(() => result.current.insert('a'));
+ let state = getBufferState(result);
+ expect(state.text).toBe('a');
+ expect(state.cursor).toEqual([0, 1]);
+ expect(state.visualCursor).toEqual([0, 1]);
+
+ act(() => result.current.insert('b'));
+ state = getBufferState(result);
+ expect(state.text).toBe('ab');
+ expect(state.cursor).toEqual([0, 2]);
+ expect(state.visualCursor).toEqual([0, 2]);
+ });
+
+ it('newline: should create a new line and move cursor', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'ab', viewport }),
+ );
+ act(() => result.current.move('end')); // cursor at [0,2]
+ act(() => result.current.newline());
+ const state = getBufferState(result);
+ expect(state.text).toBe('ab\n');
+ expect(state.lines).toEqual(['ab', '']);
+ expect(state.cursor).toEqual([1, 0]);
+ expect(state.allVisualLines).toEqual(['ab', '']);
+ expect(state.viewportVisualLines).toEqual(['ab', '']); // viewport height 3
+ expect(state.visualCursor).toEqual([1, 0]); // On the new visual line
+ });
+
+ it('backspace: should delete char to the left or merge lines', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'a\nb', viewport }),
+ );
+ act(() => {
+ result.current.move('down');
+ });
+ act(() => {
+ result.current.move('end'); // cursor to [1,1] (end of 'b')
+ });
+ act(() => result.current.backspace()); // delete 'b'
+ let state = getBufferState(result);
+ expect(state.text).toBe('a\n');
+ expect(state.cursor).toEqual([1, 0]);
+
+ act(() => result.current.backspace()); // merge lines
+ state = getBufferState(result);
+ expect(state.text).toBe('a');
+ expect(state.cursor).toEqual([0, 1]); // cursor after 'a'
+ expect(state.allVisualLines).toEqual(['a']);
+ expect(state.viewportVisualLines).toEqual(['a']);
+ expect(state.visualCursor).toEqual([0, 1]);
+ });
+
+ it('del: should delete char to the right or merge lines', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'a\nb', viewport }),
+ );
+ // cursor at [0,0]
+ act(() => result.current.del()); // delete 'a'
+ let state = getBufferState(result);
+ expect(state.text).toBe('\nb');
+ expect(state.cursor).toEqual([0, 0]);
+
+ act(() => result.current.del()); // merge lines (deletes newline)
+ state = getBufferState(result);
+ expect(state.text).toBe('b');
+ expect(state.cursor).toEqual([0, 0]);
+ expect(state.allVisualLines).toEqual(['b']);
+ expect(state.viewportVisualLines).toEqual(['b']);
+ expect(state.visualCursor).toEqual([0, 0]);
+ });
+ });
+
+ describe('Cursor Movement', () => {
+ it('move: left/right should work within and across visual lines (due to wrapping)', () => {
+ // Text: "long line1next line2" (20 chars)
+ // Viewport width 5. Word wrapping should produce:
+ // "long " (5)
+ // "line1" (5)
+ // "next " (5)
+ // "line2" (5)
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'long line1next line2', // Corrected: was 'long line1next line2'
+ viewport: { width: 5, height: 4 },
+ }),
+ );
+ // Initial cursor [0,0] logical, visual [0,0] ("l" of "long ")
+
+ act(() => result.current.move('right')); // visual [0,1] ("o")
+ expect(getBufferState(result).visualCursor).toEqual([0, 1]);
+ act(() => result.current.move('right')); // visual [0,2] ("n")
+ act(() => result.current.move('right')); // visual [0,3] ("g")
+ act(() => result.current.move('right')); // visual [0,4] (" ")
+ expect(getBufferState(result).visualCursor).toEqual([0, 4]);
+
+ act(() => result.current.move('right')); // visual [1,0] ("l" of "line1")
+ expect(getBufferState(result).visualCursor).toEqual([1, 0]);
+ expect(getBufferState(result).cursor).toEqual([0, 5]); // logical cursor
+
+ act(() => result.current.move('left')); // visual [0,4] (" " of "long ")
+ expect(getBufferState(result).visualCursor).toEqual([0, 4]);
+ expect(getBufferState(result).cursor).toEqual([0, 4]); // logical cursor
+ });
+
+ it('move: up/down should preserve preferred visual column', () => {
+ const text = 'abcde\nxy\n12345';
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: text, viewport }),
+ );
+ expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']);
+ // Place cursor at the end of "abcde" -> logical [0,5]
+ act(() => {
+ result.current.move('home'); // to [0,0]
+ });
+ for (let i = 0; i < 5; i++) {
+ act(() => {
+ result.current.move('right'); // to [0,5]
+ });
+ }
+ expect(getBufferState(result).cursor).toEqual([0, 5]);
+ expect(getBufferState(result).visualCursor).toEqual([0, 5]);
+
+ // Set preferredCol by moving up then down to the same spot, then test.
+ act(() => {
+ result.current.move('down'); // to xy, logical [1,2], visual [1,2], preferredCol should be 5
+ });
+ let state = getBufferState(result);
+ expect(state.cursor).toEqual([1, 2]); // Logical cursor at end of 'xy'
+ expect(state.visualCursor).toEqual([1, 2]); // Visual cursor at end of 'xy'
+ expect(state.preferredCol).toBe(5);
+
+ act(() => result.current.move('down')); // to '12345', preferredCol=5.
+ state = getBufferState(result);
+ expect(state.cursor).toEqual([2, 5]); // Logical cursor at end of '12345'
+ expect(state.visualCursor).toEqual([2, 5]); // Visual cursor at end of '12345'
+ expect(state.preferredCol).toBe(5); // Preferred col is maintained
+
+ act(() => result.current.move('left')); // preferredCol should reset
+ state = getBufferState(result);
+ expect(state.preferredCol).toBe(null);
+ });
+
+ it('move: home/end should go to visual line start/end', () => {
+ const initialText = 'line one\nsecond line';
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText, viewport: { width: 5, height: 5 } }),
+ );
+ expect(result.current.allVisualLines).toEqual([
+ 'line',
+ 'one',
+ 'secon',
+ 'd',
+ 'line',
+ ]);
+ // Initial cursor [0,0] (start of "line")
+ act(() => result.current.move('down')); // visual cursor from [0,0] to [1,0] ("o" of "one")
+ act(() => result.current.move('right')); // visual cursor to [1,1] ("n" of "one")
+ expect(getBufferState(result).visualCursor).toEqual([1, 1]);
+
+ act(() => result.current.move('home')); // visual cursor to [1,0] (start of "one")
+ expect(getBufferState(result).visualCursor).toEqual([1, 0]);
+
+ act(() => result.current.move('end')); // visual cursor to [1,3] (end of "one")
+ expect(getBufferState(result).visualCursor).toEqual([1, 3]); // "one" is 3 chars
+ });
+ });
+
+ describe('Visual Layout & Viewport', () => {
+ it('should wrap long lines correctly into visualLines', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'This is a very long line of text.', // 33 chars
+ viewport: { width: 10, height: 5 },
+ }),
+ );
+ const state = getBufferState(result);
+ // Expected visual lines with word wrapping (viewport width 10):
+ // "This is a"
+ // "very long"
+ // "line of"
+ // "text."
+ expect(state.allVisualLines.length).toBe(4);
+ expect(state.allVisualLines[0]).toBe('This is a');
+ expect(state.allVisualLines[1]).toBe('very long');
+ expect(state.allVisualLines[2]).toBe('line of');
+ expect(state.allVisualLines[3]).toBe('text.');
+ });
+
+ it('should update visualScrollRow when visualCursor moves out of viewport', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: 'l1\nl2\nl3\nl4\nl5',
+ viewport: { width: 5, height: 3 }, // Can show 3 visual lines
+ }),
+ );
+ // Initial: l1, l2, l3 visible. visualScrollRow = 0. visualCursor = [0,0]
+ expect(getBufferState(result).visualScrollRow).toBe(0);
+ expect(getBufferState(result).allVisualLines).toEqual([
+ 'l1',
+ 'l2',
+ 'l3',
+ 'l4',
+ 'l5',
+ ]);
+ expect(getBufferState(result).viewportVisualLines).toEqual([
+ 'l1',
+ 'l2',
+ 'l3',
+ ]);
+
+ act(() => result.current.move('down')); // vc=[1,0]
+ act(() => result.current.move('down')); // vc=[2,0] (l3)
+ expect(getBufferState(result).visualScrollRow).toBe(0);
+
+ act(() => result.current.move('down')); // vc=[3,0] (l4) - scroll should happen
+ // Now: l2, l3, l4 visible. visualScrollRow = 1.
+ let state = getBufferState(result);
+ expect(state.visualScrollRow).toBe(1);
+ expect(state.allVisualLines).toEqual(['l1', 'l2', 'l3', 'l4', 'l5']);
+ expect(state.viewportVisualLines).toEqual(['l2', 'l3', 'l4']);
+ expect(state.visualCursor).toEqual([3, 0]);
+
+ act(() => result.current.move('up')); // vc=[2,0] (l3)
+ act(() => result.current.move('up')); // vc=[1,0] (l2)
+ expect(getBufferState(result).visualScrollRow).toBe(1);
+
+ act(() => result.current.move('up')); // vc=[0,0] (l1) - scroll up
+ // Now: l1, l2, l3 visible. visualScrollRow = 0
+ state = getBufferState(result); // Assign to the existing `state` variable
+ expect(state.visualScrollRow).toBe(0);
+ expect(state.allVisualLines).toEqual(['l1', 'l2', 'l3', 'l4', 'l5']);
+ expect(state.viewportVisualLines).toEqual(['l1', 'l2', 'l3']);
+ expect(state.visualCursor).toEqual([0, 0]);
+ });
+ });
+
+ describe('Undo/Redo', () => {
+ it('should undo and redo an insert operation', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ act(() => result.current.insert('a'));
+ expect(getBufferState(result).text).toBe('a');
+
+ act(() => result.current.undo());
+ expect(getBufferState(result).text).toBe('');
+ expect(getBufferState(result).cursor).toEqual([0, 0]);
+
+ act(() => result.current.redo());
+ expect(getBufferState(result).text).toBe('a');
+ expect(getBufferState(result).cursor).toEqual([0, 1]);
+ });
+
+ it('should undo and redo a newline operation', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'test', viewport }),
+ );
+ act(() => result.current.move('end'));
+ act(() => result.current.newline());
+ expect(getBufferState(result).text).toBe('test\n');
+
+ act(() => result.current.undo());
+ expect(getBufferState(result).text).toBe('test');
+ expect(getBufferState(result).cursor).toEqual([0, 4]);
+
+ act(() => result.current.redo());
+ expect(getBufferState(result).text).toBe('test\n');
+ expect(getBufferState(result).cursor).toEqual([1, 0]);
+ });
+ });
+
+ describe('Unicode Handling', () => {
+ it('insert: should correctly handle multi-byte unicode characters', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ act(() => result.current.insert('你好'));
+ const state = getBufferState(result);
+ expect(state.text).toBe('你好');
+ expect(state.cursor).toEqual([0, 2]); // Cursor is 2 (char count)
+ expect(state.visualCursor).toEqual([0, 2]);
+ });
+
+ it('backspace: should correctly delete multi-byte unicode characters', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: '你好', viewport }),
+ );
+ act(() => result.current.move('end')); // cursor at [0,2]
+ act(() => result.current.backspace()); // delete '好'
+ let state = getBufferState(result);
+ expect(state.text).toBe('你');
+ expect(state.cursor).toEqual([0, 1]);
+
+ act(() => result.current.backspace()); // delete '你'
+ state = getBufferState(result);
+ expect(state.text).toBe('');
+ expect(state.cursor).toEqual([0, 0]);
+ });
+
+ it('move: left/right should treat multi-byte chars as single units for visual cursor', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({
+ initialText: '🐶🐱',
+ viewport: { width: 5, height: 1 },
+ }),
+ );
+ // Initial: visualCursor [0,0]
+ act(() => result.current.move('right')); // visualCursor [0,1] (after 🐶)
+ let state = getBufferState(result);
+ expect(state.cursor).toEqual([0, 1]);
+ expect(state.visualCursor).toEqual([0, 1]);
+
+ act(() => result.current.move('right')); // visualCursor [0,2] (after 🐱)
+ state = getBufferState(result);
+ expect(state.cursor).toEqual([0, 2]);
+ expect(state.visualCursor).toEqual([0, 2]);
+
+ act(() => result.current.move('left')); // visualCursor [0,1] (before 🐱 / after 🐶)
+ state = getBufferState(result);
+ expect(state.cursor).toEqual([0, 1]);
+ expect(state.visualCursor).toEqual([0, 1]);
+ });
+ });
+
+ describe('handleInput', () => {
+ it('should insert printable characters', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ act(() => result.current.handleInput('h', {}));
+ act(() => result.current.handleInput('i', {}));
+ expect(getBufferState(result).text).toBe('hi');
+ });
+
+ it('should handle "Enter" key as newline', () => {
+ const { result } = renderHook(() => useTextBuffer({ viewport }));
+ act(() => result.current.handleInput(undefined, { return: true }));
+ expect(getBufferState(result).lines).toEqual(['', '']);
+ });
+
+ it('should handle "Backspace" key', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'a', viewport }),
+ );
+ act(() => result.current.move('end'));
+ act(() => result.current.handleInput(undefined, { backspace: true }));
+ expect(getBufferState(result).text).toBe('');
+ });
+
+ it('should handle arrow keys for movement', () => {
+ const { result } = renderHook(() =>
+ useTextBuffer({ initialText: 'ab', viewport }),
+ );
+ act(() => result.current.move('end')); // cursor [0,2]
+ act(() => result.current.handleInput(undefined, { leftArrow: true })); // cursor [0,1]
+ expect(getBufferState(result).cursor).toEqual([0, 1]);
+ act(() => result.current.handleInput(undefined, { rightArrow: true })); // cursor [0,2]
+ expect(getBufferState(result).cursor).toEqual([0, 2]);
+ });
+ });
+
+ // More tests would be needed for:
+ // - setText, replaceRange
+ // - deleteWordLeft, deleteWordRight
+ // - More complex undo/redo scenarios
+ // - Selection and clipboard (copy/paste) - might need clipboard API mocks or internal state check
+ // - openInExternalEditor (heavy mocking of fs, child_process, os)
+ // - All edge cases for visual scrolling and wrapping with different viewport sizes and text content.
+});
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 661df70c..5f25f9e0 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -9,6 +9,7 @@ import fs from 'fs';
import os from 'os';
import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo } from 'react';
+import stringWidth from 'string-width';
export type Direction =
| 'left'
@@ -43,17 +44,17 @@ function clamp(v: number, min: number, max: number): number {
* code units so that surrogate‑pair emoji count as one "column".)
* ---------------------------------------------------------------------- */
-function toCodePoints(str: string): string[] {
+export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF‑32 code point, handling
// surrogate pairs correctly.
return Array.from(str);
}
-function cpLen(str: string): number {
+export function cpLen(str: string): number {
return toCodePoints(str).length;
}
-function cpSlice(str: string, start: number, end?: number): string {
+export function cpSlice(str: string, start: number, end?: number): string {
// Slice by code‑point indices and re‑join.
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
@@ -117,6 +118,221 @@ function calculateInitialCursorPosition(
}
return [0, 0]; // Default for empty text
}
+// Helper to calculate visual lines and map cursor positions
+function calculateVisualLayout(
+ logicalLines: string[],
+ logicalCursor: [number, number],
+ viewportWidth: number,
+): {
+ visualLines: string[];
+ visualCursor: [number, number];
+ logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
+ visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
+} {
+ const visualLines: string[] = [];
+ const logicalToVisualMap: Array<Array<[number, number]>> = [];
+ const visualToLogicalMap: Array<[number, number]> = [];
+ let currentVisualCursor: [number, number] = [0, 0];
+
+ logicalLines.forEach((logLine, logIndex) => {
+ logicalToVisualMap[logIndex] = [];
+ if (logLine.length === 0) {
+ // Handle empty logical line
+ logicalToVisualMap[logIndex].push([visualLines.length, 0]);
+ visualToLogicalMap.push([logIndex, 0]);
+ visualLines.push('');
+ if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
+ currentVisualCursor = [visualLines.length - 1, 0];
+ }
+ } else {
+ // Non-empty logical line
+ let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
+ const codePointsInLogLine = toCodePoints(logLine);
+
+ while (currentPosInLogLine < codePointsInLogLine.length) {
+ let currentChunk = '';
+ let currentChunkVisualWidth = 0;
+ let numCodePointsInChunk = 0;
+ let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
+ let numCodePointsAtLastWordBreak = 0;
+
+ // Iterate through code points to build the current visual line (chunk)
+ for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
+ const char = codePointsInLogLine[i];
+ const charVisualWidth = stringWidth(char);
+
+ if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
+ // Character would exceed viewport width
+ if (
+ lastWordBreakPoint !== -1 &&
+ numCodePointsAtLastWordBreak > 0 &&
+ currentPosInLogLine + numCodePointsAtLastWordBreak < i
+ ) {
+ // We have a valid word break point to use, and it's not the start of the current segment
+ currentChunk = codePointsInLogLine
+ .slice(
+ currentPosInLogLine,
+ currentPosInLogLine + numCodePointsAtLastWordBreak,
+ )
+ .join('');
+ numCodePointsInChunk = numCodePointsAtLastWordBreak;
+ } else {
+ // No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
+ // Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
+ if (
+ numCodePointsInChunk === 0 &&
+ charVisualWidth > viewportWidth
+ ) {
+ // Single character is wider than viewport, take it anyway
+ currentChunk = char;
+ numCodePointsInChunk = 1;
+ } else if (
+ numCodePointsInChunk === 0 &&
+ charVisualWidth <= viewportWidth
+ ) {
+ // This case should ideally be caught by the next iteration if the char fits.
+ // If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
+ // then numCodePointsInChunk would not be 0.
+ // This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
+ // If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
+ }
+ }
+ break; // Break from inner loop to finalize this chunk
+ }
+
+ currentChunk += char;
+ currentChunkVisualWidth += charVisualWidth;
+ numCodePointsInChunk++;
+
+ // Check for word break opportunity (space)
+ if (char === ' ') {
+ lastWordBreakPoint = i; // Store code point index of the space
+ // Store the state *before* adding the space, if we decide to break here.
+ numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
+ }
+ }
+
+ // If the inner loop completed without breaking (i.e., remaining text fits)
+ // or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
+ if (
+ numCodePointsInChunk === 0 &&
+ currentPosInLogLine < codePointsInLogLine.length
+ ) {
+ // This can happen if the very first character considered for a new visual line is wider than the viewport.
+ // In this case, we take that single character.
+ const firstChar = codePointsInLogLine[currentPosInLogLine];
+ currentChunk = firstChar;
+ numCodePointsInChunk = 1; // Ensure we advance
+ }
+
+ // If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
+ // it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
+ if (
+ numCodePointsInChunk === 0 &&
+ currentPosInLogLine < codePointsInLogLine.length
+ ) {
+ // Force advance by one character to prevent infinite loop if something went wrong
+ currentChunk = codePointsInLogLine[currentPosInLogLine];
+ numCodePointsInChunk = 1;
+ }
+
+ logicalToVisualMap[logIndex].push([
+ visualLines.length,
+ currentPosInLogLine,
+ ]);
+ visualToLogicalMap.push([logIndex, currentPosInLogLine]);
+ 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;
+ currentPosInLogLine += numCodePointsInChunk;
+
+ // If the chunk processed did not consume the entire logical line,
+ // and the character immediately following the chunk is a space,
+ // advance past this space as it acted as a delimiter for word wrapping.
+ if (
+ logicalStartOfThisChunk + numCodePointsInChunk <
+ codePointsInLogLine.length &&
+ currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
+ codePointsInLogLine[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
+ ];
+ }
+ }
+ }
+ });
+
+ // If the entire logical text was empty, ensure there's one empty visual line.
+ if (
+ logicalLines.length === 0 ||
+ (logicalLines.length === 1 && logicalLines[0] === '')
+ ) {
+ if (visualLines.length === 0) {
+ visualLines.push('');
+ if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
+ logicalToVisualMap[0].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 {
+ visualLines,
+ visualCursor: currentVisualCursor,
+ logicalToVisualMap,
+ visualToLogicalMap,
+ };
+}
export function useTextBuffer({
initialText = '',
@@ -137,9 +353,7 @@ export function useTextBuffer({
const [cursorRow, setCursorRow] = useState<number>(initialCursorRow);
const [cursorCol, setCursorCol] = useState<number>(initialCursorCol);
- const [scrollRow, setScrollRow] = useState<number>(0);
- const [scrollCol, setScrollCol] = useState<number>(0);
- const [preferredCol, setPreferredCol] = useState<number | null>(null);
+ const [preferredCol, setPreferredCol] = useState<number | null>(null); // Visual preferred col
const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
@@ -148,7 +362,18 @@ export function useTextBuffer({
const [clipboard, setClipboard] = useState<string | null>(null);
const [selectionAnchor, setSelectionAnchor] = useState<
[number, number] | null
- >(null);
+ >(null); // Logical selection
+
+ // Visual state
+ const [visualLines, setVisualLines] = useState<string[]>(['']);
+ const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]);
+ const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
+ const [logicalToVisualMap, setLogicalToVisualMap] = useState<
+ Array<Array<[number, number]>>
+ >([]);
+ const [visualToLogicalMap, setVisualToLogicalMap] = useState<
+ Array<[number, number]>
+ >([]);
const currentLine = useCallback(
(r: number): string => lines[r] ?? '',
@@ -159,30 +384,33 @@ export function useTextBuffer({
[currentLine],
);
+ // Recalculate visual layout whenever logical lines or viewport width changes
useEffect(() => {
- const { height, width } = viewport;
- let newScrollRow = scrollRow;
- let newScrollCol = scrollCol;
-
- if (cursorRow < scrollRow) {
- newScrollRow = cursorRow;
- } else if (cursorRow >= scrollRow + height) {
- newScrollRow = cursorRow - height + 1;
- }
+ const layout = calculateVisualLayout(
+ lines,
+ [cursorRow, cursorCol],
+ viewport.width,
+ );
+ setVisualLines(layout.visualLines);
+ setVisualCursor(layout.visualCursor);
+ setLogicalToVisualMap(layout.logicalToVisualMap);
+ setVisualToLogicalMap(layout.visualToLogicalMap);
+ }, [lines, cursorRow, cursorCol, viewport.width]);
- if (cursorCol < scrollCol) {
- newScrollCol = cursorCol;
- } else if (cursorCol >= scrollCol + width) {
- newScrollCol = cursorCol - width + 1;
- }
+ // Update visual scroll (vertical)
+ useEffect(() => {
+ const { height } = viewport;
+ let newVisualScrollRow = visualScrollRow;
- if (newScrollRow !== scrollRow) {
- setScrollRow(newScrollRow);
+ if (visualCursor[0] < visualScrollRow) {
+ newVisualScrollRow = visualCursor[0];
+ } else if (visualCursor[0] >= visualScrollRow + height) {
+ newVisualScrollRow = visualCursor[0] - height + 1;
}
- if (newScrollCol !== scrollCol) {
- setScrollCol(newScrollCol);
+ if (newVisualScrollRow !== visualScrollRow) {
+ setVisualScrollRow(newVisualScrollRow);
}
- }, [cursorRow, cursorCol, scrollRow, scrollCol, viewport]);
+ }, [visualCursor, visualScrollRow, viewport]);
const pushUndo = useCallback(() => {
dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
@@ -210,8 +438,6 @@ export function useTextBuffer({
const text = lines.join('\n');
- // TODO(jacobr): stop using useEffect for this case. This may require a
- // refactor of App.tsx and InputPrompt.tsx to simplify where onChange is used.
useEffect(() => {
if (onChange) {
onChange(text);
@@ -255,16 +481,21 @@ export function useTextBuffer({
newLines[cursorRow] = before + parts[0];
- if (parts.length > 2) {
- const middle = parts.slice(1, -1);
- newLines.splice(cursorRow + 1, 0, ...middle);
+ if (parts.length > 1) {
+ // Adjusted condition for inserting multiple lines
+ const remainingParts = parts.slice(1);
+ const lastPartOriginal = remainingParts.pop() ?? '';
+ newLines.splice(cursorRow + 1, 0, ...remainingParts);
+ newLines.splice(
+ cursorRow + parts.length - 1,
+ 0,
+ lastPartOriginal + after,
+ );
+ setCursorRow((prev) => prev + parts.length - 1);
+ setCursorCol(cpLen(lastPartOriginal));
+ } else {
+ setCursorCol(cpLen(before) + cpLen(parts[0]));
}
-
- const lastPart = parts[parts.length - 1]!;
- newLines.splice(cursorRow + (parts.length - 1), 0, lastPart + after);
-
- setCursorRow((prev) => prev + parts.length - 1);
- setCursorCol(cpLen(lastPart));
return newLines;
});
setPreferredCol(null);
@@ -290,7 +521,7 @@ export function useTextBuffer({
cpSlice(lineContent, cursorCol);
return newLines;
});
- setCursorCol((prev) => prev + ch.length);
+ setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length
setPreferredCol(null);
},
[pushUndo, cursorRow, cursorCol, currentLine, insertStr, setPreferredCol],
@@ -379,15 +610,15 @@ export function useTextBuffer({
]);
const setText = useCallback(
- (text: string): void => {
- dbg('setText', { text });
+ (newText: string): void => {
+ dbg('setText', { text: newText });
pushUndo();
- const newContentLines = text.replace(/\r\n?/g, '\n').split('\n');
+ const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
setLines(newContentLines.length === 0 ? [''] : newContentLines);
- setCursorRow(newContentLines.length - 1);
- setCursorCol(cpLen(newContentLines[newContentLines.length - 1] ?? ''));
- setScrollRow(0);
- setScrollCol(0);
+ // Set logical cursor to the end of the new text
+ const lastNewLineIndex = newContentLines.length - 1;
+ setCursorRow(lastNewLineIndex);
+ setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
setPreferredCol(null);
},
[pushUndo, setPreferredCol],
@@ -399,22 +630,30 @@ export function useTextBuffer({
startCol: number,
endRow: number,
endCol: number,
- text: string,
+ replacementText: string,
): boolean => {
if (
startRow > endRow ||
(startRow === endRow && startCol > endCol) ||
startRow < 0 ||
startCol < 0 ||
- endRow >= lines.length
+ endRow >= lines.length ||
+ (endRow < lines.length && endCol > currentLineLen(endRow))
) {
- console.error('Invalid range provided to replaceRange');
+ console.error('Invalid range provided to replaceRange', {
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ linesLength: lines.length,
+ endRowLineLength: currentLineLen(endRow),
+ });
return false;
}
dbg('replaceRange', {
start: [startRow, startCol],
end: [endRow, endCol],
- text,
+ text: replacementText,
});
pushUndo();
@@ -423,36 +662,74 @@ export function useTextBuffer({
const prefix = cpSlice(currentLine(startRow), 0, sCol);
const suffix = cpSlice(currentLine(endRow), eCol);
+ const normalisedReplacement = replacementText
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n');
+ const replacementParts = normalisedReplacement.split('\n');
setLines((prevLines) => {
const newLines = [...prevLines];
+ // Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
if (startRow < endRow) {
newLines.splice(startRow + 1, endRow - startRow);
}
- newLines[startRow] = prefix + suffix;
- // Now insert text at this new effective cursor position
- const tempCursorRow = startRow;
- const tempCursorCol = sCol;
- const normalised = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
- const parts = normalised.split('\n');
- const currentLineContent = newLines[tempCursorRow];
- const beforeInsert = cpSlice(currentLineContent, 0, tempCursorCol);
- const afterInsert = cpSlice(currentLineContent, tempCursorCol);
+ // Construct the new content for the startRow
+ newLines[startRow] = prefix + replacementParts[0];
- newLines[tempCursorRow] = beforeInsert + parts[0];
- if (parts.length > 2) {
- newLines.splice(tempCursorRow + 1, 0, ...parts.slice(1, -1));
- }
- const lastPart = parts[parts.length - 1]!;
- newLines.splice(
- tempCursorRow + (parts.length - 1),
- 0,
- lastPart + afterInsert,
- );
+ // If replacementText has multiple lines, insert them
+ if (replacementParts.length > 1) {
+ const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
+ // Insert middle parts (if any)
+ if (replacementParts.length > 1) {
+ // parts[0] is already used
+ newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
+ }
+
+ // The line where the last part of the replacement will go
+ const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
+ // If the last part is not the first part (multi-line replacement)
+ if (
+ targetRowForLastPart > startRow ||
+ (replacementParts.length === 1 && lastReplacementPart !== '')
+ ) {
+ // If the target row for the last part doesn't exist (because it's a new line created by replacement)
+ // ensure it's created before trying to append suffix.
+ // This case should be handled by splice if replacementParts.length > 1
+ // For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
+ // Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
+ if (
+ newLines[targetRowForLastPart] === undefined &&
+ targetRowForLastPart === startRow + 1 &&
+ replacementParts.length === 1
+ ) {
+ // This implies a single line replacement that became two lines.
+ // e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
+ // Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
+ newLines.splice(
+ targetRowForLastPart,
+ 0,
+ lastReplacementPart + suffix,
+ );
+ } else {
+ newLines[targetRowForLastPart] =
+ (newLines[targetRowForLastPart] || '') +
+ lastReplacementPart +
+ suffix;
+ }
+ } else {
+ // Single line in replacementParts, but it was the only part
+ newLines[startRow] += suffix;
+ }
- setCursorRow(tempCursorRow + parts.length - 1);
- setCursorCol(cpLen(lastPart));
+ setCursorRow(targetRowForLastPart);
+ setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
+ } else {
+ // Single line replacement (replacementParts has only one item)
+ newLines[startRow] += suffix;
+ setCursorRow(startRow);
+ setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
+ }
return newLines;
});
@@ -515,7 +792,6 @@ export function useTextBuffer({
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
return newLines;
});
- // Cursor col does not change
setPreferredCol(null);
}, [
pushUndo,
@@ -575,105 +851,192 @@ export function useTextBuffer({
const move = useCallback(
(dir: Direction): void => {
- const before = [cursorRow, cursorCol];
- let newCursorRow = cursorRow;
- let newCursorCol = cursorCol;
+ let newVisualRow = visualCursor[0];
+ let newVisualCol = visualCursor[1];
let newPreferredCol = preferredCol;
+ const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
+
switch (dir) {
case 'left':
newPreferredCol = null;
- if (newCursorCol > 0) newCursorCol--;
- else if (newCursorRow > 0) {
- newCursorRow--;
- newCursorCol = currentLineLen(newCursorRow);
+ if (newVisualCol > 0) {
+ newVisualCol--;
+ } else if (newVisualRow > 0) {
+ newVisualRow--;
+ newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
}
break;
case 'right':
newPreferredCol = null;
- if (newCursorCol < currentLineLen(newCursorRow)) newCursorCol++;
- else if (newCursorRow < lines.length - 1) {
- newCursorRow++;
- newCursorCol = 0;
+ if (newVisualCol < currentVisLineLen) {
+ newVisualCol++;
+ } else if (newVisualRow < visualLines.length - 1) {
+ newVisualRow++;
+ newVisualCol = 0;
}
break;
case 'up':
- if (newCursorRow > 0) {
- if (newPreferredCol === null) newPreferredCol = newCursorCol;
- newCursorRow--;
- newCursorCol = clamp(
+ if (newVisualRow > 0) {
+ if (newPreferredCol === null) newPreferredCol = newVisualCol;
+ newVisualRow--;
+ newVisualCol = clamp(
newPreferredCol,
0,
- currentLineLen(newCursorRow),
+ cpLen(visualLines[newVisualRow] ?? ''),
);
}
break;
case 'down':
- if (newCursorRow < lines.length - 1) {
- if (newPreferredCol === null) newPreferredCol = newCursorCol;
- newCursorRow++;
- newCursorCol = clamp(
+ if (newVisualRow < visualLines.length - 1) {
+ if (newPreferredCol === null) newPreferredCol = newVisualCol;
+ newVisualRow++;
+ newVisualCol = clamp(
newPreferredCol,
0,
- currentLineLen(newCursorRow),
+ cpLen(visualLines[newVisualRow] ?? ''),
);
}
break;
case 'home':
newPreferredCol = null;
- newCursorCol = 0;
+ newVisualCol = 0;
break;
case 'end':
newPreferredCol = null;
- newCursorCol = currentLineLen(newCursorRow);
+ newVisualCol = currentVisLineLen;
break;
+ // wordLeft and wordRight might need more sophisticated visual handling
+ // For now, they operate on the logical line derived from the visual cursor
case 'wordLeft': {
newPreferredCol = null;
- const slice = cpSlice(
- currentLine(newCursorRow),
- 0,
- newCursorCol,
- ).replace(/[\s,.;!?]+$/, '');
+ if (
+ visualToLogicalMap.length === 0 ||
+ logicalToVisualMap.length === 0
+ )
+ break;
+ const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
+ 0, 0,
+ ];
+ const currentLogCol = logColInitial + newVisualCol;
+ const lineText = lines[logRow];
+ const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(
+ /[\s,.;!?]+$/,
+ '',
+ );
let lastIdx = 0;
const regex = /[\s,.;!?]+/g;
let m;
- while ((m = regex.exec(slice)) != null) lastIdx = m.index;
- newCursorCol = lastIdx === 0 ? 0 : cpLen(slice.slice(0, lastIdx)) + 1;
+ while ((m = regex.exec(sliceToCursor)) != null) lastIdx = m.index;
+ const newLogicalCol =
+ lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;
+
+ // Map newLogicalCol back to visual
+ const targetLogicalMapEntries = logicalToVisualMap[logRow];
+ if (!targetLogicalMapEntries) break;
+ for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
+ const [visRow, logStartCol] = targetLogicalMapEntries[i];
+ if (newLogicalCol >= logStartCol) {
+ newVisualRow = visRow;
+ newVisualCol = newLogicalCol - logStartCol;
+ break;
+ }
+ }
break;
}
case 'wordRight': {
newPreferredCol = null;
- const l = currentLine(newCursorRow);
+ if (
+ visualToLogicalMap.length === 0 ||
+ logicalToVisualMap.length === 0
+ )
+ break;
+ const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
+ 0, 0,
+ ];
+ const currentLogCol = logColInitial + newVisualCol;
+ const lineText = lines[logRow];
const regex = /[\s,.;!?]+/g;
let moved = false;
let m;
- while ((m = regex.exec(l)) != null) {
- const cpIdx = cpLen(l.slice(0, m.index));
- if (cpIdx > newCursorCol) {
- newCursorCol = cpIdx;
+ let newLogicalCol = currentLineLen(logRow); // Default to end of logical line
+
+ while ((m = regex.exec(lineText)) != null) {
+ const cpIdx = cpLen(lineText.slice(0, m.index));
+ if (cpIdx > currentLogCol) {
+ newLogicalCol = cpIdx;
moved = true;
break;
}
}
- if (!moved) newCursorCol = currentLineLen(newCursorRow);
+ if (!moved && currentLogCol < currentLineLen(logRow)) {
+ // If no word break found after cursor, move to end
+ newLogicalCol = currentLineLen(logRow);
+ }
+
+ // Map newLogicalCol back to visual
+ const targetLogicalMapEntries = logicalToVisualMap[logRow];
+ if (!targetLogicalMapEntries) break;
+ for (let i = 0; i < targetLogicalMapEntries.length; i++) {
+ const [visRow, logStartCol] = targetLogicalMapEntries[i];
+ const nextLogStartCol =
+ i + 1 < targetLogicalMapEntries.length
+ ? targetLogicalMapEntries[i + 1][1]
+ : Infinity;
+ if (
+ newLogicalCol >= logStartCol &&
+ newLogicalCol < nextLogStartCol
+ ) {
+ newVisualRow = visRow;
+ newVisualCol = newLogicalCol - logStartCol;
+ break;
+ }
+ if (
+ newLogicalCol === logStartCol &&
+ i === targetLogicalMapEntries.length - 1 &&
+ cpLen(visualLines[visRow] ?? '') === 0
+ ) {
+ // Special case: moving to an empty visual line at the end of a logical line
+ newVisualRow = visRow;
+ newVisualCol = 0;
+ break;
+ }
+ }
break;
}
- default: // Add default case to satisfy linter
+ default:
break;
}
- setCursorRow(newCursorRow);
- setCursorCol(newCursorCol);
+
+ setVisualCursor([newVisualRow, newVisualCol]);
setPreferredCol(newPreferredCol);
- dbg('move', { dir, before, after: [newCursorRow, newCursorCol] });
+
+ // Update logical cursor based on new visual cursor
+ if (visualToLogicalMap[newVisualRow]) {
+ const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
+ setCursorRow(logRow);
+ setCursorCol(
+ clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)),
+ );
+ }
+
+ dbg('move', {
+ dir,
+ visualBefore: visualCursor,
+ visualAfter: [newVisualRow, newVisualCol],
+ logicalAfter: [cursorRow, cursorCol],
+ });
},
[
- cursorRow,
- cursorCol,
+ visualCursor,
+ visualLines,
preferredCol,
lines,
currentLineLen,
- currentLine,
- setPreferredCol,
+ visualToLogicalMap,
+ logicalToVisualMap,
+ cursorCol,
+ cursorRow,
],
);
@@ -702,14 +1065,7 @@ export function useTextBuffer({
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
-
- const newContentLines = newText.split('\n');
- setLines(newContentLines.length === 0 ? [''] : newContentLines);
- setCursorRow(newContentLines.length - 1);
- setCursorCol(cpLen(newContentLines[newContentLines.length - 1] ?? ''));
- setScrollRow(0);
- setScrollCol(0);
- setPreferredCol(null);
+ setText(newText);
} catch (err) {
console.error('[useTextBuffer] external editor error', err);
// TODO(jacobr): potentially revert or handle error state.
@@ -727,14 +1083,20 @@ export function useTextBuffer({
}
}
},
- [text, pushUndo, stdin, setRawMode, setPreferredCol],
+ [text, pushUndo, stdin, setRawMode, setText],
);
const handleInput = useCallback(
(input: string | undefined, key: Record<string, boolean>): boolean => {
- dbg('handleInput', { input, key, cursor: [cursorRow, cursorCol] });
- const beforeText = text; // For change detection
- const beforeCursor = [cursorRow, cursorCol];
+ dbg('handleInput', {
+ input,
+ key,
+ cursor: [cursorRow, cursorCol],
+ visualCursor,
+ });
+ const beforeText = text;
+ const beforeLogicalCursor = [cursorRow, cursorCol];
+ const beforeVisualCursor = [...visualCursor];
if (key['escape']) return false;
@@ -768,11 +1130,18 @@ export function useTextBuffer({
else if (input && !key['ctrl'] && !key['meta']) insert(input);
const textChanged = text !== beforeText;
+ // After operations, visualCursor might not be immediately updated if the change
+ // was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
+ // So, for return value, we check logical cursor change.
const cursorChanged =
- cursorRow !== beforeCursor[0] || cursorCol !== beforeCursor[1];
+ cursorRow !== beforeLogicalCursor[0] ||
+ cursorCol !== beforeLogicalCursor[1] ||
+ visualCursor[0] !== beforeVisualCursor[0] ||
+ visualCursor[1] !== beforeVisualCursor[1];
dbg('handleInput:after', {
cursor: [cursorRow, cursorCol],
+ visualCursor,
text,
});
return textChanged || cursorChanged;
@@ -781,6 +1150,7 @@ export function useTextBuffer({
text,
cursorRow,
cursorCol,
+ visualCursor,
newline,
move,
deleteWordLeft,
@@ -791,22 +1161,23 @@ export function useTextBuffer({
],
);
- const visibleLines = useMemo(
- () => lines.slice(scrollRow, scrollRow + viewport.height),
- [lines, scrollRow, viewport.height],
+ const renderedVisualLines = useMemo(
+ () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
+ [visualLines, visualScrollRow, viewport.height],
);
- // Exposed API of the hook
const returnValue: TextBuffer = {
- // State
lines,
text,
cursor: [cursorRow, cursorCol],
- scroll: [scrollRow, scrollCol],
preferredCol,
selectionAnchor,
- // Actions
+ allVisualLines: visualLines,
+ viewportVisualLines: renderedVisualLines,
+ visualCursor,
+ visualScrollRow,
+
setText,
insert,
newline,
@@ -823,7 +1194,6 @@ export function useTextBuffer({
handleInput,
openInExternalEditor,
- // Selection & Clipboard (simplified for now)
copy: useCallback(() => {
if (!selectionAnchor) return null;
const [ar, ac] = selectionAnchor;
@@ -843,34 +1213,38 @@ export function useTextBuffer({
}
setClipboard(selectedTextVal);
return selectedTextVal;
- }, [selectionAnchor, cursorRow, cursorCol, currentLine]),
+ }, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
paste: useCallback(() => {
if (clipboard === null) return false;
return insertStr(clipboard);
}, [clipboard, insertStr]),
startSelection: useCallback(
() => setSelectionAnchor([cursorRow, cursorCol]),
- [cursorRow, cursorCol],
+ [cursorRow, cursorCol, setSelectionAnchor],
),
- visibleLines,
};
return returnValue;
}
export interface TextBuffer {
// State
- lines: string[];
+ lines: string[]; // Logical lines
text: string;
- cursor: [number, number];
- scroll: [number, number];
+ cursor: [number, number]; // Logical cursor [row, col]
/**
* When the user moves the caret vertically we try to keep their original
* horizontal column even when passing through shorter lines. We remember
* that *preferred* column in this field while the user is still travelling
* vertically. Any explicit horizontal movement resets the preference.
*/
- preferredCol: number | null;
- selectionAnchor: [number, number] | null;
+ preferredCol: number | null; // Preferred visual column
+ selectionAnchor: [number, number] | null; // Logical selection anchor
+
+ // Visual state (handles wrapping)
+ allVisualLines: string[]; // All visual lines for the current text and viewport width.
+ viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
+ visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
+ visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
// Actions
@@ -956,7 +1330,4 @@ export interface TextBuffer {
copy: () => string | null;
paste: () => boolean;
startSelection: () => void;
-
- // For rendering
- visibleLines: string[];
}