diff --git a/.changeset/wide-char-copy-selection.md b/.changeset/wide-char-copy-selection.md new file mode 100644 index 00000000..f84d2050 --- /dev/null +++ b/.changeset/wide-char-copy-selection.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Fix mouse-selection copy misalignment on lines with wide (CJK, emoji) characters: drag, double-click, and triple-click selections now convert terminal cell columns into string indices before slicing, so the copied text matches the selected cells exactly. File-header rows with wide-character filenames now copy with the same cell alignment, and invisible zero-width characters at a selection boundary round-trip through the clipboard. diff --git a/src/ui/AppHost.selection.test.tsx b/src/ui/AppHost.selection.test.tsx index a1a13552..416b6fb6 100644 --- a/src/ui/AppHost.selection.test.tsx +++ b/src/ui/AppHost.selection.test.tsx @@ -5,6 +5,7 @@ import { act } from "react"; import type { AppBootstrap } from "../core/types"; import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { measureTextWidth } from "./lib/text"; // These tests drive the DiffPane mouse-drag text-selection path end to end: begin/update/end // copy-selection, double/triple-click word and line expansion, and the OSC 52 clipboard copy. @@ -42,6 +43,24 @@ function createSplitSelectionBootstrap(): AppBootstrap { return { ...bootstrap, initialMode: "split", input: { ...bootstrap.input } }; } +/** Build a diff whose changed line mixes full-width (CJK) and ASCII characters. */ +function createWideCharSelectionBootstrap(): AppBootstrap { + return createTestVcsAppBootstrap({ + changesetId: "changeset:copy-selection-cjk", + files: [ + createTestDiffFile({ + id: "i18n", + path: "i18n.ts", + before: lines("export const message = 'hello'; // greeting"), + after: lines("export const message = 'こんにちは'; // greeting"), + context: 1, + }), + ], + initialMode: "stack", + initialCopyDecorations: true, + }); +} + type Harness = Awaited>; /** Settle pending renders so a frame reflects the latest interaction. */ @@ -158,6 +177,43 @@ describe("DiffPane copy selection", () => { } }); + test("dragging across wide (CJK) characters copies exactly the selected cells", async () => { + const { setup, copied } = await renderSelectionApp(createWideCharSelectionBootstrap()); + + try { + const frame = setup.captureCharFrame(); + const wide = locateText(frame, "こんにちは"); + expect(wide).not.toBeNull(); + + // Everything left of the code is single-cell glyphs, so the string index of the code + // start is also its screen column; the drag end is then measured in terminal cells. + const row = frame.split("\n")[wide!.y]!; + const startX = row.indexOf("export const message"); + expect(startX).toBeGreaterThanOrEqual(0); + const throughWide = "export const message = 'こんにちは"; + + await act(async () => { + await setup.mockMouse.drag( + startX, + wide!.y, + startX + measureTextWidth(throughWide) - 1, + wide!.y, + MouseButtons.LEFT, + ); + }); + await flush(setup); + + // Cell-aware slicing keeps the copy aligned with the drag: without it, each full-width + // character shifted the endpoint and the clipboard over-included "'; //". + expect(copied.length).toBeGreaterThan(0); + expect(copied[copied.length - 1]).toBe(throughWide); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("double-clicking a token selects and copies the word under the pointer", async () => { const { setup, copied } = await renderSelectionApp(createSelectionBootstrap()); diff --git a/src/ui/components/panes/copySelection.test.ts b/src/ui/components/panes/copySelection.test.ts index 86223442..b6114b37 100644 --- a/src/ui/components/panes/copySelection.test.ts +++ b/src/ui/components/panes/copySelection.test.ts @@ -23,6 +23,7 @@ import { resolveStackCellGeometry, resolveSplitPaneWidths, } from "../../diff/codeColumns"; +import { measureTextWidth } from "../../lib/text"; const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const CSI_CLEAR_SCREEN = "\x1b[2J"; @@ -100,6 +101,36 @@ function createMaliciousDiffFile(): DiffFile { }; } +function createCjkDiffFile(): DiffFile { + const metadata = parseDiffFromFile( + { + name: "i18n.ts", + contents: "export const message = 'hello'; // greeting\n", + cacheKey: "cjk-before", + }, + { + name: "i18n.ts", + contents: "export const message = 'こんにちは'; // greeting\n", + cacheKey: "cjk-after", + }, + { context: 3 }, + true, + ); + + return { + id: "i18n", + path: "i18n.ts", + patch: "", + language: "typescript", + stats: { + additions: 1, + deletions: 1, + }, + metadata, + agent: null, + }; +} + function buildContext( layout: "stack" | "split" = "stack", width = 120, @@ -713,3 +744,241 @@ describe("renderCopySelectionText in split with side", () => { expect(text).toContain("export const answer = 42;"); }); }); + +describe("copy selection with wide (CJK) characters", () => { + // "export const message = 'こんにちは" is 29 code units but 34 terminal cells: each full-width + // character covers two cells, so cell columns and code-unit indices drift apart on this line. + const cjkLine = "export const message = 'こんにちは'; // greeting"; + const throughWide = "export const message = 'こんにちは"; + + function buildCjkContext() { + const { context, fileSectionLayouts, sectionGeometry } = buildContext( + "stack", + 120, + createCjkDiffFile(), + ); + const section = fileSectionLayouts[0]!; + const geometry = sectionGeometry[0]!; + const { gutterWidth } = resolveStackCellGeometry( + context.width, + geometry.lineNumberDigits, + context.showLineNumbers, + DIFF_RAIL_PREFIX_WIDTH, + ); + const codeStart = DIFF_RAIL_PREFIX_WIDTH + gutterWidth; + const codeOnlyContext: CopySelectionContext = { ...context, copyDecorations: false }; + + // Locate the CJK addition row through full-width row copies so the tests do not + // hard-code the planned-row layout. + let visualRow = -1; + for (let row = section.bodyTop; row < section.bodyTop + section.bodyHeight; row += 1) { + const text = renderCopySelectionText({ + context: codeOnlyContext, + start: { kind: "review-row", column: 0, visualRow: row }, + end: { kind: "review-row", column: context.width - 1, visualRow: row }, + }); + if (text.includes("こんにちは")) { + visualRow = row; + break; + } + } + expect(visualRow).toBeGreaterThanOrEqual(0); + + return { context, codeOnlyContext, codeStart, visualRow }; + } + + test("code-only selection ending after the wide run copies exactly the selected cells", () => { + const { codeOnlyContext, codeStart, visualRow } = buildCjkContext(); + + const text = renderCopySelectionText({ + context: codeOnlyContext, + start: { kind: "review-row", column: codeStart, visualRow }, + end: { + kind: "review-row", + column: codeStart + measureTextWidth(throughWide) - 1, + visualRow, + }, + }); + + expect(text).toBe(throughWide); + }); + + test("code-only selection starting after the wide run copies exactly the selected cells", () => { + const { codeOnlyContext, codeStart, visualRow } = buildCjkContext(); + + const text = renderCopySelectionText({ + context: codeOnlyContext, + start: { + kind: "review-row", + column: codeStart + measureTextWidth(throughWide), + visualRow, + }, + end: { kind: "review-row", column: codeOnlyContext.width - 1, visualRow }, + }); + + expect(text).toBe("'; // greeting"); + }); + + test("decorated selection ending after the wide run copies exactly the selected cells", () => { + const { context, codeStart, visualRow } = buildCjkContext(); + + const text = renderCopySelectionText({ + context, + start: { kind: "review-row", column: codeStart, visualRow }, + end: { + kind: "review-row", + column: codeStart + measureTextWidth(throughWide) - 1, + visualRow, + }, + }); + + expect(text).toBe(throughWide); + }); + + test("double-click on a word after the wide run selects the word measured in cells", () => { + const { context, codeStart, visualRow } = buildCjkContext(); + const wordStartCell = measureTextWidth("export const message = 'こんにちは'; // "); + const point: CopySelectionPoint = { + kind: "review-row", + // Click inside "greeting". + column: codeStart + wordStartCell + 2, + visualRow, + }; + + const result = expandSelectionPoint(point, 2, context); + + expect(result).toEqual({ + startCol: codeStart + wordStartCell, + endCol: codeStart + wordStartCell + "greeting".length - 1, + }); + }); + + test("double-click on a wide character selects both of its terminal cells", () => { + const { context, codeStart, visualRow } = buildCjkContext(); + // "ん" covers two cells; clicking the second cell must still select the whole character. + const wideCharStartCell = measureTextWidth("export const message = 'こ"); + const point: CopySelectionPoint = { + kind: "review-row", + column: codeStart + wideCharStartCell + 1, + visualRow, + }; + + const result = expandSelectionPoint(point, 2, context); + + expect(result).toEqual({ + startCol: codeStart + wideCharStartCell, + endCol: codeStart + wideCharStartCell + 1, + }); + }); + + test("code-only triple-click covers the full cell width of the line", () => { + const { codeOnlyContext, codeStart, visualRow } = buildCjkContext(); + const point: CopySelectionPoint = { + kind: "review-row", + column: codeStart + 2, + visualRow, + }; + + const result = expandSelectionPoint(point, 3, codeOnlyContext); + + expect(result).toEqual({ + startCol: codeStart, + endCol: codeStart + measureTextWidth(cjkLine) - 1, + }); + }); + + test("pinned-header selection stays cell-aligned for wide-character filenames", () => { + const metadata = parseDiffFromFile( + { name: "日本語.ts", contents: "export const a = 1;\n", cacheKey: "cjk-path-before" }, + { name: "日本語.ts", contents: "export const a = 2;\n", cacheKey: "cjk-path-after" }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "cjk-path", + path: "日本語.ts", + patch: "", + language: "typescript", + stats: { additions: 1, deletions: 1 }, + metadata, + agent: null, + }; + const { context, fileSectionLayouts } = buildContext("stack", 120, file); + const nextVisualRow = fileSectionLayouts[0]!.bodyTop; + + const headerCells = (startColumn: number, endColumn: number) => + renderCopySelectionText({ + context, + start: { kind: "pinned-header", column: startColumn, fileId: "cjk-path", nextVisualRow }, + end: { kind: "pinned-header", column: endColumn, fileId: "cjk-path", nextVisualRow }, + }); + + // The label starts after one padding cell: "日本語.ts" spans cells 1..9. + expect(headerCells(1, 9)).toBe("日本語.ts"); + + // DiffFileHeaderRow right-aligns the stats, so "-1" ends at cell width - 3 (one trailing + // stats space plus one padding cell). A code-unit gap would shift these cells onto "+1". + expect(headerCells(context.width - 4, context.width - 3)).toBe("-1"); + }); +}); + +describe("copy selection with zero-width characters", () => { + test("selection starting at a zero-width boundary keeps the invisible character", () => { + // A mid-line U+200B survives rendering (only leading zero-width clusters are dropped by + // sliceTextByWidth), so copying must round-trip it for the invisible bug to stay visible + // in the pasted text. + const metadata = parseDiffFromFile( + { name: "zero.ts", contents: "const x = 1;\n", cacheKey: "zero-before" }, + { + name: "zero.ts", + contents: "const x = 1;\nconst zw = 'a\u200bb';\n", + cacheKey: "zero-after", + }, + { context: 3 }, + true, + ); + const file: DiffFile = { + id: "zero", + path: "zero.ts", + patch: "", + language: "typescript", + stats: { additions: 1, deletions: 0 }, + metadata, + agent: null, + }; + const { context, fileSectionLayouts, sectionGeometry } = buildContext("stack", 120, file); + const section = fileSectionLayouts[0]!; + const geometry = sectionGeometry[0]!; + const { gutterWidth } = resolveStackCellGeometry( + context.width, + geometry.lineNumberDigits, + context.showLineNumbers, + DIFF_RAIL_PREFIX_WIDTH, + ); + const codeStart = DIFF_RAIL_PREFIX_WIDTH + gutterWidth; + const codeOnlyContext: CopySelectionContext = { ...context, copyDecorations: false }; + + let visualRow = -1; + for (let row = section.bodyTop; row < section.bodyTop + section.bodyHeight; row += 1) { + const text = renderCopySelectionText({ + context: codeOnlyContext, + start: { kind: "review-row", column: 0, visualRow: row }, + end: { kind: "review-row", column: context.width - 1, visualRow: row }, + }); + if (text.includes("zw")) { + visualRow = row; + break; + } + } + expect(visualRow).toBeGreaterThanOrEqual(0); + + // "const zw = 'a" is 13 cells; the zero-width character sits at that boundary before "b". + const text = renderCopySelectionText({ + context: codeOnlyContext, + start: { kind: "review-row", column: codeStart + 13, visualRow }, + end: { kind: "review-row", column: codeOnlyContext.width - 1, visualRow }, + }); + + expect(text).toBe("\u200bb';"); + }); +}); diff --git a/src/ui/components/panes/copySelection.ts b/src/ui/components/panes/copySelection.ts index 5f213035..75d40446 100644 --- a/src/ui/components/panes/copySelection.ts +++ b/src/ui/components/panes/copySelection.ts @@ -12,7 +12,7 @@ import { } from "../../diff/diffSectionGeometry"; import type { FileSectionLayout } from "../../lib/fileSectionLayout"; import { fileLabelParts } from "../../lib/files"; -import { fitText } from "../../lib/text"; +import { cellRangeToCharRange, fitText, measureTextWidth, sliceTextByWidth } from "../../lib/text"; import type { AppTheme } from "../../themes"; export type CopySelectionPoint = @@ -139,6 +139,12 @@ function trimCopiedLine(line: string) { return line.replace(/[ \t]+$/g, ""); } +/** Slice one rendered line by an inclusive terminal-cell range (see cellRangeToCharRange). */ +function sliceLineByCells(line: string, startCell: number, endCell: number) { + const { startIndex, endIndex } = cellRangeToCharRange(line, startCell, endCell); + return line.slice(startIndex, endIndex); +} + /** Return whether a character should be part of a double-click word selection. */ function isCopyWordChar(char: string | undefined) { return char !== undefined && /[A-Za-z0-9_$]/.test(char); @@ -164,9 +170,13 @@ function renderFileHeaderCopyText({ filename, Math.max(1, headerLabelWidth - (stateLabel?.length ?? 0)), )}${stateLabel ?? ""}`; - const availableGap = Math.max(1, width - 2 - label.length - statsText.length); + // The gap and clamp are measured in cells to mirror DiffFileHeaderRow's space-between flex + // layout, so wide-character filenames keep the stats columns aligned with the screen. + const availableGap = Math.max(1, width - 2 - measureTextWidth(label) - statsText.length); + const headerLine = ` ${label}${" ".repeat(availableGap)}${statsText} `; + const clamped = sliceTextByWidth(headerLine, 0, width); - return ` ${label}${" ".repeat(availableGap)}${statsText} `.slice(0, width).padEnd(width); + return `${clamped.text}${" ".repeat(Math.max(0, width - clamped.width))}`; } // The "pinned-header" point variant is constructed inline by callers that observe a click on the @@ -272,8 +282,8 @@ export function renderCopySelectionText({ const endColumn = end.kind === "pinned-header" && end.fileId === start.fileId ? end.column - : Math.max(0, line.length - 1); - lines.push(trimCopiedLine(line.slice(start.column, endColumn + 1))); + : Number.MAX_SAFE_INTEGER; + lines.push(trimCopiedLine(sliceLineByCells(line, start.column, endColumn))); } const { startRow, endRow } = copySelectionBodyRange(start, end); @@ -302,8 +312,8 @@ export function renderCopySelectionText({ const endColumn = end.kind === "review-row" && section.headerTop === end.visualRow ? end.column - : Math.max(0, line.length - 1); - lines.push(trimCopiedLine(line.slice(startColumn, endColumn + 1))); + : Number.MAX_SAFE_INTEGER; + lines.push(trimCopiedLine(sliceLineByCells(line, startColumn, endColumn))); } } @@ -382,9 +392,9 @@ export function renderCopySelectionText({ : 0; const endColumn = end.kind === "review-row" && lineVisualRow === end.visualRow - ? Math.min(Math.max(0, line.length - 1), Math.max(0, end.column - codeColumnOffset)) - : Math.max(0, line.length - 1); - const copiedLine = trimCopiedLine(line.slice(startColumn, endColumn + 1)); + ? Math.max(0, end.column - codeColumnOffset) + : Number.MAX_SAFE_INTEGER; + const copiedLine = trimCopiedLine(sliceLineByCells(line, startColumn, endColumn)); if (copiedLine) { lines.push(copiedLine); } @@ -415,9 +425,9 @@ export function renderCopySelectionText({ : 0; const endColumn = end.kind === "review-row" && lineVisualRow === end.visualRow - ? Math.min(Math.max(0, line.length - 1), Math.max(0, end.column - paneOffset)) - : Math.max(0, line.length - 1); - lines.push(trimCopiedLine(line.slice(startColumn, endColumn + 1))); + ? Math.max(0, end.column - paneOffset) + : Number.MAX_SAFE_INTEGER; + lines.push(trimCopiedLine(sliceLineByCells(line, startColumn, endColumn))); } } } @@ -533,27 +543,38 @@ export function expandSelectionPoint( return null; } + // Column math stays in terminal cells; word detection below walks code units and converts + // back through cellRangeToCharRange / measureTextWidth. + const lineWidth = measureTextWidth(lineText); + if (clickCount === 3) { return { startCol: globalContentStart, - endCol: globalContentStart + lineText.length - 1, + // A line of only zero-width characters measures 0 cells; clamp so the range never inverts. + endCol: globalContentStart + Math.max(0, lineWidth - 1), }; } - // Convert the global click column to a code-local column. - const localCol = Math.max(0, Math.min(lineText.length - 1, point.column - globalContentStart)); + // Convert the global click column to a code-local cell, then resolve the covering cluster. + const localCell = Math.max(0, Math.min(lineWidth - 1, point.column - globalContentStart)); + const cluster = cellRangeToCharRange(lineText, localCell, localCell); // Punctuation and whitespace are separators for word selection; selecting just the clicked // separator matches terminal/editor double-click behavior without swallowing code punctuation. - if (!isCopyWordChar(lineText[localCol])) { + if (!isCopyWordChar(lineText[cluster.startIndex])) { + const clusterStartCell = measureTextWidth(lineText.slice(0, cluster.startIndex)); + const clusterWidth = Math.max( + 1, + measureTextWidth(lineText.slice(cluster.startIndex, cluster.endIndex)), + ); return { - startCol: localCol + globalContentStart, - endCol: localCol + globalContentStart, + startCol: clusterStartCell + globalContentStart, + endCol: clusterStartCell + clusterWidth - 1 + globalContentStart, }; } - let wordStart = localCol; - let wordEnd = localCol; + let wordStart = cluster.startIndex; + let wordEnd = cluster.startIndex; // Expand left to word start. while (wordStart > 0 && isCopyWordChar(lineText[wordStart - 1])) { @@ -564,11 +585,11 @@ export function expandSelectionPoint( wordEnd += 1; } - // Convert back to global columns. wordEnd is exclusive (one past last char), - // so endCol = wordEnd - 1 is inclusive. + // Convert the code-unit word bounds back to cell columns. wordEnd is exclusive (one past + // the last char), so the inclusive endCol is the width through wordEnd minus one. return { - startCol: wordStart + globalContentStart, - endCol: wordEnd - 1 + globalContentStart, + startCol: globalContentStart + measureTextWidth(lineText.slice(0, wordStart)), + endCol: globalContentStart + measureTextWidth(lineText.slice(0, wordEnd)) - 1, }; } diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index a9e63099..43dd260a 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -126,6 +126,63 @@ export function sliceTextByWidth(text: string, offset: number, width: number) { return { text: visibleText, width: usedWidth }; } +/** + * Convert an inclusive terminal-cell range into string slice bounds (`endIndex` exclusive). + * + * Mouse selection coordinates are terminal cells while `String#slice` counts UTF-16 code units, + * and the two drift apart on non-ASCII lines: a full-width CJK character is one code unit but + * two cells, and an emoji cluster can be many code units but two cells. Clusters only partially + * covered by the cell range are included in full so a selection can never split a wide + * character, and zero-width clusters at the range start are kept so invisible characters + * round-trip through the clipboard. Indices refer to `text` as given; callers pass rendered + * lines that are already sanitized (sanitizing here could shift the returned indices off the + * caller's string). Callers must pass a normalized range (`startCell <= endCell`); inverted + * ranges are unspecified. + */ +export function cellRangeToCharRange(text: string, startCell: number, endCell: number) { + const safeStartCell = Math.max(0, startCell); + + if (printableAsciiRegex.test(text)) { + const startIndex = Math.min(text.length, safeStartCell); + return { + startIndex, + endIndex: Math.min(text.length, Math.max(startIndex, endCell + 1)), + }; + } + + let cellCursor = 0; + let unitCursor = 0; + let startIndex = -1; + let endIndex = text.length; + + for (const cluster of textClusters(text)) { + // The current cluster starts past the end cell, so everything before it is the slice end. + if (cellCursor > endCell) { + endIndex = unitCursor; + break; + } + + const clusterWidth = measureSanitizedTextWidth(cluster); + // Zero-width clusters (e.g. U+200B) occupy no cell, so they never satisfy the covering + // check; attach them to a range that starts at their position instead of dropping them. + const coversStart = + clusterWidth > 0 ? cellCursor + clusterWidth > safeStartCell : cellCursor >= safeStartCell; + if (startIndex < 0 && coversStart) { + startIndex = unitCursor; + } + + cellCursor += clusterWidth; + unitCursor += cluster.length; + } + + // A range starting past the end of the line resolves to an empty slice at the text end. + if (startIndex < 0) { + startIndex = text.length; + } + + return { startIndex, endIndex: Math.max(startIndex, endIndex) }; +} + /** Clamp text to a fixed width using a plain-dot terminal fallback marker. */ export function fitText(text: string, width: number) { const safeText = sanitizeTerminalLine(text); diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 098ea5cb..5ea6261c 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -23,7 +23,7 @@ import { isStepDownKey, isStepUpKey, } from "./keyboard"; -import { fitText, measureTextWidth, padText, sliceTextByWidth } from "./text"; +import { cellRangeToCharRange, fitText, measureTextWidth, padText, sliceTextByWidth } from "./text"; import { computeHunkRevealScrollTop } from "./hunkScroll"; import { estimateDiffSectionBodyRows, @@ -272,6 +272,33 @@ describe("ui helpers", () => { expect(measureTextWidth(padText("日本", 6))).toBe(6); }); + test("cellRangeToCharRange maps inclusive cell ranges onto code-unit slice bounds", () => { + // ASCII: cells and code units are identical, and out-of-range cells clamp to the text. + expect(cellRangeToCharRange("hello", 1, 3)).toEqual({ startIndex: 1, endIndex: 4 }); + expect(cellRangeToCharRange("hello", 0, 99)).toEqual({ startIndex: 0, endIndex: 5 }); + + // "a日本b" layout: a=cell 0, 日=cells 1-2, 本=cells 3-4, b=cell 5. + expect(cellRangeToCharRange("a日本b", 1, 2)).toEqual({ startIndex: 1, endIndex: 2 }); + expect(cellRangeToCharRange("a日本b", 3, 5)).toEqual({ startIndex: 2, endIndex: 4 }); + expect(cellRangeToCharRange("a日本b", 6, 9)).toEqual({ startIndex: 4, endIndex: 4 }); + + // Clusters partially covered by the cell range are included in full on both edges. + expect(cellRangeToCharRange("a日本b", 2, 3)).toEqual({ startIndex: 1, endIndex: 3 }); + + // Surrogate-pair emoji (👍 = two code units, two cells) never split mid-pair. + expect(cellRangeToCharRange("👍a", 1, 2)).toEqual({ startIndex: 0, endIndex: 3 }); + expect(cellRangeToCharRange("👍a", 2, 2)).toEqual({ startIndex: 2, endIndex: 3 }); + + // ZWJ emoji sequences stay one cluster (🧑‍💻 = five code units, two cells). + expect(cellRangeToCharRange("🧑‍💻x", 1, 1)).toEqual({ startIndex: 0, endIndex: 5 }); + expect(cellRangeToCharRange("🧑‍💻x", 2, 2)).toEqual({ startIndex: 5, endIndex: 6 }); + + // Zero-width characters at the range start are kept so invisible characters round-trip, + // while zero-width characters strictly before the range stay excluded. + expect(cellRangeToCharRange("\u200bab", 0, 0)).toEqual({ startIndex: 0, endIndex: 2 }); + expect(cellRangeToCharRange("\u200bab", 1, 1)).toEqual({ startIndex: 2, endIndex: 3 }); + }); + test("repeated single-character runs use the fast width path without losing correctness", () => { // Chrome glyph separators: single-cell non-ASCII characters repeated to fill a row. expect(measureTextWidth("─".repeat(240))).toBe(240);