mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 04:01:56 +00:00
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:
parent
28c3ffb914
commit
b8dd9be3d0
3 changed files with 56 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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--;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue