fix(tui): leverage Intl.Segmenter for proper Unicode word boundaries (#5022)
This commit is contained in:
@@ -4,10 +4,11 @@ import { decodePrintableKey, matchesKey } from "../keys.ts";
|
|||||||
import { KillRing } from "../kill-ring.ts";
|
import { KillRing } from "../kill-ring.ts";
|
||||||
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
|
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts";
|
||||||
import { UndoStack } from "../undo-stack.ts";
|
import { UndoStack } from "../undo-stack.ts";
|
||||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts";
|
import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts";
|
||||||
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts";
|
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts";
|
||||||
|
|
||||||
const baseSegmenter = getSegmenter();
|
const graphemeSegmenter = getGraphemeSegmenter();
|
||||||
|
const wordSegmenter = getWordSegmenter();
|
||||||
|
|
||||||
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
||||||
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
||||||
@@ -27,7 +28,11 @@ function isPasteMarker(segment: string): boolean {
|
|||||||
*
|
*
|
||||||
* Only markers whose numeric ID exists in `validIds` are merged.
|
* Only markers whose numeric ID exists in `validIds` are merged.
|
||||||
*/
|
*/
|
||||||
function segmentWithMarkers(text: string, validIds: Set<number>): Iterable<Intl.SegmentData> {
|
function segmentWithMarkers(
|
||||||
|
text: string,
|
||||||
|
baseSegmenter: Intl.Segmenter,
|
||||||
|
validIds: Set<number>,
|
||||||
|
): Iterable<Intl.SegmentData> {
|
||||||
// Fast path: no paste markers in the text or no valid IDs.
|
// Fast path: no paste markers in the text or no valid IDs.
|
||||||
if (validIds.size === 0 || !text.includes("[paste #")) {
|
if (validIds.size === 0 || !text.includes("[paste #")) {
|
||||||
return baseSegmenter.segment(text);
|
return baseSegmenter.segment(text);
|
||||||
@@ -109,7 +114,7 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chunks: TextChunk[] = [];
|
const chunks: TextChunk[] = [];
|
||||||
const segments = preSegmented ?? [...baseSegmenter.segment(line)];
|
const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
|
||||||
|
|
||||||
let currentWidth = 0;
|
let currentWidth = 0;
|
||||||
let chunkStart = 0;
|
let chunkStart = 0;
|
||||||
@@ -301,8 +306,8 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
||||||
private segment(text: string): Iterable<Intl.SegmentData> {
|
private segment(text: string, mode: "word" | "grapheme"): Iterable<Intl.SegmentData> {
|
||||||
return segmentWithMarkers(text, this.validPasteIds());
|
return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
|
||||||
}
|
}
|
||||||
|
|
||||||
getPaddingX(): number {
|
getPaddingX(): number {
|
||||||
@@ -482,7 +487,7 @@ export class Editor implements Component, Focusable {
|
|||||||
if (after.length > 0) {
|
if (after.length > 0) {
|
||||||
// Cursor is on a character (grapheme) - replace it with highlighted version
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
||||||
// Get the first grapheme from 'after'
|
// Get the first grapheme from 'after'
|
||||||
const afterGraphemes = [...this.segment(after)];
|
const afterGraphemes = [...this.segment(after, "grapheme")];
|
||||||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||||
const restAfter = after.slice(firstGrapheme.length);
|
const restAfter = after.slice(firstGrapheme.length);
|
||||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||||
@@ -855,7 +860,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Line needs wrapping - use word-aware wrapping
|
// Line needs wrapping - use word-aware wrapping
|
||||||
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
|
||||||
|
|
||||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||||
const chunk = chunks[chunkIndex];
|
const chunk = chunks[chunkIndex];
|
||||||
@@ -1213,7 +1218,7 @@ export class Editor implements Component, Focusable {
|
|||||||
const beforeCursor = line.slice(0, this.state.cursorCol);
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
||||||
|
|
||||||
// Find the last grapheme in the text before cursor
|
// Find the last grapheme in the text before cursor
|
||||||
const graphemes = [...this.segment(beforeCursor)];
|
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
||||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||||
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||||
|
|
||||||
@@ -1314,7 +1319,7 @@ export class Editor implements Component, Focusable {
|
|||||||
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
||||||
// so the cursor never lands in the middle of a multi-grapheme unit.
|
// so the cursor never lands in the middle of a multi-grapheme unit.
|
||||||
// Single-grapheme segments don't need snapping.
|
// Single-grapheme segments don't need snapping.
|
||||||
const segments = [...this.segment(logicalLine)];
|
const segments = [...this.segment(logicalLine, "grapheme")];
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
if (seg.index > this.state.cursorCol) break;
|
if (seg.index > this.state.cursorCol) break;
|
||||||
if (seg.segment.length <= 1) continue;
|
if (seg.segment.length <= 1) continue;
|
||||||
@@ -1585,7 +1590,7 @@ export class Editor implements Component, Focusable {
|
|||||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||||
|
|
||||||
// Find the first grapheme at cursor
|
// Find the first grapheme at cursor
|
||||||
const graphemes = [...this.segment(afterCursor)];
|
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
||||||
const firstGrapheme = graphemes[0];
|
const firstGrapheme = graphemes[0];
|
||||||
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||||
|
|
||||||
@@ -1642,7 +1647,7 @@ export class Editor implements Component, Focusable {
|
|||||||
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
||||||
} else {
|
} else {
|
||||||
// Line needs wrapping - use word-aware wrapping
|
// Line needs wrapping - use word-aware wrapping
|
||||||
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
visualLines.push({
|
visualLines.push({
|
||||||
logicalLine: i,
|
logicalLine: i,
|
||||||
@@ -1707,7 +1712,7 @@ export class Editor implements Component, Focusable {
|
|||||||
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
||||||
if (this.state.cursorCol < currentLine.length) {
|
if (this.state.cursorCol < currentLine.length) {
|
||||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||||
const graphemes = [...this.segment(afterCursor)];
|
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
||||||
const firstGrapheme = graphemes[0];
|
const firstGrapheme = graphemes[0];
|
||||||
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
||||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||||
@@ -1725,7 +1730,7 @@ export class Editor implements Component, Focusable {
|
|||||||
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
||||||
if (this.state.cursorCol > 0) {
|
if (this.state.cursorCol > 0) {
|
||||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
const graphemes = [...this.segment(beforeCursor)];
|
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
||||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||||
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
||||||
} else if (this.state.cursorLine > 0) {
|
} else if (this.state.cursorLine > 0) {
|
||||||
@@ -1769,41 +1774,32 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
const graphemes = [...this.segment(textBeforeCursor)];
|
const segments = [...this.segment(textBeforeCursor, "word")];
|
||||||
let newCol = this.state.cursorCol;
|
let newCol = this.state.cursorCol;
|
||||||
|
|
||||||
// Skip trailing whitespace
|
// Skip trailing whitespace
|
||||||
while (
|
while (
|
||||||
graphemes.length > 0 &&
|
segments.length > 0 &&
|
||||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
!isPasteMarker(segments[segments.length - 1]?.segment || "") &&
|
||||||
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")
|
isWhitespaceChar(segments[segments.length - 1]?.segment || "")
|
||||||
) {
|
) {
|
||||||
newCol -= graphemes.pop()?.segment.length || 0;
|
newCol -= segments.pop()?.segment.length || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphemes.length > 0) {
|
if (segments.length > 0) {
|
||||||
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
const last = segments[segments.length - 1]!;
|
||||||
if (isPasteMarker(lastGrapheme)) {
|
if (isPasteMarker(last.segment) || last.isWordLike) {
|
||||||
// Paste marker is a single atomic word
|
// Skip one word-like segment (or paste marker)
|
||||||
newCol -= graphemes.pop()?.segment.length || 0;
|
newCol -= segments.pop()?.segment.length || 0;
|
||||||
} else if (isPunctuationChar(lastGrapheme)) {
|
|
||||||
// Skip punctuation run
|
|
||||||
while (
|
|
||||||
graphemes.length > 0 &&
|
|
||||||
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
|
||||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
|
|
||||||
) {
|
|
||||||
newCol -= graphemes.pop()?.segment.length || 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Skip word run
|
// Skip non-word non-whitespace run (punctuation)
|
||||||
while (
|
while (
|
||||||
graphemes.length > 0 &&
|
segments.length > 0 &&
|
||||||
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
!isPasteMarker(segments[segments.length - 1]?.segment || "") &&
|
||||||
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
!segments[segments.length - 1]?.isWordLike &&
|
||||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
|
!isWhitespaceChar(segments[segments.length - 1]?.segment || "")
|
||||||
) {
|
) {
|
||||||
newCol -= graphemes.pop()?.segment.length || 0;
|
newCol -= segments.pop()?.segment.length || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1996,7 +1992,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
||||||
const segments = this.segment(textAfterCursor);
|
const segments = this.segment(textAfterCursor, "word");
|
||||||
const iterator = segments[Symbol.iterator]();
|
const iterator = segments[Symbol.iterator]();
|
||||||
let next = iterator.next();
|
let next = iterator.next();
|
||||||
let newCol = this.state.cursorCol;
|
let newCol = this.state.cursorCol;
|
||||||
@@ -2008,23 +2004,16 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!next.done) {
|
if (!next.done) {
|
||||||
const firstGrapheme = next.value.segment;
|
if (isPasteMarker(next.value.segment) || next.value.isWordLike) {
|
||||||
if (isPasteMarker(firstGrapheme)) {
|
// Skip one word-like segment (or paste marker)
|
||||||
// Paste marker is a single atomic word
|
newCol += next.value.segment.length;
|
||||||
newCol += firstGrapheme.length;
|
|
||||||
} else if (isPunctuationChar(firstGrapheme)) {
|
|
||||||
// Skip punctuation run
|
|
||||||
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
|
||||||
newCol += next.value.segment.length;
|
|
||||||
next = iterator.next();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Skip word run
|
// Skip non-word non-whitespace run (punctuation)
|
||||||
while (
|
while (
|
||||||
!next.done &&
|
!next.done &&
|
||||||
!isWhitespaceChar(next.value.segment) &&
|
!isPasteMarker(next.value.segment) &&
|
||||||
!isPunctuationChar(next.value.segment) &&
|
!next.value.isWordLike &&
|
||||||
!isPasteMarker(next.value.segment)
|
!isWhitespaceChar(next.value.segment)
|
||||||
) {
|
) {
|
||||||
newCol += next.value.segment.length;
|
newCol += next.value.segment.length;
|
||||||
next = iterator.next();
|
next = iterator.next();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { decodeKittyPrintable } from "../keys.ts";
|
|||||||
import { KillRing } from "../kill-ring.ts";
|
import { KillRing } from "../kill-ring.ts";
|
||||||
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts";
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts";
|
||||||
import { UndoStack } from "../undo-stack.ts";
|
import { UndoStack } from "../undo-stack.ts";
|
||||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
|
import { getGraphemeSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts";
|
||||||
|
|
||||||
const segmenter = getSegmenter();
|
const segmenter = getGraphemeSegmenter();
|
||||||
|
|
||||||
interface InputState {
|
interface InputState {
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { eastAsianWidth } from "get-east-asian-width";
|
import { eastAsianWidth } from "get-east-asian-width";
|
||||||
|
|
||||||
// Grapheme segmenter (shared instance)
|
// segmenters (shared instance)
|
||||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||||
|
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the shared grapheme segmenter instance.
|
* Get the shared grapheme segmenter instance.
|
||||||
*/
|
*/
|
||||||
export function getSegmenter(): Intl.Segmenter {
|
export function getGraphemeSegmenter(): Intl.Segmenter {
|
||||||
return segmenter;
|
return graphemeSegmenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shared word segmenter instance.
|
||||||
|
*/
|
||||||
|
export function getWordSegmenter(): Intl.Segmenter {
|
||||||
|
return wordSegmenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +70,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string
|
|||||||
if (!hasAnsi && !hasTabs) {
|
if (!hasAnsi && !hasTabs) {
|
||||||
let result = "";
|
let result = "";
|
||||||
let width = 0;
|
let width = 0;
|
||||||
for (const { segment } of segmenter.segment(text)) {
|
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||||
const w = graphemeWidth(segment);
|
const w = graphemeWidth(segment);
|
||||||
if (width + w > maxWidth) {
|
if (width + w > maxWidth) {
|
||||||
break;
|
break;
|
||||||
@@ -109,7 +117,7 @@ function truncateFragmentToWidth(text: string, maxWidth: number): { text: string
|
|||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { segment } of segmenter.segment(text.slice(i, end))) {
|
for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
|
||||||
const w = graphemeWidth(segment);
|
const w = graphemeWidth(segment);
|
||||||
if (width + w > maxWidth) {
|
if (width + w > maxWidth) {
|
||||||
return { text: result, width };
|
return { text: result, width };
|
||||||
@@ -239,7 +247,7 @@ export function visibleWidth(str: string): number {
|
|||||||
|
|
||||||
// Calculate width
|
// Calculate width
|
||||||
let width = 0;
|
let width = 0;
|
||||||
for (const { segment } of segmenter.segment(clean)) {
|
for (const { segment } of graphemeSegmenter.segment(clean)) {
|
||||||
width += graphemeWidth(segment);
|
width += graphemeWidth(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +798,7 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s
|
|||||||
}
|
}
|
||||||
// Segment this non-ANSI portion into graphemes
|
// Segment this non-ANSI portion into graphemes
|
||||||
const textPortion = word.slice(i, end);
|
const textPortion = word.slice(i, end);
|
||||||
for (const seg of segmenter.segment(textPortion)) {
|
for (const seg of graphemeSegmenter.segment(textPortion)) {
|
||||||
segments.push({ type: "grapheme", value: seg.segment });
|
segments.push({ type: "grapheme", value: seg.segment });
|
||||||
}
|
}
|
||||||
i = end;
|
i = end;
|
||||||
@@ -912,7 +920,7 @@ export function truncateToWidth(
|
|||||||
const hasTabs = text.includes("\t");
|
const hasTabs = text.includes("\t");
|
||||||
|
|
||||||
if (!hasAnsi && !hasTabs) {
|
if (!hasAnsi && !hasTabs) {
|
||||||
for (const { segment } of segmenter.segment(text)) {
|
for (const { segment } of graphemeSegmenter.segment(text)) {
|
||||||
const width = graphemeWidth(segment);
|
const width = graphemeWidth(segment);
|
||||||
if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
|
if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
|
||||||
result += segment;
|
result += segment;
|
||||||
@@ -967,7 +975,7 @@ export function truncateToWidth(
|
|||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { segment } of segmenter.segment(text.slice(i, end))) {
|
for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
|
||||||
const width = graphemeWidth(segment);
|
const width = graphemeWidth(segment);
|
||||||
if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
|
if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
|
||||||
if (pendingAnsi) {
|
if (pendingAnsi) {
|
||||||
@@ -1037,7 +1045,7 @@ export function sliceWithWidth(
|
|||||||
let textEnd = i;
|
let textEnd = i;
|
||||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||||
|
|
||||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
|
||||||
const w = graphemeWidth(segment);
|
const w = graphemeWidth(segment);
|
||||||
const inRange = currentCol >= startCol && currentCol < endCol;
|
const inRange = currentCol >= startCol && currentCol < endCol;
|
||||||
const fits = !strict || currentCol + w <= endCol;
|
const fits = !strict || currentCol + w <= endCol;
|
||||||
@@ -1105,7 +1113,7 @@ export function extractSegments(
|
|||||||
let textEnd = i;
|
let textEnd = i;
|
||||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||||
|
|
||||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
|
||||||
const w = graphemeWidth(segment);
|
const w = graphemeWidth(segment);
|
||||||
|
|
||||||
if (currentCol < beforeEnd) {
|
if (currentCol < beforeEnd) {
|
||||||
|
|||||||
@@ -591,6 +591,82 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo'
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops at fullwidth Chinese punctuation (issue #4972)", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// 你好,世界 = 你好(0-2) ,(2-3) 世界(3-5)
|
||||||
|
editor.setText("你好,世界");
|
||||||
|
// Cursor at end (col 5)
|
||||||
|
|
||||||
|
// Move left over 世界
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after ,
|
||||||
|
|
||||||
|
// Move left over ,
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好
|
||||||
|
|
||||||
|
// Move left over 你好
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start
|
||||||
|
|
||||||
|
// Move right over 你好
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好
|
||||||
|
|
||||||
|
// Move right over ,
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after ,
|
||||||
|
|
||||||
|
// Move right over 世界
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // end
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed CJK and ASCII word movement", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// "hello你好,world世界" = hello(0-5) 你好(5-7) ,(7-8) world(8-13) 世界(13-15)
|
||||||
|
editor.setText("hello你好,world世界");
|
||||||
|
// Cursor at end (col 15)
|
||||||
|
|
||||||
|
// Move left over 世界
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world'
|
||||||
|
|
||||||
|
// Move left over world
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after ,
|
||||||
|
|
||||||
|
// Move left over ,
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好
|
||||||
|
|
||||||
|
// Move left over 你好
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello'
|
||||||
|
|
||||||
|
// Move left over hello
|
||||||
|
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start
|
||||||
|
|
||||||
|
// Forward from start
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello'
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after ,
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world'
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
|
||||||
|
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); // end
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Grapheme-aware text wrapping", () => {
|
describe("Grapheme-aware text wrapping", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user