fix(tui): align input word segmentation with editor (#5068)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
117
packages/tui/src/word-navigation.ts
Normal file
117
packages/tui/src/word-navigation.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
191
packages/tui/test/word-navigation.test.ts
Normal file
191
packages/tui/test/word-navigation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user