From 7ed6b8f80b45d2a1e483ec3491ec9c8f91512323 Mon Sep 17 00:00:00 2001 From: Kazuki Matsuo Date: Fri, 24 Jul 2026 05:05:36 +0900 Subject: [PATCH 1/2] perf(ui): optimize terminal cell width measurement (#579) --- .changeset/quick-cells-measure.md | 5 +++ bun.lock | 1 + package.json | 1 + src/ui/lib/text.ts | 63 ++++++++++++++++++++----------- 4 files changed, 47 insertions(+), 23 deletions(-) create mode 100644 .changeset/quick-cells-measure.md diff --git a/.changeset/quick-cells-measure.md b/.changeset/quick-cells-measure.md new file mode 100644 index 00000000..b1835bab --- /dev/null +++ b/.changeset/quick-cells-measure.md @@ -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. diff --git a/bun.lock b/bun.lock index 89cb794c..073124a4 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", "shell-quote": "1.8.4", "string-width": "^8.2.1", "zod": "^4.3.6", diff --git a/package.json b/package.json index 2023fe6c..b996606d 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", "shell-quote": "1.8.4", "string-width": "^8.2.1", "zod": "^4.3.6" diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 43dd260a..8a5fe1a1 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -1,3 +1,4 @@ +import { eastAsianWidth } from "get-east-asian-width"; import stringWidth from "string-width"; import { sanitizeTerminalLine } from "../../lib/terminalText"; @@ -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(); +// 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. @@ -44,35 +67,29 @@ 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)) { + width += measureClusterWidth(cluster); + } + return width; } /** Measure text in terminal cells, treating CJK and emoji clusters as wide. */ @@ -99,7 +116,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; @@ -162,7 +179,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 = From e7dfe6320c1eaae44edcc55d544fbf3f13dd85d4 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 24 Jul 2026 17:27:01 -0400 Subject: [PATCH 2/2] test(benchmarks): verify terminal width optimization --- benchmarks/README.md | 2 + benchmarks/run.ts | 1 + benchmarks/terminal-width.ts | 72 ++++++++++++++++++++++++++++++++++++ package.json | 1 + src/ui/lib/text.ts | 7 ++++ src/ui/lib/ui-lib.test.ts | 37 +++++++++++++++++- 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 benchmarks/terminal-width.ts diff --git a/benchmarks/README.md b/benchmarks/README.md index f19bce33..27f3e983 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -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 @@ -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. diff --git a/benchmarks/run.ts b/benchmarks/run.ts index 9bf1e59e..729dbb78 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -13,6 +13,7 @@ const defaultScripts = [ "large-stream.ts", "interaction-latency.ts", "non-ascii-stream.ts", + "terminal-width.ts", ]; interface RunOptions { diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts new file mode 100644 index 00000000..b231db68 --- /dev/null +++ b/benchmarks/terminal-width.ts @@ -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); diff --git a/package.json b/package.json index b996606d..6f95507f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 8a5fe1a1..4f88716f 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -87,6 +87,13 @@ function measureSanitizedTextWidth(text: string) { 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; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 1a01e777..f386f505 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -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, @@ -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, @@ -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);