From 5ced3a640e59ce79ee48cee534b36832f63e0b4e Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 4 Mar 2026 15:58:58 +0100 Subject: [PATCH] refactor(tui): extracted word navigation into reusable utilities - Extracted word navigation logic into reusable `moveWordLeft()` and `moveWordRight()` utility functions. - Consolidated Unicode-aware word navigation handling (whitespace, delimiters, CJK, word-joiner) into shared utilities. - Refactored input and editor components to delegate word navigation to extracted utility functions, reducing duplication. - Removed 212 lines of duplicated word navigation logic across components while maintaining identical behavior. --- packages/tui/CHANGELOG.md | 3 + packages/tui/src/components/editor.ts | 114 ++----------------------- packages/tui/src/components/input.ts | 114 +++---------------------- packages/tui/src/utils.ts | 115 ++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 212 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2a47c9dc7..c7a4dae6b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Extracted word navigation logic into reusable `moveWordLeft` and `moveWordRight` utility functions for consistent cursor movement across components ## [13.6.2] - 2026-03-03 ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6b811fa6c..4540a6b08 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -9,8 +9,8 @@ import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; import { getSegmenter, getWordNavKind, - isWhitespaceChar, - isWordNavJoiner, + moveWordLeft, + moveWordRight, padding, truncateToWidth, visibleWidth, @@ -59,7 +59,7 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { for (const seg of segmenter.segment(line)) { const grapheme = seg.segment; - const graphemeIsWhitespace = isWhitespaceChar(grapheme); + const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace"; if (currentToken === "") { inWhitespace = graphemeIsWhitespace; @@ -2006,58 +2006,7 @@ export class Editor implements Component, Focusable { return; } - const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); - const graphemes = [...segmenter.segment(textBeforeCursor)]; - let newCol = this.#state.cursorCol; - - // Skip trailing whitespace - while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") { - newCol -= graphemes.pop()?.segment.length || 0; - } - - if (graphemes.length > 0) { - const last = graphemes[graphemes.length - 1]?.segment || ""; - const kind = getWordNavKind(last); - if (kind === "delimiter") { - // Skip delimiter run (punctuation/symbols) - while ( - graphemes.length > 0 && - getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "delimiter" - ) { - newCol -= graphemes.pop()?.segment.length || 0; - } - } else if (kind === "cjk") { - // Skip CJK run (Han/Hiragana/Katakana/Hangul) - while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "cjk") { - newCol -= graphemes.pop()?.segment.length || 0; - } - } else if (kind === "word") { - // Skip word run (letters/numbers/underscore), keeping common joiners inside words. - let hasRightWord = false; - while (graphemes.length > 0) { - const g = graphemes[graphemes.length - 1]?.segment || ""; - const k = getWordNavKind(g); - if (k === "word") { - hasRightWord = true; - newCol -= graphemes.pop()?.segment.length || 0; - continue; - } - if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) { - const left = graphemes[graphemes.length - 2]?.segment || ""; - if (getWordNavKind(left) === "word") { - newCol -= graphemes.pop()?.segment.length || 0; - continue; - } - } - break; - } - } else { - // Fallback: move by one grapheme - newCol -= graphemes.pop()?.segment.length || 0; - } - } - - this.#setCursorCol(newCol); + this.#setCursorCol(moveWordLeft(currentLine, this.#state.cursorCol)); } /** @@ -2106,60 +2055,7 @@ export class Editor implements Component, Focusable { return; } - const textAfterCursor = currentLine.slice(this.#state.cursorCol); - const graphemes = [...segmenter.segment(textAfterCursor)]; - let i = 0; - let newCol = this.#state.cursorCol; - - // Skip leading whitespace - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "whitespace") { - newCol += graphemes[i]?.segment.length || 0; - i++; - } - - if (i < graphemes.length) { - const kind = getWordNavKind(graphemes[i]?.segment || ""); - if (kind === "delimiter") { - // Skip delimiter run (punctuation/symbols) - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "delimiter") { - newCol += graphemes[i]?.segment.length || 0; - i++; - } - } else if (kind === "cjk") { - // Skip CJK run (Han/Hiragana/Katakana/Hangul) - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "cjk") { - newCol += graphemes[i]?.segment.length || 0; - i++; - } - } else if (kind === "word") { - // Skip word run (letters/numbers/underscore), keeping common joiners inside words. - let hasLeftWord = false; - while (i < graphemes.length) { - const g = graphemes[i]?.segment || ""; - const k = getWordNavKind(g); - if (k === "word") { - hasLeftWord = true; - newCol += g.length; - i++; - continue; - } - if (hasLeftWord && k === "delimiter" && isWordNavJoiner(g)) { - const right = graphemes[i + 1]?.segment || ""; - if (getWordNavKind(right) === "word") { - newCol += g.length; - i++; - continue; - } - } - break; - } - } else { - // Fallback: move by one grapheme - newCol += graphemes[i]?.segment.length || 0; - } - } - - this.#setCursorCol(newCol); + this.#setCursorCol(moveWordRight(currentLine, this.#state.cursorCol)); } // Helper method to check if cursor is at start of message (for slash command detection) diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 644158f64..167d405b2 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -2,7 +2,15 @@ import { BracketedPasteHandler } from "../bracketed-paste"; import { getEditorKeybindings } from "../keybindings"; import { KillRing } from "../kill-ring"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; -import { getSegmenter, getWordNavKind, isWordNavJoiner, padding, sliceWithWidth, visibleWidth } from "../utils"; +import { + getSegmenter, + getWordNavKind, + moveWordLeft, + moveWordRight, + padding, + sliceWithWidth, + visibleWidth, +} from "../utils"; const segmenter = getSegmenter(); @@ -336,115 +344,15 @@ export class Input implements Component, Focusable { return; } this.#lastAction = null; - - const textBeforeCursor = this.#value.slice(0, this.#cursor); - const graphemes = [...segmenter.segment(textBeforeCursor)]; - - // Skip trailing whitespace - while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") { - this.#cursor -= graphemes.pop()?.segment.length || 0; - } - - if (graphemes.length > 0) { - const last = graphemes[graphemes.length - 1]?.segment || ""; - const kind = getWordNavKind(last); - if (kind === "delimiter") { - // Skip delimiter run (punctuation/symbols) - while ( - graphemes.length > 0 && - getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "delimiter" - ) { - this.#cursor -= graphemes.pop()?.segment.length || 0; - } - } else if (kind === "cjk") { - // Skip CJK run (Han/Hiragana/Katakana/Hangul) - while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "cjk") { - this.#cursor -= graphemes.pop()?.segment.length || 0; - } - } else if (kind === "word") { - // Skip word run (letters/numbers/underscore), keeping common joiners inside words. - let hasRightWord = false; - while (graphemes.length > 0) { - const g = graphemes[graphemes.length - 1]?.segment || ""; - const k = getWordNavKind(g); - if (k === "word") { - hasRightWord = true; - this.#cursor -= graphemes.pop()?.segment.length || 0; - continue; - } - if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) { - const left = graphemes[graphemes.length - 2]?.segment || ""; - if (getWordNavKind(left) === "word") { - this.#cursor -= graphemes.pop()?.segment.length || 0; - continue; - } - } - break; - } - } else { - // Fallback: move by one grapheme - this.#cursor -= graphemes.pop()?.segment.length || 0; - } - } + this.#cursor = moveWordLeft(this.#value, this.#cursor); } #moveWordForwards(): void { if (this.#cursor >= this.#value.length) { return; } - this.#lastAction = null; - const textAfterCursor = this.#value.slice(this.#cursor); - const graphemes = [...segmenter.segment(textAfterCursor)]; - let i = 0; - - // Skip leading whitespace - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "whitespace") { - this.#cursor += graphemes[i]?.segment.length || 0; - i++; - } - - if (i < graphemes.length) { - const kind = getWordNavKind(graphemes[i]?.segment || ""); - if (kind === "delimiter") { - // Skip delimiter run (punctuation/symbols) - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "delimiter") { - this.#cursor += graphemes[i]?.segment.length || 0; - i++; - } - } else if (kind === "cjk") { - // Skip CJK run (Han/Hiragana/Katakana/Hangul) - while (i < graphemes.length && getWordNavKind(graphemes[i]?.segment || "") === "cjk") { - this.#cursor += graphemes[i]?.segment.length || 0; - i++; - } - } else if (kind === "word") { - // Skip word run (letters/numbers/underscore), keeping common joiners inside words. - let hasLeftWord = false; - while (i < graphemes.length) { - const g = graphemes[i]?.segment || ""; - const k = getWordNavKind(g); - if (k === "word") { - hasLeftWord = true; - this.#cursor += g.length; - i++; - continue; - } - if (hasLeftWord && k === "delimiter" && isWordNavJoiner(g)) { - const right = graphemes[i + 1]?.segment || ""; - if (getWordNavKind(right) === "word") { - this.#cursor += g.length; - i++; - continue; - } - } - break; - } - } else { - // Fallback: move by one grapheme - this.#cursor += graphemes[i]?.segment.length || 0; - } - } + this.#cursor = moveWordRight(this.#value, this.#cursor); } #handlePaste(pastedText: string): void { diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index cf095655f..df3930487 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -144,6 +144,120 @@ export function isWordNavJoiner(grapheme: string): boolean { return WORD_NAV_JOINERS.has(ch); } +/** + * Move the cursor one "word" to the left using Unicode-aware coarse navigation. + * + * Returns a new cursor index in the range [0, text.length]. + */ +export function moveWordLeft(text: string, cursor: number): number { + const len = text.length; + if (len === 0) return 0; + let i = Math.min(Math.max(cursor, 0), len); + if (i === 0) return 0; + + const graphemes = [...segmenter.segment(text.slice(0, i))]; + if (graphemes.length === 0) return 0; + + // Skip trailing whitespace. + while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") { + i -= graphemes.pop()?.segment.length || 0; + } + if (i === 0 || graphemes.length === 0) return i; + + const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || ""); + if (kind === "delimiter" || kind === "cjk") { + while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) { + i -= graphemes.pop()?.segment.length || 0; + } + return i; + } + + if (kind === "word") { + // Skip word run (letters/numbers/underscore), keeping common joiners inside words. + let hasRightWord = false; + while (graphemes.length > 0) { + const g = graphemes[graphemes.length - 1]?.segment || ""; + const k = getWordNavKind(g); + if (k === "word") { + hasRightWord = true; + i -= graphemes.pop()?.segment.length || 0; + continue; + } + if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) { + const left = graphemes[graphemes.length - 2]?.segment || ""; + if (getWordNavKind(left) === "word") { + i -= graphemes.pop()?.segment.length || 0; + continue; + } + } + break; + } + return i; + } + + // Fallback: move by one grapheme. + i -= graphemes.pop()?.segment.length || 0; + return Math.max(0, i); +} + +/** + * Move the cursor one "word" to the right using Unicode-aware coarse navigation. + * + * Returns a new cursor index in the range [0, text.length]. + */ +export function moveWordRight(text: string, cursor: number): number { + const len = text.length; + if (len === 0) return 0; + let i = Math.min(Math.max(cursor, 0), len); + if (i === len) return len; + + const iterator = segmenter.segment(text.slice(i))[Symbol.iterator](); + let next = iterator.next(); + + // Skip leading whitespace. + while (!next.done && getWordNavKind(next.value.segment) === "whitespace") { + i += next.value.segment.length; + next = iterator.next(); + } + if (next.done) return i; + + const firstKind = getWordNavKind(next.value.segment); + if (firstKind === "delimiter" || firstKind === "cjk") { + while (!next.done && getWordNavKind(next.value.segment) === firstKind) { + i += next.value.segment.length; + next = iterator.next(); + } + return i; + } + + if (firstKind === "word") { + let hasLeftWord = false; + while (!next.done) { + const segment = next.value.segment; + const k = getWordNavKind(segment); + if (k === "word") { + hasLeftWord = true; + i += segment.length; + next = iterator.next(); + continue; + } + if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) { + const lookahead = iterator.next(); + if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") { + i += segment.length; + next = lookahead; + continue; + } + } + break; + } + return i; + } + + // Fallback: move by one grapheme. + return i + next.value.segment.length; +} + /** * Apply background color to a line, padding to full width. * @@ -164,6 +278,7 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: /** * Extract a range of visible columns from a line. Handles ANSI codes and wide chars. + * * @param strict - If true, exclude wide chars at boundary that would extend past the range */ export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {