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

@ -12,6 +12,8 @@
- **Kitty keyboard protocol on Linux**: Fixed keyboard input not working in Ghostty on Linux when Num Lock is enabled. The Kitty protocol includes Caps Lock and Num Lock state in modifier values, which broke key detection. Now correctly masks out lock key bits when matching keyboard shortcuts. ([#243](https://github.com/badlogic/pi-mono/issues/243))
- **Emoji deletion and cursor movement**: Backspace, Delete, and arrow keys now correctly handle multi-codepoint characters like emojis. Previously, deleting an emoji would leave partial bytes, corrupting the editor state. ([#240](https://github.com/badlogic/pi-mono/issues/240))
## [0.24.0] - 2025-12-19
### Added

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