Skip to content

Commit 5982abe

Browse files
authored
fix(ui): Correct mouse click cursor positioning for wide characters (#13537)
1 parent 78b10dc commit 5982abe

File tree

2 files changed

+53
-5
lines changed

2 files changed

+53
-5
lines changed

packages/cli/src/ui/components/shared/text-buffer.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,34 @@ describe('useTextBuffer', () => {
963963
expect(state.cursor).toEqual([0, 1]);
964964
expect(state.visualCursor).toEqual([0, 1]);
965965
});
966+
967+
it('moveToVisualPosition: should correctly handle wide characters (Chinese)', () => {
968+
const { result } = renderHook(() =>
969+
useTextBuffer({
970+
initialText: '你好', // 2 chars, width 4
971+
viewport: { width: 10, height: 1 },
972+
isValidPath: () => false,
973+
}),
974+
);
975+
976+
// '你' (width 2): visual 0-1. '好' (width 2): visual 2-3.
977+
978+
// Click on '你' (first half, x=0) -> index 0
979+
act(() => result.current.moveToVisualPosition(0, 0));
980+
expect(getBufferState(result).cursor).toEqual([0, 0]);
981+
982+
// Click on '你' (second half, x=1) -> index 1 (after first char)
983+
act(() => result.current.moveToVisualPosition(0, 1));
984+
expect(getBufferState(result).cursor).toEqual([0, 1]);
985+
986+
// Click on '好' (first half, x=2) -> index 1 (before second char)
987+
act(() => result.current.moveToVisualPosition(0, 2));
988+
expect(getBufferState(result).cursor).toEqual([0, 1]);
989+
990+
// Click on '好' (second half, x=3) -> index 2 (after second char)
991+
act(() => result.current.moveToVisualPosition(0, 3));
992+
expect(getBufferState(result).cursor).toEqual([0, 2]);
993+
});
966994
});
967995

968996
describe('handleInput', () => {

packages/cli/src/ui/components/shared/text-buffer.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,7 @@ export interface TextBufferState {
853853
lines: string[];
854854
cursorRow: number;
855855
cursorCol: number;
856-
preferredCol: number | null; // This is visual preferred col
856+
preferredCol: number | null; // This is the logical character offset in the visual line
857857
undoStack: UndoHistoryEntry[];
858858
redoStack: UndoHistoryEntry[];
859859
clipboard: string | null;
@@ -2022,20 +2022,40 @@ export function useTextBuffer({
20222022
Math.min(visRow, visualLines.length - 1),
20232023
);
20242024
const visualLine = visualLines[clampedVisRow] || '';
2025-
// Clamp visCol to the length of the visual line
2026-
const clampedVisCol = Math.max(0, Math.min(visCol, cpLen(visualLine)));
20272025

20282026
if (visualToLogicalMap[clampedVisRow]) {
20292027
const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow];
2028+
2029+
const codePoints = toCodePoints(visualLine);
2030+
let currentVisX = 0;
2031+
let charOffset = 0;
2032+
2033+
for (const char of codePoints) {
2034+
const charWidth = getCachedStringWidth(char);
2035+
// If the click is within this character
2036+
if (visCol < currentVisX + charWidth) {
2037+
// Check if we clicked the second half of a wide character
2038+
if (charWidth > 1 && visCol >= currentVisX + charWidth / 2) {
2039+
charOffset++;
2040+
}
2041+
break;
2042+
}
2043+
currentVisX += charWidth;
2044+
charOffset++;
2045+
}
2046+
2047+
// Clamp charOffset to length
2048+
charOffset = Math.min(charOffset, codePoints.length);
2049+
20302050
const newCursorRow = logRow;
2031-
const newCursorCol = logStartCol + clampedVisCol;
2051+
const newCursorCol = logStartCol + charOffset;
20322052

20332053
dispatch({
20342054
type: 'set_cursor',
20352055
payload: {
20362056
cursorRow: newCursorRow,
20372057
cursorCol: newCursorCol,
2038-
preferredCol: clampedVisCol,
2058+
preferredCol: charOffset,
20392059
},
20402060
});
20412061
}

0 commit comments

Comments
 (0)