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": ["/**"], "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": ["/**"] } ] } 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 ( {buffer.text.length === 0 && placeholder ? ( {placeholder} ) : ( - 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 {display}; + return {display}; }) )} 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>; // 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> = []; + 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(initialCursorRow); const [cursorCol, setCursorCol] = useState(initialCursorCol); - const [scrollRow, setScrollRow] = useState(0); - const [scrollCol, setScrollCol] = useState(0); - const [preferredCol, setPreferredCol] = useState(null); + const [preferredCol, setPreferredCol] = useState(null); // Visual preferred col const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); @@ -148,7 +362,18 @@ export function useTextBuffer({ const [clipboard, setClipboard] = useState(null); const [selectionAnchor, setSelectionAnchor] = useState< [number, number] | null - >(null); + >(null); // Logical selection + + // Visual state + const [visualLines, setVisualLines] = useState(['']); + const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]); + const [visualScrollRow, setVisualScrollRow] = useState(0); + const [logicalToVisualMap, setLogicalToVisualMap] = useState< + Array> + >([]); + 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; + 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 (cursorRow < scrollRow) { - newScrollRow = cursorRow; - } else if (cursorRow >= scrollRow + height) { - newScrollRow = cursorRow - height + 1; - } + // Update visual scroll (vertical) + useEffect(() => { + const { height } = viewport; + let newVisualScrollRow = visualScrollRow; - if (cursorCol < scrollCol) { - newScrollCol = cursorCol; - } else if (cursorCol >= scrollCol + width) { - newScrollCol = cursorCol - width + 1; + if (visualCursor[0] < visualScrollRow) { + newVisualScrollRow = visualCursor[0]; + } else if (visualCursor[0] >= visualScrollRow + height) { + newVisualScrollRow = visualCursor[0] - height + 1; } - - if (newScrollRow !== scrollRow) { - setScrollRow(newScrollRow); + if (newVisualScrollRow !== visualScrollRow) { + setVisualScrollRow(newVisualScrollRow); } - if (newScrollCol !== scrollCol) { - setScrollCol(newScrollCol); - } - }, [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)); + // 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(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])); } - const lastPart = parts[parts.length - 1]!; - newLines.splice( - tempCursorRow + (parts.length - 1), - 0, - lastPart + afterInsert, - ); - - setCursorRow(tempCursorRow + parts.length - 1); - setCursorCol(cpLen(lastPart)); 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): 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[]; }