fix(tui): handle emoji deletion and cursor movement correctly

Backspace, Delete, and arrow keys now use Intl.Segmenter to operate
on grapheme clusters instead of individual UTF-16 code units. This
fixes deletion of emojis and other multi-codepoint characters.

fixes #240
This commit is contained in:
Mario Zechner 2025-12-19 20:39:45 +01:00
parent 28c3ffb914
commit b8dd9be3d0
3 changed files with 56 additions and 18 deletions

View file

@ -2,6 +2,9 @@ import { isAltBackspace, isArrowLeft, isArrowRight, isCtrlA, isCtrlE, isCtrlK, i
import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.)
const segmenter = new Intl.Segmenter();
/**
* Input component - single-line text input with horizontal scrolling
*/
@ -70,34 +73,48 @@ export class Input implements Component {
}
if (data === "\x7f" || data === "\x08") {
// Backspace
// Backspace - delete grapheme before cursor (handles emojis, etc.)
if (this.cursor > 0) {
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
this.cursor--;
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
this.cursor -= graphemeLength;
}
return;
}
if (isArrowLeft(data)) {
// Left arrow
// Left arrow - move by one grapheme (handles emojis, etc.)
if (this.cursor > 0) {
this.cursor--;
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
}
return;
}
if (isArrowRight(data)) {
// Right arrow
// Right arrow - move by one grapheme (handles emojis, etc.)
if (this.cursor < this.value.length) {
this.cursor++;
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
}
return;
}
if (data === "\x1b[3~") {
// Delete
// Delete - delete grapheme at cursor (handles emojis, etc.)
if (this.cursor < this.value.length) {
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
}
return;
}