Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quick-cells-measure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Optimize terminal cell width measurement so diffs with CJK, emoji, and chrome-glyph runs render faster: single-scalar clusters now measure through a fast zero-width check plus the East Asian Width table instead of string-width's expensive emoji regexes, while multi-scalar clusters still defer to string-width for identical results.
2 changes: 2 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ bun run bench:highlight-prefetch
bun run bench:large-stream
bun run bench:interaction-latency
bun run bench:non-ascii-stream
bun run bench:terminal-width
bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
Expand All @@ -59,6 +60,7 @@ bun run bench:competitors
- `large-stream.ts` — measures large split-stream first-frame and scroll cost.
- `interaction-latency.ts` — measures per-press `]` hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of `memory.ts`).
- `non-ascii-stream.ts` — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs.
- `terminal-width.ts` — measures scalar-heavy CJK and emoji width calls plus the complex-cluster fallback against equivalent `string-width` reference paths, verifying identical width checksums.
- `huge-stream.ts` — opt-in huge tier (`--include-huge` or `HUNK_BENCH_INCLUDE_HUGE=1`): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
- `large-stream-profile.ts` — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
- `memory.ts` — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
Expand Down
1 change: 1 addition & 0 deletions benchmarks/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const defaultScripts = [
"large-stream.ts",
"interaction-latency.ts",
"non-ascii-stream.ts",
"terminal-width.ts",
];

