mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 03:03:44 +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
|
|
@ -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