From 92380971da7a937f31d77f40b25edec36e0c9ef4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:56:32 +0900 Subject: [PATCH] perf(tui): carry measured widths through rendering Carry exact visible widths from Text and Box render results into their owners, and through Editor wrap/layout/render state. Stamp derived widths and render caches with a monotonic Hangul width-config epoch. Retain sidecar entries only by WeakMap owner identity, with immutable publication-time line/width proof, and conservatively remeasure escape-leading padded rows. Before: static_redraw 1633.11 us/op editor_edits 1249.71 us/op After hardened repair: static_redraw 1321.51 us/op (1.236x) editor_edits 1150.32 us/op (1.086x) Win: Exact byte/grid/scrollback hashes match across all five phases; every phase CV is below 20%, with no B/C regression. Memory: retained heap 251,393,541 -> 251,500,114 bytes (+0.042%). Op: carry exact widths through render ownership and editor layout Restores: repeated visible-width measurement in core TUI hot paths --- packages/tui/src/components/box.ts | 102 ++++-- packages/tui/src/components/editor.ts | 339 +++++++++++-------- packages/tui/src/components/text.ts | 33 +- packages/tui/src/tui.ts | 19 ++ packages/tui/src/utils.ts | 48 +++ packages/tui/test/container-memo.test.ts | 159 ++++++++- packages/tui/test/line-width-sidecar.test.ts | 86 +++++ 7 files changed, 604 insertions(+), 182 deletions(-) create mode 100644 packages/tui/test/line-width-sidecar.test.ts diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 1ba1c7212..bd3a1ea67 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,11 +1,21 @@ import type { Component } from "../tui"; -import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../utils"; +import { + getPaddingX, + getPublishedLineWidths, + getWidthConfigEpoch, + padding, + publishLineWidths, + visibleWidth, +} from "../utils"; type Cache = { width: number; + widthEpoch: number; bgSample: string | undefined; borderSample: string | undefined; childLines: (readonly string[])[]; + childWidths: (readonly number[] | undefined)[]; + childSnapshots: (readonly string[] | undefined)[]; result: string[]; }; @@ -122,43 +132,74 @@ export class Box implements Component { : undefined; // Render every child every frame (renders may carry side effects); the - // memo only skips re-deriving the padded/background rows. Per the - // Component render contract, identical child array references prove the - // content is unchanged. + // memo only skips re-deriving the padded/background rows. + const widthEpoch = getWidthConfigEpoch(); + let contentRows = 0; + const childLines = children.map(child => { + const lines = child.render(contentWidth); + contentRows += lines.length; + return lines; + }); + const childWidths = childLines.map(lines => getPublishedLineWidths(lines)); const cached = this.#cached; - let unchanged = + if ( cached !== undefined && cached.width === width && + cached.widthEpoch === widthEpoch && + cached.widthEpoch === getWidthConfigEpoch() && cached.bgSample === bgSample && cached.borderSample === borderSample && - cached.childLines.length === count; - const childLines: (readonly string[])[] = new Array(count); - let contentRows = 0; - for (let i = 0; i < count; i++) { - const lines = children[i]!.render(contentWidth); - childLines[i] = lines; - contentRows += lines.length; - if (unchanged && cached!.childLines[i] !== lines) unchanged = false; + cached.childLines.length === count && + childLines.every((lines, i) => { + if (cached.childLines[i] !== lines) return false; + const published = childWidths[i]; + const cachedPublished = cached.childWidths[i]; + if (published !== undefined || cachedPublished !== undefined) { + return published === cachedPublished; + } + const snapshot = cached.childSnapshots[i]; + return ( + snapshot !== undefined && + snapshot.length === lines.length && + lines.every((line, j) => snapshot[j] === line) + ); + }) + ) { + return cached.result; } - if (unchanged) return cached!.result; const result: string[] = []; + // Exact visible widths of `result` rows, published only when the row + // bytes are `content + spaces` (no bg/border transform of unknown width). + const resultWidths: number[] | undefined = !border && !this.#bgFn ? [] : undefined; if (contentRows > 0) { const leftPad = padding(paddingX); const interior: string[] = []; + const pushRow = (row: string, visLen: number): void => { + const padNeeded = Math.max(0, innerWidth - visLen); + const padded = padNeeded > 0 ? row + padding(padNeeded) : row; + interior.push(this.#bgFn ? this.#bgFn(padded) : padded); + resultWidths?.push(visLen + padNeeded); + }; // Top padding for (let i = 0; i < this.#paddingY; i++) { - interior.push(this.#applyBg("", innerWidth)); + pushRow("", 0); } // Content + let childIndex = 0; for (const lines of childLines) { - for (const line of lines) { - interior.push(this.#applyBg(leftPad + line, innerWidth)); + const widths = childWidths[childIndex++]; + for (let j = 0; j < lines.length; j++) { + const line = lines[j] ?? ""; + const row = paddingX > 0 ? leftPad + line : line; + const carried = widths?.[j]; + const visLen = carried !== undefined && paddingX === 0 ? carried : visibleWidth(row); + pushRow(row, visLen); } } // Bottom padding for (let i = 0; i < this.#paddingY; i++) { - interior.push(this.#applyBg("", innerWidth)); + pushRow("", 0); } if (border) { @@ -177,18 +218,19 @@ export class Box implements Component { } } - this.#cached = { width, bgSample, borderSample, childLines, result }; + const finalWidthEpoch = getWidthConfigEpoch(); + if (resultWidths !== undefined) publishLineWidths(result, resultWidths); + const childSnapshots = childLines.map((lines, i) => (childWidths[i] === undefined ? [...lines] : undefined)); + this.#cached = { + width, + widthEpoch: finalWidthEpoch, + bgSample, + borderSample, + childLines, + childWidths, + childSnapshots, + result, + }; return result; } - - #applyBg(line: string, width: number): string { - const visLen = visibleWidth(line); - const padNeeded = Math.max(0, width - visLen); - const padded = line + padding(padNeeded); - - if (this.#bgFn) { - return applyBackgroundToLine(padded, width, this.#bgFn); - } - return padded; - } } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 5736a3990..f848db5ad 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -13,6 +13,7 @@ import type { SymbolTheme } from "../symbols"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; import { getSegmenter, + getWidthConfigEpoch, getWordNavKind, moveWordLeft, moveWordRight, @@ -43,12 +44,15 @@ const segmenter = getSegmenter(); /** * Represents a chunk of text for word-wrap layout. - * Tracks both the text content and its position in the original line. + * Tracks the text content, its position in the original line, and its exact + * visible width (`width === visibleWidth(text)`, measured at build time) so + * layout/render never re-measure cached chunks. */ interface TextChunk { text: string; startIndex: number; endIndex: number; + width: number; } /** @@ -56,92 +60,121 @@ interface TextChunk { * Wraps at word boundaries when possible, falling back to character-level * wrapping for words longer than the available width. * + * Widths are carried, never recomputed: the line is segmented exactly once, + * per-grapheme widths are measured lazily at most once each, and every chunk + * is a contiguous slice of `line` (no incremental string concatenation). + * * @param line - The text line to wrap * @param maxWidth - Maximum visible width per chunk - * @returns Array of chunks with text and position information + * @param knownLineWidth - Caller-carried exact `visibleWidth(line)`, if already measured + * @returns Array of chunks with text, position, and exact visible width */ -function wordWrapLine(line: string, maxWidth: number): TextChunk[] { +function wordWrapLine(line: string, maxWidth: number, knownLineWidth?: number): TextChunk[] { if (!line || maxWidth <= 0) { - return [{ text: "", startIndex: 0, endIndex: 0 }]; + return [{ text: "", startIndex: 0, endIndex: 0, width: 0 }]; } - const lineWidth = visibleWidth(line); + const lineWidth = knownLineWidth ?? visibleWidth(line); if (lineWidth <= maxWidth) { - return [{ text: line, startIndex: 0, endIndex: line.length }]; + return [{ text: line, startIndex: 0, endIndex: line.length, width: lineWidth }]; } - const chunks: TextChunk[] = []; - - // Split into tokens (words and whitespace runs) - const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = []; - let currentToken = ""; - let tokenStart = 0; + // Single segmentation pass: grapheme start offsets (with end sentinel), + // lazily-filled grapheme widths, and word/whitespace token boundaries. + const gStart: number[] = []; + const gWidth: number[] = []; + interface Token { + startG: number; + endG: number; + startIndex: number; + endIndex: number; + isWhitespace: boolean; + } + const tokens: Token[] = []; let inWhitespace = false; - let charIndex = 0; - + let tokenStartG = 0; + let tokenStartIndex = 0; + let gCount = 0; for (const seg of segmenter.segment(line)) { - const grapheme = seg.segment; - const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace"; - - if (currentToken === "") { + const graphemeIsWhitespace = getWordNavKind(seg.segment) === "whitespace"; + if (gCount === 0) { inWhitespace = graphemeIsWhitespace; - tokenStart = charIndex; } else if (graphemeIsWhitespace !== inWhitespace) { - // Token type changed - save current token + // Token type changed - close the current token tokens.push({ - text: currentToken, - startIndex: tokenStart, - endIndex: charIndex, + startG: tokenStartG, + endG: gCount, + startIndex: tokenStartIndex, + endIndex: seg.index, isWhitespace: inWhitespace, }); - currentToken = ""; - tokenStart = charIndex; + tokenStartG = gCount; + tokenStartIndex = seg.index; inWhitespace = graphemeIsWhitespace; } - - currentToken += grapheme; - charIndex += grapheme.length; + gStart.push(seg.index); + gWidth.push(-1); + gCount++; } - - // Push final token - if (currentToken) { + gStart.push(line.length); + if (gCount > tokenStartG) { tokens.push({ - text: currentToken, - startIndex: tokenStart, - endIndex: charIndex, + startG: tokenStartG, + endG: gCount, + startIndex: tokenStartIndex, + endIndex: line.length, isWhitespace: inWhitespace, }); } - // Build chunks using word wrapping - let currentChunk = ""; - let currentWidth = 0; - let chunkStartIndex = 0; - let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace) + /** Exact `visibleWidth` of grapheme `g`, measured at most once. */ + const graphemeWidth = (g: number): number => { + let w = gWidth[g] ?? -1; + if (w < 0) { + w = visibleWidth(line.slice(gStart[g] ?? 0, gStart[g + 1] ?? line.length)); + gWidth[g] = w; + } + return w; + }; - function consumePrefixToWidth(text: string, availableWidth: number): { text: string; len: number } { - let prefix = ""; + const chunks: TextChunk[] = []; + const pushChunk = (text: string, startIndex: number, endIndex: number): void => { + chunks.push({ text, startIndex, endIndex, width: visibleWidth(text) }); + }; + + /** Widest grapheme prefix of [startG, endG) that fits `availableWidth`. */ + const consumePrefixToWidth = ( + startG: number, + endG: number, + availableWidth: number, + ): { endG: number; len: number } => { let prefixWidth = 0; - let len = 0; - for (const seg of segmenter.segment(text)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - if (prefixWidth + graphemeWidth > availableWidth) break; - prefix += grapheme; - prefixWidth += graphemeWidth; - len += grapheme.length; + let g = startG; + while (g < endG) { + const w = graphemeWidth(g); + if (prefixWidth + w > availableWidth) break; + prefixWidth += w; + g++; if (prefixWidth === availableWidth) break; } - return { text: prefix, len }; - } - function hasWideGrapheme(text: string): boolean { - for (const seg of segmenter.segment(text)) { - if (visibleWidth(seg.segment) > 1) return true; + return { endG: g, len: (gStart[g] ?? 0) - (gStart[startG] ?? 0) }; + }; + const hasWideGrapheme = (startG: number, endG: number): boolean => { + for (let g = startG; g < endG; g++) { + if (graphemeWidth(g) > 1) return true; } return false; - } + }; + + // Build chunks using word wrapping. The pending chunk is always the + // contiguous slice line[chunkStart, chunkEnd) with visible width currentWidth. + let chunkStart = 0; + let chunkEnd = 0; + let currentWidth = 0; + let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace) + for (const token of tokens) { - const tokenWidth = visibleWidth(token.text); + const tokenWidth = visibleWidth(line.slice(token.startIndex, token.endIndex)); // Skip leading whitespace at line start. Keep the skipped run mapped onto the // preceding chunk (when one exists) so every cursor position resolves to a @@ -149,7 +182,8 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { if (atLineStart && token.isWhitespace) { const prev = chunks[chunks.length - 1]; if (prev) prev.endIndex = token.endIndex; - chunkStartIndex = token.endIndex; + chunkStart = token.endIndex; + chunkEnd = token.endIndex; continue; } atLineStart = false; @@ -157,65 +191,49 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { // If this single token is wider than maxWidth, we need to break it if (tokenWidth > maxWidth) { // If we're mid-line, try to use the remaining width by consuming a prefix of this long token. - let consumedPrefix = ""; - let consumedPrefixLen = 0; // JS string index (code units) consumed from token.text - if (currentChunk && currentWidth < maxWidth) { + let consumedPrefixLen = 0; // JS string index (code units) consumed from the token + let consumedPrefixEndG = token.startG; + if (chunkEnd > chunkStart && currentWidth < maxWidth) { const remainingWidth = maxWidth - currentWidth; - const consumed = consumePrefixToWidth(token.text, remainingWidth); - consumedPrefix = consumed.text; + const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth); + consumedPrefixEndG = consumed.endG; consumedPrefixLen = consumed.len; } // First, push any accumulated chunk (optionally filled with the prefix). - if (currentChunk) { - if (consumedPrefix) { - chunks.push({ - text: currentChunk + consumedPrefix, - startIndex: chunkStartIndex, - endIndex: token.startIndex + consumedPrefixLen, - }); - currentChunk = ""; - currentWidth = 0; - chunkStartIndex = token.startIndex + consumedPrefixLen; + if (chunkEnd > chunkStart) { + if (consumedPrefixLen > 0) { + const endIndex = token.startIndex + consumedPrefixLen; + pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex); + chunkStart = endIndex; + chunkEnd = endIndex; } else { - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: token.startIndex, - }); - currentChunk = ""; - currentWidth = 0; - chunkStartIndex = token.startIndex; + pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, token.startIndex); + chunkStart = token.startIndex; + chunkEnd = token.startIndex; } + currentWidth = 0; } // Break the remaining long token by grapheme - const remainingText = consumedPrefixLen > 0 ? token.text.slice(consumedPrefixLen) : token.text; - let tokenChunk = ""; - let tokenChunkWidth = 0; - let tokenChunkStart = token.startIndex + consumedPrefixLen; - let tokenCharIndex = token.startIndex + consumedPrefixLen; - for (const seg of segmenter.segment(remainingText)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) { - chunks.push({ - text: tokenChunk, - startIndex: tokenChunkStart, - endIndex: tokenCharIndex, - }); - tokenChunk = grapheme; - tokenChunkWidth = graphemeWidth; - tokenChunkStart = tokenCharIndex; + let tcStart = token.startIndex + consumedPrefixLen; + let tcEnd = tcStart; + let tcWidth = 0; + for (let g = consumedPrefixEndG; g < token.endG; g++) { + const w = graphemeWidth(g); + const gEnd = gStart[g + 1] ?? line.length; + if (tcWidth + w > maxWidth && tcEnd > tcStart) { + pushChunk(line.slice(tcStart, tcEnd), tcStart, tcEnd); + tcStart = tcEnd; + tcWidth = w; } else { - tokenChunk += grapheme; - tokenChunkWidth += graphemeWidth; + tcWidth += w; } - tokenCharIndex += grapheme.length; + tcEnd = gEnd; } // Keep remainder as start of next chunk - if (tokenChunk) { - currentChunk = tokenChunk; - currentWidth = tokenChunkWidth; - chunkStartIndex = tokenChunkStart; + if (tcEnd > tcStart) { + chunkStart = tcStart; + chunkEnd = tcEnd; + currentWidth = tcWidth; } continue; } @@ -224,36 +242,34 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { if (currentWidth + tokenWidth > maxWidth) { // For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping // the whole token to the next line. This avoids leaving a short ASCII word alone. - if (currentChunk && !token.isWhitespace && currentWidth < maxWidth && hasWideGrapheme(token.text)) { + if ( + chunkEnd > chunkStart && + !token.isWhitespace && + currentWidth < maxWidth && + hasWideGrapheme(token.startG, token.endG) + ) { const remainingWidth = maxWidth - currentWidth; - const consumed = consumePrefixToWidth(token.text, remainingWidth); - if (consumed.text) { - chunks.push({ - text: currentChunk + consumed.text, - startIndex: chunkStartIndex, - endIndex: token.startIndex + consumed.len, - }); - const remainder = token.text.slice(consumed.len); - currentChunk = remainder; + const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth); + if (consumed.len > 0) { + const endIndex = token.startIndex + consumed.len; + pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex); + const remainder = line.slice(endIndex, token.endIndex); + chunkStart = endIndex; + chunkEnd = token.endIndex; currentWidth = visibleWidth(remainder); - chunkStartIndex = token.startIndex + consumed.len; atLineStart = false; continue; } } // Push current chunk (trimming trailing whitespace for display) - const trimmedChunk = currentChunk.trimEnd(); + const trimmedChunk = line.slice(chunkStart, chunkEnd).trimEnd(); if (trimmedChunk || chunks.length === 0) { - chunks.push({ - text: trimmedChunk, - startIndex: chunkStartIndex, - endIndex: chunkStartIndex + currentChunk.length, - }); + pushChunk(trimmedChunk, chunkStart, chunkEnd); } else { // All-whitespace chunk collapsed away: keep its span mapped on the // previous chunk so cursor positions inside it stay addressable. const prev = chunks[chunks.length - 1]; - if (prev) prev.endIndex = chunkStartIndex + currentChunk.length; + if (prev) prev.endIndex = chunkEnd; } // Start new line - skip leading whitespace atLineStart = true; @@ -262,32 +278,29 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { // point; otherwise cursor positions inside it map to no layout line. const prev = chunks[chunks.length - 1]; if (prev) prev.endIndex = token.endIndex; - currentChunk = ""; + chunkStart = token.endIndex; + chunkEnd = token.endIndex; currentWidth = 0; - chunkStartIndex = token.endIndex; } else { - currentChunk = token.text; + chunkStart = token.startIndex; + chunkEnd = token.endIndex; currentWidth = tokenWidth; - chunkStartIndex = token.startIndex; atLineStart = false; } } else { // Add token to current chunk - currentChunk += token.text; + if (chunkEnd === chunkStart) chunkStart = token.startIndex; + chunkEnd = token.endIndex; currentWidth += tokenWidth; } } // Push final chunk - if (currentChunk) { - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: line.length, - }); + if (chunkEnd > chunkStart) { + pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, line.length); } - return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }]; + return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0, width: 0 }]; } /** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */ @@ -339,10 +352,19 @@ interface EditorState { interface LayoutLine { text: string; + /** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */ + width: number; hasCursor: boolean; cursorPos?: number; } +/** Per-line measurement carried across renders: exact visible width plus + * lazily-built wrap chunks (only populated once the line needs wrapping). */ +interface WrapEntry { + width: number; + chunks: TextChunk[] | null; +} + export interface EditorTheme { borderColor: (str: string) => string; selectList: SelectListTheme; @@ -396,11 +418,14 @@ export class Editor implements Component, Focusable { // Store last layout width for cursor navigation #lastLayoutWidth: number = 80; - // Word-wrap result cache shared by #layoutText, #buildVisualLineMap, and key - // handlers within a frame. Line text is a sound key (strings are immutable); - // cleared on width change and size-bounded so stale lines don't accumulate. - #wrapCache = new Map(); + // Line measurement + word-wrap cache shared by #layoutText, + // #buildVisualLineMap, and key handlers within a frame. Line text is a + // sound key (strings are immutable); cleared on layout-width or + // width-config (Hangul jamo setting) change and size-bounded so stale + // lines don't accumulate. + #wrapCache = new Map(); #wrapCacheWidth = -1; + #wrapCacheEpoch = -1; #paddingXOverride: number | undefined; #maxHeight?: number; #scrollOffset: number = 0; @@ -895,7 +920,7 @@ export class Editor implements Component, Focusable { for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) { const layoutLine = visibleLayoutLines[visibleIndex]!; let displayText = layoutLine.text; - let displayWidth = visibleWidth(layoutLine.text); + let displayWidth = layoutLine.width; let cursorPaddingOverflow = 0; let decorated = false; let imeSafeCursorTail = false; @@ -1041,7 +1066,9 @@ export class Editor implements Component, Focusable { displayText = this.#decorate(displayText); } if (!hasCursor) { - displayWidth = visibleWidth(displayText); + // Undecorated, unsliced lines keep their carried width; any + // transform above produced a new string and must be re-measured. + displayWidth = displayText === layoutLine.text ? layoutLine.width : visibleWidth(displayText); if (displayWidth > lineContentWidth) { displayText = truncateToWidth(displayText, lineContentWidth); displayWidth = visibleWidth(displayText); @@ -1489,20 +1516,29 @@ export class Editor implements Component, Focusable { } } - #wrapLine(line: string, width: number): TextChunk[] { - if (width !== this.#wrapCacheWidth) { + /** Cached per-line measurement: exact visible width now, wrap chunks on demand. */ + #lineEntry(line: string, width: number): WrapEntry { + const epoch = getWidthConfigEpoch(); + if (width !== this.#wrapCacheWidth || epoch !== this.#wrapCacheEpoch) { this.#wrapCache.clear(); this.#wrapCacheWidth = width; + this.#wrapCacheEpoch = epoch; } - let chunks = this.#wrapCache.get(line); - if (chunks === undefined) { + let entry = this.#wrapCache.get(line); + if (entry === undefined) { if (this.#wrapCache.size >= 256) { this.#wrapCache.clear(); } - chunks = wordWrapLine(line, width); - this.#wrapCache.set(line, chunks); + entry = { width: visibleWidth(line), chunks: null }; + this.#wrapCache.set(line, entry); } - return chunks; + return entry; + } + + #wrapLine(line: string, width: number): TextChunk[] { + const entry = this.#lineEntry(line, width); + entry.chunks ??= wordWrapLine(line, width, entry.width); + return entry.chunks; } #layoutText(contentWidth: number): LayoutLine[] { @@ -1512,6 +1548,7 @@ export class Editor implements Component, Focusable { // Empty editor layoutLines.push({ text: "", + width: 0, hasCursor: true, cursorPos: 0, }); @@ -1522,19 +1559,21 @@ export class Editor implements Component, Focusable { for (let i = 0; i < this.#state.lines.length; i++) { const line = this.#state.lines[i] || ""; const isCurrentLine = i === this.#state.cursorLine; - const lineVisibleWidth = visibleWidth(line); + const lineVisibleWidth = this.#lineEntry(line, contentWidth).width; if (lineVisibleWidth <= contentWidth) { // Line fits in one layout line if (isCurrentLine) { layoutLines.push({ text: line, + width: lineVisibleWidth, hasCursor: true, cursorPos: this.#state.cursorCol, }); } else { layoutLines.push({ text: line, + width: lineVisibleWidth, hasCursor: false, }); } @@ -1575,12 +1614,14 @@ export class Editor implements Component, Focusable { if (hasCursorInChunk) { layoutLines.push({ text: chunk.text, + width: chunk.width, hasCursor: true, cursorPos: adjustedCursorPos, }); } else { layoutLines.push({ text: chunk.text, + width: chunk.width, hasCursor: false, }); } @@ -2716,7 +2757,7 @@ export class Editor implements Component, Focusable { for (let i = 0; i < this.#state.lines.length; i++) { const line = this.#state.lines[i] || ""; - const lineVisWidth = visibleWidth(line); + const lineVisWidth = this.#lineEntry(line, width).width; if (line.length === 0) { // Empty line still takes one visual line visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 571f3eee7..f18715eb5 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,5 +1,14 @@ import type { Component } from "../tui"; -import { applyBackgroundToLine, getPaddingX, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { + applyBackgroundToLine, + getPaddingX, + getWidthConfigEpoch, + padding, + publishLineWidths, + replaceTabs, + visibleWidth, + wrapTextWithAnsi, +} from "../utils"; /** * Text component - displays multi-line text with word wrapping @@ -21,6 +30,7 @@ export class Text implements Component { // Cache for rendered output #cachedText?: string; #cachedWidth?: number; + #cachedWidthEpoch?: number; #cachedLines?: string[]; constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { @@ -41,6 +51,7 @@ export class Text implements Component { this.#text = text; this.#cachedText = undefined; this.#cachedWidth = undefined; + this.#cachedWidthEpoch = undefined; this.#cachedLines = undefined; return true; } @@ -49,18 +60,25 @@ export class Text implements Component { this.#customBgFn = customBgFn; this.#cachedText = undefined; this.#cachedWidth = undefined; + this.#cachedWidthEpoch = undefined; this.#cachedLines = undefined; } invalidate(): void { this.#cachedText = undefined; this.#cachedWidth = undefined; + this.#cachedWidthEpoch = undefined; this.#cachedLines = undefined; } render(width: number): readonly string[] { // Check cache - if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) { + if ( + this.#cachedLines && + this.#cachedText === this.#text && + this.#cachedWidth === width && + this.#cachedWidthEpoch === getWidthConfigEpoch() + ) { return this.#cachedLines; } @@ -69,6 +87,7 @@ export class Text implements Component { const result: string[] = []; this.#cachedText = this.#text; this.#cachedWidth = width; + this.#cachedWidthEpoch = getWidthConfigEpoch(); this.#cachedLines = result; return result; } @@ -86,6 +105,9 @@ export class Text implements Component { const leftMargin = padding(paddingX); const rightMargin = padding(paddingX); const contentLines: string[] = []; + // Exact visible widths of `result` rows, published only when rows are + // `content + spaces` (customBgFn output width is not knowable here). + const resultWidths: number[] | undefined = this.#customBgFn ? undefined : []; for (const line of wrappedLines) { // Add margins @@ -99,6 +121,7 @@ export class Text implements Component { const visibleLen = visibleWidth(lineWithMargins); const paddingNeeded = Math.max(0, width - visibleLen); contentLines.push(lineWithMargins + padding(paddingNeeded)); + resultWidths?.push(visibleLen + paddingNeeded); } } @@ -111,10 +134,16 @@ export class Text implements Component { } const result = [...emptyLines, ...contentLines, ...emptyLines]; + if (resultWidths !== undefined) { + // Pad rows are exactly `width` cells wide. + const emptyWidths = new Array(emptyLines.length).fill(width); + publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]); + } // Update cache this.#cachedText = this.#text; this.#cachedWidth = width; + this.#cachedWidthEpoch = getWidthConfigEpoch(); this.#cachedLines = result; return result.length > 0 ? result : [""]; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 63b74dbd0..fc0f5566d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -3246,6 +3246,25 @@ export class TUI extends Container { } const code = raw.charCodeAt(i); + if (code >= 0x20 && code <= 0x7e) { + // Printable-ASCII run: every char here is exactly one cell wide, so + // the run is copied with a single slice instead of a per-char + // slice + visibleWidth call. Stop conditions mirror the general + // path: width budget (cells), source budget (maxSourceLength). + if (output.length >= maxSourceLength) break; + const cap = i + Math.min(safeWidth - cells, maxSourceLength - output.length); + let j = i + 1; + while (j < raw.length && j < cap) { + const c = raw.charCodeAt(j); + if (c < 0x20 || c > 0x7e) break; + j++; + } + output += raw.slice(i, j); + cells += j - i; + i = j; + continue; + } + const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1; const char = raw.slice(i, next); const charWidth = visibleWidth(char); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index c54c5b825..f0ee07cc8 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -30,14 +30,62 @@ export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth return hangulCompatibilityJamoWidth; } +// Monotonic epoch for width-affecting runtime configuration. Any cache or +// carried-width sidecar derived from `visibleWidth` results must be stamped +// with the epoch at computation time and discarded on mismatch, so a Hangul +// Compatibility Jamo width change invalidates every derived width. +let widthConfigEpoch = 0; + +export function getWidthConfigEpoch(): number { + return widthConfigEpoch; +} + +interface LineWidthsEntry { + epoch: number; + lines: readonly string[]; + widths: readonly number[]; +} + +// Per-render-result visible widths, keyed by the exact lines array a component +// returned. The copied strings and widths are the single publication snapshot: +// they cannot be changed through either publisher array and do not retain the +// WeakMap key. Entries therefore die with their lines-array owners. +const lineWidthSidecar = new WeakMap(); + +/** Publish exact per-line visible widths for a rendered lines array. */ +export function publishLineWidths(lines: readonly string[], widths: readonly number[]): void { + if (lines.length !== widths.length) { + throw new RangeError(`Cannot publish ${widths.length} widths for ${lines.length} lines`); + } + lineWidthSidecar.set(lines, { + epoch: widthConfigEpoch, + lines: [...lines], + widths: Object.freeze([...widths]), + }); +} + +/** Exact per-line visible widths for an unchanged `lines` array under the current width config. */ +export function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined { + const entry = lineWidthSidecar.get(lines); + if (entry === undefined || entry.epoch !== widthConfigEpoch || entry.lines.length !== lines.length) { + return undefined; + } + for (let i = 0; i < lines.length; i++) { + if (entry.lines[i] !== lines[i]) return undefined; + } + return entry.widths; +} + export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean { const changed = hangulCompatibilityJamoWidth !== width; hangulCompatibilityJamoWidth = width; + if (changed) widthConfigEpoch++; nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width)); return changed; } export function resetHangulCompatibilityJamoWidthForTests(): void { + if (hangulCompatibilityJamoWidth !== "platform") widthConfigEpoch++; hangulCompatibilityJamoWidth = "platform"; nativeSetHangulCompatJamoWidthOverride(0); } diff --git a/packages/tui/test/container-memo.test.ts b/packages/tui/test/container-memo.test.ts index 0363e1dcb..9705719e2 100644 --- a/packages/tui/test/container-memo.test.ts +++ b/packages/tui/test/container-memo.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { Box, type Component, Container, Text } from "@oh-my-pi/pi-tui"; +import { + publishLineWidths, + resetHangulCompatibilityJamoWidthForTests, + setHangulCompatibilityJamoWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui/utils"; /** * Leaf component that returns a stable cached array and counts render calls. @@ -25,6 +31,30 @@ class Probe implements Component { } } +class MutablePublishedProbe implements Component { + readonly lines = ["hi"]; + + constructor() { + publishLineWidths(this.lines, [2]); + } + + render(_width: number): readonly string[] { + return this.lines; + } +} + +class MutableProbe implements Component { + readonly lines = ["hi"]; + + render(_width: number): readonly string[] { + return this.lines; + } +} + +afterEach(() => { + resetHangulCompatibilityJamoWidthForTests(); +}); + function plain(lines: readonly string[]): string[] { return lines.map(line => stripVTControlCharacters(line).trimEnd()); } @@ -180,3 +210,130 @@ describe("Box render memoization", () => { expect(second[0]).toBe("row "); }); }); + +describe("width configuration cache invalidation", () => { + const jamo = "\u3131\u314f"; + + it("rerenders the same Text after a narrow-to-wide Hangul change", () => { + const text = new Text(jamo, 0, 0); + setHangulCompatibilityJamoWidth(1); + const narrow = text.render(6); + expect(narrow).toEqual([`${jamo}${" ".repeat(4)}`]); + + setHangulCompatibilityJamoWidth(2); + const wide = text.render(6); + expect(wide).not.toBe(narrow); + expect(wide).toEqual([`${jamo}${" ".repeat(2)}`]); + }); + + it("rerenders a nested Box after a narrow-to-wide Hangul change", () => { + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(new Text(jamo, 0, 0)); + setHangulCompatibilityJamoWidth(1); + const narrow = box.render(8); + + setHangulCompatibilityJamoWidth(2); + const wide = box.render(8); + expect(wide).not.toBe(narrow); + expect(wide).not.toEqual(narrow); + expect(wide.every(line => visibleWidth(line) === 8)).toBe(true); + }); + + it("keys the Box cache by width epoch even for ref-stable child rows", () => { + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(new Probe([jamo])); + setHangulCompatibilityJamoWidth(1); + const narrow = box.render(8); + + setHangulCompatibilityJamoWidth(2); + const wide = box.render(8); + expect(wide).not.toBe(narrow); + expect(wide).not.toEqual(narrow); + expect(wide.every(line => visibleWidth(line) === 8)).toBe(true); + }); +}); + +describe("Box carried-width proof", () => { + it("rebuilds after a published child mutates its same array", () => { + const child = new MutablePublishedProbe(); + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(child); + const before = box.render(8); + + child.lines[0] = "hello"; + const after = box.render(8); + expect(after).not.toBe(before); + expect(plain(after)).toEqual([" hello"]); + expect(after.every(line => visibleWidth(line) === 8)).toBe(true); + }); + + it("rebuilds after an unpublished child mutates its same array", () => { + const child = new MutableProbe(); + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(child); + const before = box.render(8); + + child.lines[0] = "hello"; + const after = box.render(8); + expect(after).not.toBe(before); + expect(plain(after)).toEqual([" hello"]); + expect(after.every(line => visibleWidth(line) === 8)).toBe(true); + }); + + it("falls back for direct context-sensitive leading marks", () => { + for (const line of ["\u200d\ufe0f", "\ufe0f\ufe0f", "\u20e3", "\u0301", "\u093f\u20e3", "\u0e33\ufe0f"]) { + const lines = [line]; + publishLineWidths(lines, [visibleWidth(line)]); + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(new Probe(lines)); + + const result = box.render(4); + expect(result.every(row => visibleWidth(row) === 4)).toBe(true); + } + }); + + it("falls back for SGR-hidden leading joiners and variation selectors", () => { + const line = "\x1b[31m\u200d\ufe0f\x1b[0m"; + const lines = [line]; + publishLineWidths(lines, [visibleWidth(line)]); + const box = new Box(1, 0); + box.setIgnoreTight(true); + box.addChild(new Probe(lines)); + + const result = box.render(4); + expect(result.every(row => visibleWidth(row) === 4)).toBe(true); + }); + + it("pads hard-class rows to full width from carried widths at zero paddingX", () => { + // Hard classes whose width is context-sensitive: leading Mn mark, Mc + // spacing mark, keycap, ZWJ, variation selector, Thai/Lao AM. + const lines = [ + "\u0301a", // leading Mn combining mark + "\u093f", // bare Mc spacing mark U+093F + "1\u20e3", // keycap base + U+20E3 + "\u{1f468}\u200d\u{1f469}\u200d\u{1f467}", // ZWJ emoji sequence + "a\u200db", // bare ZWJ between letters + "\u2764\ufe0f", // heart + variation selector U+FE0F + "\u0e33\ufe0f", // Thai U+0E33 + variation selector + "\u0eb3", // Lao U+0EB3 + ]; + // Publish exact per-line widths the way a real Text render does; at + // paddingX === 0 the Box must trust them (no remeasure) and still pad + // every row to the full render width. + publishLineWidths( + lines, + lines.map(line => visibleWidth(line)), + ); + const box = new Box(0, 0); + box.addChild(new Probe(lines)); + + const result = box.render(8); + expect(result.length).toBe(lines.length); + expect(result.every(row => visibleWidth(row) === 8)).toBe(true); + }); +}); diff --git a/packages/tui/test/line-width-sidecar.test.ts b/packages/tui/test/line-width-sidecar.test.ts new file mode 100644 index 000000000..f80c273c0 --- /dev/null +++ b/packages/tui/test/line-width-sidecar.test.ts @@ -0,0 +1,86 @@ +/** + * Carried-width contract: components may publish the exact `visibleWidth` of + * each line of a render result, keyed by the result array itself. Consumers + * (Box) must only ever observe widths that (a) match a direct measurement and + * (b) were computed under the current width configuration — a Hangul + * Compatibility Jamo width change must invalidate every published width. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { + getPublishedLineWidths, + getWidthConfigEpoch, + publishLineWidths, + resetHangulCompatibilityJamoWidthForTests, + setHangulCompatibilityJamoWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui/utils"; + +afterEach(() => { + resetHangulCompatibilityJamoWidthForTests(); +}); + +describe("line-width sidecar", () => { + it("returns published widths for the same array reference only", () => { + const lines = ["abc", "漢字"]; + publishLineWidths(lines, [3, 4]); + expect(getPublishedLineWidths(lines)).toEqual([3, 4]); + // A value-equal but distinct array has no published widths. + expect(getPublishedLineWidths(["abc", "漢字"])).toBeUndefined(); + }); + + it("rejects a publication whose widths do not match the line count", () => { + expect(() => publishLineWidths(["one", "two"], [3])).toThrow(RangeError); + }); + + it("keeps publication proof immutable from publishers and consumers", () => { + const lines = ["hi"]; + const widths = [2]; + publishLineWidths(lines, widths); + + widths[0] = 99; + const published = getPublishedLineWidths(lines); + expect(published).toEqual([2]); + expect(Object.isFrozen(published)).toBe(true); + expect(Reflect.set(published ?? [], "0", 7)).toBe(false); + expect(getPublishedLineWidths(lines)).toEqual([2]); + }); + + it("drops published widths after same-array content or length mutation", () => { + const lines = ["hi"]; + publishLineWidths(lines, [2]); + + lines[0] = "hello"; + expect(getPublishedLineWidths(lines)).toBeUndefined(); + + publishLineWidths(lines, [5]); + lines.push("!"); + expect(getPublishedLineWidths(lines)).toBeUndefined(); + }); + + it("drops published widths when the Hangul jamo width setting changes", () => { + const jamo = "\u3131\u314F"; + const lines = [jamo]; + setHangulCompatibilityJamoWidth(1); + publishLineWidths(lines, [visibleWidth(jamo)]); + expect(getPublishedLineWidths(lines)).toEqual([visibleWidth(jamo)]); + + // Widths measured under the old setting must not survive the switch: + // the same string now measures differently. + setHangulCompatibilityJamoWidth(2); + expect(getPublishedLineWidths(lines)).toBeUndefined(); + + // Republishing under the new setting is visible again and exact. + publishLineWidths(lines, [visibleWidth(jamo)]); + expect(getPublishedLineWidths(lines)).toEqual([4]); + }); + + it("bumps the width-config epoch only on an effective setting change", () => { + const before = getWidthConfigEpoch(); + setHangulCompatibilityJamoWidth(1); + const afterFirst = getWidthConfigEpoch(); + expect(afterFirst).toBeGreaterThan(before); + // No-op set: same value, no invalidation. + setHangulCompatibilityJamoWidth(1); + expect(getWidthConfigEpoch()).toBe(afterFirst); + }); +});