diff --git a/src/ui/AppHost.scroll-regression.test.tsx b/src/ui/AppHost.scroll-regression.test.tsx
index a39e7dcb..81685188 100644
--- a/src/ui/AppHost.scroll-regression.test.tsx
+++ b/src/ui/AppHost.scroll-regression.test.tsx
@@ -34,6 +34,27 @@ function createScrollBootstrap(): AppBootstrap {
});
}
+function createCjkUntrackedScrollBootstrap(): AppBootstrap {
+ const cjkPhrase = "這是一段很長的中文內容用來驗證分割視圖滑鼠捲動";
+ const after = Array.from(
+ { length: 40 },
+ (_, index) => `第${String(index + 1).padStart(2, "0")}行${cjkPhrase.repeat(5)}\n`,
+ ).join("");
+
+ return createTestVcsAppBootstrap({
+ changesetId: "scroll-regression-cjk",
+ files: [
+ createTestDiffFile({
+ after,
+ before: "",
+ context: 3,
+ id: "cjk-new",
+ path: "notes.md",
+ }),
+ ],
+ });
+}
+
describe("UI scroll regression", () => {
test("keeps split diff lines intact after a wheel scroll repaint", async () => {
const setup = await testRender(, {
@@ -69,4 +90,33 @@ describe("UI scroll regression", () => {
});
}
});
+
+ test("clips CJK split additions after a wheel scroll repaint", async () => {
+ const setup = await testRender(, {
+ width: 80,
+ height: 14,
+ });
+
+ try {
+ await act(async () => {
+ await setup.renderOnce();
+ await Bun.sleep(100);
+ await setup.renderOnce();
+ });
+
+ await act(async () => {
+ await setup.mockMouse.scroll(50, 8, "down");
+ await Bun.sleep(0);
+ await setup.renderOnce();
+ });
+
+ const scrolledFrame = setup.captureCharFrame();
+ expect(scrolledFrame).toContain("▌ 4 + 第04行這是一段很長的中文");
+ expect(scrolledFrame).not.toMatch(/\n[^\S\r\n]*滑鼠捲動\s*\n/);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
});
diff --git a/src/ui/diff/codeColumns.test.ts b/src/ui/diff/codeColumns.test.ts
index 356e687d..e96fa24a 100644
--- a/src/ui/diff/codeColumns.test.ts
+++ b/src/ui/diff/codeColumns.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { DiffFile } from "../../core/types";
-import { findMaxLineNumberInRows, maxFileCodeLineWidth } from "./codeColumns";
+import { findMaxLineNumberInRows, maxFileCodeLineWidth, measureRenderedCodeLineWidth } from "./codeColumns";
import type { DiffRow } from "./pierre";
/** Generate a large diff metadata fixture without checking a huge file into the repo. */
@@ -30,10 +30,13 @@ describe("code column measurement", () => {
expect(maxFileCodeLineWidth(file)).toBe("the widest generated line".length);
});
- test("counts wide CJK characters by terminal cells", () => {
- const file = createLargeLineFixture(2, "日本語");
+ test("measures CJK lines in terminal cells", () => {
+ const file = createLargeLineFixture(3, "かなカナ漢字 mixed");
- expect(maxFileCodeLineWidth(file)).toBe(6);
+ expect(measureRenderedCodeLineWidth("中文 mixed")).toBe(10);
+ expect(measureRenderedCodeLineWidth("かなカナ漢字 mixed")).toBe(18);
+ expect(measureRenderedCodeLineWidth("한글 테스트 mixed")).toBe(17);
+ expect(maxFileCodeLineWidth(file)).toBe(18);
});
});
diff --git a/src/ui/diff/codeColumns.ts b/src/ui/diff/codeColumns.ts
index be11d786..926c58ef 100644
--- a/src/ui/diff/codeColumns.ts
+++ b/src/ui/diff/codeColumns.ts
@@ -1,5 +1,5 @@
import type { DiffFile, LayoutMode } from "../../core/types";
-import { measureTextWidth } from "../lib/text";
+import { terminalCellWidth } from "../lib/text";
import type { DiffRow } from "./pierre";
export const DIFF_CODE_TAB_WIDTH = 2;
@@ -15,7 +15,7 @@ export function expandDiffTabs(text: string) {
/** Measure one rendered code line after tab expansion and newline trimming. */
export function measureRenderedCodeLineWidth(line: string | undefined) {
- return measureTextWidth(expandDiffTabs((line ?? "").replace(/\n$/, "")));
+ return terminalCellWidth(expandDiffTabs((line ?? "").replace(/\n$/, "")));
}
/** Track the widest rendered code line for one file. */
diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts
index a46b4504..bddfd18b 100644
--- a/src/ui/lib/text.ts
+++ b/src/ui/lib/text.ts
@@ -1,69 +1,153 @@
-import stringWidth from "string-width";
import { sanitizeTerminalLine } from "../../lib/terminalText";
-const printableAsciiRegex = /^[\u0020-\u007E]*$/;
-const graphemeSegmenter =
- typeof Intl !== "undefined" && "Segmenter" in Intl
- ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
- : null;
+/** Return whether a Unicode code point has zero visible terminal width. */
+function isZeroWidthCodePoint(codePoint: number) {
+ return (
+ codePoint === 0 ||
+ codePoint === 0x200b ||
+ codePoint === 0x200c ||
+ codePoint === 0x200d ||
+ codePoint === 0xfeff ||
+ (codePoint >= 0x0001 && codePoint <= 0x001f) ||
+ (codePoint >= 0x007f && codePoint <= 0x009f) ||
+ (codePoint >= 0x0300 && codePoint <= 0x036f) ||
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
+ );
+}
+
+/** Return whether a Unicode code point normally occupies two terminal cells. */
+function isWideCodePoint(codePoint: number) {
+ return (
+ codePoint >= 0x1100 &&
+ (codePoint <= 0x115f ||
+ codePoint === 0x2329 ||
+ codePoint === 0x232a ||
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd))
+ );
+}
-/** Iterate user-visible text clusters so wide and combining characters stay together. */
-function textClusters(text: string) {
- if (!graphemeSegmenter) {
- return Array.from(text);
+/** Measure one Unicode code point in terminal cells. */
+function codePointCellWidth(codePoint: number) {
+ if (isZeroWidthCodePoint(codePoint)) {
+ return 0;
}
- return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
+ return isWideCodePoint(codePoint) ? 2 : 1;
}
-function measureSanitizedTextWidth(text: string) {
- return printableAsciiRegex.test(text) ? text.length : stringWidth(text);
+/** Measure rendered text in terminal cells, counting CJK/fullwidth characters as two cells. */
+export function terminalCellWidth(text: string) {
+ let width = 0;
+
+ for (let index = 0; index < text.length; ) {
+ const codePoint = text.codePointAt(index);
+ if (codePoint === undefined) {
+ break;
+ }
+
+ width += codePointCellWidth(codePoint);
+ index += codePoint > 0xffff ? 2 : 1;
+ }
+
+ return width;
}
-/** Measure text in terminal cells, treating CJK and emoji clusters as wide. */
+/** Measure sanitized text in terminal cells, treating CJK and emoji as wide. */
export function measureTextWidth(text: string) {
- return measureSanitizedTextWidth(sanitizeTerminalLine(text));
+ return terminalCellWidth(sanitizeTerminalLine(text));
}
-/** Slice text by terminal cells without splitting wide or combining clusters. */
-export function sliceTextByWidth(text: string, offset: number, width: number) {
- const safeText = sanitizeTerminalLine(text);
- const startOffset = Math.max(0, offset);
- const maxWidth = Math.max(0, width);
- if (maxWidth === 0) {
- return { text: "", width: 0 };
- }
-
- if (printableAsciiRegex.test(safeText)) {
- const sliced = safeText.slice(startOffset, startOffset + maxWidth);
- return { text: sliced, width: sliced.length };
+/** Slice text to a visible terminal-cell window without splitting fullwidth characters. */
+export function sliceTextByTerminalCells(text: string, offset: number, width: number) {
+ if (width <= 0) {
+ return { clipped: terminalCellWidth(text) > Math.max(0, offset), text: "", width: 0 };
}
- let cursor = 0;
+ const windowStart = Math.max(0, offset);
+ const windowEnd = windowStart + width;
+ let cellCursor = 0;
+ let output = "";
let usedWidth = 0;
- let visibleText = "";
+ let clipped = false;
+ let includedPreviousVisibleCodePoint = false;
- for (const cluster of textClusters(safeText)) {
- const clusterWidth = measureSanitizedTextWidth(cluster);
- const clusterStart = cursor;
- const clusterEnd = cursor + clusterWidth;
- cursor = clusterEnd;
+ for (let index = 0; index < text.length; ) {
+ const codePoint = text.codePointAt(index);
+ if (codePoint === undefined) {
+ break;
+ }
- if (clusterEnd <= startOffset) {
+ const char = String.fromCodePoint(codePoint);
+ const charWidth = codePointCellWidth(codePoint);
+ const nextCellCursor = cellCursor + charWidth;
+ index += codePoint > 0xffff ? 2 : 1;
+
+ if (charWidth === 0) {
+ if (
+ includedPreviousVisibleCodePoint ||
+ (output.length > 0 && cellCursor >= windowStart && cellCursor <= windowEnd)
+ ) {
+ output += char;
+ }
continue;
}
- if (clusterStart < startOffset) {
+
+ if (nextCellCursor <= windowStart) {
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = false;
continue;
}
- if (usedWidth + clusterWidth > maxWidth) {
+
+ // If the requested window starts in the middle of a fullwidth glyph, omit that glyph entirely.
+ if (cellCursor < windowStart) {
+ const hiddenCellWidth = Math.min(nextCellCursor, windowEnd) - windowStart;
+ if (hiddenCellWidth > 0) {
+ output += " ".repeat(hiddenCellWidth);
+ usedWidth += hiddenCellWidth;
+ }
+
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = false;
+ continue;
+ }
+
+ if (cellCursor >= windowEnd || nextCellCursor > windowEnd) {
+ clipped = true;
break;
}
- visibleText += cluster;
- usedWidth += clusterWidth;
+ output += char;
+ usedWidth += charWidth;
+ cellCursor = nextCellCursor;
+ includedPreviousVisibleCodePoint = true;
}
- return { text: visibleText, width: usedWidth };
+ return { clipped, text: output, width: usedWidth };
+}
+
+/** Slice sanitized text by terminal cells without splitting fullwidth characters. */
+export function sliceTextByWidth(text: string, offset: number, width: number) {
+ const { text: sliced, width: usedWidth } = sliceTextByTerminalCells(
+ sanitizeTerminalLine(text),
+ offset,
+ width,
+ );
+
+ return { text: sliced, width: usedWidth };
}
/** Clamp text to a fixed width using a plain-dot terminal fallback marker. */
@@ -73,7 +157,7 @@ export function fitText(text: string, width: number) {
return "";
}
- if (measureTextWidth(safeText) <= width) {
+ if (terminalCellWidth(safeText) <= width) {
return safeText;
}
@@ -81,11 +165,11 @@ export function fitText(text: string, width: number) {
return ".";
}
- return `${sliceTextByWidth(safeText, 0, width - 1).text}.`;
+ return `${sliceTextByTerminalCells(safeText, 0, width - 1).text}.`;
}
/** Clamp and then right-pad text to an exact width. */
export function padText(text: string, width: number) {
const trimmed = fitText(text, width);
- return `${trimmed}${" ".repeat(Math.max(0, width - measureTextWidth(trimmed)))}`;
+ return `${trimmed}${" ".repeat(Math.max(0, width - terminalCellWidth(trimmed)))}`;
}
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index 661c6974..604534aa 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -22,7 +22,14 @@ import {
isStepDownKey,
isStepUpKey,
} from "./keyboard";
-import { fitText, measureTextWidth, padText, sliceTextByWidth } from "./text";
+import {
+ fitText,
+ measureTextWidth,
+ padText,
+ sliceTextByTerminalCells,
+ sliceTextByWidth,
+ terminalCellWidth,
+} from "./text";
import { computeHunkRevealScrollTop } from "./hunkScroll";
import {
estimateDiffSectionBodyRows,
@@ -301,9 +308,39 @@ describe("ui helpers", () => {
test("text helpers measure and slice wide characters by terminal cells", () => {
expect(measureTextWidth("日本語")).toBe(6);
expect(sliceTextByWidth("a日本b", 1, 4)).toEqual({ text: "日本", width: 4 });
- expect(sliceTextByWidth("a日本b", 2, 4)).toEqual({ text: "本b", width: 3 });
+ expect(sliceTextByWidth("a日本b", 2, 4)).toEqual({ text: " 本b", width: 4 });
expect(fitText("日本語", 5)).toBe("日本.");
expect(measureTextWidth(padText("日本", 6))).toBe(6);
+ expect(terminalCellWidth("中文 mixed")).toBe(10);
+ expect(terminalCellWidth("かなカナ漢字 mixed")).toBe(18);
+ expect(terminalCellWidth("한글 테스트 mixed")).toBe(17);
+ expect(sliceTextByTerminalCells("中文 mixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "中文 ",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("かなmixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "かなm",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("한글 mixed", 0, 5)).toEqual({
+ clipped: true,
+ text: "한글 ",
+ width: 5,
+ });
+ expect(sliceTextByTerminalCells("中", 1, 3)).toEqual({
+ clipped: false,
+ text: " ",
+ width: 1,
+ });
+ expect(sliceTextByTerminalCells("中文", 1, 3)).toEqual({
+ clipped: false,
+ text: " 文",
+ width: 3,
+ });
+ expect(fitText("中文 mixed", 6)).toBe("中文 .");
+ expect(padText("中文", 5)).toBe("中文 ");
});
test("agent popover helpers wrap text and right-align the card within the viewport", () => {
diff --git a/test/pty/harness.ts b/test/pty/harness.ts
index cfd71bea..65a7245d 100644
--- a/test/pty/harness.ts
+++ b/test/pty/harness.ts
@@ -402,6 +402,27 @@ export function createPtyHarness() {
return { dir, before, after };
}
+ function createUntrackedCjkRepoFixture() {
+ const dir = makeTempDir("hunk-tuistory-cjk-");
+
+ runGit(["init"], dir);
+ runGit(["config", "user.name", "Pi"], dir);
+ runGit(["config", "user.email", "pi@example.com"], dir);
+ writeText(join(dir, "README.md"), "# fixture\n");
+ runGit(["add", "."], dir);
+ runGit(["commit", "-m", "initial"], dir);
+
+ const cjkPhrase = "這是一段很長的中文內容用來驗證分割視圖滑鼠捲動";
+ const after =
+ Array.from(
+ { length: 40 },
+ (_, index) => `第${String(index + 1).padStart(2, "0")}行${cjkPhrase.repeat(5)}`,
+ ).join("\n") + "\n";
+ writeText(join(dir, "notes.md"), after);
+
+ return { dir };
+ }
+
function createGitRepoFixture(files: ChangedFileSpec[]) {
const dir = makeTempDir("hunk-tuistory-repo-");
@@ -727,6 +748,7 @@ export function createPtyHarness() {
createPagerPatchFixture,
createPinnedHeaderRepoFixture,
createScrollableFilePair,
+ createUntrackedCjkRepoFixture,
createSidebarJumpRepoFixture,
createTwoFileRepoFixture,
createWideCharacterFilePair,