Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/ui/AppHost.scroll-regression.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AppHost bootstrap={createScrollBootstrap()} />, {
Expand Down Expand Up @@ -69,4 +90,33 @@ describe("UI scroll regression", () => {
});
}
});

test("clips CJK split additions after a wheel scroll repaint", async () => {
const setup = await testRender(<AppHost bootstrap={createCjkUntrackedScrollBootstrap()} />, {
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();
});
}
});
});
11 changes: 10 additions & 1 deletion src/ui/diff/codeColumns.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});
3 changes: 2 additions & 1 deletion src/ui/diff/codeColumns.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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. */
Expand Down
96 changes: 67 additions & 29 deletions src/ui/diff/renderRows.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,15 +26,15 @@ export function fitText(text: string, width: number) {
return "";
}

if (text.length <= width) {
if (terminalCellWidth(text) <= width) {
return text;
}

if (width === 1) {
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. */
Expand All @@ -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;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading
Loading