interface RunOptions {
Expand Down
72 changes: 72 additions & 0 deletions benchmarks/terminal-width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Benchmark Hunk's scalar fast path and complex-cluster fallback against string-width.
import { performance } from "node:perf_hooks";
import stringWidth from "string-width";
import { measureTextWidth } from "../src/ui/lib/text";

const ITERATIONS = 2_000;
const WARMUP_ITERATIONS = 50;
const CJK_SCALAR_LINES = [
"export const message = 日本語のコメントです。変更内容を確認してください。",
"export function 測定値を計算する(入力: number) { return 入力 * 2; }",
"中文注释内容:这个变更优化了终端单元格宽度测量。",
"请检查新增、删除和未修改的代码行是否正确对齐。",
"한국어 주석 내용과 함수 이름을 함께 측정합니다.",
"터미널 셀 너비를 빠르게 계산하고 정렬을 유지합니다.",
] as const;
const EMOJI_SCALAR_LINES = [
"🚀 ✨ 🔧 💡 🎯 📦 🔍 standalone emoji scalars",
"✅ 🚧 🐛 🎉 🧪 📊 terminal status glyphs",
] as const;
const COMPLEX_CLUSTER_LINES = [
"🧑‍💻 👩‍🔬 👨‍👩‍👧‍👦 complex emoji clusters stay aligned",
"e\u0301 a\u0308 o\u0302 u\u0308 combining clusters keep their reference widths",
] as const;

type WidthMeasure = (text: string) => number;
type WidthCorpus = readonly string[];

/** Measure repeated width calls and retain their total so the work stays observable. */
function measureWidthCalls(measure: WidthMeasure, corpus: WidthCorpus, iterations: number) {
let checksum = 0;
const start = performance.now();

for (let iteration = 0; iteration < iterations; iteration += 1) {
for (const line of corpus) {
checksum += measure(line);
}
}

return { elapsedMs: performance.now() - start, checksum };
}

/** Verify and time one deterministic terminal-text shape. */
function measureScenario(name: string, corpus: WidthCorpus) {
for (const line of corpus) {
const actual = measureTextWidth(line);
const reference = stringWidth(line);
if (actual !== reference) {
throw new Error(`Width mismatch for ${JSON.stringify(line)}: ${actual} !== ${reference}`);
}
}

measureWidthCalls(stringWidth, corpus, WARMUP_ITERATIONS);
measureWidthCalls(measureTextWidth, corpus, WARMUP_ITERATIONS);

const reference = measureWidthCalls(stringWidth, corpus, ITERATIONS);
const optimized = measureWidthCalls(measureTextWidth, corpus, ITERATIONS);
if (optimized.checksum !== reference.checksum) {
throw new Error(`Width checksum mismatch: ${optimized.checksum} !== ${reference.checksum}`);
}

const speedup = reference.elapsedMs / optimized.elapsedMs;
console.log(`METRIC ${name}_text_width_ms=${optimized.elapsedMs.toFixed(2)}`);
// External reference timings are informational and should not gate Hunk releases.
console.log(`METRIC competitor_string_width_${name}_ms=${reference.elapsedMs.toFixed(2)}`);
console.log(`METRIC ${name}_width_measurements=${ITERATIONS * corpus.length}`);
console.log(`METRIC ${name}_width_checksum=${optimized.checksum}`);
console.log(`${name} width speedup versus string-width: ${speedup.toFixed(2)}x`);
}

measureScenario("cjk_scalar", CJK_SCALAR_LINES);
measureScenario("emoji_scalar", EMOJI_SCALAR_LINES);
measureScenario("complex_cluster", COMPLEX_CLUSTER_LINES);
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"bench:large-stream": "bun run benchmarks/large-stream.ts",
"bench:interaction-latency": "bun run benchmarks/interaction-latency.ts",
"bench:non-ascii-stream": "bun run benchmarks/non-ascii-stream.ts",
"bench:terminal-width": "bun run benchmarks/terminal-width.ts",
"bench:huge-stream": "bun run benchmarks/huge-stream.ts",
"bench:large-stream-profile": "bun run benchmarks/large-stream-profile.ts",
"bench:memory": "bun run benchmarks/memory.ts",
Expand All @@ -99,6 +100,7 @@
"chokidar": "^4.0.3",
"commander": "^14.0.3",
"diff": "^8.0.3",
"get-east-asian-width": "^1.5.0",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on implicit transitive dependencies can cause confusion, so I added get-east-asian-width as an explicit dependency. It was already included transitively via string-width.

"shell-quote": "1.8.4",
"string-width": "^8.2.1",
"zod": "^4.3.6"
Expand Down
70 changes: 47 additions & 23 deletions src/ui/lib/text.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { eastAsianWidth } from "get-east-asian-width";
import stringWidth from "string-width";
import { sanitizeTerminalLine } from "../../lib/terminalText";

Expand All @@ -16,10 +17,32 @@ function textClusters(text: string) {
return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
}

// Measured terminal-cell widths for single non-ASCII characters. Hunk re-measures the same
// chrome glyphs ("─", "▌", "│", ...) constantly, and string-width's grapheme/emoji regexes make
// each cold measure expensive, so cache per-character widths once.
const singleCharWidthCache = new Map<string, number>();
// Zero-width cluster classes restricted to a single code point. A plain u-flag character
// class stays fast per call, unlike string-width's \p{RGI_Emoji} property-of-strings regex.
const zeroWidthScalarRegex =
/^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]$/u;

/**
* Measure one grapheme cluster in terminal cells, matching string-width on every input.
*
* A single-scalar cluster can never be an emoji sequence, and every single-scalar emoji is East
* Asian Wide, so a zero-width check plus the EAW table reproduces string-width exactly.
* Multi-scalar clusters delegate to string-width itself.
*/
export function measureClusterWidth(cluster: string): number {
const codePoint = cluster.codePointAt(0);
if (codePoint === undefined) {
return 0;
}

const scalarUnitLength = codePoint > 0xffff ? 2 : 1;

if (cluster.length === scalarUnitLength) {
return zeroWidthScalarRegex.test(cluster) ? 0 : eastAsianWidth(codePoint);
}

return stringWidth(cluster);
}

/**
* Return the single UTF-16 code unit repeated across `text`, or null when text mixes characters.
Expand All @@ -44,35 +67,36 @@ function repeatedSingleUnitChar(text: string): string | null {
return text[0] ?? null;
}

/** Measure one character through string-width, memoized for repeat lookups. */
function cachedSingleCharWidth(char: string): number {
let width = singleCharWidthCache.get(char);
if (width === undefined) {
width = stringWidth(char);
singleCharWidthCache.set(char, width);
}
return width;
}

