fix(tui): align input word segmentation with editor (#5068)

This commit is contained in:
xu0o0
2026-05-28 02:50:04 +08:00
committed by GitHub
parent 61babc24cf
commit 701801dec2
5 changed files with 381 additions and 146 deletions

View File

@@ -4,14 +4,8 @@ import { decodePrintableKey, matchesKey } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import {
getGraphemeSegmenter,
getWordSegmenter,
isWhitespaceChar,
PUNCTUATION_REGEX,
truncateToWidth,
visibleWidth,
} from "../utils.ts";
import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts";
const graphemeSegmenter = getGraphemeSegmenter();
@@ -1780,48 +1774,12 @@ export class Editor implements Component, Focusable {
return;
}
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
const segments = [...this.segment(textBeforeCursor, "word")];
let newCol = this.state.cursorCol;
// Skip trailing whitespace
while (
segments.length > 0 &&
!isPasteMarker(segments[segments.length - 1]?.segment || "") &&
isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCol -= segments.pop()?.segment.length || 0;
}
if (segments.length > 0) {
const last = segments[segments.length - 1]!;
if (isPasteMarker(last.segment)) {
// Skip one paste marker.
newCol -= segments.pop()?.segment.length || 0;
} else if (last.isWordLike) {
// Skip inside one word-like segment, preserving existing ASCII punctuation boundaries.
const segment = last.segment;
const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))];
if (matches.length <= 0) {
newCol -= segment.length;
} else {
const lastMatch = matches[matches.length - 1]!;
newCol -= segment.length - (lastMatch.index + lastMatch[0].length);
}
} else {
// Skip non-word non-whitespace run (punctuation)
while (
segments.length > 0 &&
!isPasteMarker(segments[segments.length - 1]?.segment || "") &&
!segments[segments.length - 1]?.isWordLike &&
!isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCol -= segments.pop()?.segment.length || 0;
}
}
}
this.setCursorCol(newCol);
this.setCursorCol(
findWordBackward(currentLine, this.state.cursorCol, {
segment: (text) => this.segment(text, "word"),
isAtomicSegment: isPasteMarker,
}),
);
}
/**
@@ -2008,40 +1966,12 @@ export class Editor implements Component, Focusable {
return;
}
const textAfterCursor = currentLine.slice(this.state.cursorCol);
const segments = this.segment(textAfterCursor, "word");
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
let newCol = this.state.cursorCol;
// Skip leading whitespace
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
newCol += next.value.segment.length;
next = iterator.next();
}
if (!next.done) {
if (isPasteMarker(next.value.segment)) {
// Skip one paste marker.
newCol += next.value.segment.length;
} else if (next.value.isWordLike) {
// Skip inside one word-like segment, preserving existing ASCII punctuation boundaries.
newCol += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length;
} else {
// Skip non-word non-whitespace run (punctuation)
while (
!next.done &&
!isPasteMarker(next.value.segment) &&
!next.value.isWordLike &&
!isWhitespaceChar(next.value.segment)
) {
newCol += next.value.segment.length;
next = iterator.next();
}
}
}
this.setCursorCol(newCol);
this.setCursorCol(
findWordForward(currentLine, this.state.cursorCol, {
segment: (text) => this.segment(text, "word"),
isAtomicSegment: isPasteMarker,
}),
);
}
// Slash menu only allowed on the first line of the editor

View File

@@ -3,7 +3,8 @@ import { decodeKittyPrintable } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import { getGraphemeSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
const segmenter = getGraphemeSegmenter();
@@ -347,72 +348,15 @@ export class Input implements Component, Focusable {
}
private moveWordBackwards(): void {
if (this.cursor === 0) {
return;
}
if (this.cursor === 0) return;
this.lastAction = null;
const textBeforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(textBeforeCursor)];
// Skip trailing whitespace
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
if (graphemes.length > 0) {
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
if (isPunctuationChar(lastGrapheme)) {
// Skip punctuation run
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
} else {
// Skip word run
while (
graphemes.length > 0 &&
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
}
}
this.cursor = findWordBackward(this.value, this.cursor);
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) {
return;
}
if (this.cursor >= this.value.length) return;
this.lastAction = null;
const textAfterCursor = this.value.slice(this.cursor);
const segments = segmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
// Skip leading whitespace
while (!next.done && isWhitespaceChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
if (!next.done) {
const firstGrapheme = next.value.segment;
if (isPunctuationChar(firstGrapheme)) {
// Skip punctuation run
while (!next.done && isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
} else {
// Skip word run
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
}
}
this.cursor = findWordForward(this.value, this.cursor);
}
private handlePaste(pastedText: string): void {

View File

@@ -0,0 +1,117 @@
import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts";
const wordSegmenter = getWordSegmenter();
/**
* Options for word navigation functions.
* When omitted, uses the default Intl.Segmenter word segmentation.
*/
export interface WordNavigationOptions {
/** Custom segmenter returning word segments for the given text. */
segment?: (text: string) => Iterable<Intl.SegmentData>;
/** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */
isAtomicSegment?: (segment: string) => boolean;
}
/**
* Find the cursor position after moving one word backward from `cursor` in `text`.
* Skips trailing whitespace, then stops at the next word/punctuation boundary.
*
* Pure function - does not mutate any state.
*/
export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number {
if (cursor <= 0) return 0;
const textBeforeCursor = text.slice(0, cursor);
const segmentFn = options?.segment;
const isAtomic = options?.isAtomicSegment;
const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)];
let newCursor = cursor;
// Skip trailing whitespace
while (
segments.length > 0 &&
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCursor -= segments.pop()?.segment.length || 0;
}
if (segments.length === 0) return newCursor;
const last = segments[segments.length - 1]!;
if (isAtomic?.(last.segment)) {
// Skip one atomic segment.
newCursor -= last.segment.length;
} else if (last.isWordLike) {
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
const segment = last.segment;
const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))];
if (matches.length <= 0) {
newCursor -= segment.length;
} else {
const lastMatch = matches[matches.length - 1]!;
newCursor -= segment.length - (lastMatch.index + lastMatch[0].length);
}
} else {
// Skip non-word non-whitespace run (punctuation)
while (
segments.length > 0 &&
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
!segments[segments.length - 1]?.isWordLike &&
!isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCursor -= segments.pop()?.segment.length || 0;
}
}
return newCursor;
}
/**
* Find the cursor position after moving one word forward from `cursor` in `text`.
* Skips leading whitespace, then stops at the next word/punctuation boundary.
*
* Pure function - does not mutate any state.
*/
export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number {
if (cursor >= text.length) return text.length;
const textAfterCursor = text.slice(cursor);
const segmentFn = options?.segment;
const isAtomic = options?.isAtomicSegment;
const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
let newCursor = cursor;
// Skip leading whitespace
while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) {
newCursor += next.value.segment.length;
next = iterator.next();
}
if (next.done) return newCursor;
if (isAtomic?.(next.value.segment)) {
// Skip one atomic segment.
newCursor += next.value.segment.length;
} else if (next.value.isWordLike) {
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length;
} else {
// Skip non-word non-whitespace run (punctuation)
while (
!next.done &&
!isAtomic?.(next.value.segment) &&
!next.value.isWordLike &&
!isWhitespaceChar(next.value.segment)
) {
newCursor += next.value.segment.length;
next = iterator.next();
}
}
return newCursor;
}

