diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 01d9be2c..ddbd98ee 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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 diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 2f5a3eb6..a054076d 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -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 { diff --git a/packages/tui/src/word-navigation.ts b/packages/tui/src/word-navigation.ts new file mode 100644 index 00000000..7c7eced2 --- /dev/null +++ b/packages/tui/src/word-navigation.ts @@ -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; + /** 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; +} diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 355663b0..5b83ec71 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -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(); diff --git a/packages/tui/test/word-navigation.test.ts b/packages/tui/test/word-navigation.test.ts new file mode 100644 index 00000000..2e58e960 --- /dev/null +++ b/packages/tui/test/word-navigation.test.ts @@ -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([ + [ + 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); + }); +});