Support auto wrapping of in the multiline editor. (#383)

This commit is contained in:
Jacob Richman 2025-05-16 11:58:37 -07:00 committed by GitHub
parent 968e09f0b5
commit c692a0c583
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1099 additions and 173 deletions

20
.vscode/launch.json vendored
View File

@ -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>/**"]
}
]
}

1
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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>

View File

@ -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.
});

View File

@ -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 surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
function toCodePoints(str: string): string[] {
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 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 codepoint indices and rejoin.
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;
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<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[];
}