View File

@@ -114,6 +114,26 @@ describe("Input component", () => {
assert.strictEqual(input.getValue(), "foo:");
});
it("Ctrl+W handles Unicode word boundaries", () => {
const input = new Input();
// "你好世界。你好,世界" segments as: 你好|世界|。|你好||世界
input.setValue("你好世界。你好,世界");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
assert.strictEqual(input.getValue(), "你好世界。你好,");
input.handleInput("\x17"); // Ctrl+W - deletes ""
assert.strictEqual(input.getValue(), "你好世界。你好");
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
assert.strictEqual(input.getValue(), "你好世界。");
input.handleInput("\x17"); // Ctrl+W - deletes "。"
assert.strictEqual(input.getValue(), "你好世界");
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
assert.strictEqual(input.getValue(), "你好");
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
assert.strictEqual(input.getValue(), "");
});
it("Ctrl+U saves deleted text to kill ring", () => {
const input = new Input();
@@ -327,6 +347,39 @@ describe("Input component", () => {
assert.strictEqual(input.getValue(), "hello world test");
});
it("Alt+D preserves ASCII punctuation boundaries", () => {
const input = new Input();
input.setValue("foo.bar baz");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "foo"
assert.strictEqual(input.getValue(), ".bar baz");
input.handleInput("\x1bd"); // Alt+D - deletes "."
assert.strictEqual(input.getValue(), "bar baz");
input.handleInput("\x1bd"); // Alt+D - deletes "bar"
assert.strictEqual(input.getValue(), " baz");
});
it("Alt+D handles Unicode word boundaries", () => {
const input = new Input();
// "你好世界。你好,世界" segments as: 你好|世界|。|你好||世界
input.setValue("你好世界。你好,世界");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
assert.strictEqual(input.getValue(), "世界。你好,世界");
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
assert.strictEqual(input.getValue(), "。你好,世界");
input.handleInput("\x1bd"); // Alt+D - deletes "。"
assert.strictEqual(input.getValue(), "你好,世界");
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
assert.strictEqual(input.getValue(), ",世界");
input.handleInput("\x1bd"); // Alt+D - deletes ""
assert.strictEqual(input.getValue(), "世界");
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
assert.strictEqual(input.getValue(), "");
});
it("handles yank in middle of text", () => {
const input = new Input();

View File

@@ -0,0 +1,191 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { findWordBackward, findWordForward } from "../src/word-navigation.ts";
describe("findWordBackward", () => {
it("basic words: hello world", () => {
const text = "hello world";
assert.strictEqual(findWordBackward(text, 11), 6);
assert.strictEqual(findWordBackward(text, 6), 0);
});
it("dotted: foo.bar", () => {
const text = "foo.bar";
assert.strictEqual(findWordBackward(text, 7), 4);
assert.strictEqual(findWordBackward(text, 4), 3);
assert.strictEqual(findWordBackward(text, 3), 0);
});
it("colon: foo:bar", () => {
const text = "foo:bar";
assert.strictEqual(findWordBackward(text, 7), 4);
assert.strictEqual(findWordBackward(text, 4), 3);
assert.strictEqual(findWordBackward(text, 3), 0);
});
it("path: path/to/file", () => {
const text = "path/to/file";
assert.strictEqual(findWordBackward(text, 12), 8);
assert.strictEqual(findWordBackward(text, 8), 7);
// "/to" is one word-like segment with "/" as punctuation boundary
assert.strictEqual(findWordBackward(text, 7), 5);
assert.strictEqual(findWordBackward(text, 5), 4);
assert.strictEqual(findWordBackward(text, 4), 0);
});
it("CJK mixed", () => {
const text = "你好世界 test";
assert.strictEqual(findWordBackward(text, text.length), 5);
// Intl.Segmenter treats each CJK char as a separate word-like segment
assert.strictEqual(findWordBackward(text, 5), 2);
assert.strictEqual(findWordBackward(text, 2), 0);
});
it("whitespace at boundaries", () => {
const text = " hello ";
assert.strictEqual(findWordBackward(text, 9), 2);
assert.strictEqual(findWordBackward(text, 2), 0);
});
it("punctuation run: foo...bar", () => {
const text = "foo...bar";
assert.strictEqual(findWordBackward(text, 9), 6);
assert.strictEqual(findWordBackward(text, 6), 3);
assert.strictEqual(findWordBackward(text, 3), 0);
});
it("cursor at 0 returns 0", () => {
assert.strictEqual(findWordBackward("hello", 0), 0);
});
});
describe("findWordForward", () => {
it("basic words: hello world", () => {
const text = "hello world";
assert.strictEqual(findWordForward(text, 0), 5);
assert.strictEqual(findWordForward(text, 5), 11);
});
it("dotted: foo.bar", () => {
const text = "foo.bar";
assert.strictEqual(findWordForward(text, 0), 3);
assert.strictEqual(findWordForward(text, 3), 4);
assert.strictEqual(findWordForward(text, 4), 7);
});
it("colon: foo:bar", () => {
const text = "foo:bar";
assert.strictEqual(findWordForward(text, 0), 3);
assert.strictEqual(findWordForward(text, 3), 4);
assert.strictEqual(findWordForward(text, 4), 7);
});
it("path: path/to/file", () => {
const text = "path/to/file";
assert.strictEqual(findWordForward(text, 0), 4);
assert.strictEqual(findWordForward(text, 4), 5);
assert.strictEqual(findWordForward(text, 5), 7);
assert.strictEqual(findWordForward(text, 7), 8);
assert.strictEqual(findWordForward(text, 8), 12);
});
it("CJK mixed", () => {
const text = "你好世界 test";
const firstEnd = findWordForward(text, 0);
assert.ok(firstEnd > 0);
assert.ok(firstEnd <= 4);
// Walk to end
let pos = 0;
while (pos < text.length) {
const next = findWordForward(text, pos);
if (next === pos) break;
pos = next;
}
assert.strictEqual(pos, text.length);
});
it("whitespace at boundaries", () => {
const text = " hello ";
assert.strictEqual(findWordForward(text, 0), 7);
assert.strictEqual(findWordForward(text, 7), 9);
});
it("punctuation run: foo...bar", () => {
const text = "foo...bar";
assert.strictEqual(findWordForward(text, 0), 3);
assert.strictEqual(findWordForward(text, 3), 6);
assert.strictEqual(findWordForward(text, 6), 9);
});
it("cursor at end returns end", () => {
assert.strictEqual(findWordForward("hello", 5), 5);
});
});
describe("atomic segments", () => {
const marker = "[paste #1 +5 lines]";
const text = `hello ${marker} world`;
const isAtomic = (s: string) => s === marker;
// The functions slice text before calling segment(), so we map each expected
// substring to its pre-split segments.
const segmentMap = new Map<string, Intl.SegmentData[]>([
[
text, // full text (not used but for clarity)
[
{ segment: "hello", index: 0, input: text, isWordLike: true },
{ segment: " ", index: 5, input: text, isWordLike: false },
{ segment: marker, index: 6, input: text, isWordLike: true },
{ segment: " ", index: 25, input: text, isWordLike: false },
{ segment: "world", index: 26, input: text, isWordLike: true },
],
],
[
// backward from end: slice(0, 31) = full text
text.slice(0, text.length),
[
{ segment: "hello", index: 0, input: text, isWordLike: true },
{ segment: " ", index: 5, input: text, isWordLike: false },
{ segment: marker, index: 6, input: text, isWordLike: true },
{ segment: " ", index: 25, input: text, isWordLike: false },
{ segment: "world", index: 26, input: text, isWordLike: true },
],
],
[
// backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] "
text.slice(0, 26),
[
{ segment: "hello", index: 0, input: text, isWordLike: true },
{ segment: " ", index: 5, input: text, isWordLike: false },
{ segment: marker, index: 6, input: text, isWordLike: true },
{ segment: " ", index: 25, input: text, isWordLike: false },
],
],
[
// forward from 6: slice(6) = "[paste #1 +5 lines] world"
text.slice(6),
[
{ segment: marker, index: 0, input: text, isWordLike: true },
{ segment: " ", index: 19, input: text, isWordLike: false },
{ segment: "world", index: 20, input: text, isWordLike: true },
],
],
]);
const opts = {
segment: (input: string) => segmentMap.get(input) ?? [],
isAtomicSegment: isAtomic,
};
it("backward skips word then stops before atomic marker", () => {
assert.strictEqual(findWordBackward(text, text.length, opts), 26);
});
it("backward skips whitespace then atomic marker as one unit", () => {
assert.strictEqual(findWordBackward(text, 26, opts), 6);
});
it("forward skips atomic marker as one unit", () => {
assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length);
});
});