function measureSanitizedTextWidth(text: string) {
if (printableAsciiRegex.test(text)) {
return text.length;
}

// Fast path for chrome glyph runs like "─".repeat(separatorWidth): string-width costs
// milliseconds for long non-ASCII runs, but a run of one repeated non-combining character is
// always run-length × single-character width. Each repeated unit with a non-zero width is its
// own grapheme cluster, so the multiplication is exact.
// Fast path for chrome glyph runs like "─".repeat(separatorWidth): a run of one repeated
// non-combining character is always run-length × single-character width. Each repeated unit
// with a non-zero width is its own grapheme cluster, so the multiplication is exact.
const repeatedChar = repeatedSingleUnitChar(text);
if (repeatedChar !== null) {
const charWidth = cachedSingleCharWidth(repeatedChar);
// Zero-width units (combining marks) can merge with neighbors; defer to string-width.
const charWidth = measureClusterWidth(repeatedChar);
// Zero-width units (combining marks) can merge with neighbors; fall through to the
// cluster loop, which segments them correctly.
if (charWidth > 0) {
return charWidth * text.length;
}
}

return stringWidth(text);
let width = 0;
for (const cluster of textClusters(text)) {
const codePoint = cluster.codePointAt(0)!;
const scalarUnitLength = codePoint > 0xffff ? 2 : 1;
if (cluster.length !== scalarUnitLength) {
// Use one whole-text fallback instead of re-segmenting every complex cluster separately.
// This preserves the previous path for ZWJ emoji and combining-heavy text.
return stringWidth(text);
}
width += measureClusterWidth(cluster);
}
return width;
}

/** Measure text in terminal cells, treating CJK and emoji clusters as wide. */
Expand All @@ -99,7 +123,7 @@ export function sliceTextByWidth(text: string, offset: number, width: number) {
let visibleText = "";

for (const cluster of textClusters(safeText)) {
const clusterWidth = measureSanitizedTextWidth(cluster);
const clusterWidth = measureClusterWidth(cluster);
const clusterStart = cursor;
const clusterEnd = cursor + clusterWidth;
cursor = clusterEnd;
Expand Down Expand Up @@ -162,7 +186,7 @@ export function cellRangeToCharRange(text: string, startCell: number, endCell: n
break;
}

const clusterWidth = measureSanitizedTextWidth(cluster);
const clusterWidth = measureClusterWidth(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 =
Expand Down
37 changes: 36 additions & 1 deletion src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
import type { KeyEvent } from "@opentui/core";
import stringWidth from "string-width";
import type { DiffFile } from "../../core/types";
import {
buildMenuSpecs,
Expand All @@ -23,7 +24,14 @@ import {
isStepDownKey,
isStepUpKey,
} from "./keyboard";
import { cellRangeToCharRange, fitText, measureTextWidth, padText, sliceTextByWidth } from "./text";
import {
cellRangeToCharRange,
fitText,
measureClusterWidth,
measureTextWidth,
padText,
sliceTextByWidth,
} from "./text";
import { computeHunkRevealScrollTop } from "./hunkScroll";
import {
estimateDiffSectionBodyRows,
Expand Down Expand Up @@ -299,6 +307,33 @@ describe("ui helpers", () => {
expect(cellRangeToCharRange("\u200bab", 1, 1)).toEqual({ startIndex: 2, endIndex: 3 });
});

test("cluster width measurement matches string-width across terminal text shapes", () => {
const clusters = [
"",
"\0",
"\u200b",
"\u0301",
"\ud800",
"─",
"·",
"日",
"👍",
"e\u0301",
"1\u20e3",
"🧑‍💻",
"\u1100\u1161\u11a8",
];

for (const cluster of clusters) {
expect(measureClusterWidth(cluster)).toBe(stringWidth(cluster));
}

const complexLines = ["🧑‍💻 👩‍🔬 terminal tools", "e\u0301 a\u0308 combining text"];
for (const line of complexLines) {
expect(measureTextWidth(line)).toBe(stringWidth(line));
}
});

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);
Expand Down