mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(cli) Scrollbar for input prompt (#21992)
This commit is contained in:
@@ -220,6 +220,7 @@ describe('InputPrompt', () => {
|
||||
col = newText.length;
|
||||
}
|
||||
mockBuffer.cursor = [0, col];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
@@ -2273,6 +2274,7 @@ describe('InputPrompt', () => {
|
||||
async ({ text, visualCursor }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
@@ -2322,6 +2324,7 @@ describe('InputPrompt', () => {
|
||||
async ({ text, visualCursor, visualToLogicalMap }) => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
|
||||
@@ -2342,6 +2345,7 @@ describe('InputPrompt', () => {
|
||||
const text = 'first line\n\nthird line';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
@@ -2361,11 +2365,98 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling large inputs', () => {
|
||||
it('should correctly render scrolling down and up for large inputs', async () => {
|
||||
const lines = Array.from({ length: 50 }).map((_, i) => `testline ${i}`);
|
||||
|
||||
// Since we need to test how the React component tree responds to TextBuffer state changes,
|
||||
// we must provide a fake TextBuffer implementation that triggers re-renders like the real one.
|
||||
|
||||
const TestWrapper = () => {
|
||||
const [bufferState, setBufferState] = useState({
|
||||
text: lines.join('\n'),
|
||||
lines,
|
||||
allVisualLines: lines,
|
||||
viewportVisualLines: lines.slice(0, 10),
|
||||
visualToLogicalMap: lines.map((_, i) => [i, 0]),
|
||||
visualCursor: [0, 0] as [number, number],
|
||||
visualScrollRow: 0,
|
||||
viewportHeight: 10,
|
||||
});
|
||||
|
||||
const fakeBuffer = {
|
||||
...mockBuffer,
|
||||
...bufferState,
|
||||
handleInput: vi.fn().mockImplementation((key) => {
|
||||
let newRow = bufferState.visualCursor[0];
|
||||
let newScroll = bufferState.visualScrollRow;
|
||||
if (key.name === 'down') {
|
||||
newRow = Math.min(49, newRow + 1);
|
||||
if (newRow >= newScroll + 10) newScroll++;
|
||||
} else if (key.name === 'up') {
|
||||
newRow = Math.max(0, newRow - 1);
|
||||
if (newRow < newScroll) newScroll--;
|
||||
}
|
||||
setBufferState({
|
||||
...bufferState,
|
||||
visualCursor: [newRow, 0],
|
||||
visualScrollRow: newScroll,
|
||||
viewportVisualLines: lines.slice(newScroll, newScroll + 10),
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
return <InputPrompt {...props} buffer={fakeBuffer} />;
|
||||
};
|
||||
|
||||
const { stdout, unmount, stdin } = await renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial render
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 0');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 49');
|
||||
});
|
||||
|
||||
// Move cursor to bottom
|
||||
for (let i = 0; i < 49; i++) {
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Arrow Down
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 49');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 0');
|
||||
});
|
||||
|
||||
// Move cursor back to top
|
||||
for (let i = 0; i < 49; i++) {
|
||||
act(() => {
|
||||
stdin.write('\x1b[A'); // Arrow Up
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('testline 0');
|
||||
expect(stdout.lastFrame()).not.toContain('testline 49');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline rendering', () => {
|
||||
it('should correctly render multiline input including blank lines', async () => {
|
||||
const text = 'hello\n\nworld';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
|
||||
@@ -3592,7 +3683,9 @@ describe('InputPrompt', () => {
|
||||
async ({ relX, relY, mouseCol, mouseRow }) => {
|
||||
props.buffer.text = 'hello world\nsecond line';
|
||||
props.buffer.lines = ['hello world', 'second line'];
|
||||
props.buffer.allVisualLines = ['hello world', 'second line'];
|
||||
props.buffer.viewportVisualLines = ['hello world', 'second line'];
|
||||
props.buffer.viewportHeight = 10;
|
||||
props.buffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -3630,6 +3723,7 @@ describe('InputPrompt', () => {
|
||||
it('should unfocus embedded shell on click', async () => {
|
||||
props.buffer.text = 'hello';
|
||||
props.buffer.lines = ['hello'];
|
||||
props.buffer.allVisualLines = ['hello'];
|
||||
props.buffer.viewportVisualLines = ['hello'];
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.isEmbeddedShellFocused = true;
|
||||
@@ -3671,6 +3765,7 @@ describe('InputPrompt', () => {
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
viewportHeight: 10,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
@@ -3759,6 +3854,7 @@ describe('InputPrompt', () => {
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
viewportHeight: 10,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
@@ -3830,7 +3926,9 @@ describe('InputPrompt', () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
props.buffer.text = 'hello world';
|
||||
props.buffer.lines = ['hello world'];
|
||||
props.buffer.allVisualLines = ['hello world'];
|
||||
props.buffer.viewportVisualLines = ['hello world'];
|
||||
props.buffer.viewportHeight = 10;
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.buffer.visualCursor = [0, 11];
|
||||
props.buffer.visualScrollRow = 0;
|
||||
@@ -4137,6 +4235,7 @@ describe('InputPrompt', () => {
|
||||
const text = 'hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
|
||||
@@ -4167,6 +4266,7 @@ describe('InputPrompt', () => {
|
||||
const text = '👍hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
|
||||
@@ -4196,6 +4296,7 @@ describe('InputPrompt', () => {
|
||||
const text = '😀😀😀';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
|
||||
@@ -4225,7 +4326,9 @@ describe('InputPrompt', () => {
|
||||
const lines = ['😀😀', 'hello 😀', 'world'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.allVisualLines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.viewportHeight = 10;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -4262,7 +4365,9 @@ describe('InputPrompt', () => {
|
||||
const lines = ['first line', 'second line', 'third line'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.allVisualLines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.viewportHeight = 10;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
@@ -4303,6 +4408,7 @@ describe('InputPrompt', () => {
|
||||
it('should report cursor position 0 when input is empty and placeholder is shown', async () => {
|
||||
mockBuffer.text = '';
|
||||
mockBuffer.lines = [''];
|
||||
mockBuffer.allVisualLines = [''];
|
||||
mockBuffer.viewportVisualLines = [''];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 0];
|
||||
@@ -4335,6 +4441,7 @@ describe('InputPrompt', () => {
|
||||
const applyVisualState = (visualLine: string, cursorCol: number): void => {
|
||||
mockBuffer.text = logicalLine;
|
||||
mockBuffer.lines = [logicalLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.viewportVisualLines = [visualLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
|
||||
@@ -12,6 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -95,6 +99,10 @@ export function isTerminalPasteTrusted(
|
||||
return kittyProtocolSupported;
|
||||
}
|
||||
|
||||
export type ScrollableItem =
|
||||
| { type: 'visualLine'; lineText: string; absoluteVisualIdx: number }
|
||||
| { type: 'ghostLine'; ghostLine: string; index: number };
|
||||
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -268,6 +276,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
@@ -556,7 +565,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (isEmbeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
const visualRow = buffer.visualScrollRow + relY;
|
||||
const currentScrollTop = Math.round(
|
||||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||||
);
|
||||
const visualRow = currentScrollTop + relY;
|
||||
buffer.moveToVisualPosition(visualRow, relX);
|
||||
},
|
||||
{ isActive: focus },
|
||||
@@ -570,7 +582,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(_event, relX, relY) => {
|
||||
if (!isAlternateBuffer) return;
|
||||
|
||||
const visualLine = buffer.viewportVisualLines[relY];
|
||||
const currentScrollTop = Math.round(
|
||||
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
|
||||
);
|
||||
const visualLine = buffer.allVisualLines[currentScrollTop + relY];
|
||||
if (!visualLine) return;
|
||||
|
||||
// Even if we click past the end of the line, we might want to collapse an expanded paste
|
||||
@@ -578,10 +593,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const logicalPos = isPastEndOfLine
|
||||
? null
|
||||
: buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
: buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX);
|
||||
|
||||
// Check for paste placeholder (collapsed state)
|
||||
if (logicalPos) {
|
||||
@@ -603,7 +615,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// If we didn't click a placeholder to expand, check if we are inside or after
|
||||
// an expanded paste region and collapse it.
|
||||
const row = buffer.visualScrollRow + relY;
|
||||
const visualRow = currentScrollTop + relY;
|
||||
const mapEntry = buffer.visualToLogicalMap[visualRow];
|
||||
const row = mapEntry ? mapEntry[0] : visualRow;
|
||||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||||
if (expandedId) {
|
||||
buffer.togglePasteExpansion(
|
||||
@@ -1350,10 +1364,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
const getGhostTextLines = useCallback(() => {
|
||||
if (
|
||||
@@ -1468,6 +1480,155 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
|
||||
const scrollableData = useMemo(() => {
|
||||
const items: ScrollableItem[] = buffer.allVisualLines.map(
|
||||
(lineText, index) => ({
|
||||
type: 'visualLine',
|
||||
lineText,
|
||||
absoluteVisualIdx: index,
|
||||
}),
|
||||
);
|
||||
|
||||
additionalLines.forEach((ghostLine, index) => {
|
||||
items.push({
|
||||
type: 'ghostLine',
|
||||
ghostLine,
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [buffer.allVisualLines, additionalLines]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: ScrollableItem; index: number }) => {
|
||||
if (item.type === 'ghostLine') {
|
||||
const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine));
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { lineText, absoluteVisualIdx } = item;
|
||||
// console.log('renderItem called with:', lineText);
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return <Text> </Text>;
|
||||
|
||||
const isOnCursorLine =
|
||||
focus && absoluteVisualIdx === cursorVisualRowAbsolute;
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const visualStartCol =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
if (isOnCursorLine) {
|
||||
const relCol = cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (relCol >= segStart && relCol < segEnd) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
relCol - segStart,
|
||||
relCol - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(display, 0, relCol - segStart) +
|
||||
highlighted +
|
||||
cpSlice(display, relCol - segStart + 1);
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
charCount += segLen;
|
||||
}
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file' || seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
!currentLineGhost
|
||||
) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>{currentLineGhost}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[
|
||||
buffer.visualToLogicalMap,
|
||||
buffer.lines,
|
||||
buffer.transformationsByLine,
|
||||
buffer.cursor,
|
||||
buffer.visualToTransformedMap,
|
||||
focus,
|
||||
cursorVisualRowAbsolute,
|
||||
cursorVisualColAbsolute,
|
||||
showCursor,
|
||||
inlineGhost,
|
||||
inputWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
@@ -1485,6 +1646,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}, [useBackgroundColor, isLowColor, terminalBg]);
|
||||
|
||||
const prevCursorRef = useRef(buffer.visualCursor);
|
||||
const prevTextRef = useRef(buffer.text);
|
||||
|
||||
// Effect to ensure cursor remains visible after interactions
|
||||
useEffect(() => {
|
||||
const cursorChanged = prevCursorRef.current !== buffer.visualCursor;
|
||||
const textChanged = prevTextRef.current !== buffer.text;
|
||||
|
||||
prevCursorRef.current = buffer.visualCursor;
|
||||
prevTextRef.current = buffer.text;
|
||||
|
||||
if (!cursorChanged && !textChanged) return;
|
||||
|
||||
if (!listRef.current || !focus) return;
|
||||
const { scrollTop, innerHeight } = listRef.current.getScrollState();
|
||||
if (innerHeight === 0) return;
|
||||
|
||||
const cursorVisualRow = buffer.visualCursor[0];
|
||||
const actualScrollTop = Math.round(scrollTop);
|
||||
|
||||
// If cursor is out of the currently visible viewport...
|
||||
if (
|
||||
cursorVisualRow < actualScrollTop ||
|
||||
cursorVisualRow >= actualScrollTop + innerHeight
|
||||
) {
|
||||
// Calculate minimal scroll to make it visible
|
||||
let newScrollTop = actualScrollTop;
|
||||
if (cursorVisualRow < actualScrollTop) {
|
||||
newScrollTop = cursorVisualRow;
|
||||
} else if (cursorVisualRow >= actualScrollTop + innerHeight) {
|
||||
newScrollTop = cursorVisualRow - innerHeight + 1;
|
||||
}
|
||||
|
||||
listRef.current.scrollToIndex({ index: newScrollTop });
|
||||
}
|
||||
}, [buffer.visualCursor, buffer.text, focus]);
|
||||
|
||||
const listBackgroundColor =
|
||||
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
@@ -1615,153 +1816,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText: string, visualIdxInRenderedSet: number) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return null;
|
||||
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const startColInTransformed =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualStartCol = startColInTransformed;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
display,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
// Advance the running counter even when not on cursor line
|
||||
charCount += segLen;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
: `ghost-${item.index}`
|
||||
}
|
||||
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
);
|
||||
}),
|
||||
)
|
||||
width="100%"
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -93,7 +93,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
|
||||
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> second message
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -120,30 +120,30 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
(r:) commit
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
git commit -m "feat: add search" in src/app
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
(r:) commit
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
git commit -m "feat: add search" in src/app
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Image ...reenshot2x.png]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> @/path/to/screenshots/screenshot2x.png
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
|
||||
@@ -29,6 +29,8 @@ export type VirtualizedListProps<T> = {
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
targetScrollIndex?: number;
|
||||
backgroundColor?: string;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStatic?: boolean;
|
||||
@@ -39,6 +41,7 @@ export type VirtualizedListProps<T> = {
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
containerHeight?: number;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
@@ -159,6 +162,17 @@ function VirtualizedList<T>(
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof props.targetScrollIndex === 'number') {
|
||||
// NOTE: When targetScrollIndex is specified, we rely on the component
|
||||
// correctly tracking targetScrollIndex instead of initialScrollIndex.
|
||||
// We set isInitialScrollSet.current = true inside the second layout effect
|
||||
// to avoid it overwriting the targetScrollIndex.
|
||||
return {
|
||||
index: props.targetScrollIndex,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
@@ -242,7 +256,7 @@ function VirtualizedList<T>(
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
|
||||
const scrollableContainerHeight = containerHeight;
|
||||
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
@@ -259,6 +273,32 @@ function VirtualizedList<T>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevOffsetsLength = useRef(offsets.length);
|
||||
|
||||
// NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1),
|
||||
// we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset!
|
||||
// We wait until offsets populate.
|
||||
if (
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
offsets.length > 1) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
prevOffsetsLength.current <= 1 &&
|
||||
offsets.length > 1)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
} else {
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
}
|
||||
|
||||
const actualScrollTop = useMemo(() => {
|
||||
const offset = offsets[scrollAnchor.index];
|
||||
if (typeof offset !== 'number') {
|
||||
@@ -309,9 +349,14 @@ function VirtualizedList<T>(
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
|
||||
// If targetScrollIndex is provided, we NEVER auto-snap to the bottom
|
||||
// because the parent is explicitly managing the scroll position.
|
||||
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
||||
|
||||
if (
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
shouldAutoScroll &&
|
||||
((listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged))
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
@@ -331,6 +376,7 @@ function VirtualizedList<T>(
|
||||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
// We still clamp the scroll top if it's completely out of bounds
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
|
||||
if (
|
||||
@@ -359,6 +405,7 @@ function VirtualizedList<T>(
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -366,11 +413,17 @@ function VirtualizedList<T>(
|
||||
isInitialScrollSet.current ||
|
||||
offsets.length <= 1 ||
|
||||
totalHeight <= 0 ||
|
||||
containerHeight <= 0
|
||||
scrollableContainerHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
// If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex
|
||||
isInitialScrollSet.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
@@ -404,19 +457,21 @@ function VirtualizedList<T>(
|
||||
initialScrollOffsetInIndex,
|
||||
offsets,
|
||||
totalHeight,
|
||||
containerHeight,
|
||||
scrollableContainerHeight,
|
||||
getAnchorForScrollTop,
|
||||
data.length,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > actualScrollTop + scrollableContainerHeight,
|
||||
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
@@ -618,11 +673,11 @@ function VirtualizedList<T>(
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - containerHeight);
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -635,7 +690,6 @@ function VirtualizedList<T>(
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
containerHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -646,6 +700,7 @@ function VirtualizedList<T>(
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
|
||||
@@ -2907,6 +2907,25 @@ export function useTextBuffer({
|
||||
|
||||
const [scrollRowState, setScrollRowState] = useState<number>(0);
|
||||
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = scrollRowState;
|
||||
|
||||
if (visualCursor[0] < scrollRowState) {
|
||||
newVisualScrollRow = visualCursor[0];
|
||||
} else if (visualCursor[0] >= scrollRowState + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
|
||||
|
||||
if (newVisualScrollRow !== scrollRowState) {
|
||||
setScrollRowState(newVisualScrollRow);
|
||||
}
|
||||
|
||||
const actualScrollRowState = newVisualScrollRow;
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
onChange(text);
|
||||
@@ -2920,28 +2939,6 @@ export function useTextBuffer({
|
||||
});
|
||||
}, [viewport.width, viewport.height]);
|
||||
|
||||
// Update visual scroll (vertical)
|
||||
useEffect(() => {
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = scrollRowState;
|
||||
|
||||
if (visualCursor[0] < scrollRowState) {
|
||||
newVisualScrollRow = visualCursor[0];
|
||||
} else if (visualCursor[0] >= scrollRowState + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
// When the number of visual lines shrinks (e.g., after widening the viewport),
|
||||
// ensure scroll never starts beyond the last valid start so we can render a full window.
|
||||
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
|
||||
|
||||
if (newVisualScrollRow !== scrollRowState) {
|
||||
setScrollRowState(newVisualScrollRow);
|
||||
}
|
||||
}, [visualCursor, scrollRowState, viewport, visualLines.length]);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
if (typeof ch !== 'string') {
|
||||
@@ -3495,10 +3492,10 @@ export function useTextBuffer({
|
||||
const visualScrollRow = useMemo(() => {
|
||||
const totalVisualLines = visualLines.length;
|
||||
return Math.min(
|
||||
scrollRowState,
|
||||
actualScrollRowState,
|
||||
Math.max(0, totalVisualLines - viewport.height),
|
||||
);
|
||||
}, [visualLines.length, scrollRowState, viewport.height]);
|
||||
}, [visualLines.length, actualScrollRowState, viewport.height]);
|
||||
|
||||
const renderedVisualLines = useMemo(
|
||||
() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
|
||||
@@ -3694,6 +3691,7 @@ export function useTextBuffer({
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
viewportHeight: viewport.height,
|
||||
visualToLogicalMap,
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
@@ -3799,6 +3797,7 @@ export function useTextBuffer({
|
||||
renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
viewport.height,
|
||||
visualToLogicalMap,
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
@@ -3914,6 +3913,7 @@ export interface TextBuffer {
|
||||
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)
|
||||
viewportHeight: number; // The maximum height of the viewport
|
||||
/**
|
||||
* For each visual line (by absolute index in allVisualLines) provides a tuple
|
||||
* [logicalLineIndex, startColInLogical] that maps where that visual line
|
||||
|
||||
Reference in New Issue
Block a user