[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨ add @clayterm/virtualizer v1 (PRs 1–3)

Implement the virtualizer package for viewport virtualization over
large terminal text output. This covers PRs 1–3 of the implementation
plan:

- PR 1: Export createDisplayWidth from renderer (WASM per-codepoint
wcwidth, R.WIDTH.* tests)
- PR 2: Virtualizer core — appendLine, resolveViewport, ring buffer,
ANSI scanner, wrap walker, getLineDisplayWidth (~66 tests)
- PR 3: scrollBy + scrollToFraction with all deferred scroll-dependent
tests (~92 tests total)

resize() still throws "not implemented" — deferred to PR 4.

Taras Mankovski (Apr 11, 2026, 6:30 AM EDT) 7462e0df c1e987fc

+1939
+1
mod.ts
··· 3 3 export * from "./input.ts"; 4 4 export * from "./settings.ts"; 5 5 export * from "./termcodes.ts"; 6 + export { createDisplayWidth } from "./width.ts";
+30
src/clayterm.c
··· 7 7 * output — pointer to output byte buffer 8 8 * length — length of output byte buffer 9 9 * measure — Clay text measurement callback 10 + * display_width — per-codepoint wcwidth sum over a UTF-8 string 10 11 */ 11 12 12 13 #include "clayterm.h" ··· 656 657 dims[0] = (float)w; 657 658 dims[1] = 1.0f; 658 659 } 660 + 661 + /* ── display_width — per-codepoint wcwidth sum ───────────────────── */ 662 + 663 + /** 664 + * Compute the display width of a UTF-8 string by summing max(0, wcwidth(cp)) 665 + * for each Unicode codepoint. No ANSI skipping — pure per-codepoint sum. 666 + * 667 + * @param str Pointer to a UTF-8 encoded string (not necessarily null-terminated). 668 + * @param len Byte length of the string. 669 + * @return Total display width (non-negative). 670 + */ 671 + int display_width(const char *str, int len) { 672 + int w = 0; 673 + const char *p = str; 674 + int rem = len; 675 + while (rem > 0) { 676 + uint32_t cp; 677 + int n = utf8_decode(&cp, p); 678 + if (n <= 0) { 679 + n = 1; 680 + } 681 + int cw = wcwidth(cp); 682 + if (cw > 0) 683 + w += cw; 684 + p += n; 685 + rem -= n; 686 + } 687 + return w; 688 + }
+47
test/width.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "./suite.ts"; 2 + import { createDisplayWidth } from "../width.ts"; 3 + 4 + describe("createDisplayWidth", () => { 5 + let displayWidth: (text: string) => number; 6 + 7 + beforeEach(async () => { 8 + displayWidth = await createDisplayWidth(); 9 + }); 10 + 11 + it("R.WIDTH.ascii — ASCII characters each have width 1", () => { 12 + expect(displayWidth("hello")).toBe(5); 13 + }); 14 + 15 + it("R.WIDTH.cjk — CJK characters each have width 2", () => { 16 + expect(displayWidth("文字")).toBe(4); 17 + }); 18 + 19 + it("R.WIDTH.combining — combining marks have width 0", () => { 20 + expect(displayWidth("e\u0301")).toBe(1); 21 + }); 22 + 23 + it("R.WIDTH.zwj-emoji — ZWJ emoji sequence uses per-codepoint wcwidth", () => { 24 + expect(displayWidth("👨‍👩‍👧‍👦")).toBe(8); 25 + }); 26 + 27 + it("R.WIDTH.additivity — width of concatenation equals sum of widths", () => { 28 + let pairs: [string, string][] = [ 29 + ["hello", "world"], 30 + ["文", "字"], 31 + ["abc", "文字"], 32 + ["e\u0301", "hello"], 33 + ]; 34 + for (let [a, b] of pairs) { 35 + expect(displayWidth(a + b)).toBe(displayWidth(a) + displayWidth(b)); 36 + } 37 + }); 38 + 39 + it("R.WIDTH.empty-string — empty string has width 0", () => { 40 + expect(displayWidth("")).toBe(0); 41 + }); 42 + 43 + it("R.WIDTH.zero-width — zero-width characters have width 0", () => { 44 + expect(displayWidth("\u200B")).toBe(0); 45 + expect(displayWidth("\u200D")).toBe(0); 46 + }); 47 + });
+48
virtualizer/ansi-scanner.ts
··· 1 + /** 2 + * If text[pos] starts an ESC-initiated CSI or OSC sequence, return the 3 + * byte length of the full sequence (inclusive). Otherwise return 0. 4 + * 5 + * CSI: ESC [ ... <final byte 0x40–0x7E> 6 + * OSC: ESC ] ... <BEL (0x07) or ST (ESC \)> 7 + */ 8 + export function skipAnsiSequence(text: string, pos: number): number { 9 + if (text.charCodeAt(pos) !== 0x1b) return 0; 10 + if (pos + 1 >= text.length) return 0; 11 + 12 + let next = text.charCodeAt(pos + 1); 13 + 14 + // CSI: ESC [ 15 + if (next === 0x5b) { 16 + let i = pos + 2; 17 + while (i < text.length) { 18 + let ch = text.charCodeAt(i); 19 + if (ch >= 0x40 && ch <= 0x7e) { 20 + return i - pos + 1; 21 + } 22 + i++; 23 + } 24 + // Unterminated CSI — consume what we have 25 + return i - pos; 26 + } 27 + 28 + // OSC: ESC ] 29 + if (next === 0x5d) { 30 + let i = pos + 2; 31 + while (i < text.length) { 32 + let ch = text.charCodeAt(i); 33 + // BEL terminator 34 + if (ch === 0x07) { 35 + return i - pos + 1; 36 + } 37 + // ST terminator: ESC backslash 38 + if (ch === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c) { 39 + return i - pos + 2; 40 + } 41 + i++; 42 + } 43 + // Unterminated OSC — consume what we have 44 + return i - pos; 45 + } 46 + 47 + return 0; 48 + }
+9
virtualizer/deno.json
··· 1 + { 2 + "name": "@clayterm/virtualizer", 3 + "license": "MIT", 4 + "exports": { ".": "./mod.ts" }, 5 + "imports": { 6 + "@std/testing": "jsr:@std/testing@1", 7 + "@std/expect": "jsr:@std/expect@1" 8 + } 9 + }
+42
virtualizer/deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@std/assert@^1.0.14": "1.0.19", 5 + "jsr:@std/assert@^1.0.17": "1.0.19", 6 + "jsr:@std/expect@1": "1.0.17", 7 + "jsr:@std/internal@^1.0.10": "1.0.12", 8 + "jsr:@std/internal@^1.0.12": "1.0.12", 9 + "jsr:@std/testing@1": "1.0.17" 10 + }, 11 + "jsr": { 12 + "@std/assert@1.0.19": { 13 + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 14 + "dependencies": [ 15 + "jsr:@std/internal@^1.0.12" 16 + ] 17 + }, 18 + "@std/expect@1.0.17": { 19 + "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", 20 + "dependencies": [ 21 + "jsr:@std/assert@^1.0.14", 22 + "jsr:@std/internal@^1.0.10" 23 + ] 24 + }, 25 + "@std/internal@1.0.12": { 26 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 27 + }, 28 + "@std/testing@1.0.17": { 29 + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", 30 + "dependencies": [ 31 + "jsr:@std/assert@^1.0.17", 32 + "jsr:@std/internal@^1.0.12" 33 + ] 34 + } 35 + }, 36 + "workspace": { 37 + "dependencies": [ 38 + "jsr:@std/expect@1", 39 + "jsr:@std/testing@1" 40 + ] 41 + } 42 + }
+6
virtualizer/mod.ts
··· 1 + export { Virtualizer } from "./virtualizer.ts"; 2 + export type { 3 + ResolvedViewport, 4 + ViewportEntry, 5 + VirtualizerOptions, 6 + } from "./types.ts";
+68
virtualizer/ring-buffer.ts
··· 1 + export interface LineEntry { 2 + text: string; 3 + displayWidth: number; 4 + lineIndex: number; 5 + } 6 + 7 + export interface AppendResult { 8 + lineIndex: number; 9 + evicted?: { displayWidth: number; lineIndex: number }; 10 + } 11 + 12 + export class RingBuffer { 13 + private _items: (LineEntry | undefined)[]; 14 + private _capacity: number; 15 + private _head: number; 16 + private _count: number; 17 + private _nextIndex: number; 18 + 19 + constructor(capacity: number) { 20 + this._capacity = capacity; 21 + this._items = new Array(capacity); 22 + this._head = 0; 23 + this._count = 0; 24 + this._nextIndex = 0; 25 + } 26 + 27 + get capacity(): number { 28 + return this._capacity; 29 + } 30 + 31 + get lineCount(): number { 32 + return this._count; 33 + } 34 + 35 + get baseIndex(): number { 36 + if (this._count === 0) return this._nextIndex; 37 + return this._items[this._head]!.lineIndex; 38 + } 39 + 40 + append(text: string, displayWidth: number): AppendResult { 41 + let lineIndex = this._nextIndex++; 42 + let evicted: { displayWidth: number; lineIndex: number } | undefined; 43 + 44 + if (this._count === this._capacity) { 45 + let evictedEntry = this._items[this._head]!; 46 + evicted = { 47 + displayWidth: evictedEntry.displayWidth, 48 + lineIndex: evictedEntry.lineIndex, 49 + }; 50 + this._head = (this._head + 1) % this._capacity; 51 + this._count--; 52 + } 53 + 54 + let slot = (this._head + this._count) % this._capacity; 55 + this._items[slot] = { text, displayWidth, lineIndex }; 56 + this._count++; 57 + 58 + return { lineIndex, evicted }; 59 + } 60 + 61 + get(lineIndex: number): LineEntry | undefined { 62 + if (this._count === 0) return undefined; 63 + let base = this._items[this._head]!.lineIndex; 64 + let offset = lineIndex - base; 65 + if (offset < 0 || offset >= this._count) return undefined; 66 + return this._items[(this._head + offset) % this._capacity]; 67 + } 68 + }
+68
virtualizer/test/ansi-golden.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(text: string): number { 5 + let cp = text.codePointAt(0)!; 6 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 7 + if (cp < 0x20) return 0; 8 + return 1; 9 + } 10 + 11 + describe("G.ANSI — ANSI golden fixtures", () => { 12 + it("G.ANSI.simple-sgr — SGR sequence does not add sub-rows", () => { 13 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 14 + let idx = v.appendLine("\x1b[31mred\x1b[0m"); 15 + let vp = v.resolveViewport(); 16 + expect(vp.entries[0].totalSubRows).toBe(1); 17 + expect(v.getLineDisplayWidth(idx)).toBe(3); 18 + }); 19 + 20 + it("G.ANSI.sgr-at-wrap-boundary — wrap occurs at visible char boundary, not inside SGR", () => { 21 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); 22 + v.appendLine("abcde\x1b[31mfghij"); 23 + let vp = v.resolveViewport(); 24 + let entry = vp.entries[0]; 25 + // Visible: "abcde" (5) then "fghij" (5). CSI "\x1b[31m" at indices 5–9 (5 chars). 26 + // Wrap after 5 visible chars → wrap point at index 10 (start of 'f', after CSI) 27 + expect(entry.wrapPoints).toEqual([10]); 28 + let slices = [entry.text.slice(0, 10), entry.text.slice(10)]; 29 + expect(slices[0]).toBe("abcde\x1b[31m"); 30 + expect(slices[1]).toBe("fghij"); 31 + }); 32 + 33 + it("G.ANSI.osc-with-bel — OSC with BEL terminator", () => { 34 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 35 + let idx = v.appendLine("\x1b]0;title\x07visible"); 36 + let vp = v.resolveViewport(); 37 + expect(vp.entries[0].totalSubRows).toBe(1); 38 + expect(v.getLineDisplayWidth(idx)).toBe(7); 39 + }); 40 + 41 + it("G.ANSI.osc-with-st — OSC with ST terminator", () => { 42 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 43 + let idx = v.appendLine("\x1b]0;title\x1b\\visible"); 44 + let vp = v.resolveViewport(); 45 + expect(vp.entries[0].totalSubRows).toBe(1); 46 + expect(v.getLineDisplayWidth(idx)).toBe(7); 47 + }); 48 + 49 + it("G.ANSI.nested-csi-in-wrapped-line — multiple CSI in wrapping line, no wrap inside CSI", () => { 50 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); 51 + // "a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me" — 5 visible chars: a, b, c, d, e 52 + v.appendLine("a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me"); 53 + let vp = v.resolveViewport(); 54 + let entry = vp.entries[0]; 55 + // 5 visible chars at columns 3 → 2 sub-rows 56 + expect(entry.totalSubRows).toBe(2); 57 + // No wrap point should fall inside any CSI sequence 58 + let text = entry.text; 59 + for (let wp of entry.wrapPoints) { 60 + expect(text.charCodeAt(wp)).not.toBe(0x5b); // not '[' 61 + // wp should not be between ESC and final byte 62 + if (wp > 0 && text.charCodeAt(wp - 1) === 0x1b) { 63 + // wp right after ESC — that's inside the sequence 64 + expect(true).toBe(false); 65 + } 66 + } 67 + }); 68 + });
+90
virtualizer/test/ansi.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(text: string): number { 5 + let cp = text.codePointAt(0)!; 6 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 7 + if (cp < 0x20) return 0; 8 + return 1; 9 + } 10 + 11 + describe("C.ANSI — ANSI handling", () => { 12 + it("C.ANSI.csi-skipped-in-width — CSI bytes do not contribute to displayWidth", () => { 13 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 14 + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); 15 + expect(v.getLineDisplayWidth(idx)).toBe(5); 16 + }); 17 + 18 + it("C.ANSI.osc-skipped-in-width — OSC payload does not contribute to displayWidth", () => { 19 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 20 + let idx = v.appendLine("\x1b]0;title\x07visible"); 21 + expect(v.getLineDisplayWidth(idx)).toBe(7); 22 + }); 23 + 24 + it("C.ANSI.wrap-point-not-inside-csi — no wrapPoint falls inside a CSI sequence", () => { 25 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); 26 + // "abcde\x1b[31mfghij" — 5 visible chars, then CSI at string index 5–9, then 5 more visible 27 + let text = "abcde\x1b[31mfghij"; 28 + v.appendLine(text); 29 + let vp = v.resolveViewport(); 30 + let entry = vp.entries[0]; 31 + // CSI bytes are at string indices 5–9 — no wrap point should fall inside the CSI 32 + for (let wp of entry.wrapPoints) { 33 + expect(wp < 5 || wp >= 10).toBe(true); 34 + } 35 + }); 36 + 37 + it("C.ANSI.wrap-point-not-inside-osc — no wrapPoint falls inside an OSC sequence", () => { 38 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); 39 + // 5 visible chars, then OSC, then 5 more visible chars 40 + let text = "abcde\x1b]0;title\x07fghij"; 41 + v.appendLine(text); 42 + let vp = v.resolveViewport(); 43 + let entry = vp.entries[0]; 44 + // OSC spans from index 5 to 14 (inclusive of BEL) — no wrap point inside 45 + for (let wp of entry.wrapPoints) { 46 + expect(wp < 5 || wp >= 15).toBe(true); 47 + } 48 + }); 49 + 50 + it("C.ANSI.unrecognized-not-skipped — unrecognized ESC sequences are not skipped", () => { 51 + let recorded: string[] = []; 52 + let recordingMeasure = (text: string): number => { 53 + recorded.push(text); 54 + return 1; 55 + }; 56 + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); 57 + // SS3 (ESC O) is not CSI/OSC — should not be skipped 58 + v.appendLine("\x1bOA"); 59 + let joined = recorded.join(""); 60 + expect(joined).toContain("O"); 61 + expect(joined).toContain("A"); 62 + }); 63 + 64 + it("C.ANSI.escapes-contribute-zero-width — multiple CSI sequences contribute zero width", () => { 65 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 66 + let idx = v.appendLine("\x1b[31m\x1b[42mhello\x1b[0m"); 67 + expect(v.getLineDisplayWidth(idx)).toBe(5); 68 + }); 69 + 70 + it("R.ANSI.call-discipline — measureWidth never receives ESC or CSI/OSC body bytes", () => { 71 + let recorded: string[] = []; 72 + let recordingMeasure = (text: string): number => { 73 + recorded.push(text); 74 + return 1; 75 + }; 76 + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 40, rows: 24 }); 77 + v.appendLine("\x1b[1m\x1b[31mhello\x1b[0m \x1b]0;title\x07world"); 78 + v.resolveViewport(); 79 + for (let arg of recorded) { 80 + expect(arg.includes("\x1b")).toBe(false); 81 + } 82 + }); 83 + 84 + it("C.ANSI.no-style-state-across-lines — prior line SGR does not affect next line", () => { 85 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 86 + v.appendLine("\x1b[31mred"); 87 + let idx2 = v.appendLine("plain"); 88 + expect(v.getLineDisplayWidth(idx2)).toBe(5); 89 + }); 90 + });
+84
virtualizer/test/append.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(_text: string): number { 5 + return 1; 6 + } 7 + 8 + describe("C.APPEND — appendLine", () => { 9 + it("C.APPEND.stores-text — text is retrievable via resolveViewport", () => { 10 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 11 + v.appendLine("hello world"); 12 + let vp = v.resolveViewport(); 13 + expect(vp.entries[0].text).toBe("hello world"); 14 + }); 15 + 16 + it("C.APPEND.increments-estimated-total — totalEstimatedVisualRows increases correctly", () => { 17 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); 18 + let before = v.totalEstimatedVisualRows; 19 + v.appendLine("hello"); // width 5, ceil(5/10) = 1 20 + expect(v.totalEstimatedVisualRows - before).toBe(1); 21 + }); 22 + 23 + it("C.APPEND.bottom-follow-advances-anchor — anchor tracks newest line when at bottom", () => { 24 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 25 + expect(v.isAtBottom).toBe(true); 26 + let idx0 = v.appendLine("line 0"); 27 + expect(v.anchorLineIndex).toBe(idx0); 28 + let idx1 = v.appendLine("line 1"); 29 + expect(v.anchorLineIndex).toBe(idx1); 30 + let idx2 = v.appendLine("line 2"); 31 + expect(v.anchorLineIndex).toBe(idx2); 32 + 33 + let vp = v.resolveViewport(); 34 + let lastEntry = vp.entries[vp.entries.length - 1]; 35 + expect(lastEntry.lineIndex).toBe(idx2); 36 + }); 37 + 38 + it("C.APPEND.does-not-invalidate-existing-wrap-cache — cached wrap points survive new appends", () => { 39 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); 40 + v.appendLine("abcdefghij"); // wraps at 5 41 + let vp1 = v.resolveViewport(); 42 + let wp1 = vp1.entries[0].wrapPoints.slice(); 43 + 44 + v.appendLine("more text"); 45 + let vp2 = v.resolveViewport(); 46 + // The first line should still be in the viewport with same wrap points 47 + let entry = vp2.entries.find((e) => e.text === "abcdefghij"); 48 + if (entry) { 49 + expect(entry.wrapPoints).toEqual(wp1); 50 + } 51 + }); 52 + 53 + it("C.APPEND.identity-counter-independent-of-buffer — indices always increase", () => { 54 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); 55 + let a = v.appendLine("A"); // 0 56 + let b = v.appendLine("B"); // 1 57 + let c = v.appendLine("C"); // 2, evicts A 58 + let d = v.appendLine("D"); // 3, evicts B 59 + expect([a, b, c, d]).toEqual([0, 1, 2, 3]); 60 + }); 61 + 62 + it("C.APPEND.no-bottom-follow-when-scrolled-up — anchor stays when not at bottom", () => { 63 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); 64 + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); 65 + // anchor at 9 (bottom-follow). scrollBy(-5) → anchor at 4. 66 + v.scrollBy(-5); 67 + let anchorBefore = v.anchorLineIndex; 68 + expect(anchorBefore).toBe(4); 69 + let vp1 = v.resolveViewport(); 70 + let entries1 = vp1.entries.map((e) => e.lineIndex); 71 + // rows=3, forward walk from 4: [4,5,6] (full) 72 + expect(entries1).toEqual([4, 5, 6]); 73 + 74 + // Append 3 more lines — anchor should not move, viewport stays full at [4,5,6] 75 + v.appendLine("extra 1"); 76 + v.appendLine("extra 2"); 77 + v.appendLine("extra 3"); 78 + 79 + expect(v.anchorLineIndex).toBe(anchorBefore); 80 + let vp2 = v.resolveViewport(); 81 + let entries2 = vp2.entries.map((e) => e.lineIndex); 82 + expect(entries2).toEqual(entries1); 83 + }); 84 + });
+52
virtualizer/test/empty.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(_text: string): number { 5 + return 1; 6 + } 7 + 8 + describe("C.EMPTY — empty buffer", () => { 9 + it("C.EMPTY.construction-state — initial state is empty and at bottom", () => { 10 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 11 + expect(v.lineCount).toBe(0); 12 + expect(v.totalEstimatedVisualRows).toBe(0); 13 + expect(v.currentEstimatedVisualRow).toBe(0); 14 + expect(v.isAtBottom).toBe(true); 15 + }); 16 + 17 + it("C.EMPTY.resolve-returns-empty — resolveViewport on empty buffer returns empty entries", () => { 18 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 19 + let vp = v.resolveViewport(); 20 + expect(vp.entries.length).toBe(0); 21 + expect(vp.totalEstimatedVisualRows).toBe(0); 22 + expect(vp.currentEstimatedVisualRow).toBe(0); 23 + expect(vp.isAtBottom).toBe(true); 24 + }); 25 + 26 + it("C.EMPTY.first-append-uses-counter — first appendLine returns 0", () => { 27 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 28 + expect(v.appendLine("first")).toBe(0); 29 + }); 30 + 31 + it("C.EMPTY.identity-counter-not-reset-after-eviction — counter continues after eviction", () => { 32 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); 33 + v.appendLine("A"); // 0 34 + v.appendLine("B"); // 1 35 + v.appendLine("C"); // 2 36 + expect(v.baseIndex).toBe(2); 37 + }); 38 + 39 + it("C.EMPTY.scrollBy-noop — scrollBy on empty buffer is a no-op", () => { 40 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 41 + v.scrollBy(5); 42 + expect(v.isAtBottom).toBe(true); 43 + expect(v.lineCount).toBe(0); 44 + }); 45 + 46 + it("C.EMPTY.scrollToFraction-noop — scrollToFraction on empty buffer is a no-op", () => { 47 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 48 + v.scrollToFraction(0.5); 49 + expect(v.isAtBottom).toBe(true); 50 + expect(v.lineCount).toBe(0); 51 + }); 52 + });
+122
virtualizer/test/eviction.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(_text: string): number { 5 + return 1; 6 + } 7 + 8 + describe("C.EVICT — basic eviction (non-scroll subset)", () => { 9 + it("C.EVICT.triggers-at-capacity — lineCount stays at maxLines", () => { 10 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); 11 + v.appendLine("A"); 12 + v.appendLine("B"); 13 + v.appendLine("C"); 14 + v.appendLine("D"); 15 + expect(v.lineCount).toBe(3); 16 + }); 17 + 18 + it("C.EVICT.removes-oldest — oldest line is evicted", () => { 19 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); 20 + v.appendLine("A"); // 0 21 + v.appendLine("B"); // 1 22 + v.appendLine("C"); // 2 23 + v.appendLine("D"); // 3, evicts A 24 + let vp = v.resolveViewport(); 25 + let indices = vp.entries.map((e) => e.lineIndex); 26 + expect(indices).toEqual([1, 2, 3]); 27 + }); 28 + 29 + it("C.EVICT.baseIndex-advances — baseIndex tracks eviction", () => { 30 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); 31 + v.appendLine("A"); expect(v.baseIndex).toBe(0); 32 + v.appendLine("B"); expect(v.baseIndex).toBe(0); 33 + v.appendLine("C"); expect(v.baseIndex).toBe(1); 34 + v.appendLine("D"); expect(v.baseIndex).toBe(2); 35 + }); 36 + 37 + it("C.EVICT.estimation-decremented — total estimate accounts for eviction", () => { 38 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24, maxLines: 3 }); 39 + v.appendLine("hello"); // 5 chars, 1 row 40 + v.appendLine("world"); // 5 chars, 1 row 41 + v.appendLine("three"); // 5 chars, 1 row 42 + expect(v.totalEstimatedVisualRows).toBe(3); 43 + v.appendLine("four!"); // evicts "hello", total stays 3 44 + expect(v.totalEstimatedVisualRows).toBe(3); 45 + }); 46 + 47 + it("C.EVICT.maxLines-one — single-line buffer works correctly", () => { 48 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); 49 + v.appendLine("A"); // 0 50 + v.appendLine("B"); // 1 51 + expect(v.lineCount).toBe(1); 52 + expect(v.baseIndex).toBe(1); 53 + let vp = v.resolveViewport(); 54 + expect(vp.entries[0].lineIndex).toBe(1); 55 + }); 56 + 57 + it("C.EVICT.anchor-survives-when-newer — anchor not affected by eviction of older line", () => { 58 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); 59 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); // indices 0–4, anchor at 4 60 + v.scrollBy(-1); // anchor at 3, isAtBottom=false 61 + expect(v.anchorLineIndex).toBe(3); 62 + 63 + v.appendLine("line 5"); // evicts index 0 64 + expect(v.anchorLineIndex).toBe(3); // unchanged 65 + let vp = v.resolveViewport(); 66 + expect(vp.entries[0].lineIndex).toBeGreaterThanOrEqual(1); 67 + }); 68 + 69 + it("C.EVICT.viewport-stable-when-anchor-survives — viewport unchanged after eviction of older line", () => { 70 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 2, maxLines: 5 }); 71 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); 72 + // anchor at 4 (bottom-follow). scrollBy(-1) → anchor at 3. 73 + v.scrollBy(-1); 74 + expect(v.anchorLineIndex).toBe(3); 75 + // rows=2, forward from 3: [3,4] (full) 76 + let vp1 = v.resolveViewport(); 77 + let entries1 = vp1.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); 78 + expect(entries1).toEqual([ 79 + { lineIndex: 3, text: "line 3" }, 80 + { lineIndex: 4, text: "line 4" }, 81 + ]); 82 + 83 + v.appendLine("line 5"); // evicts index 0, anchor still at 3 84 + let vp2 = v.resolveViewport(); 85 + let entries2 = vp2.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); 86 + expect(entries2).toEqual(entries1); 87 + }); 88 + 89 + it("C.EVICT.anchor-clamps-when-evicted — anchor clamped to next surviving line", () => { 90 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); 91 + v.appendLine("A"); // 0 92 + v.appendLine("B"); // 1 93 + v.appendLine("C"); // 2, anchor at 2 via bottom-follow 94 + v.scrollBy(-2); // anchor at 0, isAtBottom=false 95 + expect(v.anchorLineIndex).toBe(0); 96 + 97 + v.appendLine("D"); // evicts A(0), anchor was at 0 → clamp to 1 98 + expect(v.anchorLineIndex).toBe(1); 99 + expect(v.anchorSubRow).toBe(0); 100 + }); 101 + 102 + it("C.EVICT.currentEstimate-decremented-when-anchor-survives — currentEstimate adjusts for eviction", () => { 103 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); 104 + for (let i = 0; i < 5; i++) v.appendLine("x"); // each 1 row 105 + v.scrollBy(-1); // anchor at 3 106 + let before = v.currentEstimatedVisualRow; 107 + 108 + v.appendLine("y"); // evicts index 0 (1 row) 109 + // currentEstimate should decrease by 1 (evicted line's estimate) 110 + expect(v.currentEstimatedVisualRow).toBe(before - 1); 111 + }); 112 + 113 + it("C.EVICT.currentEstimate-zero-when-anchor-evicted — currentEstimate resets to 0", () => { 114 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); 115 + v.appendLine("A"); // 0 116 + v.appendLine("B"); // 1 117 + v.appendLine("C"); // 2 118 + v.scrollBy(-2); // anchor at 0 119 + v.appendLine("D"); // evicts A(0), anchor clamped to 1 120 + expect(v.currentEstimatedVisualRow).toBe(0); 121 + }); 122 + });
+25
virtualizer/test/fixtures/real-world-ansi.ts
··· 1 + // Real-world ANSI fixture strings captured from common terminal programs. 2 + 3 + // ls --color output (directory and file listing with SGR) 4 + export const LS_COLOR = 5 + "\x1b[0m\x1b[01;34mnode_modules\x1b[0m \x1b[01;32mpackage.json\x1b[0m \x1b[00msrc\x1b[0m \x1b[01;32mtsconfig.json\x1b[0m"; 6 + 7 + // git diff --color output (added/removed lines) 8 + export const GIT_DIFF_ADD = 9 + "\x1b[32m+export function createDisplayWidth(): Promise<(text: string) => number> {\x1b[m"; 10 + export const GIT_DIFF_REMOVE = 11 + "\x1b[31m-// TODO: implement width calculation\x1b[m"; 12 + export const GIT_DIFF_HEADER = 13 + "\x1b[1mdiff --git a/width.ts b/width.ts\x1b[m"; 14 + 15 + // gcc diagnostics with bold and color 16 + export const GCC_ERROR = 17 + "\x1b[1m\x1b[35msrc/main.c:42:5:\x1b[0m \x1b[1m\x1b[31merror:\x1b[0m \x1b[1mexpected ';' after expression\x1b[0m"; 18 + 19 + // npm output with multiple SGR parameters 20 + export const NPM_WARN = 21 + "\x1b[33m\x1b[1mnpm\x1b[22m \x1b[39m\x1b[33mWARN\x1b[39m \x1b[35mdeprecated\x1b[39m request@2.88.2: request has been deprecated"; 22 + 23 + // OSC title-set (window title) followed by visible text 24 + export const OSC_TITLE = 25 + "\x1b]0;user@host:~/project\x07$ ls -la";
+86
virtualizer/test/fraction.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(_text: string): number { 5 + return 1; 6 + } 7 + 8 + describe("C.FRACTION — scrollToFraction", () => { 9 + it("C.FRACTION.zero-scrolls-to-top", () => { 10 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 11 + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); 12 + v.scrollToFraction(0); 13 + expect(v.anchorLineIndex).toBe(v.baseIndex); 14 + expect(v.anchorSubRow).toBe(0); 15 + }); 16 + 17 + it("C.FRACTION.one-scrolls-to-bottom", () => { 18 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 19 + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); 20 + v.scrollToFraction(1); 21 + expect(v.isAtBottom).toBe(true); 22 + }); 23 + 24 + it("C.FRACTION.half-anchor-in-valid-range", () => { 25 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 26 + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); 27 + v.scrollToFraction(0.5); 28 + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); 29 + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); 30 + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); 31 + }); 32 + 33 + it("R.FRACTION.half-lands-near-middle — 100 equal-width lines, fraction 0.5 lands at line 50", () => { 34 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 35 + for (let i = 0; i < 100; i++) v.appendLine("x"); // each line width 1, 1 sub-row 36 + v.scrollToFraction(0.5); 37 + expect(v.anchorLineIndex).toBe(v.baseIndex + 50); 38 + }); 39 + 40 + it("C.FRACTION.empty-buffer-noop", () => { 41 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 42 + v.scrollToFraction(0.5); 43 + expect(v.lineCount).toBe(0); 44 + expect(v.isAtBottom).toBe(true); 45 + }); 46 + 47 + it("C.FRACTION.subrow-clamped — anchorSubRow is in valid range", () => { 48 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); 49 + // Mix of short and long lines 50 + for (let i = 0; i < 50; i++) { 51 + v.appendLine(i % 3 === 0 ? "a".repeat(25) : "short"); 52 + } 53 + v.scrollToFraction(0.7); 54 + let entry = v.getLineDisplayWidth(v.anchorLineIndex); 55 + if (entry !== undefined) { 56 + let estimate = Math.max(1, Math.ceil(entry / 10)); 57 + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); 58 + expect(v.anchorSubRow).toBeLessThan(estimate); 59 + } 60 + }); 61 + 62 + it("C.FRACTION.currentEstimate-updated — currentEstimatedVisualRow reflects walk", () => { 63 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 64 + for (let i = 0; i < 50; i++) v.appendLine("x"); 65 + v.scrollToFraction(0.5); 66 + // Manually verify: 50 lines each 1 sub-row, target = floor(0.5*50) = 25 67 + // Walk 25 lines → accumulated = 25, anchorSubRow = 0 68 + // currentEstimatedVisualRow = 25 + 0 = 25 69 + expect(v.currentEstimatedVisualRow).toBe(25); 70 + }); 71 + 72 + it("C.FRACTION.zero-after-eviction — fraction 0 goes to baseIndex after eviction", () => { 73 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); 74 + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); 75 + v.scrollToFraction(0); 76 + expect(v.anchorLineIndex).toBe(v.baseIndex); // 30, not 0 77 + }); 78 + 79 + it("C.FRACTION.mid-after-eviction — fraction 0.5 in valid range after eviction", () => { 80 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); 81 + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); 82 + v.scrollToFraction(0.5); 83 + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); 84 + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); 85 + }); 86 + });
+53
virtualizer/test/invariants.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function mockMeasureWidth(text: string): number { 5 + let w = 0; 6 + for (let i = 0; i < text.length; i++) { 7 + let cp = text.codePointAt(i)!; 8 + if (cp > 0xffff) { i++; w += 2; } 9 + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; 10 + else if (cp < 0x20) w += 0; 11 + else w += 1; 12 + } 13 + return w; 14 + } 15 + 16 + describe("C.INV1 — monotonic identity", () => { 17 + it("C.INV1.monotonic-identity — each lineIndex strictly greater than previous", () => { 18 + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24 }); 19 + let indices: number[] = []; 20 + for (let i = 0; i < 10; i++) { 21 + indices.push(v.appendLine(`line ${i}`)); 22 + } 23 + for (let i = 1; i < indices.length; i++) { 24 + expect(indices[i]).toBeGreaterThan(indices[i - 1]); 25 + } 26 + }); 27 + 28 + it("C.INV1.identity-survives-eviction — surviving lines keep their lineIndex", () => { 29 + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 3 }); 30 + v.appendLine("A"); // 0 31 + v.appendLine("B"); // 1 32 + v.appendLine("C"); // 2 33 + v.appendLine("D"); // 3 — evicts A(0) 34 + expect(v.baseIndex).toBe(1); 35 + let vp = v.resolveViewport(); 36 + let lineIndices = vp.entries.map((e) => e.lineIndex); 37 + expect(lineIndices).toContain(1); 38 + expect(lineIndices).toContain(2); 39 + expect(lineIndices).toContain(3); 40 + }); 41 + 42 + it("C.INV1.identity-never-reused — each returned index is unique even with eviction", () => { 43 + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 1 }); 44 + let indices = [ 45 + v.appendLine("A"), 46 + v.appendLine("B"), 47 + v.appendLine("C"), 48 + v.appendLine("D"), 49 + ]; 50 + expect(indices).toEqual([0, 1, 2, 3]); 51 + expect(new Set(indices).size).toBe(4); 52 + }); 53 + });
+123
virtualizer/test/real-world-ansi.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + import { skipAnsiSequence } from "../ansi-scanner.ts"; 4 + import * as fixtures from "./fixtures/real-world-ansi.ts"; 5 + 6 + function charMeasure(text: string): number { 7 + let cp = text.codePointAt(0)!; 8 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 9 + if (cp < 0x20) return 0; 10 + return 1; 11 + } 12 + 13 + function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { 14 + if (wrapPoints.length === 0) return [text]; 15 + let slices: string[] = []; 16 + let prev = 0; 17 + for (let wp of wrapPoints) { 18 + slices.push(text.slice(prev, wp)); 19 + prev = wp; 20 + } 21 + slices.push(text.slice(prev)); 22 + return slices; 23 + } 24 + 25 + function stripAnsi(text: string): string { 26 + let result = ""; 27 + let i = 0; 28 + while (i < text.length) { 29 + let skip = skipAnsiSequence(text, i); 30 + if (skip > 0) { i += skip; continue; } 31 + result += text[i]; 32 + i++; 33 + } 34 + return result; 35 + } 36 + 37 + function visibleWidth(text: string): number { 38 + let stripped = stripAnsi(text); 39 + let w = 0; 40 + for (let i = 0; i < stripped.length; i++) { 41 + let cp = stripped.codePointAt(i)!; 42 + if (cp > 0xffff) { i++; w += 2; } 43 + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; 44 + else if (cp < 0x20) w += 0; 45 + else w += 1; 46 + } 47 + return w; 48 + } 49 + 50 + function assertAllOutputInvariants( 51 + v: Virtualizer, 52 + columns: number, 53 + ) { 54 + let vp = v.resolveViewport(); 55 + 56 + // O-1: entries ordered 57 + for (let i = 1; i < vp.entries.length; i++) { 58 + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); 59 + } 60 + 61 + for (let entry of vp.entries) { 62 + // O-3: wrapPoints monotonic 63 + for (let j = 1; j < entry.wrapPoints.length; j++) { 64 + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); 65 + } 66 + for (let wp of entry.wrapPoints) { 67 + expect(wp).toBeGreaterThan(0); 68 + expect(wp).toBeLessThan(entry.text.length); 69 + 70 + // O-4: not in surrogate 71 + let prev = entry.text.charCodeAt(wp - 1); 72 + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); 73 + 74 + // O-5: not in escape 75 + let i = 0; 76 + while (i < entry.text.length) { 77 + let skip = skipAnsiSequence(entry.text, i); 78 + if (skip > 0) { 79 + expect(wp <= i || wp >= i + skip).toBe(true); 80 + i += skip; 81 + } else { 82 + i++; 83 + } 84 + } 85 + } 86 + 87 + // O-6: totalSubRows = wrapPoints.length + 1 88 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); 89 + 90 + // O-7: subrow bounds 91 + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); 92 + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); 93 + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); 94 + 95 + // O-9: sliced width within columns 96 + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); 97 + for (let slice of slices) { 98 + expect(visibleWidth(slice)).toBeLessThanOrEqual(columns); 99 + } 100 + } 101 + 102 + // O-8: visible rows within budget 103 + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); 104 + expect(totalVisible).toBeLessThanOrEqual(v.rows); 105 + } 106 + 107 + let allFixtures = Object.entries(fixtures); 108 + 109 + describe("Real-world ANSI fixtures", () => { 110 + for (let [name, text] of allFixtures) { 111 + it(`${name} at columns 80`, () => { 112 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 113 + v.appendLine(text); 114 + assertAllOutputInvariants(v, 80); 115 + }); 116 + 117 + it(`${name} at columns 40`, () => { 118 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); 119 + v.appendLine(text); 120 + assertAllOutputInvariants(v, 40); 121 + }); 122 + } 123 + });
+118
virtualizer/test/scroll.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(text: string): number { 5 + let cp = text.codePointAt(0)!; 6 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 7 + if (cp < 0x20) return 0; 8 + return 1; 9 + } 10 + 11 + describe("C.SCROLL — scrollBy", () => { 12 + it("C.SCROLL.down-one-row — scrolling down shifts viewport", () => { 13 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); 14 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); 15 + // After 5 appends with bottom-follow, anchor at line 4. 16 + // scrollBy(-4) to move anchor to line 0. 17 + v.scrollBy(-4); 18 + expect(v.anchorLineIndex).toBe(0); 19 + let vp1 = v.resolveViewport(); 20 + expect(vp1.entries.map((e) => e.lineIndex)).toEqual([0, 1, 2]); 21 + 22 + v.scrollBy(1); 23 + let vp2 = v.resolveViewport(); 24 + expect(vp2.entries.map((e) => e.lineIndex)).toEqual([1, 2, 3]); 25 + }); 26 + 27 + it("C.SCROLL.up-past-top-clamps — scrolling past top clamps to baseIndex", () => { 28 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 29 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); 30 + v.scrollBy(-100); 31 + expect(v.anchorLineIndex).toBe(v.baseIndex); 32 + expect(v.anchorSubRow).toBe(0); 33 + }); 34 + 35 + it("C.SCROLL.down-past-bottom-clamps-and-sets-isAtBottom", () => { 36 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 37 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); 38 + // anchor at 4 (bottom-follow), isAtBottom=true 39 + v.scrollBy(-3); // anchor at line 1 40 + expect(v.isAtBottom).toBe(false); 41 + v.scrollBy(100); 42 + expect(v.isAtBottom).toBe(true); 43 + }); 44 + 45 + it("C.SCROLL.up-clears-isAtBottom — scrolling up clears isAtBottom", () => { 46 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 47 + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); 48 + expect(v.isAtBottom).toBe(true); 49 + v.scrollBy(-1); 50 + expect(v.isAtBottom).toBe(false); 51 + }); 52 + 53 + it("C.SCROLL.empty-buffer-noop — scrollBy on empty buffer is a no-op", () => { 54 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 55 + v.scrollBy(5); 56 + expect(v.isAtBottom).toBe(true); 57 + expect(v.lineCount).toBe(0); 58 + }); 59 + 60 + it("C.SCROLL.wrapping-lines-counted — sub-rows are counted when scrolling", () => { 61 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); 62 + v.appendLine("abcdefghijklmnopqrstuvwxy"); // 25 chars, 3 sub-rows (index 0) 63 + v.appendLine("hello"); // 5 chars, 1 sub-row (index 1) 64 + // After append, anchor at line 1 (bottom-follow). 65 + // scrollBy(-3) to go back 3 visual rows: 66 + // 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 67 + v.scrollBy(-3); 68 + expect(v.anchorLineIndex).toBe(0); 69 + expect(v.anchorSubRow).toBe(0); 70 + 71 + // scrollBy(2) → sub-row 0 → 1 → 2 72 + v.scrollBy(2); 73 + expect(v.anchorLineIndex).toBe(0); 74 + expect(v.anchorSubRow).toBe(2); 75 + }); 76 + 77 + it("C.SCROLL.currentEstimate-nondecreasing-on-scroll-down", () => { 78 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 79 + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); 80 + v.scrollBy(-10); 81 + let prev = v.currentEstimatedVisualRow; 82 + v.scrollBy(1); 83 + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); 84 + }); 85 + 86 + it("C.SCROLL.nonincreasing-on-scroll-up", () => { 87 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 88 + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); 89 + v.scrollBy(-10); 90 + let prev = v.currentEstimatedVisualRow; 91 + v.scrollBy(-1); 92 + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); 93 + }); 94 + 95 + it("C.SCROLL.monotonicity-under-exact-estimate-mismatch — wide chars maintain monotonicity", () => { 96 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); 97 + for (let i = 0; i < 20; i++) { 98 + v.appendLine("文字abc文d"); // mixed wide/narrow 99 + } 100 + v.scrollBy(-50); // scroll near top 101 + 102 + // Forward: each step >= previous 103 + let prev = v.currentEstimatedVisualRow; 104 + for (let i = 0; i < 10; i++) { 105 + v.scrollBy(1); 106 + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); 107 + prev = v.currentEstimatedVisualRow; 108 + } 109 + 110 + // Backward: each step <= previous 111 + prev = v.currentEstimatedVisualRow; 112 + for (let i = 0; i < 10; i++) { 113 + v.scrollBy(-1); 114 + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); 115 + prev = v.currentEstimatedVisualRow; 116 + } 117 + }); 118 + });
+2
virtualizer/test/suite.ts
··· 1 + export { beforeEach, describe, it } from "@std/testing/bdd"; 2 + export { expect } from "@std/expect";
+202
virtualizer/test/viewport.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + import { skipAnsiSequence } from "../ansi-scanner.ts"; 4 + 5 + function charMeasure(text: string): number { 6 + let cp = text.codePointAt(0)!; 7 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 8 + if (cp < 0x20) return 0; 9 + return 1; 10 + } 11 + 12 + function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { 13 + if (wrapPoints.length === 0) return [text]; 14 + let slices: string[] = []; 15 + let prev = 0; 16 + for (let wp of wrapPoints) { 17 + slices.push(text.slice(prev, wp)); 18 + prev = wp; 19 + } 20 + slices.push(text.slice(prev)); 21 + return slices; 22 + } 23 + 24 + function stripAnsi(text: string): string { 25 + let result = ""; 26 + let i = 0; 27 + while (i < text.length) { 28 + let skip = skipAnsiSequence(text, i); 29 + if (skip > 0) { 30 + i += skip; 31 + continue; 32 + } 33 + result += text[i]; 34 + i++; 35 + } 36 + return result; 37 + } 38 + 39 + function visibleWidth(text: string, measureWidth: (t: string) => number): number { 40 + let stripped = stripAnsi(text); 41 + let w = 0; 42 + for (let i = 0; i < stripped.length; i++) { 43 + let cp = stripped.codePointAt(i)!; 44 + let charLen = cp > 0xffff ? 2 : 1; 45 + w += measureWidth(String.fromCodePoint(cp)); 46 + if (charLen === 2) i++; 47 + } 48 + return w; 49 + } 50 + 51 + describe("C.VIEWPORT — resolveViewport output invariants", () => { 52 + function makeVirtualizer(columns = 20, rows = 5) { 53 + return new Virtualizer({ measureWidth: charMeasure, columns, rows }); 54 + } 55 + 56 + it("C.VIEWPORT.entries-ordered — entries have strictly increasing lineIndex", () => { 57 + let v = makeVirtualizer(); 58 + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); 59 + let vp = v.resolveViewport(); 60 + for (let i = 1; i < vp.entries.length; i++) { 61 + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); 62 + } 63 + }); 64 + 65 + it("C.VIEWPORT.text-is-original — entry text is the original string", () => { 66 + let v = makeVirtualizer(); 67 + let original = "hello world"; 68 + v.appendLine(original); 69 + let vp = v.resolveViewport(); 70 + expect(vp.entries[0].text).toBe(original); 71 + }); 72 + 73 + it("C.VIEWPORT.wrapPoints-monotonic — wrapPoints are strictly increasing in (0, text.length)", () => { 74 + let v = makeVirtualizer(5); 75 + v.appendLine("abcdefghijklmno"); // 15 chars, wraps at 5, 10 76 + let vp = v.resolveViewport(); 77 + let wp = vp.entries[0].wrapPoints; 78 + for (let i = 0; i < wp.length; i++) { 79 + expect(wp[i]).toBeGreaterThan(0); 80 + expect(wp[i]).toBeLessThan(15); 81 + if (i > 0) expect(wp[i]).toBeGreaterThan(wp[i - 1]); 82 + } 83 + }); 84 + 85 + it("C.VIEWPORT.wrapPoints-not-in-surrogate — wrapPoints don't split surrogate pairs", () => { 86 + let v = makeVirtualizer(3); 87 + // Emoji are surrogate pairs in UTF-16: each is 2 code units 88 + v.appendLine("a😀b😀c"); 89 + let vp = v.resolveViewport(); 90 + let text = vp.entries[0].text; 91 + for (let wp of vp.entries[0].wrapPoints) { 92 + if (wp > 0) { 93 + let prev = text.charCodeAt(wp - 1); 94 + // prev should not be a high surrogate (0xD800–0xDBFF) 95 + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); 96 + } 97 + } 98 + }); 99 + 100 + it("C.VIEWPORT.wrapPoints-not-in-escape — wrapPoints don't fall inside ANSI sequences", () => { 101 + let v = makeVirtualizer(5); 102 + v.appendLine("abcde\x1b[31mfghij\x1b[0mklmno"); 103 + let vp = v.resolveViewport(); 104 + let text = vp.entries[0].text; 105 + for (let wp of vp.entries[0].wrapPoints) { 106 + // Check that wp is not inside an ANSI sequence 107 + let i = 0; 108 + while (i < text.length) { 109 + let skip = skipAnsiSequence(text, i); 110 + if (skip > 0) { 111 + // wp should not be in (i, i+skip) 112 + expect(wp <= i || wp >= i + skip).toBe(true); 113 + i += skip; 114 + } else { 115 + i++; 116 + } 117 + } 118 + } 119 + }); 120 + 121 + it("C.VIEWPORT.totalSubRows-equals-wrapPoints-plus-one", () => { 122 + let v = makeVirtualizer(5); 123 + v.appendLine("abcdefghij"); // wraps once at 5 → 2 sub-rows 124 + let vp = v.resolveViewport(); 125 + for (let entry of vp.entries) { 126 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); 127 + } 128 + }); 129 + 130 + it("C.VIEWPORT.subrow-bounds — firstSubRow and visibleSubRows are valid", () => { 131 + let v = makeVirtualizer(5); 132 + v.appendLine("abcdefghijklmno"); 133 + let vp = v.resolveViewport(); 134 + for (let entry of vp.entries) { 135 + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); 136 + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); 137 + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); 138 + } 139 + }); 140 + 141 + it("C.VIEWPORT.visible-rows-within-budget — total visible sub-rows ≤ rows", () => { 142 + let v = makeVirtualizer(10, 5); 143 + for (let i = 0; i < 20; i++) v.appendLine(`line ${i} with some text`); 144 + let vp = v.resolveViewport(); 145 + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); 146 + expect(totalVisible).toBeLessThanOrEqual(5); 147 + }); 148 + 149 + it("C.VIEWPORT.sliced-width-within-columns — each sub-row fits within columns", () => { 150 + let v = makeVirtualizer(10, 24); 151 + v.appendLine("abcdefghijklmnopqrstuvwxyz"); 152 + let vp = v.resolveViewport(); 153 + for (let entry of vp.entries) { 154 + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); 155 + for (let slice of slices) { 156 + expect(visibleWidth(slice, charMeasure)).toBeLessThanOrEqual(10); 157 + } 158 + } 159 + }); 160 + 161 + it("C.VIEWPORT.self-contained — viewport can be reconstructed from text + wrapPoints alone", () => { 162 + let v = makeVirtualizer(10, 24); 163 + v.appendLine("hello world, this is a long line of text"); 164 + let vp = v.resolveViewport(); 165 + for (let entry of vp.entries) { 166 + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); 167 + expect(slices.length).toBe(entry.totalSubRows); 168 + } 169 + }); 170 + 171 + it("C.VIEWPORT.estimation-fields-present — all estimation fields defined", () => { 172 + let v = makeVirtualizer(); 173 + v.appendLine("test"); 174 + let vp = v.resolveViewport(); 175 + expect(vp.totalEstimatedVisualRows).not.toBe(undefined); 176 + expect(vp.currentEstimatedVisualRow).not.toBe(undefined); 177 + expect(vp.isAtBottom).not.toBe(undefined); 178 + }); 179 + 180 + it("C.VIEWPORT.estimation-inequality — currentEstimatedVisualRow in valid range", () => { 181 + let v = makeVirtualizer(); 182 + v.appendLine("test"); 183 + let vp = v.resolveViewport(); 184 + expect(vp.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); 185 + expect(vp.currentEstimatedVisualRow).toBeLessThan(vp.totalEstimatedVisualRows); 186 + 187 + // Empty case 188 + let v2 = makeVirtualizer(); 189 + let vp2 = v2.resolveViewport(); 190 + expect(vp2.totalEstimatedVisualRows).toBe(0); 191 + expect(vp2.currentEstimatedVisualRow).toBe(0); 192 + }); 193 + 194 + it("C.VIEWPORT.empty-buffer — empty resolves to empty entries", () => { 195 + let v = makeVirtualizer(); 196 + let vp = v.resolveViewport(); 197 + expect(vp.entries.length).toBe(0); 198 + expect(vp.totalEstimatedVisualRows).toBe(0); 199 + expect(vp.currentEstimatedVisualRow).toBe(0); 200 + expect(vp.isAtBottom).toBe(true); 201 + }); 202 + });
+62
virtualizer/test/width.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + describe("C.WIDTH — width usage", () => { 5 + it("C.WIDTH.ansi-stripped-before-measure — measureWidth never sees ANSI bytes", () => { 6 + let recorded: string[] = []; 7 + let recordingMeasure = (text: string): number => { 8 + recorded.push(text); 9 + return 1; 10 + }; 11 + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); 12 + v.appendLine("\x1b[31mhello\x1b[0m"); 13 + for (let arg of recorded) { 14 + expect(arg).not.toContain("\x1b"); 15 + expect(arg).not.toContain("["); 16 + expect(arg).not.toContain("3"); 17 + expect(arg).not.toContain("1"); 18 + expect(arg).not.toContain("m"); 19 + } 20 + // Only visible chars passed 21 + let joined = recorded.join(""); 22 + expect(joined).toBe("hello"); 23 + }); 24 + 25 + it("C.WIDTH.displayWidth-matches-visible-content — cached width reflects visible chars only", () => { 26 + let measureWidth = (_text: string): number => 1; 27 + let v = new Virtualizer({ measureWidth, columns: 80, rows: 24 }); 28 + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); 29 + expect(v.getLineDisplayWidth(idx)).toBe(5); 30 + }); 31 + 32 + it("C.WIDTH.additivity-in-wrapping — each sub-row fits within columns", () => { 33 + let measureWidth = (text: string): number => { 34 + let cp = text.codePointAt(0)!; 35 + return cp >= 0x4e00 && cp <= 0x9fff ? 2 : 1; 36 + }; 37 + let v = new Virtualizer({ measureWidth, columns: 10, rows: 24 }); 38 + v.appendLine("abcdefghijklmnopqrstuvwxyz"); 39 + let vp = v.resolveViewport(); 40 + let entry = vp.entries[0]; 41 + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); 42 + for (let slice of slices) { 43 + let w = 0; 44 + for (let i = 0; i < slice.length; i++) { 45 + w += measureWidth(slice[i]); 46 + } 47 + expect(w).toBeLessThanOrEqual(10); 48 + } 49 + }); 50 + }); 51 + 52 + function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { 53 + if (wrapPoints.length === 0) return [text]; 54 + let slices: string[] = []; 55 + let prev = 0; 56 + for (let wp of wrapPoints) { 57 + slices.push(text.slice(prev, wp)); 58 + prev = wp; 59 + } 60 + slices.push(text.slice(prev)); 61 + return slices; 62 + }
+84
virtualizer/test/wrap-golden.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import { Virtualizer } from "../mod.ts"; 3 + 4 + function charMeasure(text: string): number { 5 + let cp = text.codePointAt(0)!; 6 + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; 7 + if (cp < 0x20) return 0; 8 + return 1; 9 + } 10 + 11 + describe("G.WRAP — wrapping golden fixtures", () => { 12 + function resolve(text: string, columns: number) { 13 + let v = new Virtualizer({ measureWidth: charMeasure, columns, rows: 24 }); 14 + v.appendLine(text); 15 + let vp = v.resolveViewport(); 16 + return vp.entries[0]; 17 + } 18 + 19 + it("G.WRAP.ascii-exact-fit — 10 ASCII chars at columns 10", () => { 20 + let entry = resolve("abcdefghij", 10); 21 + expect(entry.wrapPoints).toEqual([]); 22 + expect(entry.totalSubRows).toBe(1); 23 + }); 24 + 25 + it("G.WRAP.ascii-one-over — 11 ASCII chars at columns 10", () => { 26 + let entry = resolve("abcdefghijk", 10); 27 + expect(entry.wrapPoints).toEqual([10]); 28 + expect(entry.totalSubRows).toBe(2); 29 + }); 30 + 31 + it("G.WRAP.cjk-boundary — wide char forced to next row when only 1 column left", () => { 32 + // "abc文d" — widths 1,1,1,2,1 = 6 total 33 + // At columns 5: abc fills 3, 文 needs 2, 3+2=5 fits. So no wrap. 34 + // Wait — re-read plan: wrap before 文 at index 3 → [3] 35 + // Let me check: columns=5, "abc" = 3 cols, "文" = 2 cols, 3+2=5 ≤ 5, so it fits. 36 + // Plan says wrapPoint at [3]. But 3+2=5 is exact fit. Let me re-check the plan... 37 + // Plan says: "abc文d" (widths 1,1,1,2,1=6) columns 5, wrap before 文 at string index 3 → [3] 38 + // But with columns=5, "abc" uses 3, then "文" needs 2, and 3+2=5 ≤ 5. It fits! 39 + // The total is 6 (including 'd'), so 'd' at width 6 > 5 causes wrap. 40 + // So: "abc文" = 5 cols in row 1, "d" = 1 col in row 2 41 + // The wrap happens before 'd'. 文 is at string index 3 (1 char), d is at index 4. 42 + // So wrapPoint = [4], not [3]. 43 + // 44 + // But the plan explicitly states [3]. Let me re-read: 45 + // "abc文d" (widths 1,1,1,2,1=6), columns 5 46 + // Plan says: "wrap before 文 at string index 3 → [3]" 47 + // This would mean: row 1 = "abc" (3 cols), row 2 = "文d" (3 cols) 48 + // But 3 + 2 = 5 ≤ 5 — 文 fits on row 1! 49 + // Unless the plan intended columns=4? 50 + // 51 + // The test fixture is from the plan, so I'll test what the algorithm actually does 52 + // and verify it's correct behavior. With columns=5, "abc文" fits (width 5). 53 + // The wrap happens before "d" wouldn't happen either since 5+1=6 > 5, wrap before d at index 4. 54 + // 55 + // Actually wait: the wrap point is where we'd exceed columns. 56 + // a(1) b(2) c(3) 文(5) — fits exactly. d would be col 6 > 5 → wrap before d. 57 + // d is at string index 4 (文 is one char at index 3). So wrapPoint = [4]. 58 + // 59 + // The plan example seems wrong about columns=5 producing [3]. 60 + // With columns=4: a(1) b(2) c(3) 文 needs 2, 3+2=5>4 → wrap before 文 at index 3. [3]. ✓ 61 + // I'll test with columns=4 to match the plan's expected output. 62 + let entry = resolve("abc文d", 4); 63 + expect(entry.wrapPoints).toEqual([3]); 64 + expect(entry.totalSubRows).toBe(2); 65 + }); 66 + 67 + it("G.WRAP.cjk-exact-fit — CJK chars fit exactly", () => { 68 + let entry = resolve("文字", 4); // width 4 69 + expect(entry.wrapPoints).toEqual([]); 70 + expect(entry.totalSubRows).toBe(1); 71 + }); 72 + 73 + it("G.WRAP.cjk-exact-fit-at-boundary — mixed content exactly fills columns", () => { 74 + let entry = resolve("abc文", 5); // widths 1+1+1+2=5 75 + expect(entry.wrapPoints).toEqual([]); 76 + expect(entry.totalSubRows).toBe(1); 77 + }); 78 + 79 + it("G.WRAP.empty-line — empty string produces no wrap", () => { 80 + let entry = resolve("", 80); 81 + expect(entry.wrapPoints).toEqual([]); 82 + expect(entry.totalSubRows).toBe(1); 83 + }); 84 + });
+22
virtualizer/types.ts
··· 1 + export interface VirtualizerOptions { 2 + measureWidth: (text: string) => number; 3 + maxLines?: number; 4 + columns: number; 5 + rows: number; 6 + } 7 + 8 + export interface ViewportEntry { 9 + lineIndex: number; 10 + text: string; 11 + wrapPoints: number[]; 12 + totalSubRows: number; 13 + firstSubRow: number; 14 + visibleSubRows: number; 15 + } 16 + 17 + export interface ResolvedViewport { 18 + entries: ViewportEntry[]; 19 + totalEstimatedVisualRows: number; 20 + currentEstimatedVisualRow: number; 21 + isAtBottom: boolean; 22 + }
+388
virtualizer/virtualizer.ts
··· 1 + import { RingBuffer } from "./ring-buffer.ts"; 2 + import { computeDisplayWidth, computeWrapPoints } from "./wrap-walker.ts"; 3 + import type { 4 + ResolvedViewport, 5 + ViewportEntry, 6 + VirtualizerOptions, 7 + } from "./types.ts"; 8 + 9 + export class Virtualizer { 10 + private _ringBuffer: RingBuffer; 11 + private _wrapCache: Map<number, number[]>; 12 + private _measureWidth: (text: string) => number; 13 + private _columns: number; 14 + private _rows: number; 15 + private _anchorLineIndex: number; 16 + private _anchorSubRow: number; 17 + private _isAtBottom: boolean; 18 + private _totalEstimatedVisualRows: number; 19 + private _currentEstimatedVisualRow: number; 20 + 21 + constructor(options: VirtualizerOptions) { 22 + let maxLines = options.maxLines ?? 10_000; 23 + this._ringBuffer = new RingBuffer(maxLines); 24 + this._wrapCache = new Map(); 25 + this._measureWidth = options.measureWidth; 26 + this._columns = options.columns; 27 + this._rows = options.rows; 28 + this._anchorLineIndex = 0; 29 + this._anchorSubRow = 0; 30 + this._isAtBottom = true; 31 + this._totalEstimatedVisualRows = 0; 32 + this._currentEstimatedVisualRow = 0; 33 + } 34 + 35 + // Read-only observable state 36 + get lineCount(): number { 37 + return this._ringBuffer.lineCount; 38 + } 39 + 40 + get baseIndex(): number { 41 + return this._ringBuffer.baseIndex; 42 + } 43 + 44 + get columns(): number { 45 + return this._columns; 46 + } 47 + 48 + get rows(): number { 49 + return this._rows; 50 + } 51 + 52 + get totalEstimatedVisualRows(): number { 53 + return this._totalEstimatedVisualRows; 54 + } 55 + 56 + get currentEstimatedVisualRow(): number { 57 + return this._currentEstimatedVisualRow; 58 + } 59 + 60 + get isAtBottom(): boolean { 61 + return this._isAtBottom; 62 + } 63 + 64 + get anchorLineIndex(): number { 65 + return this._anchorLineIndex; 66 + } 67 + 68 + get anchorSubRow(): number { 69 + return this._anchorSubRow; 70 + } 71 + 72 + private _estimateVisualRows(displayWidth: number): number { 73 + return Math.max(1, Math.ceil(displayWidth / this._columns)); 74 + } 75 + 76 + /** 77 + * appendLine(text) — §10.2, 6-step control flow: 78 + * 79 + * 1. Compute displayWidth (always) 80 + * 2. If at capacity: evict oldest line 81 + * 3. Store the line in ring buffer (always) 82 + * 4. Increment totalEstimatedVisualRows (always) 83 + * 5. If isAtBottom: advance anchor (always) 84 + * 6. Return lineIndex (always) 85 + */ 86 + appendLine(text: string): number { 87 + // Step 1: Compute displayWidth 88 + let displayWidth = computeDisplayWidth(text, this._measureWidth); 89 + 90 + // Step 2: If at capacity, evict before insertion 91 + if ( 92 + this._ringBuffer.lineCount === this._ringBuffer.capacity 93 + ) { 94 + let evictedLineIndex = this._ringBuffer.baseIndex; 95 + let evictedEntry = this._ringBuffer.get(evictedLineIndex)!; 96 + let evictedEstimate = this._estimateVisualRows(evictedEntry.displayWidth); 97 + 98 + // Remove evicted line's wrap cache entry (§11.3 step 4) 99 + this._wrapCache.delete(evictedLineIndex); 100 + 101 + // Decrement total by evicted line's estimate 102 + this._totalEstimatedVisualRows -= evictedEstimate; 103 + 104 + // Handle anchor per §11.4 105 + if (this._anchorLineIndex > evictedLineIndex) { 106 + this._currentEstimatedVisualRow -= evictedEstimate; 107 + } else if (this._anchorLineIndex === evictedLineIndex) { 108 + if (this._ringBuffer.lineCount > 1) { 109 + // Clamp anchor to next line 110 + this._anchorLineIndex = evictedLineIndex + 1; 111 + this._anchorSubRow = 0; 112 + this._currentEstimatedVisualRow = 0; 113 + } 114 + // If lineCount === 1 (maxLines=1): transient empty, resolved by step 3 115 + } 116 + } 117 + 118 + // Step 3: Store the line 119 + let result = this._ringBuffer.append(text, displayWidth); 120 + let newLineIndex = result.lineIndex; 121 + 122 + // Step 4: Increment totalEstimatedVisualRows 123 + let newEstimate = this._estimateVisualRows(displayWidth); 124 + this._totalEstimatedVisualRows += newEstimate; 125 + 126 + // Step 5: If isAtBottom, advance anchor 127 + if (this._isAtBottom) { 128 + this._anchorLineIndex = newLineIndex; 129 + this._anchorSubRow = 0; 130 + this._currentEstimatedVisualRow = 131 + this._totalEstimatedVisualRows - newEstimate; 132 + } 133 + 134 + // Step 6: Return lineIndex 135 + return newLineIndex; 136 + } 137 + 138 + resize(_columns: number, _rows: number): void { 139 + throw new Error("not implemented"); 140 + } 141 + 142 + /** 143 + * scrollBy(deltaVisualRows) — §10.4 144 + * 145 + * Walk forward (positive) or backward (negative) from anchor through 146 + * exact sub-rows. Clamp at buffer boundaries. Update isAtBottom and 147 + * currentEstimatedVisualRow. 148 + */ 149 + scrollBy(deltaVisualRows: number): void { 150 + if (this._ringBuffer.lineCount === 0) return; 151 + 152 + let remaining = deltaVisualRows; 153 + 154 + if (remaining > 0) { 155 + // Scroll down (forward) 156 + while (remaining > 0) { 157 + let entry = this._ringBuffer.get(this._anchorLineIndex); 158 + if (!entry) break; 159 + 160 + let wrapPoints = this._getWrapPoints(this._anchorLineIndex, entry.text); 161 + let totalSubRows = wrapPoints.length + 1; 162 + let availableInLine = totalSubRows - this._anchorSubRow - 1; 163 + 164 + if (remaining <= availableInLine) { 165 + this._anchorSubRow += remaining; 166 + remaining = 0; 167 + } else { 168 + // Move to next line 169 + let nextEntry = this._ringBuffer.get(this._anchorLineIndex + 1); 170 + if (!nextEntry) { 171 + // Clamp at bottom of current line 172 + this._anchorSubRow = totalSubRows - 1; 173 + remaining = 0; 174 + } else { 175 + remaining -= availableInLine + 1; 176 + this._anchorLineIndex++; 177 + this._anchorSubRow = 0; 178 + } 179 + } 180 + } 181 + 182 + // Check if we're at the very bottom 183 + let lastLineIndex = this._ringBuffer.baseIndex + this._ringBuffer.lineCount - 1; 184 + if (this._anchorLineIndex === lastLineIndex) { 185 + let lastEntry = this._ringBuffer.get(lastLineIndex)!; 186 + let lastWrap = this._getWrapPoints(lastLineIndex, lastEntry.text); 187 + let lastTotalSubRows = lastWrap.length + 1; 188 + if (this._anchorSubRow === lastTotalSubRows - 1) { 189 + this._isAtBottom = true; 190 + } 191 + } 192 + } else if (remaining < 0) { 193 + // Scroll up (backward) 194 + if (this._isAtBottom) { 195 + this._isAtBottom = false; 196 + } 197 + 198 + remaining = -remaining; // work with positive count 199 + 200 + while (remaining > 0) { 201 + if (this._anchorSubRow >= remaining) { 202 + this._anchorSubRow -= remaining; 203 + remaining = 0; 204 + } else { 205 + remaining -= this._anchorSubRow; 206 + // Move to previous line 207 + let prevEntry = this._ringBuffer.get(this._anchorLineIndex - 1); 208 + if (!prevEntry) { 209 + // Clamp at top 210 + this._anchorSubRow = 0; 211 + remaining = 0; 212 + } else { 213 + this._anchorLineIndex--; 214 + let prevWrap = this._getWrapPoints(this._anchorLineIndex, prevEntry.text); 215 + let prevTotalSubRows = prevWrap.length + 1; 216 + this._anchorSubRow = prevTotalSubRows - 1; 217 + remaining -= 1; // consumed one row entering this line's last sub-row 218 + } 219 + } 220 + } 221 + } 222 + 223 + // Update currentEstimatedVisualRow: sum estimates for all lines before anchor + anchorSubRow 224 + this._recomputeCurrentEstimate(); 225 + } 226 + 227 + /** 228 + * scrollToFraction(fraction) — §10.5 229 + * 230 + * Map fraction to an estimated visual row, then walk from baseIndex 231 + * to find the corresponding anchor position. 232 + */ 233 + scrollToFraction(fraction: number): void { 234 + if (this._ringBuffer.lineCount === 0) return; 235 + 236 + let target = Math.min( 237 + Math.floor(fraction * this._totalEstimatedVisualRows), 238 + Math.max(this._totalEstimatedVisualRows - 1, 0), 239 + ); 240 + 241 + let accumulated = 0; 242 + let baseIndex = this._ringBuffer.baseIndex; 243 + let lastLineIndex = baseIndex + this._ringBuffer.lineCount - 1; 244 + 245 + for (let i = baseIndex; i <= lastLineIndex; i++) { 246 + let entry = this._ringBuffer.get(i)!; 247 + let estimate = this._estimateVisualRows(entry.displayWidth); 248 + 249 + if (accumulated + estimate > target) { 250 + this._anchorLineIndex = i; 251 + this._anchorSubRow = Math.min(target - accumulated, estimate - 1); 252 + break; 253 + } 254 + 255 + accumulated += estimate; 256 + 257 + if (i === lastLineIndex) { 258 + // fraction >= 1 or rounding landed past end 259 + this._anchorLineIndex = i; 260 + this._anchorSubRow = estimate - 1; 261 + } 262 + } 263 + 264 + // Determine isAtBottom 265 + let lastEntry = this._ringBuffer.get(lastLineIndex)!; 266 + let lastEstimate = this._estimateVisualRows(lastEntry.displayWidth); 267 + this._isAtBottom = 268 + this._anchorLineIndex === lastLineIndex && 269 + this._anchorSubRow === lastEstimate - 1; 270 + 271 + this._recomputeCurrentEstimate(); 272 + } 273 + 274 + private _recomputeCurrentEstimate(): void { 275 + let estimate = 0; 276 + let baseIndex = this._ringBuffer.baseIndex; 277 + for (let i = baseIndex; i < this._anchorLineIndex; i++) { 278 + let entry = this._ringBuffer.get(i); 279 + if (!entry) break; 280 + estimate += this._estimateVisualRows(entry.displayWidth); 281 + } 282 + estimate += this._anchorSubRow; 283 + this._currentEstimatedVisualRow = estimate; 284 + } 285 + 286 + getLineDisplayWidth(lineIndex: number): number | undefined { 287 + return this._ringBuffer.get(lineIndex)?.displayWidth; 288 + } 289 + 290 + private _getWrapPoints(lineIndex: number, text: string): number[] { 291 + let cached = this._wrapCache.get(lineIndex); 292 + if (cached !== undefined) return cached; 293 + let wp = computeWrapPoints(text, this._columns, this._measureWidth); 294 + this._wrapCache.set(lineIndex, wp); 295 + return wp; 296 + } 297 + 298 + resolveViewport(): ResolvedViewport { 299 + if (this._ringBuffer.lineCount === 0) { 300 + return { 301 + entries: [], 302 + totalEstimatedVisualRows: 0, 303 + currentEstimatedVisualRow: 0, 304 + isAtBottom: true, 305 + }; 306 + } 307 + 308 + let forwardEntries: ViewportEntry[] = []; 309 + let rowsBudget = this._rows; 310 + let currentLineIndex = this._anchorLineIndex; 311 + let startSubRow = this._anchorSubRow; 312 + 313 + // Walk forward from anchor, filling viewport rows 314 + while (rowsBudget > 0) { 315 + let entry = this._ringBuffer.get(currentLineIndex); 316 + if (!entry) break; 317 + 318 + let wrapPoints = this._getWrapPoints(currentLineIndex, entry.text); 319 + let totalSubRows = wrapPoints.length + 1; 320 + 321 + let firstSubRow = startSubRow; 322 + let availableSubRows = totalSubRows - firstSubRow; 323 + let visibleSubRows = Math.min(availableSubRows, rowsBudget); 324 + 325 + forwardEntries.push({ 326 + lineIndex: currentLineIndex, 327 + text: entry.text, 328 + wrapPoints, 329 + totalSubRows, 330 + firstSubRow, 331 + visibleSubRows, 332 + }); 333 + 334 + rowsBudget -= visibleSubRows; 335 + currentLineIndex++; 336 + startSubRow = 0; 337 + } 338 + 339 + // Backfill: if forward walk didn't fill the viewport, walk backward 340 + let backEntries: ViewportEntry[] = []; 341 + if (rowsBudget > 0) { 342 + // First, expand anchor line's visible sub-rows upward if possible 343 + if (this._anchorSubRow > 0 && forwardEntries.length > 0) { 344 + let anchor = forwardEntries[0]; 345 + let fillAbove = Math.min(this._anchorSubRow, rowsBudget); 346 + forwardEntries[0] = { 347 + ...anchor, 348 + firstSubRow: anchor.firstSubRow - fillAbove, 349 + visibleSubRows: anchor.visibleSubRows + fillAbove, 350 + }; 351 + rowsBudget -= fillAbove; 352 + } 353 + 354 + // Walk backward through earlier lines 355 + let backLineIndex = this._anchorLineIndex - 1; 356 + while (rowsBudget > 0 && backLineIndex >= this._ringBuffer.baseIndex) { 357 + let entry = this._ringBuffer.get(backLineIndex); 358 + if (!entry) break; 359 + 360 + let wrapPoints = this._getWrapPoints(backLineIndex, entry.text); 361 + let totalSubRows = wrapPoints.length + 1; 362 + let visibleSubRows = Math.min(totalSubRows, rowsBudget); 363 + let firstSubRow = totalSubRows - visibleSubRows; 364 + 365 + backEntries.push({ 366 + lineIndex: backLineIndex, 367 + text: entry.text, 368 + wrapPoints, 369 + totalSubRows, 370 + firstSubRow, 371 + visibleSubRows, 372 + }); 373 + 374 + rowsBudget -= visibleSubRows; 375 + backLineIndex--; 376 + } 377 + } 378 + 379 + let entries = [...backEntries.reverse(), ...forwardEntries]; 380 + 381 + return { 382 + entries, 383 + totalEstimatedVisualRows: this._totalEstimatedVisualRows, 384 + currentEstimatedVisualRow: this._currentEstimatedVisualRow, 385 + isAtBottom: this._isAtBottom, 386 + }; 387 + } 388 + }
+66
virtualizer/wrap-walker.ts
··· 1 + import { skipAnsiSequence } from "./ansi-scanner.ts"; 2 + 3 + /** 4 + * Compute the display width of a line, skipping recognized ANSI sequences 5 + * and calling measureWidth only on visible characters. 6 + */ 7 + export function computeDisplayWidth( 8 + text: string, 9 + measureWidth: (text: string) => number, 10 + ): number { 11 + let width = 0; 12 + let i = 0; 13 + while (i < text.length) { 14 + let skip = skipAnsiSequence(text, i); 15 + if (skip > 0) { 16 + i += skip; 17 + continue; 18 + } 19 + // Handle surrogate pairs 20 + let cp = text.codePointAt(i)!; 21 + let charLen = cp > 0xffff ? 2 : 1; 22 + width += measureWidth(String.fromCodePoint(cp)); 23 + i += charLen; 24 + } 25 + return width; 26 + } 27 + 28 + /** 29 + * Compute wrap points for a line at the given column width. 30 + * Returns strictly increasing UTF-16 offsets where wraps occur. 31 + * 32 + * - Wide chars (width 2) with only 1 column left → wrap before. 33 + * - Never splits surrogate pairs. 34 + * - Never splits inside recognized ANSI sequences. 35 + */ 36 + export function computeWrapPoints( 37 + text: string, 38 + columns: number, 39 + measureWidth: (text: string) => number, 40 + ): number[] { 41 + let wrapPoints: number[] = []; 42 + let col = 0; 43 + let i = 0; 44 + 45 + while (i < text.length) { 46 + let skip = skipAnsiSequence(text, i); 47 + if (skip > 0) { 48 + i += skip; 49 + continue; 50 + } 51 + 52 + let cp = text.codePointAt(i)!; 53 + let charLen = cp > 0xffff ? 2 : 1; 54 + let w = measureWidth(String.fromCodePoint(cp)); 55 + 56 + if (w > 0 && col + w > columns) { 57 + wrapPoints.push(i); 58 + col = 0; 59 + } 60 + 61 + col += w; 62 + i += charLen; 63 + } 64 + 65 + return wrapPoints; 66 + }
+41
width.ts
··· 1 + import { compiled } from "./wasm.ts"; 2 + 3 + export async function createDisplayWidth(): Promise<(text: string) => number> { 4 + let memory = new WebAssembly.Memory({ initial: 4 }); 5 + 6 + let instance = await WebAssembly.instantiate(compiled, { 7 + env: { memory }, 8 + clay: { 9 + measureTextFunction() {}, 10 + queryScrollOffsetFunction(ret: number) { 11 + let v = new DataView(memory.buffer); 12 + v.setFloat32(ret, 0, true); 13 + v.setFloat32(ret + 4, 0, true); 14 + }, 15 + }, 16 + }); 17 + 18 + let exports = instance.exports as unknown as { 19 + __heap_base: WebAssembly.Global; 20 + display_width(ptr: number, len: number): number; 21 + }; 22 + 23 + let heap = exports.__heap_base.value as number; 24 + let encoder = new TextEncoder(); 25 + 26 + return function displayWidth(text: string): number { 27 + let encoded = encoder.encode(text); 28 + let len = encoded.byteLength; 29 + 30 + // Grow memory if needed to fit the encoded string 31 + let needed = heap + len; 32 + let pages = Math.ceil(needed / 65536); 33 + let current = memory.buffer.byteLength / 65536; 34 + if (pages > current) { 35 + memory.grow(pages - current); 36 + } 37 + 38 + new Uint8Array(memory.buffer, heap, len).set(encoded); 39 + return exports.display_width(heap, len); 40 + }; 41 + }