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
This commit is contained in:
metaphorics
2026-07-17 14:56:32 +09:00
parent 48241afcc4
commit 92380971da
7 changed files with 604 additions and 182 deletions
+72 -30
View File
@@ -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;
}
}
+190 -149
View File
@@ -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<string, TextChunk[]>();
// 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<string, WrapEntry>();
#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 });
+31 -2
View File
@@ -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<number>(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 : [""];
+19
View File
@@ -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);
+48
View File
@@ -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<readonly string[], LineWidthsEntry>();
/** 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);
}
+158 -1
View File
@@ -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("<B>row </B>");
});
});
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);
});
});
@@ -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);
});
});