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.
This commit is contained in:
can1357
2026-03-04 15:58:58 +01:00
parent e4fa6afffc
commit 5ced3a640e
4 changed files with 134 additions and 212 deletions
+3
View File
@@ -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
+5 -109
View File
@@ -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)
+11 -103
View File
@@ -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 {
+115
View File
@@ -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 {