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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user