diff --git a/demo/scroll.ts b/demo/scroll.ts new file mode 100644 index 0000000..3e3c3db --- /dev/null +++ b/demo/scroll.ts @@ -0,0 +1,395 @@ +import { each, ensure, main, until } from "effection"; +import { + close, + createDisplayWidth, + createTerm, + fixed, + grow, + type InputEvent, + type Op, + open, + rgba, + text, +} from "../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../settings.ts"; +import { Virtualizer, type ViewportEntry } from "../virtualizer/mod.ts"; +import { skipAnsiSequence } from "../virtualizer/ansi-scanner.ts"; +import { useInput } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const FG = rgba(204, 204, 204); +const DIM = rgba(100, 100, 110); +const GUTTER_FG = rgba(100, 120, 160); +const GUTTER_SEP = rgba(60, 60, 70); +const STATUS_BG = rgba(30, 30, 40); +const STATUS_FG = rgba(180, 180, 190); +const ACCENT = rgba(80, 180, 255); +const THUMB = rgba(120, 120, 140); +const TRACK = rgba(40, 40, 50); + +function stripAnsi(s: string): string { + let out = ""; + let i = 0; + while (i < s.length) { + let skip = skipAnsiSequence(s, i); + if (skip > 0) { i += skip; continue; } + out += s[i]; + i++; + } + return out; +} + +function sliceSubRows(entry: ViewportEntry): string[] { + let { text: t, wrapPoints, firstSubRow, visibleSubRows } = entry; + let breaks = [0, ...wrapPoints, t.length]; + let result: string[] = []; + for (let i = firstSubRow; i < firstSubRow + visibleSubRows; i++) { + result.push(t.slice(breaks[i], breaks[i + 1])); + } + return result; +} + +function gutterWidth(v: Virtualizer): number { + let maxLine = v.baseIndex + v.lineCount; + let digits = Math.max(1, Math.floor(Math.log10(maxLine)) + 1); + return Math.max(4, digits + 2); // " N │" +} + +function gutterText(lineNum: number | null, width: number): string { + if (lineNum === null) { + return " ".repeat(width - 2) + " │"; + } + let num = String(lineNum); + let pad = width - 2 - num.length; // -2 for " │" + return " ".repeat(Math.max(0, pad)) + num + " │"; +} + +function thumbGeometry( + v: Virtualizer, + viewportHeight: number, +): { thumbPos: number; thumbSize: number } { + let vp = v.resolveViewport(); + let total = vp.totalEstimatedVisualRows; + let current = vp.currentEstimatedVisualRow; + let thumbSize = total > viewportHeight + ? Math.max(1, Math.round(viewportHeight * viewportHeight / total)) + : viewportHeight; + let thumbPos = total > 1 + ? Math.round(current / Math.max(total - 1, 1) * (viewportHeight - thumbSize)) + : 0; + return { thumbPos, thumbSize }; +} + +function buildOps( + v: Virtualizer, + columns: number, + rows: number, +): Op[] { + let ops: Op[] = []; + let vp = v.resolveViewport(); + let gw = gutterWidth(v); + let viewportHeight = rows - 1; + + let { thumbPos, thumbSize } = thumbGeometry(v, viewportHeight); + + // Root + ops.push(open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + })); + + // Viewport area + ops.push(open("viewport", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + })); + + let rowIndex = 0; + for (let entry of vp.entries) { + let subRows = sliceSubRows(entry); + for (let si = 0; si < subRows.length; si++) { + let isFirstSubRow = entry.firstSubRow + si === 0; + let lineNum = isFirstSubRow ? entry.lineIndex + 1 : null; + let content = stripAnsi(subRows[si]); + + // Scrollbar character + let scrollChar = rowIndex >= thumbPos && rowIndex < thumbPos + thumbSize + ? "█" : "│"; + let scrollColor = rowIndex >= thumbPos && rowIndex < thumbPos + thumbSize + ? THUMB : TRACK; + + ops.push( + open(`r${rowIndex}`, { + layout: { direction: "ltr", height: fixed(1), width: grow() }, + }), + // Gutter + open("", { layout: { width: fixed(gw), height: fixed(1) } }), + text(gutterText(lineNum, gw), { + color: lineNum !== null ? GUTTER_FG : GUTTER_SEP, + }), + close(), + // Content + open("", { layout: { width: grow(), height: fixed(1) } }), + text(content || " ", { color: FG }), + close(), + // Scrollbar + open("", { layout: { width: fixed(1), height: fixed(1) } }), + text(scrollChar, { color: scrollColor }), + close(), + close(), + ); + rowIndex++; + } + } + + // Fill remaining rows if viewport not full + while (rowIndex < viewportHeight) { + let scrollChar = rowIndex >= thumbPos && rowIndex < thumbPos + thumbSize + ? "█" : " "; + let scrollColor = rowIndex >= thumbPos && rowIndex < thumbPos + thumbSize + ? THUMB : TRACK; + + ops.push( + open(`r${rowIndex}`, { + layout: { direction: "ltr", height: fixed(1), width: grow() }, + }), + open("", { layout: { width: fixed(gw), height: fixed(1) } }), + text("~", { color: DIM }), + close(), + open("", { layout: { width: grow(), height: fixed(1) } }), + close(), + open("", { layout: { width: fixed(1), height: fixed(1) } }), + text(scrollChar, { color: scrollColor }), + close(), + close(), + ); + rowIndex++; + } + + ops.push(close()); // viewport + + // Status bar + let bottomLabel = vp.isAtBottom ? " BOTTOM" : ""; + let status = + ` lines: ${v.lineCount} row ${vp.currentEstimatedVisualRow}/${vp.totalEstimatedVisualRows}${bottomLabel} j/k:scroll g/G:top/bottom q:quit`; + ops.push( + open("status", { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + padding: { left: 1 }, + }, + bg: STATUS_BG, + }), + text(status, { color: STATUS_FG }), + close(), + ); + + ops.push(close()); // root + + return ops; +} + +function generateContent(): string[] { + let lines: string[] = []; + lines.push("Welcome to the Clayterm Virtualizer scroll demo"); + lines.push("═".repeat(48)); + lines.push(""); + + for (let i = 1; i <= 50; i++) { + lines.push(`Line ${i}: The quick brown fox jumps over the lazy dog`); + } + + lines.push(""); + lines.push("--- Long lines that will wrap ---"); + for (let i = 0; i < 10; i++) { + lines.push( + `[wrap-${i}] ` + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + ); + } + + lines.push(""); + lines.push("--- Short lines ---"); + for (let i = 0; i < 20; i++) { + lines.push(` ${i}`); + } + + lines.push(""); + lines.push("--- Mixed content ---"); + for (let i = 0; i < 50; i++) { + if (i % 5 === 0) lines.push(""); + else if (i % 7 === 0) lines.push("─".repeat(60)); + else if (i % 3 === 0) { + lines.push( + ` Item ${i}: ${"abcdefghij".repeat(Math.floor(i / 3 + 1))}`, + ); + } else lines.push(` Entry #${i}`); + } + + lines.push(""); + lines.push("--- Numbered block ---"); + for (let i = 1; i <= 60; i++) { + lines.push(`${String(i).padStart(4, " ")} ${"█".repeat(i % 40 + 1)}`); + } + + lines.push(""); + lines.push("═".repeat(48)); + lines.push("End of generated content"); + + return lines; +} + +function handleEvent( + event: InputEvent, + v: Virtualizer, + viewportRows: number, + columns: number, + drag: { active: boolean; offset: number }, +): boolean { + if (event.type === "keydown" && event.ctrl && event.key === "c") return true; + if (event.type === "keydown" && event.key === "q") return true; + + if (event.type === "keydown") { + switch (event.code) { + case "j": + case "ArrowDown": + case "Enter": + v.scrollBy(1); + break; + case "k": + case "ArrowUp": + v.scrollBy(-1); + break; + case "d": + case "PageDown": + v.scrollBy(Math.max(1, Math.floor(viewportRows / 2))); + break; + case "u": + case "PageUp": + v.scrollBy(-Math.max(1, Math.floor(viewportRows / 2))); + break; + case "g": + case "Home": + v.scrollToFraction(0); + break; + case "End": + v.scrollToFraction(1); + break; + } + // G (shift+g) — check key, not code + if (event.key === "G") { + v.scrollToFraction(1); + } + } + + if (event.type === "wheel") { + v.scrollBy(event.direction === "down" ? 3 : -3); + } + + if ( + event.type === "mousedown" && + event.button === "left" && + event.x === columns - 1 && + event.y < viewportRows + ) { + let { thumbPos, thumbSize } = thumbGeometry(v, viewportRows); + if (event.y >= thumbPos && event.y < thumbPos + thumbSize) { + drag.active = true; + drag.offset = event.y - thumbPos; + } else { + let fraction = event.y / Math.max(viewportRows - thumbSize, 1); + v.scrollToFraction(Math.min(Math.max(fraction, 0), 1)); + } + } + + if (event.type === "mousemove" && drag.active) { + let { thumbSize } = thumbGeometry(v, viewportRows); + let fraction = (event.y - drag.offset) / Math.max(viewportRows - thumbSize, 1); + v.scrollToFraction(Math.min(Math.max(fraction, 0), 1)); + } + + if (event.type === "mouseup") { + drag.active = false; + } + + return false; +} + +await main(function* () { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + Deno.stdin.setRaw(true); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let measureWidth = yield* until(createDisplayWidth()); + + // Load content + let lines: string[]; + if (Deno.args.length > 0) { + lines = []; + for (let path of Deno.args) { + let content = Deno.readTextFileSync(path); + for (let line of content.split("\n")) lines.push(line); + } + } else { + lines = generateContent(); + } + + // Compute initial gutter width from line count + let maxDigits = Math.max(1, Math.floor(Math.log10(lines.length)) + 1); + let gw = Math.max(4, maxDigits + 2); + let viewportRows = rows - 1; + let textColumns = columns - gw - 1; + + let v = new Virtualizer({ + measureWidth, + columns: textColumns, + rows: viewportRows, + }); + + for (let line of lines) { + v.appendLine(line); + } + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + Deno.stdout.writeSync(tty.apply); + + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + let drag = { active: false, offset: 0 }; + + // Initial render + Deno.stdout.writeSync(term.render(buildOps(v, columns, rows)).output); + + for (let event of yield* each(input)) { + let quit = handleEvent(event, v, viewportRows, columns, drag); + if (quit) break; + + if (event.type === "resize") { + drag.active = false; + columns = event.width; + rows = event.height; + viewportRows = rows - 1; + gw = gutterWidth(v); + textColumns = columns - gw - 1; + v.resize(textColumns, viewportRows); + term = yield* until(createTerm({ width: columns, height: rows })); + } + + Deno.stdout.writeSync(term.render(buildOps(v, columns, rows)).output); + + yield* each.next(); + } +}); diff --git a/mod.ts b/mod.ts index 8862d13..7924f6a 100644 --- a/mod.ts +++ b/mod.ts @@ -3,3 +3,4 @@ export * from "./term.ts"; export * from "./input.ts"; export * from "./settings.ts"; export * from "./termcodes.ts"; +export { createDisplayWidth } from "./width.ts"; diff --git a/specs/clayterm-spec.md b/specs/clayterm-spec.md new file mode 100644 index 0000000..c639e59 --- /dev/null +++ b/specs/clayterm-spec.md @@ -0,0 +1,518 @@ +# Clayterm Current-State Specification + +**Version:** 0.1 (draft) +**Status:** Current-state specification. Normative for the rendering contract. Descriptive for settling surfaces. + +--- + +## 1. Purpose + +Clayterm is a terminal rendering engine. It accepts a declarative description of a terminal UI layout, performs layout computation and cell-level diffing internally, and returns ANSI escape byte sequences suitable for direct write to a terminal output stream. + +This specification defines Clayterm's current-state rendering contract: its architectural model, its invariants, its stable public API surface, and its intentional boundaries. It is written to allow future feature work to extend the project without destabilizing the core. + +This specification does not attempt to define areas of Clayterm that are still settling. Where the project has working but evolving surfaces — including the input parsing API, pointer event model, and certain wrapper types — those are described in Section 12 as current implementation rather than normative contract. + +--- + +## 2. Scope + +### In scope (normative) + +- The rendering pipeline and its architectural commitments +- The frame-snapshot rendering model +- The stable public rendering API +- The descriptor model and core helpers +- Element identity and frame semantics +- Boundary responsibilities (what Clayterm owns and what it does not) + +### In scope (non-normative, descriptive) + +- Current implementation surfaces that are settling but not yet stable enough to freeze (Section 12) +- Implementation notes that aid understanding but do not define contract (Section 14) + +### Out of scope + +- Internal C code organization, function names, or file structure +- WASM memory layout or compilation details beyond behavioral requirements +- Performance targets or benchmark methodology +- Packaging, CI, or distribution workflow details +- Higher-level UI framework concerns (e.g., component lifecycle, reconciliation) +- Demo applications +- The crankterm project or any specific framework built on Clayterm + +--- + +## 3. Terminology + +**Frame.** A single, complete rendering pass. Each frame begins with the caller providing descriptors and ends with the renderer returning ANSI bytes. Frames are independent; the renderer carries no UI tree state between them. + +**Descriptor (op).** A plain object that declares one element of the UI tree for a single frame. Descriptors are typed by an identifier field and carry layout, styling, and content properties. The set of descriptors for a frame is ordered and forms an implicit tree via open/close pairing. + +**Descriptor array.** An ordered array of descriptors constituting a complete frame description. The array is the input to the rendering transaction. + +**Render transaction.** The process of accepting a descriptor array, performing layout, walking render commands, diffing against the previous frame's cell buffer, and producing ANSI byte output. A render transaction is a single, synchronous operation from the caller's perspective. + +**ANSI bytes.** A byte sequence of UTF-8–encoded ANSI escape codes and text content that, when written to a terminal file descriptor, produces the visual output described by the frame's descriptors. ANSI bytes include cursor-positioning sequences, SGR (Select Graphic Rendition) attribute sequences, and UTF-8 text. + +**Renderer core.** The WASM module and its TS entry points that together implement the render transaction. The renderer core owns layout computation, render-command walking, cell-buffer diffing, and ANSI byte generation. + +**Caller.** Any code that invokes Clayterm's public API to produce terminal output. The caller owns terminal setup, IO, input handling, and application lifecycle. + +**Higher-level framework.** A component model, reconciler, or application framework built on top of Clayterm. Examples include crankterm. Clayterm has no dependency on any higher-level framework, and this specification does not constrain their design. + +**Term.** An instance of the Clayterm renderer, bound to specific terminal dimensions. A Term is the object through which the caller performs render transactions. + +--- + +## 4. Architectural Model + +*This section is normative.* + +### 4.1 Pipeline + +Clayterm implements a rendering pipeline with the following stages: + +1. **Descriptor acceptance.** The caller provides a complete descriptor array representing the desired UI state for a single frame. + +2. **Transfer.** The renderer transfers the frame description into the WASM module. The transfer mechanism is an implementation detail. The normative requirement is that the transfer occurs as part of a single render transaction; the caller does not interact with the transfer mechanism directly. + +3. **Render transaction.** The WASM module processes the frame description. Internally, it drives a layout engine to compute element positions and sizes, walks the resulting render commands to populate a cell buffer, and diffs the cell buffer against the previous frame. + +4. **Output generation.** For each cell that differs from the previous frame, the renderer emits ANSI escape sequences (cursor positioning, color attributes, and text) into an output buffer. + +5. **Output retrieval.** The caller reads the ANSI byte output. + +### 4.2 Single-transaction rendering + +A frame MUST be rendered in a single transaction that crosses the TS→WASM boundary once. The caller provides the complete descriptor array, invokes the render transaction, and reads the output. There are no intermediate callbacks, yields, or partial results. + +### 4.3 Frame-snapshot model + +Each render transaction operates on a complete, self-contained snapshot of the UI. The renderer MUST NOT maintain an internal component tree or UI state across frames. The only state the renderer retains between frames is the cell buffer used for diffing, which is an implementation detail of output minimization and not observable to the caller except through reduced output size. + +### 4.4 Double-buffered diffing + +The renderer maintains two cell buffers: a front buffer (the previously rendered frame) and a back buffer (the frame being rendered). After populating the back buffer from the current frame's render commands, the renderer compares it against the front buffer and emits ANSI bytes only for cells that differ. The buffers are then swapped. This mechanism is internal to the renderer and not directly observable to the caller. + +--- + +## 5. Contract Layer Boundary + +*This section is normative.* + +This specification defines the **architectural rendering contract**: the commitments that make Clayterm what it is and that callers and framework authors can depend on. + +This specification **does not** define the following as normative: + +- **The internal transfer encoding.** The mechanism by which descriptors are serialized for the WASM module — its byte format, opcode structure, and field encoding — is an implementation detail. The normative commitment is that the transfer happens within a single render transaction; the encoding is described in Section 12.1 as current implementation surface. + +- **Validation or error semantics.** How the renderer responds to invalid input (malformed descriptor arrays, unbalanced open/close pairs) is not yet specified as contract. Section 9.1 defines what constitutes valid input. Behavior for invalid input is currently unspecified. + +- **The complete set of descriptor properties.** The existence of the core descriptor constructors (`open`, `close`, `text`) and the core sizing helpers (`grow`, `fixed`, `fit`) is normative. The full set of properties accepted by these constructors — which layout fields, which styling options, which configuration groups are available — is current implementation surface described in Section 12.2. New property groups have been added over time and more may follow. + +- **The return type wrapper of `render()`.** The commitment that `render()` produces ANSI bytes accessible as a `Uint8Array` is normative. The wrapper type around those bytes is current implementation surface described in Section 12.3. + +Future readers should not treat current implementation surface as identical to the contract boundary. + +--- + +## 6. Core Invariants + +*This section is normative.* + +**INV-1. Zero IO.** The renderer MUST NOT perform any terminal input or output. It MUST NOT write to stdout, read from stdin, open file descriptors, or interact with the terminal device. The renderer produces bytes; the caller writes them. + +**INV-2. Single transaction per frame.** Each frame MUST be rendered in a single transaction that crosses the TS→WASM boundary once. The caller provides the complete frame as a descriptor array and receives ANSI bytes in return. + +**INV-3. Frame-snapshot independence.** The renderer MUST NOT require the caller to maintain or provide state across frames beyond calling `render()` on the same Term instance. Each descriptor array fully describes its frame. + +**INV-4. ANSI byte output.** The output of a render transaction MUST be a byte sequence of valid UTF-8–encoded ANSI escape codes that is directly writable to a terminal output stream without further transformation or encoding. + +**INV-5. Layout/render/diff ownership.** The renderer owns the layout computation, render-command walk, cell-buffer diffing, and ANSI byte generation stages. The caller MUST NOT need to perform any of these operations. + +**INV-6. Internal lifecycle symmetry.** The renderer's internal layout lifecycle (begin-layout and end-layout calls to the underlying layout engine) MUST be symmetric: both calls occur within the same render transaction, in the same function scope. + +**INV-7. Element identity disambiguation.** When multiple elements within a frame share the same tag name, the renderer MUST disambiguate their identities so that the layout engine does not conflate them. The disambiguation mechanism is an implementation detail, but the guarantee is normative: identical tag names MUST NOT cause layout corruption or element conflation. + +**INV-8. Separation of concerns.** The rendering concern and the input-parsing concern MUST remain independent. Neither MUST depend on the other's state, types, or API surface. They MAY share a compiled WASM binary for loading efficiency, but this is an implementation convenience, not an architectural coupling. + +--- + +## 7. Rendering Contract + +*This section is normative.* + +### 7.1 Inputs + +The rendering transaction accepts: + +- A **descriptor array**: an ordered array of descriptor objects constituting a complete frame. The array MUST contain balanced open/close pairs forming a valid tree structure. + +The descriptor array is the sole required input to a render transaction. + +### 7.2 Rendering transaction + +When the caller invokes a render transaction: + +1. The renderer accepts the descriptor array and transfers the frame description into the WASM module. +2. The WASM module processes the frame: it computes layout, walks render commands, populates the cell buffer, diffs against the previous frame, and writes ANSI bytes for changed cells. +3. Control returns to the caller with the ANSI byte output available. + +The render transaction is synchronous from the caller's perspective once invoked. It MUST NOT yield, suspend, or require callbacks during execution. + +### 7.3 Output + +The render transaction produces ANSI bytes as a `Uint8Array`. These bytes: + +- MUST be valid UTF-8 +- MUST consist of ANSI escape sequences (CSI, SGR) and text content +- MUST be directly writable to a terminal file descriptor to produce the described visual output +- MUST represent only the cells that changed since the previous frame (on a Term instance that has rendered at least one prior frame) + +The output reflects the complete visual state of the frame. The caller SHOULD write the output to the terminal without modification. + +### 7.4 Lifecycle + +A Term instance is created for specific terminal dimensions. The caller provides width and height at creation time. + +To handle terminal resize, the caller creates a new Term with the new dimensions. The previous Term instance becomes stale and SHOULD NOT be used for further rendering. + +Creation of a Term is asynchronous because it may involve WASM module preparation. A Term instance MAY be used for any number of render transactions. The Term retains its cell buffers across frames for diffing purposes. + +--- + +## 8. Public Rendering API + +*This section is normative. Only items with high confidence of stability are included. See Section 5 for what this section does and does not freeze.* + +### 8.1 Term creation + +``` +createTerm(options: { width: number; height: number }): Promise +``` + +Creates a new Term instance bound to the specified terminal dimensions. The returned promise resolves when the renderer is ready. The `width` and `height` parameters specify the terminal dimensions in character cells. + +### 8.2 Render invocation + +``` +term.render(ops: Op[]): +``` + +Accepts an ordered array of descriptor objects and performs a render transaction as defined in Section 7. Returns the ANSI byte output as a `Uint8Array`. + +The return type is specified here only to the extent that the ANSI bytes MUST be accessible as a `Uint8Array`. The precise shape of the return value — whether it is a bare `Uint8Array`, a wrapper object, or a structure carrying additional data — is part of the current implementation surface described in Section 12.3 and is not locked down by this specification. + +### 8.3 Descriptor constructors + +Descriptors are created using constructor functions that return plain objects. The caller assembles these into an array. This pattern — functions returning plain descriptors, composed into arrays — is normative. A builder, fluent, or mutation-based API is explicitly rejected. + +#### 8.3.1 open + +``` +open(name: string, props?): OpenElement +``` + +Creates an element-open descriptor. The `name` parameter provides a tag name for the element. The optional `props` parameter carries configuration for layout, styling, and behavior. + +Elements opened with `open()` MUST be closed with a corresponding `close()` descriptor later in the same descriptor array. + +The set of properties accepted by `props` is part of the current implementation surface described in Section 12.2. This specification defines the existence and signature of `open()` normatively but does not freeze the complete property surface, which has been extended incrementally and may continue to grow. + +#### 8.3.2 close + +``` +close(): CloseElement +``` + +Creates an element-close descriptor. Each `close()` MUST correspond to a preceding `open()`. + +#### 8.3.3 text + +``` +text(content: string, props?): Text +``` + +Creates a text descriptor. The `content` parameter provides the text string to render. The optional `props` parameter carries text styling configuration. + +Text descriptors MUST appear between a matching open/close pair. + +The set of styling properties accepted by `props` is part of the current implementation surface and may be extended. + +### 8.4 Sizing helpers + +These functions produce sizing-axis values for use in element layout configuration: + +``` +grow(): SizingAxis +``` +The element expands to fill available space in the parent along this axis. + +``` +fixed(value: number): SizingAxis +``` +The element has a fixed size of `value` cells along this axis. + +``` +fit(min?: number, max?: number): SizingAxis +``` +The element sizes to fit its content, optionally constrained by minimum and maximum bounds. + +### 8.5 Color helper + +``` +rgba(r: number, g: number, b: number, a?: number): number +``` + +Packs color channel values (each 0–255) into a single 32-bit integer in ARGB format. Alpha defaults to 255 (fully opaque). The returned value is used wherever the descriptor model expects a color. + +--- + +## 9. Descriptor Model + +*This section is normative.* + +### 9.1 Descriptor-array pattern + +The rendering input is an ordered array of descriptor objects. Each descriptor is a plain JavaScript/TypeScript object created by a constructor function (Section 8.3). Descriptors are not classes, do not carry methods, and do not participate in a prototype chain. They MAY be spread, composed, stored, or inspected as ordinary objects. + +The array is processed in order. Open and close descriptors form an implicit tree. The renderer processes them sequentially. + +A descriptor array with unbalanced open/close pairs, or with close descriptors that do not match a preceding open, is invalid input. Callers SHOULD validate descriptor arrays before rendering. The renderer's behavior when given an invalid descriptor array is unspecified by this specification. + +### 9.2 Transfer to the WASM module + +As part of the render transaction, the descriptor array is transferred into a form that the WASM module can process. This transfer is handled internally by the renderer and is not an operation the caller performs or observes. The transfer mechanism is an implementation detail described in Section 12.1. + +### 9.3 Descriptor identity + +Each element descriptor is assigned an identity within the frame for use by the underlying layout engine. When multiple elements share the same tag name (the `name` parameter to `open()`), the renderer MUST disambiguate their identities automatically. The disambiguation mechanism is an implementation detail. The normative requirement is that the caller MUST NOT need to provide globally unique names; the renderer handles uniqueness internally. + +--- + +## 10. Identity and Frame Semantics + +*This section is normative.* + +### 10.1 Frame completeness + +A descriptor array provided to `render()` MUST represent a complete frame. The renderer does not support incremental updates, partial frames, or delta descriptions. Every frame fully specifies the desired UI state. + +### 10.2 Descriptor ordering + +Descriptors MUST be provided in depth-first tree order. An `open()` descriptor begins an element; its children (including nested open/close pairs and text descriptors) follow in order; a `close()` descriptor ends the element. The renderer processes descriptors in the order they appear in the array. + +### 10.3 Element identity within a frame + +Within a single frame, each element MUST have an unambiguous identity for the layout engine. As specified in Section 9.3, the renderer handles disambiguation. Two elements with the same tag name in the same frame MUST NOT cause layout corruption, hash collision, or identity conflation. + +### 10.4 No cross-frame identity + +The renderer does not track element identity across frames. An element named "sidebar" in frame N and an element named "sidebar" in frame N+1 are not related from the renderer's perspective. Cross-frame identity, if needed, is the responsibility of a higher-level framework. + +--- + +## 11. Boundaries and Non-Responsibilities + +*This section is normative.* + +### 11.1 The renderer does not perform IO + +The renderer MUST NOT write to any output stream. The renderer MUST NOT read from any input stream. The renderer produces bytes; the caller decides when and how to write them. This enables the renderer to operate in any environment where WebAssembly is available, including browsers, server-side runtimes, and embedded contexts. + +### 11.2 The renderer does not manage terminal state + +The renderer MUST NOT emit escape sequences for any of the following terminal-management operations: + +- Entering or leaving the alternate screen buffer +- Hiding or showing the cursor +- Setting the cursor shape or blink state +- Enabling or disabling mouse reporting +- Enabling or disabling keyboard protocol modes (e.g., Kitty progressive enhancement) +- Enabling or disabling raw mode or similar terminal disciplines + +These are the caller's responsibility. The renderer's output contains only the escape sequences needed to render the frame content (cursor positioning for cell writes, SGR attributes for styling, and UTF-8 text). + +### 11.3 The renderer does not own application lifecycle + +The renderer MUST NOT maintain a run loop, event loop, timer, or subscription mechanism. It does not schedule frames. It does not manage component state. It renders when asked and returns. The decision of when to render is entirely the caller's. + +### 11.4 The renderer does not own input parsing + +Input parsing (keyboard events, mouse events, escape sequence decoding) is an independent concern. It is not part of the rendering contract defined by this specification. The renderer MUST NOT depend on input-parsing state, types, or API. + +Clayterm currently provides input-parsing functionality alongside the renderer in the same package. This co-location is an implementation detail, not an architectural coupling. Section 12.4 describes the current input surface. + +### 11.5 The renderer does not own higher-level framework concerns + +The renderer MUST NOT implement or depend on: + +- Component models or component lifecycles +- Reconciliation or diffing of descriptor trees (the renderer diffs *cells*, not *trees*) +- State management or reactivity +- Event propagation through a component hierarchy + +These are the domain of higher-level frameworks built on Clayterm. + +--- + +## 12. Current Surface That Remains Elastic + +*This entire section is non-normative. It describes the current implementation surface to aid consumers and future spec authors. The shapes described here are real, working, and in many cases deliberately designed, but they do not yet meet the stability threshold for normative specification. They MAY change in future versions without constituting a breaking change to the normative core defined above.* + +### 12.1 Transfer encoding (command protocol) + +The renderer currently serializes descriptors into a flat byte buffer using a command protocol based on fixed-width `Uint32` words. Each descriptor is encoded as an opcode word followed by descriptor-specific data. Element-open descriptors use a property mask to indicate which optional field groups (layout, border, corner radius, clip, floating, scroll) are present, followed by the data for each indicated group. Strings are encoded as length-prefixed UTF-8 byte sequences within the word stream. Floats are stored as bit-reinterpreted `Uint32` values. + +This encoding has been extended incrementally (floating, clip, and scroll groups were added after the initial protocol) but has never been restructured. It is likely to remain stable in structure while continuing to grow. However, specific opcode values, mask definitions, and field layouts are implementation details and are not locked down by this specification. + +### 12.2 Descriptor property groups + +The `open()` constructor currently accepts the following property groups in its `props` parameter: + +- **`layout`** — sizing (width and height, specified via sizing helpers), padding (per-side), alignment (currently numeric enum values, with a planned transition to string literals), direction (top-to-bottom or left-to-right), and gap +- **`border`** — per-side border widths and border color +- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters +- **`clip`** — clip region configuration for scroll containers +- **`floating`** — floating-element configuration (offset, parent reference, attach points, z-index) +- **`scroll`** — scroll container configuration + +The `text()` constructor currently accepts: `color`, `fontSize`, `letterSpacing`, `lineHeight`, and attribute flags (`bold`, `italic`, `underline`, `strikethrough`). + +These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. Alignment values are expected to transition from numeric to string-literal form. + +**Border width and layout interaction.** In the current underlying layout engine (Clay), border configuration does not affect layout computation. Borders are drawn as visual overlays within the element's bounding box. A bordered element with zero padding will have its borders drawn over its content. Callers must add padding equal to or greater than the border width to prevent overlap. This behavior has been explicitly discussed by the project; the current position is to document it rather than auto-compensate, but this decision may be revisited. + +### 12.3 Render return type + +The `render()` method currently returns a `RenderResult` object shaped as `{ output: Uint8Array, events: PointerEvent[] }`. + +The `output` field is the ANSI byte output specified normatively in Section 7.3 and Section 8.2. + +The `events` field contains pointer events (enter, leave, click) derived from the underlying layout engine's element hit-testing. This field was added during a pointer-events feature implementation. The pointer event model is functional but has acknowledged gaps (no modifier keys on click events) and its interaction protocol (calling `setPointer(x, y, down)` before rendering, then reading events from the return value) was arrived at through iteration rather than upfront design. + +The return type of `render()` has changed twice since the project's inception (string, then `Uint8Array`, then `RenderResult`). While the ANSI bytes commitment (Section 7.3) is stable, the wrapper shape around those bytes is not. Future versions may restructure the return type. + +### 12.4 Input parsing surface + +Clayterm currently provides terminal input parsing alongside the renderer. The input API was designed by the project lead and has clear design intent, but it has undergone more revision than the rendering core and faces known upcoming forces that will reshape it (Kitty progressive enhancement field surfacing, terminfo binary parsing, possible package separation). + +The current input surface includes: + +**`createInput(options?): Promise`** — Creates an input parser instance. Options currently include `escLatency` (milliseconds to wait before resolving a lone ESC byte as the Escape key, default 25ms) and `terminfo` (a `Uint8Array` of raw terminfo binary, accepted but with C-side parsing not yet implemented). + +**`input.scan(bytes?): ScanResult`** — Feeds raw terminal bytes into the parser and returns parsed events. The `bytes` parameter is optional; calling without arguments triggers a rescan for ESC timeout resolution. + +**`ScanResult`** — Currently shaped as `{ events: InputEvent[], pending?: { delay: number, deadline: number } }`. The `events` array contains parsed events. The `pending` field, when present, indicates that an ambiguous ESC byte is buffered and provides both a relative delay and an absolute deadline for the caller to schedule a rescan. + +**`InputEvent` discriminated union** — Currently discriminated on a `type` field with these variants: `CharEvent` (insertable character), `KeyEvent` (special/control key), `MouseEvent` (button press/release), `DragEvent` (motion with button held), `WheelEvent` (scroll tick), `ResizeEvent`. The discriminant values and the type splits are deliberate design decisions. However, the field sets within each variant are expected to grow when Kitty progressive enhancement types are surfaced in the TypeScript layer (the C struct has already been extended with fields that are not yet mapped to the TS types). + +The input API is architecturally independent from the renderer (see INV-8). Whether it remains in the same package or becomes a separate module is an open question. + +### 12.5 Pointer event model + +Clayterm currently supports pointer hit-testing via the underlying layout engine's element-identification mechanism. The current surface includes: + +- `setPointer(x, y, down)` — sets the pointer position and button state for the next render +- Pointer events returned as part of `RenderResult.events`: `pointerenter`, `pointerleave`, `pointerclick` + +This surface is functional but should not be treated as stable contract. The calling convention was discovered through iteration, the event model has acknowledged gaps, and the approach may evolve. + +### 12.6 Validation and packing + +**`validate(ops)`** — A function that checks a descriptor array for structural errors (unbalanced open/close pairs, invalid field types). Currently exported and used in tests. Its intended status as public API versus internal utility is not established. + +**`pack(ops, mem, offset)`** — A function that serializes a descriptor array into the transfer encoding described in Section 12.1. Currently exported and used internally by `render()`. Its exposure as public API is incidental; it was not explicitly designated as caller-facing. + +--- + +## 13. Deferred / Future Areas + +*This section is non-normative. These topics are explicitly excluded from this specification. Their omission is intentional, not an oversight.* + +**Section / region rendering mode.** Rendering into a portion of the normal screen buffer rather than the alternate screen. Partially prototyped but not landed. + +**Scroll container API.** The underlying layout engine supports scroll containers. No TypeScript-side API exists for providing scroll state to the renderer. + +**Full Kitty progressive enhancement event types.** The C-side input parser struct has been extended for progressive enhancement fields. The TypeScript event types have not been updated to surface them. + +**Terminfo binary parsing.** The input API accepts a `terminfo` option, but C-side parsing is not implemented. + +**CSI helper for terminal setup.** A helper for generating paired apply/rollback byte arrays for terminal mode configuration was discussed but not implemented. + +**Browser-specific adapter.** The renderer's zero-IO architecture makes browser portability possible. No adapter exists. + +**`betweenChildren` border support.** The underlying layout engine supports this. It is not exposed in the descriptor model. + +**Whether input parsing should be a separate package.** Architecturally independent (INV-8) but currently co-located. The distribution decision is open. + +--- + +## 14. Implementation Notes + +*This section is non-normative. These notes describe current implementation details that aid understanding but do not define contract. They may change without notice.* + +**WASM module structure.** The renderer is implemented in C and compiled to WebAssembly as a single module. The module contains both rendering and input-parsing functionality; they share a binary but maintain independent state. + +**WASM loading.** The current implementation loads the WASM binary relative to the module's location, compiles it once, and instantiates it per Term or Input with fresh memory. The loading mechanism has changed and may change again. + +**WASM co-location.** The WASM binary file is expected to be co-located with the JavaScript module files. Both JSR and npm package builds include the artifact. + +**Memory layout.** WASM linear memory is initialized with 256 pages (16MB). The renderer state struct and the transfer buffer are allocated in WASM linear memory. The specific layout is an implementation detail. + +**Output buffer lifetime.** The ANSI byte output resides in WASM linear memory. The `Uint8Array` returned by `render()` is a view over this memory. The output is valid until the next `render()` call on the same Term instance, at which point the buffer may be reused. Callers who need to retain the output beyond the next render SHOULD copy it. + +**Layout engine.** The underlying layout engine is Clay, included as a dependency. Clay provides flexbox-like layout computation with support for fixed, grow, and fit sizing; padding; alignment; direction; gap; floating elements; clip regions; and scroll containers. + +**Text measurement.** Text width measurement uses `wcwidth`-based character width computation, supporting ASCII, CJK wide characters, and other Unicode codepoints. + +**Cell representation.** Each cell in the buffer stores a Unicode codepoint, a foreground color (packed ARGB with attribute flags in the high byte), and a background color. + +**Border junction resolution.** When bordered elements share edges, the renderer accumulates per-cell direction bitmasks and resolves them to correct box-drawing junction glyphs in a post-render pass. + +--- + +## Appendix A. Confidence Notes + +### Why the rendering core is specified more aggressively than other surfaces + +The rendering architecture — `createTerm`, `render(ops)`, the descriptor constructors, the bytes-output commitment, and the core invariants — was designed at the project's inception and has been stable since. It has survived the addition of pointer events, border junction resolution, and the crankterm integration without revision to its fundamental shapes. Its key abstractions (flat descriptor arrays, single render transaction, ANSI byte output) were chosen over explicitly rejected alternatives (per-element FFI, protobuf, builder pattern, string output). This level of stability and intentionality justifies normative specification. + +The input API arrived later, has been through significantly more design churn (rejected first draft, iterative event type splits, naming changes, ongoing Kitty progressive enhancement design), and faces known upcoming forces that will reshape it. It has clear design ownership from the project lead, which distinguishes it from purely implementation-driven features like the pointer event model, but design ownership is not the same as contract readiness. + +The pointer event model and render return wrapper are the least settled of the currently shipping features. Both were introduced during feature implementation rather than designed as part of the core architecture. The return type of `render()` has changed twice. The pointer calling convention was discovered through iteration. These are working and useful, but they carry the lowest confidence of any feature currently in the codebase. + +### How to interpret "currently exported" + +Several symbols are currently accessible from Clayterm's module exports — including `pack()`, `validate()`, and numerous input-related types — without clear evidence that they were intended as stable public contract. Being exported may mean "needed by internal modules" or "not yet audited for public/internal boundary." + +This specification does not treat the export list as a contract boundary. Instead, it uses stability over time, design ownership, survival of corrections, and absence of known reshaping forces as the criteria for normative inclusion. + +--- + +## Open Decisions Intentionally Left Out of This Spec + +The following decisions are open. This specification omits them deliberately. Future readers should not interpret their absence as oversight or implicit resolution. + +1. **What is the normative return type of `render()`?** This specification commits to ANSI bytes as `Uint8Array` but does not lock down the wrapper type. The current `RenderResult` shape may evolve. + +2. **Is pointer event detection part of the rendering contract?** The current implementation returns pointer events from `render()`. This specification does not include pointer events in the normative core. Whether pointer detection is intrinsic to the renderer or should be a separate concern is unresolved. + +3. **Is the input API part of the Clayterm specification?** This specification describes it in Section 12.4 but does not specify it normatively. The input API may become a separate package or specification. + +4. **Are `pack()` and `validate()` public API?** Both are currently exported. Neither is specified normatively here. + +5. **What are the normative Kitty progressive enhancement event types?** The C-side struct has been extended. The TypeScript types have not been updated. This specification does not attempt to predict the final shapes. + +6. **How should border widths interact with layout?** The current behavior (borders do not affect layout) is inherited from the underlying layout engine. The project has questioned whether this is the right design. This specification describes the current behavior in Section 12.2 without committing to it. + +7. **Should the rendering and input concerns be distributed as separate packages?** They are architecturally independent (INV-8) but currently co-located. + +8. **What is the specification for section / region rendering mode?** Partially prototyped but not ready for specification. + +9. **What are the specific transfer encoding details?** The encoding structure is described in Section 12.1 as current implementation surface. Locking down opcode values would constrain future extensions unnecessarily. + +10. **What is the complete set of descriptor properties?** The property groups available in `open()` and `text()` are described in Section 12.2 as current implementation surface. They have been extended incrementally and will continue to grow. + +11. **What are the validation and error semantics?** How the renderer responds to invalid input is unspecified. Callers SHOULD validate, but the validation model is not yet settled enough to define normatively. diff --git a/specs/clayterm-virtualizer-spec.md b/specs/clayterm-virtualizer-spec.md new file mode 100644 index 0000000..eac3fa3 --- /dev/null +++ b/specs/clayterm-virtualizer-spec.md @@ -0,0 +1,239 @@ +# `@clayterm/virtualizer` Current-State Specification + +**Status:** Current-state specification for the implementation on `feat/virtualizer-v1`. + +This document was imported from the draft virtualizer spec in `~/Downloads` and updated to match the current implementation in this repository. It is intended to describe the implemented package surface and behavior as it exists in-tree today. Where the implementation is known to be more provisional or implementation-shaped than the original draft contract, that is called out explicitly. + +## 1. Scope + +`@clayterm/virtualizer` is a TypeScript package that provides viewport virtualization over large terminal text output. It owns: + +- ring-buffer storage of logical lines +- per-line cached display width +- per-line wrap-point caching at the current column width +- anchor-based scrolling +- viewport resolution +- approximate scrollbar-estimation fields + +It does not own: + +- ANSI style parsing beyond CSI/OSC skipping for width and wrapping +- rendering, layout, or terminal output +- input normalization +- input parsing +- search, selection, filtering, or disk-backed scrollback + +## 2. Package Boundary + +The package lives in [`virtualizer/`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer) and exports: + +- [`Virtualizer`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/virtualizer.ts) +- [`VirtualizerOptions`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/types.ts) +- [`ViewportEntry`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/types.ts) +- [`ResolvedViewport`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/types.ts) + +The package has no runtime import of `@clayterm/clayterm`. Width measurement is injected by the caller. + +The intended width provider from the renderer package is `createDisplayWidth()` from root [`width.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/width.ts), which is an async factory returning a synchronous `(text: string) => number` function. + +## 3. Public API + +The current implemented API is: + +```ts +class Virtualizer { + constructor(options: VirtualizerOptions); + + readonly lineCount: number; + readonly baseIndex: number; + readonly columns: number; + readonly rows: number; + readonly totalEstimatedVisualRows: number; + readonly currentEstimatedVisualRow: number; + readonly isAtBottom: boolean; + readonly anchorLineIndex: number; + readonly anchorSubRow: number; + + appendLine(text: string): number; + resize(columns: number, rows: number): void; + scrollBy(deltaVisualRows: number): void; + scrollToFraction(fraction: number): void; + resolveViewport(): ResolvedViewport; + + getLineDisplayWidth(lineIndex: number): number | undefined; +} + +interface VirtualizerOptions { + measureWidth: (text: string) => number; + maxLines?: number; + columns: number; + rows: number; +} + +interface ViewportEntry { + lineIndex: number; + text: string; + wrapPoints: number[]; + totalSubRows: number; + firstSubRow: number; + visibleSubRows: number; +} + +interface ResolvedViewport { + entries: ViewportEntry[]; + totalEstimatedVisualRows: number; + currentEstimatedVisualRow: number; + isAtBottom: boolean; +} +``` + +Notes: + +- `appendLine()` returns the assigned monotonic `lineIndex`. +- `resolveViewport()` reads `columns` and `rows` from internal state and takes no parameters. +- `getLineDisplayWidth()` is a current implemented API for exposing cached per-line display width. It returns `undefined` for evicted or never-assigned indices. + +## 4. Width Model And ANSI Handling + +The current implementation expects `measureWidth(text)` to be: + +- synchronous +- ANSI-agnostic +- additive across concatenation +- based on per-codepoint `wcwidth` semantics + +The virtualizer owns ANSI CSI/OSC skipping internally. + +Recognized sequences: + +- CSI: `ESC [` ... final byte in `0x40..0x7E` +- OSC: `ESC ]` ... terminated by BEL or `ESC \` + +Current behavior: + +- recognized CSI/OSC bytes are skipped for both cached display width and wrap computation +- `measureWidth` is called only on visible codepoints +- unrecognized escape families are not skipped +- no ANSI style state is carried between lines + +Implementation note: + +- unterminated CSI/OSC sequences are currently consumed to the end of the string by the scanner in [`virtualizer/ansi-scanner.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/ansi-scanner.ts) + +## 5. Data Model + +The virtualizer maintains: + +- a ring buffer of `{ text, displayWidth, lineIndex }` +- `baseIndex` +- a monotonic next-line counter +- current `columns` and `rows` +- anchor state: `(anchorLineIndex, anchorSubRow, isAtBottom)` +- `totalEstimatedVisualRows` +- `currentEstimatedVisualRow` +- a wrap cache `Map` + +Current storage implementation lives in [`virtualizer/ring-buffer.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/ring-buffer.ts). + +Current empty-buffer behavior: + +- `lineCount === 0` +- `totalEstimatedVisualRows === 0` +- `currentEstimatedVisualRow === 0` +- `isAtBottom === true` +- `resolveViewport().entries` is empty +- `scrollBy()` and `scrollToFraction()` are no-ops + +## 6. Operations + +### 6.1 `appendLine(text)` + +Current implementation flow: + +1. Compute `displayWidth` with ANSI skipping. +2. If the buffer is at capacity, evict the oldest line before insertion. +3. Delete the evicted line’s wrap-cache entry. +4. Decrement `totalEstimatedVisualRows` by the evicted line’s estimated rows. +5. Adjust anchor and current estimate if eviction occurred before the anchor. +6. Store the new line with the next monotonic `lineIndex`. +7. Increment `totalEstimatedVisualRows` by `max(1, ceil(displayWidth / columns))`. +8. If `isAtBottom`, advance anchor to the new line at sub-row 0. + +### 6.2 `scrollBy(deltaVisualRows)` + +Current implementation: + +- walks exact sub-rows using cached or freshly computed wrap points +- clamps at top and bottom +- clears `isAtBottom` when scrolling up from bottom +- sets `isAtBottom` when clamped to the last exact sub-row of the last line +- recomputes `currentEstimatedVisualRow` from estimated line prefixes plus the anchor sub-row + +### 6.3 `scrollToFraction(fraction)` + +Current implementation: + +- maps the fraction into an estimated target row +- walks from `baseIndex` using estimated rows per line +- lands on an estimated sub-row within the selected line +- recomputes `currentEstimatedVisualRow` after updating the anchor + +### 6.4 `resize(columns, rows)` + +Current implementation: + +- clears the wrap cache on column change +- recomputes `totalEstimatedVisualRows` by summing `ceil(displayWidth / columns)` +- updates `columns` +- recomputes `currentEstimatedVisualRow` +- updates `rows` + +Current implementation detail: + +- anchor sub-row clamping is currently based on the estimated row count for the anchor line at the new width, not an exact re-wrap of that line + +### 6.5 `resolveViewport()` + +Current implementation: + +1. Walks forward from the anchor, filling as many rows as possible. +2. If the forward walk does not fill the viewport, it backfills upward: + - first by expanding the anchor line upward when `anchorSubRow > 0` + - then by including preceding lines +3. Returns ordered `ViewportEntry` records plus estimation metadata. + +Each `ViewportEntry` contains the original `text`, all wrap points for that line, and a visible sub-row window. + +## 7. Output Invariants The Implementation Intends To Satisfy + +The current test suite checks the following output invariants through public API behavior: + +- `entries` ordered by ascending `lineIndex` +- `text` preserved exactly +- `wrapPoints` strictly increasing +- no wrap point inside surrogate pairs +- no wrap point inside recognized CSI/OSC +- `totalSubRows === wrapPoints.length + 1` +- `firstSubRow` and `visibleSubRows` stay within bounds +- total visible sub-rows do not exceed the viewport row budget +- every visible slice fits within `columns` (when `columns` ≥ the maximum single-glyph width returned by `measureWidth`; see §8) +- output can be reconstructed from `text + wrapPoints` + +## 8. Current Implementation Notes And Known Gaps + +### Columns ≥ max glyph width precondition + +`columns` must be at least as wide as the widest single glyph that `measureWidth` can return. With a standard `wcwidth`-based provider, CJK ideographs are width 2, so `columns` must be ≥ 2. When this precondition is violated (e.g. `columns: 1` with CJK text), the wrapping algorithm cannot split a single glyph and it overflows its sub-row. The O-9 invariant ("every visible slice fits within `columns`") does not hold in that case. The wrap-golden test suite documents the overflow behavior for reference but the configuration is unsupported. + +### Other notes + +These notes document the current branch shape rather than defining desired future contract. + +- The renderer-side width provider is exposed as `createDisplayWidth(): Promise<(text: string) => number>`, not a directly callable synchronous top-level export. +- `getLineDisplayWidth()` is currently public to support direct verification of cached display width. +- The wrap golden fixture for `abc文d` is currently exercised at `columns = 4` in [`virtualizer/test/wrap-golden.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/wrap-golden.test.ts), reflecting the corrected boundary case used by the branch’s tests rather than the older draft wording from Downloads. +- The implementation and tests should be treated as the source of truth for current behavior where they diverge from the older draft. + +## 9. Source Alignment + +This file replaces the earlier draft-only location in `~/Downloads` with an in-repo copy under [`specs/`](/Users/tarasmankovski/Repositories/frontside/clayterm/specs). It is intended to track the implemented branch and should be updated together with public API or behavioral changes in [`virtualizer/`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer). diff --git a/specs/clayterm-virtualizer-test-plan.md b/specs/clayterm-virtualizer-test-plan.md new file mode 100644 index 0000000..4d6bc5a --- /dev/null +++ b/specs/clayterm-virtualizer-test-plan.md @@ -0,0 +1,216 @@ +# `@clayterm/virtualizer` Current-State Test Plan + +**Status:** Current-state test plan for the implementation in this repository. + +This document was imported from the draft virtualizer test plan in `~/Downloads` and updated to reflect the test suites, filenames, and assertion strategy that exist on `feat/virtualizer-v1`. + +## 1. Scope + +This plan covers: + +- root renderer width-provider tests for `createDisplayWidth()` +- virtualizer conformance-style tests in [`virtualizer/test/`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test) +- golden fixtures +- real-world ANSI fixtures +- property-style randomized tests +- informational benchmark gates + +Current suite count: + +- root renderer width tests: 7 +- virtualizer tests: 113 + +These counts reflect the current branch contents. + +## 2. Test Inventory By File + +Root package: + +- [`test/width.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/test/width.test.ts): renderer width-provider tests for `createDisplayWidth()` + +Virtualizer package: + +- [`virtualizer/test/invariants.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/invariants.test.ts) +- [`virtualizer/test/width.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/width.test.ts) +- [`virtualizer/test/ansi.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/ansi.test.ts) +- [`virtualizer/test/append.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/append.test.ts) +- [`virtualizer/test/empty.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/empty.test.ts) +- [`virtualizer/test/viewport.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/viewport.test.ts) +- [`virtualizer/test/wrap-golden.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/wrap-golden.test.ts) +- [`virtualizer/test/ansi-golden.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/ansi-golden.test.ts) +- [`virtualizer/test/real-world-ansi.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/real-world-ansi.test.ts) +- [`virtualizer/test/scroll.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/scroll.test.ts) +- [`virtualizer/test/fraction.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/fraction.test.ts) +- [`virtualizer/test/eviction.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/eviction.test.ts) +- [`virtualizer/test/resize.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/resize.test.ts) +- [`virtualizer/test/exactness.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/exactness.test.ts) +- [`virtualizer/test/property.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/property.test.ts) +- [`virtualizer/test/bench.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/bench.test.ts) + +## 3. Assertion Strategy + +The current branch uses three broad test styles: + +- deterministic conformance-style tests +- reference-behavior tests for exact formula/provider expectations +- informational benchmarks + +Current observability surfaces used by tests: + +- `appendLine()` return value +- read-only `Virtualizer` state getters +- `resolveViewport()` output +- `getLineDisplayWidth(lineIndex)` +- instrumented `measureWidth` mocks + +The addition of `getLineDisplayWidth()` is important for current coverage because several width assertions now verify cached width directly rather than inferring it from row counts. + +## 4. Traceability To Current Suites + +### 4.1 Renderer width-provider + +Covered in [`test/width.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/test/width.test.ts): + +- `R.WIDTH.ascii` +- `R.WIDTH.cjk` +- `R.WIDTH.combining` +- `R.WIDTH.zwj-emoji` +- `R.WIDTH.additivity` +- `R.WIDTH.empty-string` +- `R.WIDTH.zero-width` + +These validate `createDisplayWidth()` as the current reference provider. + +### 4.2 Core identity and append behavior + +Covered in: + +- [`virtualizer/test/invariants.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/invariants.test.ts) +- [`virtualizer/test/append.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/append.test.ts) +- [`virtualizer/test/empty.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/empty.test.ts) + +Current assertions include: + +- monotonic line indices +- identity survival across eviction +- identity non-reuse +- empty-buffer semantics +- bottom-follow behavior +- append behavior while scrolled up +- wrap-cache stability across append + +### 4.3 Width and ANSI handling + +Covered in: + +- [`virtualizer/test/width.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/width.test.ts) +- [`virtualizer/test/ansi.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/ansi.test.ts) +- [`virtualizer/test/ansi-golden.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/ansi-golden.test.ts) +- [`virtualizer/test/real-world-ansi.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/real-world-ansi.test.ts) + +Current assertions include: + +- `measureWidth` never sees recognized ANSI bytes +- cached display width excludes recognized CSI/OSC +- unrecognized escape forms are not skipped +- wrap points never land inside recognized sequences +- no ANSI state leaks across lines +- captured ANSI fixtures behave consistently at multiple widths + +### 4.4 Viewport invariants and wrapping fixtures + +Covered in: + +- [`virtualizer/test/viewport.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/viewport.test.ts) +- [`virtualizer/test/wrap-golden.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/wrap-golden.test.ts) + +Current assertions include: + +- ordering and text preservation +- wrap-point monotonicity and bounds +- surrogate-pair safety +- visible row budget +- slice width within columns +- self-contained reconstruction + +Current fixture note: + +- the branch’s `G.WRAP.cjk-boundary` fixture uses `abc文d` at `columns = 4`, matching the corrected test expectation in code + +### 4.5 Scroll, fraction, eviction, and resize + +Covered in: + +- [`virtualizer/test/scroll.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/scroll.test.ts) +- [`virtualizer/test/fraction.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/fraction.test.ts) +- [`virtualizer/test/eviction.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/eviction.test.ts) +- [`virtualizer/test/resize.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/resize.test.ts) + +Current assertions include: + +- top and bottom clamping +- `isAtBottom` transitions +- fractional jumps to top and bottom +- eviction anchor recovery +- viewport stability when eviction removes older lines +- cache invalidation across resize +- `displayWidth` stability across resize via `getLineDisplayWidth()` + +## 5. Property And Benchmark Coverage + +Property-style randomized coverage lives in [`virtualizer/test/property.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/property.test.ts): + +- identity monotonicity and non-reuse +- eviction stability +- append independence from scroll position +- viewport invariants after random operations +- estimation-field constraints +- resize invariant preservation + +Informational benchmark coverage lives in [`virtualizer/test/bench.test.ts`](/Users/tarasmankovski/Repositories/frontside/clayterm/virtualizer/test/bench.test.ts): + +- Gate 1: width-provider overhead +- Gate 2: extended ANSI corpus +- Gate 3: estimate vs exact wrap-count match rate +- Gate 4: `scrollToFraction` performance +- Gate 5: skip-optimization comparison +- Gate 6: ANSI-heavy viewport resolution ratio +- Gate 7: resize performance + +These are informational measurements, not conformance gates. + +## 6. Commands + +Run root width-provider tests: + +```sh +deno test --allow-read test/width.test.ts +``` + +Run virtualizer tests: + +```sh +cd virtualizer && deno test +``` + +## 7. Current-State Notes + +This plan is intentionally aligned to the branch implementation, not the older Downloads draft verbatim. + +Notable updates from the older draft: + +- width assertions now use `getLineDisplayWidth()` directly where the branch exposes it +- the renderer width-provider surface is `createDisplayWidth()` rather than a direct synchronous export +- the current suite count is lower than the draft’s projected count because the implementation consolidates multiple properties into fewer files and parameterized tests +- the `wrap-golden` boundary fixture has been corrected to the current checked-in test case + +## 8. Maintenance + +This file should be updated when: + +- the `Virtualizer` public API changes +- test files are renamed or reorganized +- current assertion strategy changes +- additional real-world fixtures or benchmark gates are added + +The in-repo `specs/` directory is now the canonical location for these virtualizer docs. diff --git a/src/clayterm.c b/src/clayterm.c index a9bedf6..ab11910 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -7,6 +7,7 @@ * output — pointer to output byte buffer * length — length of output byte buffer * measure — Clay text measurement callback + * display_width — per-codepoint wcwidth sum over a UTF-8 string */ #include "clayterm.h" @@ -656,3 +657,32 @@ void measure(int ret, int txt) { dims[0] = (float)w; dims[1] = 1.0f; } + +/* ── display_width — per-codepoint wcwidth sum ───────────────────── */ + +/** + * Compute the display width of a UTF-8 string by summing max(0, wcwidth(cp)) + * for each Unicode codepoint. No ANSI skipping — pure per-codepoint sum. + * + * @param str Pointer to a UTF-8 encoded string (not necessarily null-terminated). + * @param len Byte length of the string. + * @return Total display width (non-negative). + */ +int display_width(const char *str, int len) { + int w = 0; + const char *p = str; + int rem = len; + while (rem > 0) { + uint32_t cp; + int n = utf8_decode(&cp, p); + if (n <= 0) { + n = 1; + } + int cw = wcwidth(cp); + if (cw > 0) + w += cw; + p += n; + rem -= n; + } + return w; +} diff --git a/test/width.test.ts b/test/width.test.ts new file mode 100644 index 0000000..195f270 --- /dev/null +++ b/test/width.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createDisplayWidth } from "../width.ts"; + +describe("createDisplayWidth", () => { + let displayWidth: (text: string) => number; + + beforeEach(async () => { + displayWidth = await createDisplayWidth(); + }); + + it("R.WIDTH.ascii — ASCII characters each have width 1", () => { + expect(displayWidth("hello")).toBe(5); + }); + + it("R.WIDTH.cjk — CJK characters each have width 2", () => { + expect(displayWidth("文字")).toBe(4); + }); + + it("R.WIDTH.combining — combining marks have width 0", () => { + expect(displayWidth("e\u0301")).toBe(1); + }); + + it("R.WIDTH.zwj-emoji — ZWJ emoji sequence uses per-codepoint wcwidth", () => { + expect(displayWidth("👨‍👩‍👧‍👦")).toBe(8); + }); + + it("R.WIDTH.additivity — width of concatenation equals sum of widths", () => { + let pairs: [string, string][] = [ + ["hello", "world"], + ["文", "字"], + ["abc", "文字"], + ["e\u0301", "hello"], + ]; + for (let [a, b] of pairs) { + expect(displayWidth(a + b)).toBe(displayWidth(a) + displayWidth(b)); + } + }); + + it("R.WIDTH.empty-string — empty string has width 0", () => { + expect(displayWidth("")).toBe(0); + }); + + it("R.WIDTH.zero-width — zero-width characters have width 0", () => { + expect(displayWidth("\u200B")).toBe(0); + expect(displayWidth("\u200D")).toBe(0); + }); +}); diff --git a/virtualizer/ansi-scanner.ts b/virtualizer/ansi-scanner.ts new file mode 100644 index 0000000..209b4fc --- /dev/null +++ b/virtualizer/ansi-scanner.ts @@ -0,0 +1,48 @@ +/** + * If text[pos] starts an ESC-initiated CSI or OSC sequence, return the + * byte length of the full sequence (inclusive). Otherwise return 0. + * + * CSI: ESC [ ... + * OSC: ESC ] ... + */ +export function skipAnsiSequence(text: string, pos: number): number { + if (text.charCodeAt(pos) !== 0x1b) return 0; + if (pos + 1 >= text.length) return 0; + + let next = text.charCodeAt(pos + 1); + + // CSI: ESC [ + if (next === 0x5b) { + let i = pos + 2; + while (i < text.length) { + let ch = text.charCodeAt(i); + if (ch >= 0x40 && ch <= 0x7e) { + return i - pos + 1; + } + i++; + } + // Unterminated CSI — consume what we have + return i - pos; + } + + // OSC: ESC ] + if (next === 0x5d) { + let i = pos + 2; + while (i < text.length) { + let ch = text.charCodeAt(i); + // BEL terminator + if (ch === 0x07) { + return i - pos + 1; + } + // ST terminator: ESC backslash + if (ch === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c) { + return i - pos + 2; + } + i++; + } + // Unterminated OSC — consume what we have + return i - pos; + } + + return 0; +} diff --git a/virtualizer/deno.json b/virtualizer/deno.json new file mode 100644 index 0000000..9a89e67 --- /dev/null +++ b/virtualizer/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@clayterm/virtualizer", + "license": "MIT", + "exports": { ".": "./mod.ts" }, + "imports": { + "@std/testing": "jsr:@std/testing@1", + "@std/expect": "jsr:@std/expect@1" + } +} diff --git a/virtualizer/deno.lock b/virtualizer/deno.lock new file mode 100644 index 0000000..98a73db --- /dev/null +++ b/virtualizer/deno.lock @@ -0,0 +1,42 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/assert@^1.0.17": "1.0.19", + "jsr:@std/expect@1": "1.0.17", + "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/testing@1": "1.0.17" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/expect@1.0.17": { + "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", + "dependencies": [ + "jsr:@std/assert@^1.0.14", + "jsr:@std/internal@^1.0.10" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/assert@^1.0.17", + "jsr:@std/internal@^1.0.12" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/expect@1", + "jsr:@std/testing@1" + ] + } +} diff --git a/virtualizer/mod.ts b/virtualizer/mod.ts new file mode 100644 index 0000000..a915955 --- /dev/null +++ b/virtualizer/mod.ts @@ -0,0 +1,6 @@ +export { Virtualizer } from "./virtualizer.ts"; +export type { + ResolvedViewport, + ViewportEntry, + VirtualizerOptions, +} from "./types.ts"; diff --git a/virtualizer/ring-buffer.ts b/virtualizer/ring-buffer.ts new file mode 100644 index 0000000..eda6c20 --- /dev/null +++ b/virtualizer/ring-buffer.ts @@ -0,0 +1,68 @@ +export interface LineEntry { + text: string; + displayWidth: number; + lineIndex: number; +} + +export interface AppendResult { + lineIndex: number; + evicted?: { displayWidth: number; lineIndex: number }; +} + +export class RingBuffer { + private _items: (LineEntry | undefined)[]; + private _capacity: number; + private _head: number; + private _count: number; + private _nextIndex: number; + + constructor(capacity: number) { + this._capacity = capacity; + this._items = new Array(capacity); + this._head = 0; + this._count = 0; + this._nextIndex = 0; + } + + get capacity(): number { + return this._capacity; + } + + get lineCount(): number { + return this._count; + } + + get baseIndex(): number { + if (this._count === 0) return this._nextIndex; + return this._items[this._head]!.lineIndex; + } + + append(text: string, displayWidth: number): AppendResult { + let lineIndex = this._nextIndex++; + let evicted: { displayWidth: number; lineIndex: number } | undefined; + + if (this._count === this._capacity) { + let evictedEntry = this._items[this._head]!; + evicted = { + displayWidth: evictedEntry.displayWidth, + lineIndex: evictedEntry.lineIndex, + }; + this._head = (this._head + 1) % this._capacity; + this._count--; + } + + let slot = (this._head + this._count) % this._capacity; + this._items[slot] = { text, displayWidth, lineIndex }; + this._count++; + + return { lineIndex, evicted }; + } + + get(lineIndex: number): LineEntry | undefined { + if (this._count === 0) return undefined; + let base = this._items[this._head]!.lineIndex; + let offset = lineIndex - base; + if (offset < 0 || offset >= this._count) return undefined; + return this._items[(this._head + offset) % this._capacity]; + } +} diff --git a/virtualizer/test/ansi-golden.test.ts b/virtualizer/test/ansi-golden.test.ts new file mode 100644 index 0000000..1086329 --- /dev/null +++ b/virtualizer/test/ansi-golden.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("G.ANSI — ANSI golden fixtures", () => { + it("G.ANSI.simple-sgr — SGR sequence does not add sub-rows", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mred\x1b[0m"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(3); + }); + + it("G.ANSI.sgr-at-wrap-boundary — wrap occurs at visible char boundary, not inside SGR", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + v.appendLine("abcde\x1b[31mfghij"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // Visible: "abcde" (5) then "fghij" (5). CSI "\x1b[31m" at indices 5–9 (5 chars). + // Wrap after 5 visible chars → wrap point at index 10 (start of 'f', after CSI) + expect(entry.wrapPoints).toEqual([10]); + let slices = [entry.text.slice(0, 10), entry.text.slice(10)]; + expect(slices[0]).toBe("abcde\x1b[31m"); + expect(slices[1]).toBe("fghij"); + }); + + it("G.ANSI.osc-with-bel — OSC with BEL terminator", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x07visible"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("G.ANSI.osc-with-st — OSC with ST terminator", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x1b\\visible"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("G.ANSI.nested-csi-in-wrapped-line — multiple CSI in wrapping line, no wrap inside CSI", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); + // "a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me" — 5 visible chars: a, b, c, d, e + v.appendLine("a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // 5 visible chars at columns 3 → 2 sub-rows + expect(entry.totalSubRows).toBe(2); + // No wrap point should fall inside any CSI sequence + let text = entry.text; + for (let wp of entry.wrapPoints) { + expect(text.charCodeAt(wp)).not.toBe(0x5b); // not '[' + // wp should not be between ESC and final byte + if (wp > 0 && text.charCodeAt(wp - 1) === 0x1b) { + // wp right after ESC — that's inside the sequence + expect(true).toBe(false); + } + } + }); +}); diff --git a/virtualizer/test/ansi.test.ts b/virtualizer/test/ansi.test.ts new file mode 100644 index 0000000..06870e9 --- /dev/null +++ b/virtualizer/test/ansi.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("C.ANSI — ANSI handling", () => { + it("C.ANSI.csi-skipped-in-width — CSI bytes do not contribute to displayWidth", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("C.ANSI.osc-skipped-in-width — OSC payload does not contribute to displayWidth", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x07visible"); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("C.ANSI.wrap-point-not-inside-csi — no wrapPoint falls inside a CSI sequence", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + // "abcde\x1b[31mfghij" — 5 visible chars, then CSI at string index 5–9, then 5 more visible + let text = "abcde\x1b[31mfghij"; + v.appendLine(text); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // CSI bytes are at string indices 5–9 — no wrap point should fall inside the CSI + for (let wp of entry.wrapPoints) { + expect(wp < 5 || wp >= 10).toBe(true); + } + }); + + it("C.ANSI.wrap-point-not-inside-osc — no wrapPoint falls inside an OSC sequence", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + // 5 visible chars, then OSC, then 5 more visible chars + let text = "abcde\x1b]0;title\x07fghij"; + v.appendLine(text); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // OSC spans from index 5 to 14 (inclusive of BEL) — no wrap point inside + for (let wp of entry.wrapPoints) { + expect(wp < 5 || wp >= 15).toBe(true); + } + }); + + it("C.ANSI.unrecognized-not-skipped — unrecognized ESC sequences are not skipped", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); + // SS3 (ESC O) is not CSI/OSC — should not be skipped + v.appendLine("\x1bOA"); + let joined = recorded.join(""); + expect(joined).toContain("O"); + expect(joined).toContain("A"); + }); + + it("C.ANSI.escapes-contribute-zero-width — multiple CSI sequences contribute zero width", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31m\x1b[42mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("R.ANSI.call-discipline — measureWidth never receives ESC or CSI/OSC body bytes", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 40, rows: 24 }); + v.appendLine("\x1b[1m\x1b[31mhello\x1b[0m \x1b]0;title\x07world"); + v.resolveViewport(); + for (let arg of recorded) { + expect(arg.includes("\x1b")).toBe(false); + } + }); + + it("C.ANSI.no-style-state-across-lines — prior line SGR does not affect next line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("\x1b[31mred"); + let idx2 = v.appendLine("plain"); + expect(v.getLineDisplayWidth(idx2)).toBe(5); + }); +}); diff --git a/virtualizer/test/append.test.ts b/virtualizer/test/append.test.ts new file mode 100644 index 0000000..c8e0000 --- /dev/null +++ b/virtualizer/test/append.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.APPEND — appendLine", () => { + it("C.APPEND.stores-text — text is retrievable via resolveViewport", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("hello world"); + let vp = v.resolveViewport(); + expect(vp.entries[0].text).toBe("hello world"); + }); + + it("C.APPEND.increments-estimated-total — totalEstimatedVisualRows increases correctly", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + let before = v.totalEstimatedVisualRows; + v.appendLine("hello"); // width 5, ceil(5/10) = 1 + expect(v.totalEstimatedVisualRows - before).toBe(1); + }); + + it("C.APPEND.bottom-follow-advances-anchor — anchor tracks newest line when at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.isAtBottom).toBe(true); + let idx0 = v.appendLine("line 0"); + expect(v.anchorLineIndex).toBe(idx0); + let idx1 = v.appendLine("line 1"); + expect(v.anchorLineIndex).toBe(idx1); + let idx2 = v.appendLine("line 2"); + expect(v.anchorLineIndex).toBe(idx2); + + let vp = v.resolveViewport(); + let lastEntry = vp.entries[vp.entries.length - 1]; + expect(lastEntry.lineIndex).toBe(idx2); + }); + + it("C.APPEND.does-not-invalidate-existing-wrap-cache — cached wrap points survive new appends", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + v.appendLine("abcdefghij"); // wraps at 5 + let vp1 = v.resolveViewport(); + let wp1 = vp1.entries[0].wrapPoints.slice(); + + v.appendLine("more text"); + let vp2 = v.resolveViewport(); + // The first line should still be in the viewport with same wrap points + let entry = vp2.entries.find((e) => e.text === "abcdefghij"); + if (entry) { + expect(entry.wrapPoints).toEqual(wp1); + } + }); + + it("C.APPEND.identity-counter-independent-of-buffer — indices always increase", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); + let a = v.appendLine("A"); // 0 + let b = v.appendLine("B"); // 1 + let c = v.appendLine("C"); // 2, evicts A + let d = v.appendLine("D"); // 3, evicts B + expect([a, b, c, d]).toEqual([0, 1, 2, 3]); + }); + + it("C.APPEND.no-bottom-follow-when-scrolled-up — anchor stays when not at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); + // anchor at 9 (bottom-follow). scrollBy(-5) → anchor at 4. + v.scrollBy(-5); + let anchorBefore = v.anchorLineIndex; + expect(anchorBefore).toBe(4); + let vp1 = v.resolveViewport(); + let entries1 = vp1.entries.map((e) => e.lineIndex); + // rows=3, forward walk from 4: [4,5,6] (full) + expect(entries1).toEqual([4, 5, 6]); + + // Append 3 more lines — anchor should not move, viewport stays full at [4,5,6] + v.appendLine("extra 1"); + v.appendLine("extra 2"); + v.appendLine("extra 3"); + + expect(v.anchorLineIndex).toBe(anchorBefore); + let vp2 = v.resolveViewport(); + let entries2 = vp2.entries.map((e) => e.lineIndex); + expect(entries2).toEqual(entries1); + }); +}); diff --git a/virtualizer/test/bench.test.ts b/virtualizer/test/bench.test.ts new file mode 100644 index 0000000..edb59de --- /dev/null +++ b/virtualizer/test/bench.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function assertViewportInvariants(v: Virtualizer) { + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + for (let wp of entry.wrapPoints) { + let i = 0; + while (i < entry.text.length) { + let skip = skipAnsiSequence(entry.text, i); + if (skip > 0) { + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + } +} + +// Generate ANSI-heavy lines like htop/npm output +function ansiHeavyLine(len: number): string { + let parts: string[] = []; + for (let i = 0; i < len; i++) { + if (i % 5 === 0) parts.push(`\x1b[${(i % 7) + 30}m`); + parts.push(String.fromCharCode(65 + (i % 26))); + } + parts.push("\x1b[0m"); + return parts.join(""); +} + +describe("Validation-gate benchmarks (informational)", () => { + it("Gate 1: measureWidth overhead — appendLine with 10K lines", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 10_000 }); + let lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push("a".repeat(80)); + } + + let start = performance.now(); + for (let line of lines) { + v.appendLine(line); + } + let elapsed = performance.now() - start; + let perCall = (elapsed / 10_000) * 1000; // microseconds + console.log(` Gate 1: ${perCall.toFixed(2)}μs/appendLine (target ≤1μs, fail >5μs)`); + // Informational — not a hard pass/fail + expect(v.lineCount).toBe(10_000); + }); + + it("Gate 2: Extended ANSI corpus — invariants hold at multiple widths", () => { + let corpus = [ + ansiHeavyLine(80), + ansiHeavyLine(120), + "\x1b[1m\x1b[31m" + "ERROR".repeat(20) + "\x1b[0m", + "\x1b]0;npm install\x07\x1b[32m✓\x1b[0m packages installed", + "plain text line with no escapes at all", + ]; + + for (let cols of [80, 40, 20]) { + let v = new Virtualizer({ measureWidth: charMeasure, columns: cols, rows: 24 }); + for (let line of corpus) v.appendLine(line); + assertViewportInvariants(v); + } + }); + + it("Gate 3: Estimation accuracy — ceil(dw/cols) vs exact match rate", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24, maxLines: 10_000 }); + let lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + let len = 10 + (i % 100); + if (i % 3 === 0) { + lines.push("文字".repeat(len / 2)); + } else { + lines.push("a".repeat(len)); + } + } + for (let line of lines) v.appendLine(line); + + // Resolve to get exact wrap counts for visible lines + let vp = v.resolveViewport(); + let matches = 0; + let total = vp.entries.length; + for (let entry of vp.entries) { + let dw = v.getLineDisplayWidth(entry.lineIndex)!; + let estimated = Math.max(1, Math.ceil(dw / 40)); + if (estimated === entry.totalSubRows) matches++; + } + let rate = total > 0 ? (matches / total) * 100 : 100; + console.log(` Gate 3: ${rate.toFixed(1)}% match rate (target >99%, fail <95%)`); + expect(rate).toBeGreaterThan(90); // soft check + }); + + it("Gate 4: scrollToFraction performance — 100K lines", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 100_000 }); + for (let i = 0; i < 100_000; i++) { + v.appendLine("a".repeat(40 + (i % 40))); + } + + let start = performance.now(); + v.scrollToFraction(0.5); + let elapsed = performance.now() - start; + console.log(` Gate 4: scrollToFraction(0.5) at 100K lines: ${elapsed.toFixed(2)}ms (target <5ms)`); + expect(v.anchorLineIndex).toBeGreaterThan(0); + }); + + it("Gate 5: Skip optimization — frame time comparison", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 50 }); + for (let i = 0; i < 1000; i++) v.appendLine(`line ${i}`); + v.scrollBy(-500); // scroll to middle + + // Append while scrolled up — should not need to resolve new lines + let start = performance.now(); + for (let i = 0; i < 100; i++) { + v.appendLine(`new line ${i}`); + v.resolveViewport(); + } + let withSkip = performance.now() - start; + + // At bottom — appends change viewport + let v2 = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 50 }); + for (let i = 0; i < 1000; i++) v2.appendLine(`line ${i}`); + + start = performance.now(); + for (let i = 0; i < 100; i++) { + v2.appendLine(`new line ${i}`); + v2.resolveViewport(); + } + let withoutSkip = performance.now() - start; + + console.log(` Gate 5: scrolled-up=${withSkip.toFixed(2)}ms, at-bottom=${withoutSkip.toFixed(2)}ms`); + // Just verify both complete without error + expect(v.lineCount).toBeGreaterThan(0); + }); + + it("Gate 6: resolveViewport ANSI-heavy vs plain — ratio", () => { + let v1 = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 50 }); + let v2 = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 50 }); + + for (let i = 0; i < 200; i++) { + v1.appendLine("a".repeat(80)); + v2.appendLine(ansiHeavyLine(80)); + } + + let start = performance.now(); + for (let i = 0; i < 100; i++) v1.resolveViewport(); + let plain = performance.now() - start; + + start = performance.now(); + for (let i = 0; i < 100; i++) v2.resolveViewport(); + let ansi = performance.now() - start; + + let ratio = plain > 0 ? ansi / plain : 1; + console.log(` Gate 6: plain=${plain.toFixed(2)}ms, ANSI=${ansi.toFixed(2)}ms, ratio=${ratio.toFixed(2)}x (target <2x)`); + expect(ratio).toBeLessThan(10); // soft bound + }); + + it("Gate 7: resize performance — 100K lines", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 100_000 }); + for (let i = 0; i < 100_000; i++) { + v.appendLine("a".repeat(40 + (i % 40))); + } + + let start = performance.now(); + v.resize(40, 24); + let elapsed = performance.now() - start; + console.log(` Gate 7: resize(40, 24) at 100K lines: ${elapsed.toFixed(2)}ms (target <4ms, fail >8ms)`); + expect(v.columns).toBe(40); + }); +}); diff --git a/virtualizer/test/empty.test.ts b/virtualizer/test/empty.test.ts new file mode 100644 index 0000000..ed4b7e6 --- /dev/null +++ b/virtualizer/test/empty.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.EMPTY — empty buffer", () => { + it("C.EMPTY.construction-state — initial state is empty and at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.lineCount).toBe(0); + expect(v.totalEstimatedVisualRows).toBe(0); + expect(v.currentEstimatedVisualRow).toBe(0); + expect(v.isAtBottom).toBe(true); + }); + + it("C.EMPTY.resolve-returns-empty — resolveViewport on empty buffer returns empty entries", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + expect(vp.currentEstimatedVisualRow).toBe(0); + expect(vp.isAtBottom).toBe(true); + }); + + it("C.EMPTY.first-append-uses-counter — first appendLine returns 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.appendLine("first")).toBe(0); + }); + + it("C.EMPTY.identity-counter-not-reset-after-eviction — counter continues after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + expect(v.baseIndex).toBe(2); + }); + + it("C.EMPTY.scrollBy-noop — scrollBy on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollBy(5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); + + it("C.EMPTY.scrollToFraction-noop — scrollToFraction on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollToFraction(0.5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); +}); diff --git a/virtualizer/test/eviction.test.ts b/virtualizer/test/eviction.test.ts new file mode 100644 index 0000000..36ad9d2 --- /dev/null +++ b/virtualizer/test/eviction.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.EVICT — basic eviction (non-scroll subset)", () => { + it("C.EVICT.triggers-at-capacity — lineCount stays at maxLines", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); + v.appendLine("B"); + v.appendLine("C"); + v.appendLine("D"); + expect(v.lineCount).toBe(3); + }); + + it("C.EVICT.removes-oldest — oldest line is evicted", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.appendLine("D"); // 3, evicts A + let vp = v.resolveViewport(); + let indices = vp.entries.map((e) => e.lineIndex); + expect(indices).toEqual([1, 2, 3]); + }); + + it("C.EVICT.baseIndex-advances — baseIndex tracks eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); + v.appendLine("A"); expect(v.baseIndex).toBe(0); + v.appendLine("B"); expect(v.baseIndex).toBe(0); + v.appendLine("C"); expect(v.baseIndex).toBe(1); + v.appendLine("D"); expect(v.baseIndex).toBe(2); + }); + + it("C.EVICT.estimation-decremented — total estimate accounts for eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24, maxLines: 3 }); + v.appendLine("hello"); // 5 chars, 1 row + v.appendLine("world"); // 5 chars, 1 row + v.appendLine("three"); // 5 chars, 1 row + expect(v.totalEstimatedVisualRows).toBe(3); + v.appendLine("four!"); // evicts "hello", total stays 3 + expect(v.totalEstimatedVisualRows).toBe(3); + }); + + it("C.EVICT.maxLines-one — single-line buffer works correctly", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + expect(v.lineCount).toBe(1); + expect(v.baseIndex).toBe(1); + let vp = v.resolveViewport(); + expect(vp.entries[0].lineIndex).toBe(1); + }); + + it("C.EVICT.anchor-survives-when-newer — anchor not affected by eviction of older line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); // indices 0–4, anchor at 4 + v.scrollBy(-1); // anchor at 3, isAtBottom=false + expect(v.anchorLineIndex).toBe(3); + + v.appendLine("line 5"); // evicts index 0 + expect(v.anchorLineIndex).toBe(3); // unchanged + let vp = v.resolveViewport(); + expect(vp.entries[0].lineIndex).toBeGreaterThanOrEqual(1); + }); + + it("C.EVICT.viewport-stable-when-anchor-survives — viewport unchanged after eviction of older line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 2, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // anchor at 4 (bottom-follow). scrollBy(-1) → anchor at 3. + v.scrollBy(-1); + expect(v.anchorLineIndex).toBe(3); + // rows=2, forward from 3: [3,4] (full) + let vp1 = v.resolveViewport(); + let entries1 = vp1.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); + expect(entries1).toEqual([ + { lineIndex: 3, text: "line 3" }, + { lineIndex: 4, text: "line 4" }, + ]); + + v.appendLine("line 5"); // evicts index 0, anchor still at 3 + let vp2 = v.resolveViewport(); + let entries2 = vp2.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); + expect(entries2).toEqual(entries1); + }); + + it("C.EVICT.anchor-clamps-when-evicted — anchor clamped to next surviving line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2, anchor at 2 via bottom-follow + v.scrollBy(-2); // anchor at 0, isAtBottom=false + expect(v.anchorLineIndex).toBe(0); + + v.appendLine("D"); // evicts A(0), anchor was at 0 → clamp to 1 + expect(v.anchorLineIndex).toBe(1); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.EVICT.currentEstimate-decremented-when-anchor-survives — currentEstimate adjusts for eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine("x"); // each 1 row + v.scrollBy(-1); // anchor at 3 + let before = v.currentEstimatedVisualRow; + + v.appendLine("y"); // evicts index 0 (1 row) + // currentEstimate should decrease by 1 (evicted line's estimate) + expect(v.currentEstimatedVisualRow).toBe(before - 1); + }); + + it("C.EVICT.currentEstimate-zero-when-anchor-evicted — currentEstimate resets to 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.scrollBy(-2); // anchor at 0 + v.appendLine("D"); // evicts A(0), anchor clamped to 1 + expect(v.currentEstimatedVisualRow).toBe(0); + }); + + it("R.EVICT.maxLines1-not-at-bottom — anchor clamps to surviving line when isAtBottom is false", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // lineIndex 0, anchor 0, isAtBottom true + v.scrollBy(-1); // isAtBottom false, anchor still 0 + expect(v.isAtBottom).toBe(false); + + v.appendLine("B"); // evicts A(0), inserts B(1) + // Anchor must clamp to the surviving line (1), not stay on evicted (0) + expect(v.anchorLineIndex).toBe(1); + expect(v.anchorSubRow).toBe(0); + expect(v.lineCount).toBe(1); + + // resolveViewport must return the surviving line, not empty + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(1); + expect(vp.entries[0].lineIndex).toBe(1); + expect(vp.entries[0].text).toBe("B"); + }); +}); diff --git a/virtualizer/test/exactness.test.ts b/virtualizer/test/exactness.test.ts new file mode 100644 index 0000000..56877ee --- /dev/null +++ b/virtualizer/test/exactness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("Exactness vs approximation structural tests", () => { + it("Exact viewport assertions — O-1 through O-9 hold with varied content", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 15, rows: 10 }); + v.appendLine("short"); + v.appendLine("a".repeat(30)); + v.appendLine("文字abc文字def"); + v.appendLine("\x1b[31mcolored\x1b[0m text"); + v.appendLine(""); + + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-3: wrapPoints monotonic + for (let j = 1; j < entry.wrapPoints.length; j++) { + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); + } + // O-6: totalSubRows + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + // O-7: subrow bounds + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + } + + // O-8: total visible ≤ rows + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(10); + }); + + it("Structural estimation non-negative — totalEstimatedVisualRows ≥ 0 after random ops", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10, maxLines: 50 }); + for (let i = 0; i < 100; i++) { + v.appendLine("x".repeat(i % 40)); + } + v.scrollBy(-30); + v.resize(15, 10); + expect(v.totalEstimatedVisualRows).toBeGreaterThanOrEqual(0); + }); + + it("Structural estimation zero-empty — both fields 0 on empty buffer", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.totalEstimatedVisualRows).toBe(0); + expect(v.currentEstimatedVisualRow).toBe(0); + }); + + it("Structural estimation positive-nonempty — totalEstimatedVisualRows > 0 when lineCount > 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("x"); + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + }); + + it("Structural currentEstimate inequality — valid range when lineCount > 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 }); + for (let i = 0; i < 50; i++) v.appendLine("a".repeat(i % 30)); + v.scrollBy(-20); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); + + it("Reference-behavior exact formula — estimation matches ceil(displayWidth/columns)", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + let before = v.totalEstimatedVisualRows; + v.appendLine("a".repeat(15)); // displayWidth=15, ceil(15/10)=2 + expect(v.totalEstimatedVisualRows - before).toBe(2); + }); +}); diff --git a/virtualizer/test/fixtures/real-world-ansi.ts b/virtualizer/test/fixtures/real-world-ansi.ts new file mode 100644 index 0000000..4ef5190 --- /dev/null +++ b/virtualizer/test/fixtures/real-world-ansi.ts @@ -0,0 +1,25 @@ +// Real-world ANSI fixture strings captured from common terminal programs. + +// ls --color output (directory and file listing with SGR) +export const LS_COLOR = + "\x1b[0m\x1b[01;34mnode_modules\x1b[0m \x1b[01;32mpackage.json\x1b[0m \x1b[00msrc\x1b[0m \x1b[01;32mtsconfig.json\x1b[0m"; + +// git diff --color output (added/removed lines) +export const GIT_DIFF_ADD = + "\x1b[32m+export function createDisplayWidth(): Promise<(text: string) => number> {\x1b[m"; +export const GIT_DIFF_REMOVE = + "\x1b[31m-// TODO: implement width calculation\x1b[m"; +export const GIT_DIFF_HEADER = + "\x1b[1mdiff --git a/width.ts b/width.ts\x1b[m"; + +// gcc diagnostics with bold and color +export const GCC_ERROR = + "\x1b[1m\x1b[35msrc/main.c:42:5:\x1b[0m \x1b[1m\x1b[31merror:\x1b[0m \x1b[1mexpected ';' after expression\x1b[0m"; + +// npm output with multiple SGR parameters +export const NPM_WARN = + "\x1b[33m\x1b[1mnpm\x1b[22m \x1b[39m\x1b[33mWARN\x1b[39m \x1b[35mdeprecated\x1b[39m request@2.88.2: request has been deprecated"; + +// OSC title-set (window title) followed by visible text +export const OSC_TITLE = + "\x1b]0;user@host:~/project\x07$ ls -la"; diff --git a/virtualizer/test/fraction.test.ts b/virtualizer/test/fraction.test.ts new file mode 100644 index 0000000..99b10e3 --- /dev/null +++ b/virtualizer/test/fraction.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.FRACTION — scrollToFraction", () => { + it("C.FRACTION.zero-scrolls-to-top", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0); + expect(v.anchorLineIndex).toBe(v.baseIndex); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.FRACTION.one-scrolls-to-bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(1); + expect(v.isAtBottom).toBe(true); + }); + + it("C.FRACTION.half-anchor-in-valid-range", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); + }); + + it("R.FRACTION.half-lands-near-middle — 100 equal-width lines, fraction 0.5 lands at line 50", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine("x"); // each line width 1, 1 sub-row + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBe(v.baseIndex + 50); + }); + + it("C.FRACTION.empty-buffer-noop", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollToFraction(0.5); + expect(v.lineCount).toBe(0); + expect(v.isAtBottom).toBe(true); + }); + + it("C.FRACTION.subrow-clamped — anchorSubRow is in valid range", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + // Mix of short and long lines + for (let i = 0; i < 50; i++) { + v.appendLine(i % 3 === 0 ? "a".repeat(25) : "short"); + } + v.scrollToFraction(0.7); + let entry = v.getLineDisplayWidth(v.anchorLineIndex); + if (entry !== undefined) { + let estimate = Math.max(1, Math.ceil(entry / 10)); + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); + expect(v.anchorSubRow).toBeLessThan(estimate); + } + }); + + it("C.FRACTION.currentEstimate-updated — currentEstimatedVisualRow reflects walk", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 50; i++) v.appendLine("x"); + v.scrollToFraction(0.5); + // Manually verify: 50 lines each 1 sub-row, target = floor(0.5*50) = 25 + // Walk 25 lines → accumulated = 25, anchorSubRow = 0 + // currentEstimatedVisualRow = 25 + 0 = 25 + expect(v.currentEstimatedVisualRow).toBe(25); + }); + + it("C.FRACTION.zero-after-eviction — fraction 0 goes to baseIndex after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0); + expect(v.anchorLineIndex).toBe(v.baseIndex); // 30, not 0 + }); + + it("C.FRACTION.mid-after-eviction — fraction 0.5 in valid range after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); + }); +}); diff --git a/virtualizer/test/invariants.test.ts b/virtualizer/test/invariants.test.ts new file mode 100644 index 0000000..2856d8a --- /dev/null +++ b/virtualizer/test/invariants.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function mockMeasureWidth(text: string): number { + let w = 0; + for (let i = 0; i < text.length; i++) { + let cp = text.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +describe("C.INV1 — monotonic identity", () => { + it("C.INV1.monotonic-identity — each lineIndex strictly greater than previous", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24 }); + let indices: number[] = []; + for (let i = 0; i < 10; i++) { + indices.push(v.appendLine(`line ${i}`)); + } + for (let i = 1; i < indices.length; i++) { + expect(indices[i]).toBeGreaterThan(indices[i - 1]); + } + }); + + it("C.INV1.identity-survives-eviction — surviving lines keep their lineIndex", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.appendLine("D"); // 3 — evicts A(0) + expect(v.baseIndex).toBe(1); + let vp = v.resolveViewport(); + let lineIndices = vp.entries.map((e) => e.lineIndex); + expect(lineIndices).toContain(1); + expect(lineIndices).toContain(2); + expect(lineIndices).toContain(3); + }); + + it("C.INV1.identity-never-reused — each returned index is unique even with eviction", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 1 }); + let indices = [ + v.appendLine("A"), + v.appendLine("B"), + v.appendLine("C"), + v.appendLine("D"), + ]; + expect(indices).toEqual([0, 1, 2, 3]); + expect(new Set(indices).size).toBe(4); + }); +}); diff --git a/virtualizer/test/property.test.ts b/virtualizer/test/property.test.ts new file mode 100644 index 0000000..8548ae2 --- /dev/null +++ b/virtualizer/test/property.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +// Simple deterministic PRNG for reproducibility +function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomLine(rng: () => number): string { + let kind = rng(); + if (kind < 0.3) { + // ASCII + let len = Math.floor(rng() * 120); + return Array.from({ length: len }, () => + String.fromCharCode(32 + Math.floor(rng() * 95)) + ).join(""); + } else if (kind < 0.5) { + // CJK + let len = Math.floor(rng() * 40); + return Array.from({ length: len }, () => + String.fromCharCode(0x4e00 + Math.floor(rng() * 0x5200)) + ).join(""); + } else if (kind < 0.7) { + // Mixed with ANSI + let parts: string[] = []; + let len = Math.floor(rng() * 50); + for (let i = 0; i < len; i++) { + if (rng() < 0.2) { + parts.push(`\x1b[${Math.floor(rng() * 37 + 1)}m`); + } else { + parts.push(String.fromCharCode(32 + Math.floor(rng() * 95))); + } + } + return parts.join(""); + } else { + // Empty or short + return "x".repeat(Math.floor(rng() * 5)); + } +} + +function assertViewportInvariants(v: Virtualizer) { + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + let totalVisible = 0; + for (let entry of vp.entries) { + // O-3: wrapPoints monotonic and in range + for (let j = 0; j < entry.wrapPoints.length; j++) { + expect(entry.wrapPoints[j]).toBeGreaterThan(0); + expect(entry.wrapPoints[j]).toBeLessThan(entry.text.length); + if (j > 0) expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); + } + + // O-4: not in surrogate + for (let wp of entry.wrapPoints) { + if (wp > 0) { + let prev = entry.text.charCodeAt(wp - 1); + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); + } + } + + // O-5: not in escape + for (let wp of entry.wrapPoints) { + let i = 0; + while (i < entry.text.length) { + let skip = skipAnsiSequence(entry.text, i); + if (skip > 0) { + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + + // O-6: totalSubRows + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + + // O-7: subrow bounds + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + + totalVisible += entry.visibleSubRows; + } + + // O-8: visible rows within budget + expect(totalVisible).toBeLessThanOrEqual(v.rows); +} + +describe("Property-based tests", () => { + it("P.IDENTITY.never-reused — no two returned lineIndex values are equal", () => { + let rng = mulberry32(42); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 10, maxLines: 20 }); + let indices = new Set(); + for (let i = 0; i < 100; i++) { + let idx = v.appendLine(randomLine(rng)); + expect(indices.has(idx)).toBe(false); + indices.add(idx); + } + }); + + it("P.IDENTITY.monotonic — later lineIndex > earlier lineIndex", () => { + let rng = mulberry32(123); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 10, maxLines: 50 }); + let prev = -1; + for (let i = 0; i < 100; i++) { + let idx = v.appendLine(randomLine(rng)); + expect(idx).toBeGreaterThan(prev); + prev = idx; + } + }); + + it("P.IDENTITY.survives-eviction — surviving lines' lineIndex unchanged", () => { + let rng = mulberry32(456); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 10, maxLines: 10 }); + let assigned = new Map(); + for (let i = 0; i < 50; i++) { + let text = randomLine(rng); + let idx = v.appendLine(text); + assigned.set(idx, text); + } + // Verify surviving lines + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(assigned.get(entry.lineIndex)).toBe(entry.text); + } + }); + + it("P.APPEND.scroll-position-independent — totalEstimatedVisualRows increment same regardless of scroll", () => { + let text = "hello world test line"; + let v1 = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 }); + let v2 = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 }); + + for (let i = 0; i < 20; i++) { + v1.appendLine(`line ${i}`); + v2.appendLine(`line ${i}`); + } + v2.scrollBy(-10); // different scroll position + + let before1 = v1.totalEstimatedVisualRows; + let before2 = v2.totalEstimatedVisualRows; + v1.appendLine(text); + v2.appendLine(text); + expect(v1.totalEstimatedVisualRows - before1).toBe(v2.totalEstimatedVisualRows - before2); + }); + + it("P.VIEWPORT.all-invariants — O-1 through O-9 hold after random ops", () => { + let rng = mulberry32(789); + for (let trial = 0; trial < 5; trial++) { + let columns = 10 + Math.floor(rng() * 70); + let v = new Virtualizer({ measureWidth: charMeasure, columns, rows: 10, maxLines: 30 }); + + for (let i = 0; i < 50; i++) { + v.appendLine(randomLine(rng)); + } + + // Random scroll + let delta = Math.floor(rng() * 40) - 20; + v.scrollBy(delta); + + assertViewportInvariants(v); + } + }); + + it("P.EVICT.viewport-stable — if anchor survives, viewport identical before/after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 3, maxLines: 10 }); + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); + // Scroll to middle so anchor is far from eviction frontier + v.scrollBy(-3); + let anchorBefore = v.anchorLineIndex; + let vp1 = v.resolveViewport(); + let entries1 = vp1.entries.map((e) => e.lineIndex); + + v.appendLine("new line"); // evicts oldest + // Anchor should survive (it's in the middle) + if (v.anchorLineIndex === anchorBefore) { + let vp2 = v.resolveViewport(); + let entries2 = vp2.entries.map((e) => e.lineIndex); + expect(entries2).toEqual(entries1); + } + }); + + it("P.ESTIMATE.constraints-hold — estimation constraints hold after every op", () => { + let rng = mulberry32(999); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10, maxLines: 30 }); + + for (let i = 0; i < 60; i++) { + v.appendLine(randomLine(rng)); + expect(v.totalEstimatedVisualRows).toBeGreaterThanOrEqual(0); + if (v.lineCount > 0) { + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + } + } + + // After scroll + v.scrollBy(-15); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + + // After resize + v.resize(15, 10); + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); + + it("P.ESTIMATE.monotonicity-under-mismatch — scrollBy maintains monotonicity with wide chars", () => { + let rng = mulberry32(111); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + for (let i = 0; i < 30; i++) { + v.appendLine(randomLine(rng)); + } + v.scrollBy(-50); // scroll near top + + let prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 15; i++) { + v.scrollBy(1); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + + prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 15; i++) { + v.scrollBy(-1); + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + }); + + it("P.RESIZE.viewport-invariants-hold — all invariants hold after random resize", () => { + let rng = mulberry32(222); + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 30; i++) { + v.appendLine(randomLine(rng)); + } + + let widths = [20, 40, 60, 80, 120, 10]; + for (let w of widths) { + v.resize(w, 24); + assertViewportInvariants(v); + } + }); +}); diff --git a/virtualizer/test/real-world-ansi.test.ts b/virtualizer/test/real-world-ansi.test.ts new file mode 100644 index 0000000..3f2d3cf --- /dev/null +++ b/virtualizer/test/real-world-ansi.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; +import * as fixtures from "./fixtures/real-world-ansi.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { i += skip; continue; } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +function assertAllOutputInvariants( + v: Virtualizer, + columns: number, +) { + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-3: wrapPoints monotonic + for (let j = 1; j < entry.wrapPoints.length; j++) { + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); + } + for (let wp of entry.wrapPoints) { + expect(wp).toBeGreaterThan(0); + expect(wp).toBeLessThan(entry.text.length); + + // O-4: not in surrogate + let prev = entry.text.charCodeAt(wp - 1); + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); + + // O-5: not in escape + let i = 0; + while (i < entry.text.length) { + let skip = skipAnsiSequence(entry.text, i); + if (skip > 0) { + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + + // O-6: totalSubRows = wrapPoints.length + 1 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + + // O-7: subrow bounds + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + + // O-9: sliced width within columns + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice)).toBeLessThanOrEqual(columns); + } + } + + // O-8: visible rows within budget + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(v.rows); +} + +let allFixtures = Object.entries(fixtures); + +describe("Real-world ANSI fixtures", () => { + for (let [name, text] of allFixtures) { + it(`${name} at columns 80`, () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine(text); + assertAllOutputInvariants(v, 80); + }); + + it(`${name} at columns 40`, () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); + v.appendLine(text); + assertAllOutputInvariants(v, 40); + }); + } +}); diff --git a/virtualizer/test/resize.test.ts b/virtualizer/test/resize.test.ts new file mode 100644 index 0000000..187ca1f --- /dev/null +++ b/virtualizer/test/resize.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { i += skip; continue; } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +describe("C.RESIZE — resize", () => { + it("C.RESIZE.wrap-cache-invalidated — wrap points recomputed after column change", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(100)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(2); // ceil(100/80)=2 + + v.resize(50, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(2); // ceil(100/50)=2, but with exact wrapping + // Verify wrap points are different from width-80 + expect(vp2.entries[0].wrapPoints).not.toEqual(vp1.entries[0].wrapPoints); + }); + + it("C.RESIZE.anchor-subrow-clamped — anchorSubRow clamped to new wrap count", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 24 }); + v.appendLine("a".repeat(100)); // 5 sub-rows at width 20 + v.scrollBy(4); // anchorSubRow=4 + expect(v.anchorSubRow).toBe(4); + + v.resize(50, 24); // now 2 sub-rows → clamp to 1 + expect(v.anchorSubRow).toBe(1); + }); + + it("C.RESIZE.anchor-subrow-clamped-exact — uses exact wrapping for clamp, not estimate", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); + // "文" is width 2. At columns=3: 1 fits per row (2+2=4 > 3) → 10 exact sub-rows + // estimate = ceil(20/3) = 7 + v.appendLine("文".repeat(10)); + v.scrollBy(8); // anchorSubRow = 8 (valid in exact range 0-9) + expect(v.anchorSubRow).toBe(8); + + // Resize to columns=5: 2 chars fit per row (2+2=4 ≤ 5) → 5 exact sub-rows + // estimate = ceil(20/5) = 4 + // Should clamp to exact(5)-1 = 4, not estimate(4)-1 = 3 + v.resize(5, 24); + expect(v.anchorSubRow).toBe(4); + }); + + it("C.RESIZE.bottom-follow-preserved — isAtBottom survives resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("hello"); + expect(v.isAtBottom).toBe(true); + v.resize(40, 24); + expect(v.isAtBottom).toBe(true); + }); + + it("C.RESIZE.row-only-no-invalidation — row-only change does not affect wrapping", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(100)); + let vp1 = v.resolveViewport(); + let total1 = v.totalEstimatedVisualRows; + let anchor1 = v.anchorLineIndex; + + v.resize(80, 40); + expect(v.totalEstimatedVisualRows).toBe(total1); + expect(v.anchorLineIndex).toBe(anchor1); + }); + + it("C.RESIZE.viewport-correct-after-resize — all output invariants hold after resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("short"); + v.appendLine("a".repeat(100)); + v.appendLine("文字".repeat(20)); + + v.resize(40, 24); + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-6: totalSubRows + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + // O-7: subrow bounds + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + // O-9: sliced width within columns + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice)).toBeLessThanOrEqual(40); + } + } + + // O-8: visible rows within budget + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(24); + }); + + it("C.RESIZE.estimation-fields-valid-after-resize — estimation constraints hold", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + + v.resize(40, 24); + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); + + it("C.RESIZE.displayWidth-unchanged — cached displayWidth unchanged across resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("hello"); + let before = v.getLineDisplayWidth(idx); + v.resize(40, 24); + expect(v.getLineDisplayWidth(idx)).toBe(before); + }); + + it("C.RESIZE.empty-buffer — resize on empty buffer does not error", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.resize(40, 24); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + }); +}); + +describe("G.RESIZE — resize golden fixtures", () => { + it("G.RESIZE.narrow-to-wide — wrapping removed when columns increase", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); + v.appendLine("a".repeat(80)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(2); + + v.resize(80, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(1); + expect(vp2.entries[0].wrapPoints).toEqual([]); + }); + + it("G.RESIZE.wide-to-narrow — wrapping added when columns decrease", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(80)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(1); + + v.resize(20, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(4); + expect(vp2.entries[0].wrapPoints.length).toBe(3); + }); +}); + +describe("C.APPEND.caches-displayWidth (deferred from PR 2)", () => { + it("C.APPEND.caches-displayWidth — displayWidth unchanged after resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("abc"); + expect(v.getLineDisplayWidth(idx)).toBe(3); + v.resize(2, 24); + expect(v.getLineDisplayWidth(idx)).toBe(3); + }); +}); diff --git a/virtualizer/test/scroll.test.ts b/virtualizer/test/scroll.test.ts new file mode 100644 index 0000000..82c0ffa --- /dev/null +++ b/virtualizer/test/scroll.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("C.SCROLL — scrollBy", () => { + it("C.SCROLL.down-one-row — scrolling down shifts viewport", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // After 5 appends with bottom-follow, anchor at line 4. + // scrollBy(-4) to move anchor to line 0. + v.scrollBy(-4); + expect(v.anchorLineIndex).toBe(0); + let vp1 = v.resolveViewport(); + expect(vp1.entries.map((e) => e.lineIndex)).toEqual([0, 1, 2]); + + v.scrollBy(1); + let vp2 = v.resolveViewport(); + expect(vp2.entries.map((e) => e.lineIndex)).toEqual([1, 2, 3]); + }); + + it("C.SCROLL.up-past-top-clamps — scrolling past top clamps to baseIndex", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + v.scrollBy(-100); + expect(v.anchorLineIndex).toBe(v.baseIndex); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.SCROLL.down-past-bottom-clamps-and-sets-isAtBottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // anchor at 4 (bottom-follow), isAtBottom=true + v.scrollBy(-3); // anchor at line 1 + expect(v.isAtBottom).toBe(false); + v.scrollBy(100); + expect(v.isAtBottom).toBe(true); + }); + + it("C.SCROLL.up-clears-isAtBottom — scrolling up clears isAtBottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + expect(v.isAtBottom).toBe(true); + v.scrollBy(-1); + expect(v.isAtBottom).toBe(false); + }); + + it("C.SCROLL.empty-buffer-noop — scrollBy on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollBy(5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); + + it("C.SCROLL.wrapping-lines-counted — sub-rows are counted when scrolling", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + v.appendLine("abcdefghijklmnopqrstuvwxy"); // 25 chars, 3 sub-rows (index 0) + v.appendLine("hello"); // 5 chars, 1 sub-row (index 1) + // After append, anchor at line 1 (bottom-follow). + // scrollBy(-3) to go back 3 visual rows: + // from line 1 sub-row 0 → back 1 to line 0 sub-row 2 → back 1 to sub-row 1 → back 1 to sub-row 0 + v.scrollBy(-3); + expect(v.anchorLineIndex).toBe(0); + expect(v.anchorSubRow).toBe(0); + + // scrollBy(2) → sub-row 0 → 1 → 2 + v.scrollBy(2); + expect(v.anchorLineIndex).toBe(0); + expect(v.anchorSubRow).toBe(2); + }); + + it("C.SCROLL.currentEstimate-nondecreasing-on-scroll-down", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); + v.scrollBy(-10); + let prev = v.currentEstimatedVisualRow; + v.scrollBy(1); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); + }); + + it("C.SCROLL.nonincreasing-on-scroll-up", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); + v.scrollBy(-10); + let prev = v.currentEstimatedVisualRow; + v.scrollBy(-1); + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); + }); + + it("C.SCROLL.monotonicity-under-exact-estimate-mismatch — wide chars maintain monotonicity", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + for (let i = 0; i < 20; i++) { + v.appendLine("文字abc文d"); // mixed wide/narrow + } + v.scrollBy(-50); // scroll near top + + // Forward: each step >= previous + let prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 10; i++) { + v.scrollBy(1); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + + // Backward: each step <= previous + prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 10; i++) { + v.scrollBy(-1); + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + }); + + it("C.ESTIMATE.current-within-total — holds when exact wraps exceed estimate", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); + // "文" is width 2. At columns=3: 1 per row → 10 exact sub-rows + // estimate = ceil(20/3) = 7 + v.appendLine("文".repeat(10)); + v.scrollBy(9); // scroll to last exact sub-row + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); +}); diff --git a/virtualizer/test/suite.ts b/virtualizer/test/suite.ts new file mode 100644 index 0000000..334d8a2 --- /dev/null +++ b/virtualizer/test/suite.ts @@ -0,0 +1,2 @@ +export { beforeEach, describe, it } from "@std/testing/bdd"; +export { expect } from "@std/expect"; diff --git a/virtualizer/test/viewport.test.ts b/virtualizer/test/viewport.test.ts new file mode 100644 index 0000000..1fe07f5 --- /dev/null +++ b/virtualizer/test/viewport.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string, measureWidth: (t: string) => number): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + w += measureWidth(String.fromCodePoint(cp)); + if (charLen === 2) i++; + } + return w; +} + +describe("C.VIEWPORT — resolveViewport output invariants", () => { + function makeVirtualizer(columns = 20, rows = 5) { + return new Virtualizer({ measureWidth: charMeasure, columns, rows }); + } + + it("C.VIEWPORT.entries-ordered — entries have strictly increasing lineIndex", () => { + let v = makeVirtualizer(); + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); + let vp = v.resolveViewport(); + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + }); + + it("C.VIEWPORT.text-is-original — entry text is the original string", () => { + let v = makeVirtualizer(); + let original = "hello world"; + v.appendLine(original); + let vp = v.resolveViewport(); + expect(vp.entries[0].text).toBe(original); + }); + + it("C.VIEWPORT.wrapPoints-monotonic — wrapPoints are strictly increasing in (0, text.length)", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghijklmno"); // 15 chars, wraps at 5, 10 + let vp = v.resolveViewport(); + let wp = vp.entries[0].wrapPoints; + for (let i = 0; i < wp.length; i++) { + expect(wp[i]).toBeGreaterThan(0); + expect(wp[i]).toBeLessThan(15); + if (i > 0) expect(wp[i]).toBeGreaterThan(wp[i - 1]); + } + }); + + it("C.VIEWPORT.wrapPoints-not-in-surrogate — wrapPoints don't split surrogate pairs", () => { + let v = makeVirtualizer(3); + // Emoji are surrogate pairs in UTF-16: each is 2 code units + v.appendLine("a😀b😀c"); + let vp = v.resolveViewport(); + let text = vp.entries[0].text; + for (let wp of vp.entries[0].wrapPoints) { + if (wp > 0) { + let prev = text.charCodeAt(wp - 1); + // prev should not be a high surrogate (0xD800–0xDBFF) + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); + } + } + }); + + it("C.VIEWPORT.wrapPoints-not-in-escape — wrapPoints don't fall inside ANSI sequences", () => { + let v = makeVirtualizer(5); + v.appendLine("abcde\x1b[31mfghij\x1b[0mklmno"); + let vp = v.resolveViewport(); + let text = vp.entries[0].text; + for (let wp of vp.entries[0].wrapPoints) { + // Check that wp is not inside an ANSI sequence + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + // wp should not be in (i, i+skip) + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + }); + + it("C.VIEWPORT.totalSubRows-equals-wrapPoints-plus-one", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghij"); // wraps once at 5 → 2 sub-rows + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + } + }); + + it("C.VIEWPORT.subrow-bounds — firstSubRow and visibleSubRows are valid", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghijklmno"); + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + } + }); + + it("C.VIEWPORT.visible-rows-within-budget — total visible sub-rows ≤ rows", () => { + let v = makeVirtualizer(10, 5); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i} with some text`); + let vp = v.resolveViewport(); + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(5); + }); + + it("C.VIEWPORT.sliced-width-within-columns — each sub-row fits within columns (requires columns ≥ max glyph width)", () => { + let v = makeVirtualizer(10, 24); + v.appendLine("abcdefghijklmnopqrstuvwxyz"); + v.appendLine("abc文def字ghi"); // includes width-2 CJK glyphs + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice, charMeasure)).toBeLessThanOrEqual(10); + } + } + }); + + it("C.VIEWPORT.self-contained — viewport can be reconstructed from text + wrapPoints alone", () => { + let v = makeVirtualizer(10, 24); + v.appendLine("hello world, this is a long line of text"); + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + expect(slices.length).toBe(entry.totalSubRows); + } + }); + + it("C.VIEWPORT.estimation-fields-present — all estimation fields defined", () => { + let v = makeVirtualizer(); + v.appendLine("test"); + let vp = v.resolveViewport(); + expect(vp.totalEstimatedVisualRows).not.toBe(undefined); + expect(vp.currentEstimatedVisualRow).not.toBe(undefined); + expect(vp.isAtBottom).not.toBe(undefined); + }); + + it("C.VIEWPORT.estimation-inequality — currentEstimatedVisualRow in valid range", () => { + let v = makeVirtualizer(); + v.appendLine("test"); + let vp = v.resolveViewport(); + expect(vp.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(vp.currentEstimatedVisualRow).toBeLessThan(vp.totalEstimatedVisualRows); + + // Empty case + let v2 = makeVirtualizer(); + let vp2 = v2.resolveViewport(); + expect(vp2.totalEstimatedVisualRows).toBe(0); + expect(vp2.currentEstimatedVisualRow).toBe(0); + }); + + it("C.VIEWPORT.empty-buffer — empty resolves to empty entries", () => { + let v = makeVirtualizer(); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + expect(vp.currentEstimatedVisualRow).toBe(0); + expect(vp.isAtBottom).toBe(true); + }); +}); diff --git a/virtualizer/test/width.test.ts b/virtualizer/test/width.test.ts new file mode 100644 index 0000000..555f94f --- /dev/null +++ b/virtualizer/test/width.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +describe("C.WIDTH — width usage", () => { + it("C.WIDTH.ansi-stripped-before-measure — measureWidth never sees ANSI bytes", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); + v.appendLine("\x1b[31mhello\x1b[0m"); + for (let arg of recorded) { + expect(arg).not.toContain("\x1b"); + expect(arg).not.toContain("["); + expect(arg).not.toContain("3"); + expect(arg).not.toContain("1"); + expect(arg).not.toContain("m"); + } + // Only visible chars passed + let joined = recorded.join(""); + expect(joined).toBe("hello"); + }); + + it("C.WIDTH.displayWidth-matches-visible-content — cached width reflects visible chars only", () => { + let measureWidth = (_text: string): number => 1; + let v = new Virtualizer({ measureWidth, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("C.WIDTH.additivity-in-wrapping — each sub-row fits within columns", () => { + let measureWidth = (text: string): number => { + let cp = text.codePointAt(0)!; + return cp >= 0x4e00 && cp <= 0x9fff ? 2 : 1; + }; + let v = new Virtualizer({ measureWidth, columns: 10, rows: 24 }); + v.appendLine("abcdefghijklmnopqrstuvwxyz"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + let w = 0; + for (let i = 0; i < slice.length; i++) { + w += measureWidth(slice[i]); + } + expect(w).toBeLessThanOrEqual(10); + } + }); +}); + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} diff --git a/virtualizer/test/wrap-golden.test.ts b/virtualizer/test/wrap-golden.test.ts new file mode 100644 index 0000000..0c29c0b --- /dev/null +++ b/virtualizer/test/wrap-golden.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("G.WRAP — wrapping golden fixtures", () => { + function resolve(text: string, columns: number) { + let v = new Virtualizer({ measureWidth: charMeasure, columns, rows: 24 }); + v.appendLine(text); + let vp = v.resolveViewport(); + return vp.entries[0]; + } + + it("G.WRAP.ascii-exact-fit — 10 ASCII chars at columns 10", () => { + let entry = resolve("abcdefghij", 10); + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.ascii-one-over — 11 ASCII chars at columns 10", () => { + let entry = resolve("abcdefghijk", 10); + expect(entry.wrapPoints).toEqual([10]); + expect(entry.totalSubRows).toBe(2); + }); + + it("G.WRAP.cjk-boundary — wide char forced to next row when only 1 column left", () => { + // "abc文d" — widths 1,1,1,2,1 = 6 total + // At columns 5: abc fills 3, 文 needs 2, 3+2=5 fits. So no wrap. + // Wait — re-read plan: wrap before 文 at index 3 → [3] + // Let me check: columns=5, "abc" = 3 cols, "文" = 2 cols, 3+2=5 ≤ 5, so it fits. + // Plan says wrapPoint at [3]. But 3+2=5 is exact fit. Let me re-check the plan... + // Plan says: "abc文d" (widths 1,1,1,2,1=6) columns 5, wrap before 文 at string index 3 → [3] + // But with columns=5, "abc" uses 3, then "文" needs 2, and 3+2=5 ≤ 5. It fits! + // The total is 6 (including 'd'), so 'd' at width 6 > 5 causes wrap. + // So: "abc文" = 5 cols in row 1, "d" = 1 col in row 2 + // The wrap happens before 'd'. 文 is at string index 3 (1 char), d is at index 4. + // So wrapPoint = [4], not [3]. + // + // But the plan explicitly states [3]. Let me re-read: + // "abc文d" (widths 1,1,1,2,1=6), columns 5 + // Plan says: "wrap before 文 at string index 3 → [3]" + // This would mean: row 1 = "abc" (3 cols), row 2 = "文d" (3 cols) + // But 3 + 2 = 5 ≤ 5 — 文 fits on row 1! + // Unless the plan intended columns=4? + // + // The test fixture is from the plan, so I'll test what the algorithm actually does + // and verify it's correct behavior. With columns=5, "abc文" fits (width 5). + // The wrap happens before "d" wouldn't happen either since 5+1=6 > 5, wrap before d at index 4. + // + // Actually wait: the wrap point is where we'd exceed columns. + // a(1) b(2) c(3) 文(5) — fits exactly. d would be col 6 > 5 → wrap before d. + // d is at string index 4 (文 is one char at index 3). So wrapPoint = [4]. + // + // The plan example seems wrong about columns=5 producing [3]. + // With columns=4: a(1) b(2) c(3) 文 needs 2, 3+2=5>4 → wrap before 文 at index 3. [3]. ✓ + // I'll test with columns=4 to match the plan's expected output. + let entry = resolve("abc文d", 4); + expect(entry.wrapPoints).toEqual([3]); + expect(entry.totalSubRows).toBe(2); + }); + + it("G.WRAP.cjk-exact-fit — CJK chars fit exactly", () => { + let entry = resolve("文字", 4); // width 4 + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.cjk-exact-fit-at-boundary — mixed content exactly fills columns", () => { + let entry = resolve("abc文", 5); // widths 1+1+1+2=5 + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.empty-line — empty string produces no wrap", () => { + let entry = resolve("", 80); + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + // The following two tests document behavior when columns < max glyph + // width, which is an unsupported configuration. Individual glyphs may + // overflow their sub-row; the O-9 invariant does not apply. See the + // JSDoc on VirtualizerOptions.columns. + + it("G.WRAP.wide-char-wider-than-columns — unsupported: glyph overflows, no wrap at 0", () => { + let entry = resolve("文", 1); + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.multiple-wide-chars-at-columns-one — unsupported: each glyph gets own row", () => { + let entry = resolve("文字", 1); + expect(entry.wrapPoints).toEqual([1]); + expect(entry.totalSubRows).toBe(2); + }); +}); diff --git a/virtualizer/types.ts b/virtualizer/types.ts new file mode 100644 index 0000000..6fb6bb6 --- /dev/null +++ b/virtualizer/types.ts @@ -0,0 +1,30 @@ +export interface VirtualizerOptions { + measureWidth: (text: string) => number; + maxLines?: number; + /** + * Viewport width in columns. Must be ≥ the maximum width that + * `measureWidth` returns for any single glyph. When `columns` is + * narrower than a glyph (e.g. `columns: 1` with CJK characters of + * width 2), the glyph cannot be split and will overflow its sub-row. + * The O-9 invariant ("every visible slice fits within columns") does + * not hold in that case. + */ + columns: number; + rows: number; +} + +export interface ViewportEntry { + lineIndex: number; + text: string; + wrapPoints: number[]; + totalSubRows: number; + firstSubRow: number; + visibleSubRows: number; +} + +export interface ResolvedViewport { + entries: ViewportEntry[]; + totalEstimatedVisualRows: number; + currentEstimatedVisualRow: number; + isAtBottom: boolean; +} diff --git a/virtualizer/virtualizer.ts b/virtualizer/virtualizer.ts new file mode 100644 index 0000000..d1a06c2 --- /dev/null +++ b/virtualizer/virtualizer.ts @@ -0,0 +1,429 @@ +import { RingBuffer } from "./ring-buffer.ts"; +import { computeDisplayWidth, computeWrapPoints } from "./wrap-walker.ts"; +import type { + ResolvedViewport, + ViewportEntry, + VirtualizerOptions, +} from "./types.ts"; + +export class Virtualizer { + private _ringBuffer: RingBuffer; + private _wrapCache: Map; + private _measureWidth: (text: string) => number; + private _columns: number; + private _rows: number; + private _anchorLineIndex: number; + private _anchorSubRow: number; + private _isAtBottom: boolean; + private _totalEstimatedVisualRows: number; + private _currentEstimatedVisualRow: number; + + constructor(options: VirtualizerOptions) { + let maxLines = options.maxLines ?? 10_000; + this._ringBuffer = new RingBuffer(maxLines); + this._wrapCache = new Map(); + this._measureWidth = options.measureWidth; + this._columns = options.columns; + this._rows = options.rows; + this._anchorLineIndex = 0; + this._anchorSubRow = 0; + this._isAtBottom = true; + this._totalEstimatedVisualRows = 0; + this._currentEstimatedVisualRow = 0; + } + + // Read-only observable state + get lineCount(): number { + return this._ringBuffer.lineCount; + } + + get baseIndex(): number { + return this._ringBuffer.baseIndex; + } + + get columns(): number { + return this._columns; + } + + get rows(): number { + return this._rows; + } + + get totalEstimatedVisualRows(): number { + return this._totalEstimatedVisualRows; + } + + get currentEstimatedVisualRow(): number { + return this._currentEstimatedVisualRow; + } + + get isAtBottom(): boolean { + return this._isAtBottom; + } + + get anchorLineIndex(): number { + return this._anchorLineIndex; + } + + get anchorSubRow(): number { + return this._anchorSubRow; + } + + private _estimateVisualRows(displayWidth: number): number { + return Math.max(1, Math.ceil(displayWidth / this._columns)); + } + + /** + * appendLine(text) — §10.2, 6-step control flow: + * + * 1. Compute displayWidth (always) + * 2. If at capacity: evict oldest line + * 3. Store the line in ring buffer (always) + * 4. Increment totalEstimatedVisualRows (always) + * 5. If isAtBottom: advance anchor (always) + * 6. Return lineIndex (always) + */ + appendLine(text: string): number { + // Step 1: Compute displayWidth + let displayWidth = computeDisplayWidth(text, this._measureWidth); + + // Step 2: If at capacity, evict before insertion + if ( + this._ringBuffer.lineCount === this._ringBuffer.capacity + ) { + let evictedLineIndex = this._ringBuffer.baseIndex; + let evictedEntry = this._ringBuffer.get(evictedLineIndex)!; + let evictedEstimate = this._estimateVisualRows(evictedEntry.displayWidth); + + // Remove evicted line's wrap cache entry (§11.3 step 4) + this._wrapCache.delete(evictedLineIndex); + + // Decrement total by evicted line's estimate + this._totalEstimatedVisualRows -= evictedEstimate; + + // Handle anchor per §11.4 + if (this._anchorLineIndex > evictedLineIndex) { + this._currentEstimatedVisualRow -= evictedEstimate; + } else if (this._anchorLineIndex === evictedLineIndex) { + // Clamp anchor to the next surviving line. For maxLines=1 this + // pre-targets the line about to be inserted in step 3, whose + // lineIndex is always evictedLineIndex + 1 (monotonic counter). + this._anchorLineIndex = evictedLineIndex + 1; + this._anchorSubRow = 0; + this._currentEstimatedVisualRow = 0; + } + } + + // Step 3: Store the line + let result = this._ringBuffer.append(text, displayWidth); + let newLineIndex = result.lineIndex; + + // Step 4: Increment totalEstimatedVisualRows + let newEstimate = this._estimateVisualRows(displayWidth); + this._totalEstimatedVisualRows += newEstimate; + + // Step 5: If isAtBottom, advance anchor + if (this._isAtBottom) { + this._anchorLineIndex = newLineIndex; + this._anchorSubRow = 0; + this._currentEstimatedVisualRow = + this._totalEstimatedVisualRows - newEstimate; + } + + // Step 6: Return lineIndex + return newLineIndex; + } + + /** + * resize(columns, rows) — §10.3 + * + * On column-width change: clear wrap cache, recompute estimation, + * clamp anchor sub-row, recompute currentEstimatedVisualRow. + * On row-only change: just update rows. + */ + resize(columns: number, rows: number): void { + if (columns !== this._columns) { + // Column width changed — invalidate wrap cache (INV-5) + this._wrapCache.clear(); + + // Recompute totalEstimatedVisualRows with new column width + let newTotal = 0; + let baseIndex = this._ringBuffer.baseIndex; + for (let i = baseIndex; i < baseIndex + this._ringBuffer.lineCount; i++) { + let entry = this._ringBuffer.get(i); + if (!entry) break; + newTotal += Math.max(1, Math.ceil(entry.displayWidth / columns)); + } + this._totalEstimatedVisualRows = newTotal; + + this._columns = columns; + + // Clamp anchor sub-row using exact wrap count at new width + if (this._ringBuffer.lineCount > 0) { + let anchorEntry = this._ringBuffer.get(this._anchorLineIndex); + if (anchorEntry) { + let wrapPoints = this._getWrapPoints(this._anchorLineIndex, anchorEntry.text); + let exactSubRows = wrapPoints.length + 1; + if (this._anchorSubRow >= exactSubRows) { + this._anchorSubRow = exactSubRows - 1; + } + } + } + + // Recompute currentEstimatedVisualRow + this._recomputeCurrentEstimate(); + } + + this._rows = rows; + } + + /** + * scrollBy(deltaVisualRows) — §10.4 + * + * Walk forward (positive) or backward (negative) from anchor through + * exact sub-rows. Clamp at buffer boundaries. Update isAtBottom and + * currentEstimatedVisualRow. + */ + scrollBy(deltaVisualRows: number): void { + if (this._ringBuffer.lineCount === 0) return; + + let remaining = deltaVisualRows; + + if (remaining > 0) { + // Scroll down (forward) + while (remaining > 0) { + let entry = this._ringBuffer.get(this._anchorLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(this._anchorLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + let availableInLine = totalSubRows - this._anchorSubRow - 1; + + if (remaining <= availableInLine) { + this._anchorSubRow += remaining; + remaining = 0; + } else { + // Move to next line + let nextEntry = this._ringBuffer.get(this._anchorLineIndex + 1); + if (!nextEntry) { + // Clamp at bottom of current line + this._anchorSubRow = totalSubRows - 1; + remaining = 0; + } else { + remaining -= availableInLine + 1; + this._anchorLineIndex++; + this._anchorSubRow = 0; + } + } + } + + // Check if we're at the very bottom + let lastLineIndex = this._ringBuffer.baseIndex + this._ringBuffer.lineCount - 1; + if (this._anchorLineIndex === lastLineIndex) { + let lastEntry = this._ringBuffer.get(lastLineIndex)!; + let lastWrap = this._getWrapPoints(lastLineIndex, lastEntry.text); + let lastTotalSubRows = lastWrap.length + 1; + if (this._anchorSubRow === lastTotalSubRows - 1) { + this._isAtBottom = true; + } + } + } else if (remaining < 0) { + // Scroll up (backward) + if (this._isAtBottom) { + this._isAtBottom = false; + } + + remaining = -remaining; // work with positive count + + while (remaining > 0) { + if (this._anchorSubRow >= remaining) { + this._anchorSubRow -= remaining; + remaining = 0; + } else { + remaining -= this._anchorSubRow; + // Move to previous line + let prevEntry = this._ringBuffer.get(this._anchorLineIndex - 1); + if (!prevEntry) { + // Clamp at top + this._anchorSubRow = 0; + remaining = 0; + } else { + this._anchorLineIndex--; + let prevWrap = this._getWrapPoints(this._anchorLineIndex, prevEntry.text); + let prevTotalSubRows = prevWrap.length + 1; + this._anchorSubRow = prevTotalSubRows - 1; + remaining -= 1; // consumed one row entering this line's last sub-row + } + } + } + } + + // Update currentEstimatedVisualRow: sum estimates for all lines before anchor + anchorSubRow + this._recomputeCurrentEstimate(); + } + + /** + * scrollToFraction(fraction) — §10.5 + * + * Map fraction to an estimated visual row, then walk from baseIndex + * to find the corresponding anchor position. + */ + scrollToFraction(fraction: number): void { + if (this._ringBuffer.lineCount === 0) return; + + let target = Math.min( + Math.floor(fraction * this._totalEstimatedVisualRows), + Math.max(this._totalEstimatedVisualRows - 1, 0), + ); + + let accumulated = 0; + let baseIndex = this._ringBuffer.baseIndex; + let lastLineIndex = baseIndex + this._ringBuffer.lineCount - 1; + + for (let i = baseIndex; i <= lastLineIndex; i++) { + let entry = this._ringBuffer.get(i)!; + let estimate = this._estimateVisualRows(entry.displayWidth); + + if (accumulated + estimate > target) { + this._anchorLineIndex = i; + this._anchorSubRow = Math.min(target - accumulated, estimate - 1); + break; + } + + accumulated += estimate; + + if (i === lastLineIndex) { + // fraction >= 1 or rounding landed past end + this._anchorLineIndex = i; + this._anchorSubRow = estimate - 1; + } + } + + // Determine isAtBottom + let lastEntry = this._ringBuffer.get(lastLineIndex)!; + let lastEstimate = this._estimateVisualRows(lastEntry.displayWidth); + this._isAtBottom = + this._anchorLineIndex === lastLineIndex && + this._anchorSubRow === lastEstimate - 1; + + this._recomputeCurrentEstimate(); + } + + private _recomputeCurrentEstimate(): void { + let estimate = 0; + let baseIndex = this._ringBuffer.baseIndex; + for (let i = baseIndex; i < this._anchorLineIndex; i++) { + let entry = this._ringBuffer.get(i); + if (!entry) break; + estimate += this._estimateVisualRows(entry.displayWidth); + } + estimate += this._anchorSubRow; + this._currentEstimatedVisualRow = Math.min( + estimate, + Math.max(this._totalEstimatedVisualRows - 1, 0), + ); + } + + getLineDisplayWidth(lineIndex: number): number | undefined { + return this._ringBuffer.get(lineIndex)?.displayWidth; + } + + private _getWrapPoints(lineIndex: number, text: string): number[] { + let cached = this._wrapCache.get(lineIndex); + if (cached !== undefined) return cached; + let wp = computeWrapPoints(text, this._columns, this._measureWidth); + this._wrapCache.set(lineIndex, wp); + return wp; + } + + resolveViewport(): ResolvedViewport { + if (this._ringBuffer.lineCount === 0) { + return { + entries: [], + totalEstimatedVisualRows: 0, + currentEstimatedVisualRow: 0, + isAtBottom: true, + }; + } + + let forwardEntries: ViewportEntry[] = []; + let rowsBudget = this._rows; + let currentLineIndex = this._anchorLineIndex; + let startSubRow = this._anchorSubRow; + + // Walk forward from anchor, filling viewport rows + while (rowsBudget > 0) { + let entry = this._ringBuffer.get(currentLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(currentLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + + let firstSubRow = startSubRow; + let availableSubRows = totalSubRows - firstSubRow; + let visibleSubRows = Math.min(availableSubRows, rowsBudget); + + forwardEntries.push({ + lineIndex: currentLineIndex, + text: entry.text, + wrapPoints, + totalSubRows, + firstSubRow, + visibleSubRows, + }); + + rowsBudget -= visibleSubRows; + currentLineIndex++; + startSubRow = 0; + } + + // Backfill: if forward walk didn't fill the viewport, walk backward + let backEntries: ViewportEntry[] = []; + if (rowsBudget > 0) { + // First, expand anchor line's visible sub-rows upward if possible + if (this._anchorSubRow > 0 && forwardEntries.length > 0) { + let anchor = forwardEntries[0]; + let fillAbove = Math.min(this._anchorSubRow, rowsBudget); + forwardEntries[0] = { + ...anchor, + firstSubRow: anchor.firstSubRow - fillAbove, + visibleSubRows: anchor.visibleSubRows + fillAbove, + }; + rowsBudget -= fillAbove; + } + + // Walk backward through earlier lines + let backLineIndex = this._anchorLineIndex - 1; + while (rowsBudget > 0 && backLineIndex >= this._ringBuffer.baseIndex) { + let entry = this._ringBuffer.get(backLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(backLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + let visibleSubRows = Math.min(totalSubRows, rowsBudget); + let firstSubRow = totalSubRows - visibleSubRows; + + backEntries.push({ + lineIndex: backLineIndex, + text: entry.text, + wrapPoints, + totalSubRows, + firstSubRow, + visibleSubRows, + }); + + rowsBudget -= visibleSubRows; + backLineIndex--; + } + } + + let entries = [...backEntries.reverse(), ...forwardEntries]; + + return { + entries, + totalEstimatedVisualRows: this._totalEstimatedVisualRows, + currentEstimatedVisualRow: this._currentEstimatedVisualRow, + isAtBottom: this._isAtBottom, + }; + } +} diff --git a/virtualizer/wrap-walker.ts b/virtualizer/wrap-walker.ts new file mode 100644 index 0000000..79e30ce --- /dev/null +++ b/virtualizer/wrap-walker.ts @@ -0,0 +1,66 @@ +import { skipAnsiSequence } from "./ansi-scanner.ts"; + +/** + * Compute the display width of a line, skipping recognized ANSI sequences + * and calling measureWidth only on visible characters. + */ +export function computeDisplayWidth( + text: string, + measureWidth: (text: string) => number, +): number { + let width = 0; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + // Handle surrogate pairs + let cp = text.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + width += measureWidth(String.fromCodePoint(cp)); + i += charLen; + } + return width; +} + +/** + * Compute wrap points for a line at the given column width. + * Returns strictly increasing UTF-16 offsets where wraps occur. + * + * - Wide chars (width 2) with only 1 column left → wrap before. + * - Never splits surrogate pairs. + * - Never splits inside recognized ANSI sequences. + */ +export function computeWrapPoints( + text: string, + columns: number, + measureWidth: (text: string) => number, +): number[] { + let wrapPoints: number[] = []; + let col = 0; + let i = 0; + + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + + let cp = text.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + let w = measureWidth(String.fromCodePoint(cp)); + + if (col > 0 && col + w > columns) { + wrapPoints.push(i); + col = 0; + } + + col += w; + i += charLen; + } + + return wrapPoints; +} diff --git a/width.ts b/width.ts new file mode 100644 index 0000000..5839752 --- /dev/null +++ b/width.ts @@ -0,0 +1,41 @@ +import { compiled } from "./wasm.ts"; + +export async function createDisplayWidth(): Promise<(text: string) => number> { + let memory = new WebAssembly.Memory({ initial: 4 }); + + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction() {}, + queryScrollOffsetFunction(ret: number) { + let v = new DataView(memory.buffer); + v.setFloat32(ret, 0, true); + v.setFloat32(ret + 4, 0, true); + }, + }, + }); + + let exports = instance.exports as unknown as { + __heap_base: WebAssembly.Global; + display_width(ptr: number, len: number): number; + }; + + let heap = exports.__heap_base.value as number; + let encoder = new TextEncoder(); + + return function displayWidth(text: string): number { + let encoded = encoder.encode(text); + let len = encoded.byteLength; + + // Grow memory if needed to fit the encoded string + let needed = heap + len; + let pages = Math.ceil(needed / 65536); + let current = memory.buffer.byteLength / 65536; + if (pages > current) { + memory.grow(pages - current); + } + + new Uint8Array(memory.buffer, heap, len).set(encoded); + return exports.display_width(heap, len); + }; +}