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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 : [""];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user