Support auto wrapping of in the multiline editor. (#383)
This commit is contained in:
parent
968e09f0b5
commit
c692a0c583
|
@ -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>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
});
|
|
@ -9,6 +9,7 @@ import fs from 'fs';
|
|||
import os from 'os';
|
||||
import pathMod from 'path';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
|
@ -43,17 +44,17 @@ function clamp(v: number, min: number, max: number): number {
|
|||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
function toCodePoints(str: string): string[] {
|
||||
export function toCodePoints(str: string): string[] {
|
||||
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
||||
// surrogate pairs correctly.
|
||||
return Array.from(str);
|
||||
}
|
||||
|
||||
function cpLen(str: string): number {
|
||||
export function cpLen(str: string): number {
|
||||
return toCodePoints(str).length;
|
||||
}
|
||||
|
||||
function cpSlice(str: string, start: number, end?: number): string {
|
||||
export function cpSlice(str: string, start: number, end?: number): string {
|
||||
// Slice by code‑point indices and re‑join.
|
||||
const arr = toCodePoints(str).slice(start, end);
|
||||
return arr.join('');
|
||||
|
@ -117,6 +118,221 @@ function calculateInitialCursorPosition(
|
|||
}
|
||||
return [0, 0]; // Default for empty text
|
||||
}
|
||||
// Helper to calculate visual lines and map cursor positions
|
||||
function calculateVisualLayout(
|
||||
logicalLines: string[],
|
||||
logicalCursor: [number, number],
|
||||
viewportWidth: number,
|
||||
): {
|
||||
visualLines: string[];
|
||||
visualCursor: [number, number];
|
||||
logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||
visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
|
||||
} {
|
||||
const visualLines: string[] = [];
|
||||
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
||||
const visualToLogicalMap: Array<[number, number]> = [];
|
||||
let currentVisualCursor: [number, number] = [0, 0];
|
||||
|
||||
logicalLines.forEach((logLine, logIndex) => {
|
||||
logicalToVisualMap[logIndex] = [];
|
||||
if (logLine.length === 0) {
|
||||
// Handle empty logical line
|
||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||
visualToLogicalMap.push([logIndex, 0]);
|
||||
visualLines.push('');
|
||||
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
|
||||
currentVisualCursor = [visualLines.length - 1, 0];
|
||||
}
|
||||
} else {
|
||||
// Non-empty logical line
|
||||
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
||||
const codePointsInLogLine = toCodePoints(logLine);
|
||||
|
||||
while (currentPosInLogLine < codePointsInLogLine.length) {
|
||||
let currentChunk = '';
|
||||
let currentChunkVisualWidth = 0;
|
||||
let numCodePointsInChunk = 0;
|
||||
let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
|
||||
let numCodePointsAtLastWordBreak = 0;
|
||||
|
||||
// Iterate through code points to build the current visual line (chunk)
|
||||
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
|
||||
const char = codePointsInLogLine[i];
|
||||
const charVisualWidth = stringWidth(char);
|
||||
|
||||
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
|
||||
// Character would exceed viewport width
|
||||
if (
|
||||
lastWordBreakPoint !== -1 &&
|
||||
numCodePointsAtLastWordBreak > 0 &&
|
||||
currentPosInLogLine + numCodePointsAtLastWordBreak < i
|
||||
) {
|
||||
// We have a valid word break point to use, and it's not the start of the current segment
|
||||
currentChunk = codePointsInLogLine
|
||||
.slice(
|
||||
currentPosInLogLine,
|
||||
currentPosInLogLine + numCodePointsAtLastWordBreak,
|
||||
)
|
||||
.join('');
|
||||
numCodePointsInChunk = numCodePointsAtLastWordBreak;
|
||||
} else {
|
||||
// No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
|
||||
// Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
charVisualWidth > viewportWidth
|
||||
) {
|
||||
// Single character is wider than viewport, take it anyway
|
||||
currentChunk = char;
|
||||
numCodePointsInChunk = 1;
|
||||
} else if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
charVisualWidth <= viewportWidth
|
||||
) {
|
||||
// This case should ideally be caught by the next iteration if the char fits.
|
||||
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
|
||||
// then numCodePointsInChunk would not be 0.
|
||||
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
|
||||
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
|
||||
}
|
||||
}
|
||||
break; // Break from inner loop to finalize this chunk
|
||||
}
|
||||
|
||||
currentChunk += char;
|
||||
currentChunkVisualWidth += charVisualWidth;
|
||||
numCodePointsInChunk++;
|
||||
|
||||
// Check for word break opportunity (space)
|
||||
if (char === ' ') {
|
||||
lastWordBreakPoint = i; // Store code point index of the space
|
||||
// Store the state *before* adding the space, if we decide to break here.
|
||||
numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
|
||||
}
|
||||
}
|
||||
|
||||
// If the inner loop completed without breaking (i.e., remaining text fits)
|
||||
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// This can happen if the very first character considered for a new visual line is wider than the viewport.
|
||||
// In this case, we take that single character.
|
||||
const firstChar = codePointsInLogLine[currentPosInLogLine];
|
||||
currentChunk = firstChar;
|
||||
numCodePointsInChunk = 1; // Ensure we advance
|
||||
}
|
||||
|
||||
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
|
||||
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// Force advance by one character to prevent infinite loop if something went wrong
|
||||
currentChunk = codePointsInLogLine[currentPosInLogLine];
|
||||
numCodePointsInChunk = 1;
|
||||
}
|
||||
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLines.length,
|
||||
currentPosInLogLine,
|
||||
]);
|
||||
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
||||
visualLines.push(currentChunk);
|
||||
|
||||
// Cursor mapping logic
|
||||
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
|
||||
if (logIndex === logicalCursor[0]) {
|
||||
const cursorLogCol = logicalCursor[1]; // This is a code point index
|
||||
if (
|
||||
cursorLogCol >= currentPosInLogLine &&
|
||||
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
|
||||
) {
|
||||
currentVisualCursor = [
|
||||
visualLines.length - 1,
|
||||
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
|
||||
];
|
||||
} else if (
|
||||
cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
|
||||
numCodePointsInChunk > 0
|
||||
) {
|
||||
// Cursor is exactly at the end of this non-empty chunk
|
||||
currentVisualCursor = [
|
||||
visualLines.length - 1,
|
||||
numCodePointsInChunk,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||
currentPosInLogLine += numCodePointsInChunk;
|
||||
|
||||
// If the chunk processed did not consume the entire logical line,
|
||||
// and the character immediately following the chunk is a space,
|
||||
// advance past this space as it acted as a delimiter for word wrapping.
|
||||
if (
|
||||
logicalStartOfThisChunk + numCodePointsInChunk <
|
||||
codePointsInLogLine.length &&
|
||||
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
|
||||
codePointsInLogLine[currentPosInLogLine] === ' '
|
||||
) {
|
||||
currentPosInLogLine++;
|
||||
}
|
||||
}
|
||||
// After all chunks of a non-empty logical line are processed,
|
||||
// if the cursor is at the very end of this logical line, update visual cursor.
|
||||
if (
|
||||
logIndex === logicalCursor[0] &&
|
||||
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
|
||||
) {
|
||||
const lastVisualLineIdx = visualLines.length - 1;
|
||||
if (
|
||||
lastVisualLineIdx >= 0 &&
|
||||
visualLines[lastVisualLineIdx] !== undefined
|
||||
) {
|
||||
currentVisualCursor = [
|
||||
lastVisualLineIdx,
|
||||
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If the entire logical text was empty, ensure there's one empty visual line.
|
||||
if (
|
||||
logicalLines.length === 0 ||
|
||||
(logicalLines.length === 1 && logicalLines[0] === '')
|
||||
) {
|
||||
if (visualLines.length === 0) {
|
||||
visualLines.push('');
|
||||
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
|
||||
logicalToVisualMap[0].push([0, 0]);
|
||||
visualToLogicalMap.push([0, 0]);
|
||||
}
|
||||
currentVisualCursor = [0, 0];
|
||||
}
|
||||
// Handle cursor at the very end of the text (after all processing)
|
||||
// This case might be covered by the loop end condition now, but kept for safety.
|
||||
else if (
|
||||
logicalCursor[0] === logicalLines.length - 1 &&
|
||||
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
|
||||
visualLines.length > 0
|
||||
) {
|
||||
const lastVisLineIdx = visualLines.length - 1;
|
||||
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
|
||||
}
|
||||
|
||||
return {
|
||||
visualLines,
|
||||
visualCursor: currentVisualCursor,
|
||||
logicalToVisualMap,
|
||||
visualToLogicalMap,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTextBuffer({
|
||||
initialText = '',
|
||||
|
@ -137,9 +353,7 @@ export function useTextBuffer({
|
|||
|
||||
const [cursorRow, setCursorRow] = useState<number>(initialCursorRow);
|
||||
const [cursorCol, setCursorCol] = useState<number>(initialCursorCol);
|
||||
const [scrollRow, setScrollRow] = useState<number>(0);
|
||||
const [scrollCol, setScrollCol] = useState<number>(0);
|
||||
const [preferredCol, setPreferredCol] = useState<number | null>(null);
|
||||
const [preferredCol, setPreferredCol] = useState<number | null>(null); // Visual preferred col
|
||||
|
||||
const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
|
||||
|
@ -148,7 +362,18 @@ export function useTextBuffer({
|
|||
const [clipboard, setClipboard] = useState<string | null>(null);
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<
|
||||
[number, number] | null
|
||||
>(null);
|
||||
>(null); // Logical selection
|
||||
|
||||
// Visual state
|
||||
const [visualLines, setVisualLines] = useState<string[]>(['']);
|
||||
const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]);
|
||||
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
||||
const [logicalToVisualMap, setLogicalToVisualMap] = useState<
|
||||
Array<Array<[number, number]>>
|
||||
>([]);
|
||||
const [visualToLogicalMap, setVisualToLogicalMap] = useState<
|
||||
Array<[number, number]>
|
||||
>([]);
|
||||
|
||||
const currentLine = useCallback(
|
||||
(r: number): string => lines[r] ?? '',
|
||||
|
@ -159,30 +384,33 @@ export function useTextBuffer({
|
|||
[currentLine],
|
||||
);
|
||||
|
||||
// Recalculate visual layout whenever logical lines or viewport width changes
|
||||
useEffect(() => {
|
||||
const { height, width } = viewport;
|
||||
let newScrollRow = scrollRow;
|
||||
let newScrollCol = scrollCol;
|
||||
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[];
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue