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

@ -793,14 +793,20 @@ export class Editor implements Component {
this.historyIndex = -1; // Exit history browsing mode
if (this.state.cursorCol > 0) {
// Delete character in current line
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
const line = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = line.slice(0, this.state.cursorCol);
const before = line.slice(0, this.state.cursorCol - 1);
// Find the last grapheme in the text before cursor
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
const before = line.slice(0, this.state.cursorCol - graphemeLength);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.state.cursorCol--;
this.state.cursorCol -= graphemeLength;
} else if (this.state.cursorLine > 0) {
// Merge with previous line
const currentLine = this.state.lines[this.state.cursorLine] || "";
@ -943,9 +949,16 @@ export class Editor implements Component {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
// Delete character at cursor position (forward delete)
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
const afterCursor = currentLine.slice(this.state.cursorCol);
// Find the first grapheme at cursor
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol + 1);
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
this.state.lines[this.state.cursorLine] = before + after;
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// At end of line - merge with next line
@ -1087,18 +1100,24 @@ export class Editor implements Component {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (deltaCol > 0) {
// Moving right
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
if (this.state.cursorCol < currentLine.length) {
this.state.cursorCol++;
const afterCursor = currentLine.slice(this.state.cursorCol);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// Wrap to start of next logical line
this.state.cursorLine++;
this.state.cursorCol = 0;
}
} else {
// Moving left
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
if (this.state.cursorCol > 0) {
this.state.cursorCol--;
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
} else if (this.state.cursorLine > 0) {
// Wrap to end of previous logical line
this.state.cursorLine--;

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;
}