From 978183ff4c7d8d57ad376b641d185cf8f0a80f48 Mon Sep 17 00:00:00 2001 From: iamken1204 Date: Mon, 11 May 2026 15:25:56 +0800 Subject: [PATCH 1/2] fix(ui): clip CJK split rows by cell width Measure and slice diff text by terminal cell width so long CJK lines stay within split panes after mouse-wheel repaints. Add unit coverage for Chinese, Japanese, and Korean width handling plus a PTY regression for untracked Mandarin-only content in split nowrap mode. --- src/ui/AppHost.scroll-regression.test.tsx | 50 ++++++++ src/ui/diff/codeColumns.test.ts | 11 +- src/ui/diff/codeColumns.ts | 3 +- src/ui/diff/renderRows.tsx | 96 +++++++++++----- src/ui/lib/text.ts | 134 +++++++++++++++++++++- src/ui/lib/ui-lib.test.ts | 22 +++- test/pty/harness.ts | 22 ++++ test/pty/ui-integration.test.ts | 29 +++++ 8 files changed, 332 insertions(+), 35 deletions(-) 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 c0857fff..c0372319 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 { maxFileCodeLineWidth } from "./codeColumns"; +import { maxFileCodeLineWidth, measureRenderedCodeLineWidth } from "./codeColumns"; /** Generate a large diff metadata fixture without checking a huge file into the repo. */ function createLargeLineFixture(lineCount: number, widestLine: string): DiffFile { @@ -28,4 +28,13 @@ describe("code column measurement", () => { expect(maxFileCodeLineWidth(file)).toBe("the widest generated line".length); }); + + test("measures CJK lines in terminal cells", () => { + const file = createLargeLineFixture(3, "かなカナ漢字 mixed"); + + 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 ebf080df..46984e03 100644 --- a/src/ui/diff/codeColumns.ts +++ b/src/ui/diff/codeColumns.ts @@ -1,4 +1,5 @@ import type { DiffFile, LayoutMode } from "../../core/types"; +import { terminalCellWidth } from "../lib/text"; export const DIFF_CODE_TAB_WIDTH = 2; export const DIFF_RAIL_PREFIX_WIDTH = 1; @@ -11,7 +12,7 @@ export function expandDiffTabs(text: string) { /** Measure one rendered code line after tab expansion and newline trimming. */ export function measureRenderedCodeLineWidth(line: string | undefined) { - return expandDiffTabs((line ?? "").replace(/\n$/, "")).length; + return terminalCellWidth(expandDiffTabs((line ?? "").replace(/\n$/, ""))); } /** Track the widest rendered code line for one file. */ diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx index 3576e074..a22e09ff 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -1,5 +1,6 @@ import { memo, type ReactNode } from "react"; import type { DiffFile } from "../../core/types"; +import { sliceTextByTerminalCells, terminalCellWidth } from "../lib/text"; import type { AppTheme } from "../themes"; import { resolveSplitCellGeometry, @@ -25,7 +26,7 @@ export function fitText(text: string, width: number) { return ""; } - if (text.length <= width) { + if (terminalCellWidth(text) <= width) { return text; } @@ -33,7 +34,7 @@ export function fitText(text: string, width: number) { return "…"; } - return `${text.slice(0, width - 1)}…`; + return `${sliceTextByTerminalCells(text, 0, width - 1).text}…`; } /** Slice styled spans to one visible window while preserving color runs. */ @@ -55,16 +56,24 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) { break; } - if (remainingOffset >= span.text.length) { - remainingOffset -= span.text.length; + const spanWidth = terminalCellWidth(span.text); + if (remainingOffset >= spanWidth) { + remainingOffset -= spanWidth; continue; } - const start = remainingOffset; - const text = span.text.slice(start, start + remaining); + const offsetBeforeSlice = remainingOffset; + const { + clipped, + text, + width: textWidth, + } = sliceTextByTerminalCells(span.text, remainingOffset, remaining); remainingOffset = 0; if (text.length === 0) { + if (clipped && offsetBeforeSlice === 0) { + break; + } continue; } @@ -80,8 +89,11 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) { sliced.push(nextSpan); } - remaining -= text.length; - usedWidth += text.length; + remaining -= textWidth; + usedWidth += textWidth; + if (clipped) { + break; + } } return { @@ -160,36 +172,62 @@ function wrapSpans(spans: RenderSpan[], width: number) { const lines: RenderSpan[][] = [[]]; let current = lines[0]!; - let remaining = width; + let currentWidth = 0; + + const startNextLine = () => { + current = []; + lines.push(current); + currentWidth = 0; + }; + + const appendToCurrentLine = (span: RenderSpan, text: string, textWidth: number) => { + if (text.length === 0) { + return; + } + + const nextSpan = { + ...span, + text, + }; + const previous = current.at(-1); + if (previous && previous.fg === nextSpan.fg && previous.bg === nextSpan.bg) { + previous.text += nextSpan.text; + } else { + current.push(nextSpan); + } + + currentWidth += textWidth; + }; for (const span of spans) { - let offset = 0; + for (let index = 0; index < span.text.length; ) { + const codePoint = span.text.codePointAt(index); + if (codePoint === undefined) { + break; + } + + const text = String.fromCodePoint(codePoint); + const textWidth = terminalCellWidth(text); + index += codePoint > 0xffff ? 2 : 1; - while (offset < span.text.length) { - if (remaining <= 0) { - current = []; - lines.push(current); - remaining = width; + if (textWidth === 0) { + appendToCurrentLine(span, text, 0); + continue; } - const text = span.text.slice(offset, offset + remaining); - if (text.length === 0) { - break; + if (textWidth > width) { + if (currentWidth > 0) { + startNextLine(); + } + appendToCurrentLine(span, "…", 1); + continue; } - const nextSpan = { - ...span, - text, - }; - const previous = current.at(-1); - if (previous && previous.fg === nextSpan.fg && previous.bg === nextSpan.bg) { - previous.text += nextSpan.text; - } else { - current.push(nextSpan); + if (currentWidth + textWidth > width) { + startNextLine(); } - offset += text.length; - remaining -= text.length; + appendToCurrentLine(span, text, textWidth); } } diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index a5a5760e..383ebbb5 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -1,10 +1,138 @@ +/** 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)) + ); +} + +/** Measure one Unicode code point in terminal cells. */ +function codePointCellWidth(codePoint: number) { + if (isZeroWidthCodePoint(codePoint)) { + return 0; + } + + return isWideCodePoint(codePoint) ? 2 : 1; +} + +/** 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; +} + +/** 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 }; + } + + const windowStart = Math.max(0, offset); + const windowEnd = windowStart + width; + let cellCursor = 0; + let output = ""; + let usedWidth = 0; + let clipped = false; + let includedPreviousVisibleCodePoint = false; + + for (let index = 0; index < text.length; ) { + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + break; + } + + 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 (nextCellCursor <= windowStart) { + cellCursor = nextCellCursor; + includedPreviousVisibleCodePoint = false; + continue; + } + + // If the requested window starts in the middle of a fullwidth glyph, omit that glyph entirely. + if (cellCursor < windowStart) { + cellCursor = nextCellCursor; + includedPreviousVisibleCodePoint = false; + continue; + } + + if (cellCursor >= windowEnd || nextCellCursor > windowEnd) { + clipped = true; + break; + } + + output += char; + usedWidth += charWidth; + cellCursor = nextCellCursor; + includedPreviousVisibleCodePoint = true; + } + + return { clipped, text: output, width: usedWidth }; +} + /** Clamp text to a fixed width using a plain-dot terminal fallback marker. */ export function fitText(text: string, width: number) { if (width <= 0) { return ""; } - if (text.length <= width) { + if (terminalCellWidth(text) <= width) { return text; } @@ -12,11 +140,11 @@ export function fitText(text: string, width: number) { return "."; } - return `${text.slice(0, width - 1)}.`; + return `${sliceTextByTerminalCells(text, 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.padEnd(width, " "); + 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 98c77b8f..cc42ab0f 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -21,7 +21,7 @@ import { isStepDownKey, isStepUpKey, } from "./keyboard"; -import { fitText, padText } from "./text"; +import { fitText, padText, sliceTextByTerminalCells, terminalCellWidth } from "./text"; import { computeHunkRevealScrollTop } from "./hunkScroll"; import { estimateDiffSectionBodyRows, measureDiffSectionGeometry } from "./diffSectionGeometry"; import { resizeSidebarWidth } from "./sidebar"; @@ -206,6 +206,26 @@ describe("ui helpers", () => { expect(fitText("hello", 4)).toBe("hel."); expect(padText("hello", 4)).toBe("hel."); expect(padText("ok", 4)).toBe("ok "); + 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(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 5e841642..6134ead5 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -201,6 +201,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-"); @@ -523,6 +544,7 @@ export function createPtyHarness() { createPagerPatchFixture, createPinnedHeaderRepoFixture, createScrollableFilePair, + createUntrackedCjkRepoFixture, createSidebarJumpRepoFixture, createTwoFileRepoFixture, launchHunk, diff --git a/test/pty/ui-integration.test.ts b/test/pty/ui-integration.test.ts index cd769913..3adf7598 100644 --- a/test/pty/ui-integration.test.ts +++ b/test/pty/ui-integration.test.ts @@ -1093,6 +1093,35 @@ describe("live UI integration", () => { } }); + test("mouse wheel scrolling keeps untracked CJK additions inside split panes", async () => { + const fixture = harness.createUntrackedCjkRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "split", "--no-wrap"], + cwd: fixture.dir, + cols: 80, + rows: 14, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, { + timeout: 15_000, + }); + + await session.waitIdle({ timeout: 200 }); + await session.scrollDown(1); + const scrolled = await harness.waitForSnapshot( + session, + (text) => text.includes("notes.md (untracked)") && text.includes("第04行"), + 5_000, + ); + + expect(scrolled).toContain("▌ 4 + 第04行這是一段很長的中文"); + expect(scrolled).not.toMatch(/\n[^\S\r\n]*滑鼠捲動\s*\n/); + } finally { + session.close(); + } + }); + test("arrow-key horizontal scrolling reveals hidden code columns in a real PTY", async () => { const fixture = harness.createLongWrapFilePair(); const session = await harness.launchHunk({ From eb2cde9687df5a3b3e5309137cf3ed1bca9d52e1 Mon Sep 17 00:00:00 2001 From: Kettan Date: Mon, 11 May 2026 15:56:16 +0800 Subject: [PATCH 2/2] fix(ui): preserve CJK horizontal scroll alignment Reserve the hidden half-cell when horizontal scrolling starts inside a fullwidth glyph so later spans do not shift left. Add unit coverage for the mid-glyph CJK offset boundary. --- src/ui/lib/text.ts | 6 ++++++ src/ui/lib/ui-lib.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 383ebbb5..ad88145b 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -107,6 +107,12 @@ export function sliceTextByTerminalCells(text: string, offset: number, width: nu // 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; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index cc42ab0f..f074595f 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -224,6 +224,16 @@ describe("ui helpers", () => { 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("中文 "); });