fix: lineEditor のサロゲートペア対応と Ctrl+J 改行挿入を追加
This commit is contained in:
parent
f9c30be093
commit
6bea78adb4
@ -120,6 +120,26 @@ describe('parseInputData', () => {
|
||||
expect(cb.calls).not.toContain('char:f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ctrl+J key detection', () => {
|
||||
it('should emit char event for Ctrl+J', () => {
|
||||
// Given
|
||||
const cb = createCallbacks();
|
||||
// When
|
||||
parseInputData('\x0A', cb);
|
||||
// Then
|
||||
expect(cb.calls).toEqual(['char:\n']);
|
||||
});
|
||||
|
||||
it('should emit char event for Ctrl+J mixed with regular chars', () => {
|
||||
// Given
|
||||
const cb = createCallbacks();
|
||||
// When
|
||||
parseInputData('a\x0Ab', cb);
|
||||
// Then
|
||||
expect(cb.calls).toEqual(['char:a', 'char:\n', 'char:b']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readMultilineInput cursor navigation', () => {
|
||||
@ -958,4 +978,85 @@ describe('readMultilineInput cursor navigation', () => {
|
||||
expect(result).toBe('abcdefghijklmnopqrstuXvwx\n123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('surrogate pair (emoji) support', () => {
|
||||
it('should move left past emoji', async () => {
|
||||
// Given
|
||||
setupRawStdin(['🎵\x1B[DX\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('X🎵');
|
||||
});
|
||||
|
||||
it('should move right through emoji', async () => {
|
||||
// Given
|
||||
setupRawStdin(['🎵\x1B[H\x1B[CX\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('🎵X');
|
||||
});
|
||||
|
||||
it('should backspace emoji completely', async () => {
|
||||
// Given
|
||||
setupRawStdin(['🎵\x7F\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not leave broken surrogate pair after backspace', async () => {
|
||||
// Given
|
||||
setupRawStdin(['a🎵b\x7FX\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('a🎵X');
|
||||
});
|
||||
|
||||
it('should handle multiple emojis with arrow navigation', async () => {
|
||||
// Given
|
||||
setupRawStdin(['😀🎵\x1B[D\x1B[DX\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('X😀🎵');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ctrl+J inserts newline', () => {
|
||||
it('should insert newline with Ctrl+J at end of line', async () => {
|
||||
// Given
|
||||
setupRawStdin(['abc\x0Adef\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('abc\ndef');
|
||||
});
|
||||
|
||||
it('should insert newline with Ctrl+J mid-line', async () => {
|
||||
// Given
|
||||
setupRawStdin(['abcdef\x1B[H\x1B[C\x1B[C\x1B[C\x0A\r']);
|
||||
|
||||
// When
|
||||
const result = await callReadMultilineInput('> ');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('abc\ndef');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -250,6 +250,28 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
||||
|
||||
// --- Buffer position helpers ---
|
||||
|
||||
/** Get the JS string length of the character at buffer position `pos` */
|
||||
function charLengthAt(pos: number): number {
|
||||
if (pos >= buffer.length) return 0;
|
||||
const code = buffer.charCodeAt(pos);
|
||||
if (code >= 0xD800 && code <= 0xDBFF && pos + 1 < buffer.length) {
|
||||
const next = buffer.charCodeAt(pos + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Get the JS string length of the character immediately before buffer position `pos` */
|
||||
function charLengthBefore(pos: number): number {
|
||||
if (pos === 0) return 0;
|
||||
const code = buffer.charCodeAt(pos - 1);
|
||||
if (code >= 0xDC00 && code <= 0xDFFF && pos >= 2) {
|
||||
const prev = buffer.charCodeAt(pos - 2);
|
||||
if (prev >= 0xD800 && prev <= 0xDBFF) return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function getLineStartAt(pos: number): number {
|
||||
const lastNl = buffer.lastIndexOf('\n', pos - 1);
|
||||
return lastNl + 1;
|
||||
@ -475,9 +497,10 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
||||
|
||||
function deleteCharBefore(): void {
|
||||
if (cursorPos <= getLineStart()) return;
|
||||
const charWidth = getDisplayWidth(buffer[cursorPos - 1]!);
|
||||
deleteRange(cursorPos - 1, cursorPos);
|
||||
cursorPos--;
|
||||
const len = charLengthBefore(cursorPos);
|
||||
const charWidth = getDisplayWidth(buffer.slice(cursorPos - len, cursorPos));
|
||||
deleteRange(cursorPos - len, cursorPos);
|
||||
cursorPos -= len;
|
||||
process.stdout.write(`\x1B[${charWidth}D`);
|
||||
rerenderFromCursor();
|
||||
}
|
||||
@ -547,8 +570,9 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
||||
onArrowLeft() {
|
||||
if (state !== 'normal') return;
|
||||
if (cursorPos > getLineStart()) {
|
||||
const charWidth = getDisplayWidth(buffer[cursorPos - 1]!);
|
||||
cursorPos--;
|
||||
const len = charLengthBefore(cursorPos);
|
||||
const charWidth = getDisplayWidth(buffer.slice(cursorPos - len, cursorPos));
|
||||
cursorPos -= len;
|
||||
process.stdout.write(`\x1B[${charWidth}D`);
|
||||
} else if (getLineStart() > 0) {
|
||||
cursorPos = getLineStart() - 1;
|
||||
@ -560,8 +584,9 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
||||
onArrowRight() {
|
||||
if (state !== 'normal') return;
|
||||
if (cursorPos < getLineEnd()) {
|
||||
const charWidth = getDisplayWidth(buffer[cursorPos]!);
|
||||
cursorPos++;
|
||||
const len = charLengthAt(cursorPos);
|
||||
const charWidth = getDisplayWidth(buffer.slice(cursorPos, cursorPos + len));
|
||||
cursorPos += len;
|
||||
process.stdout.write(`\x1B[${charWidth}C`);
|
||||
} else if (cursorPos < buffer.length && buffer[cursorPos] === '\n') {
|
||||
cursorPos++;
|
||||
@ -672,6 +697,7 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
||||
if (ch === '\x0B') { deleteToLineEnd(); return; }
|
||||
if (ch === '\x15') { deleteToLineStart(); return; }
|
||||
if (ch === '\x17') { deleteWord(); return; }
|
||||
if (ch === '\x0A') { insertNewline(); return; }
|
||||
// Ignore unknown control characters
|
||||
if (ch.charCodeAt(0) < 0x20) return;
|
||||
// Regular character
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user