[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 resize + conformance + exactness tests (PR 4)

Implement Virtualizer.resize(): clear wrap cache on column change,
recompute estimated visual rows, clamp anchor sub-row. Row-only
changes update rows without invalidation.

Add 17 new tests: C.RESIZE.* (8), G.RESIZE.* (2), deferred
C.APPEND.caches-displayWidth (1), exactness structural tests (6).
109 virtualizer tests total, 249 repo-wide.

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

+306 -2
+81
virtualizer/test/exactness.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("Exactness vs approximation structural tests", () => { 12 + it("Exact viewport assertions — O-1 through O-9 hold with varied content", () => { 13 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 15, rows: 10 }); 14 + v.appendLine("short"); 15 + v.appendLine("a".repeat(30)); 16 + v.appendLine("文字abc文字def"); 17 + v.appendLine("\x1b[31mcolored\x1b[0m text"); 18 + v.appendLine(""); 19 + 20 + let vp = v.resolveViewport(); 21 + 22 + // O-1: entries ordered 23 + for (let i = 1; i < vp.entries.length; i++) { 24 + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); 25 + } 26 + 27 + for (let entry of vp.entries) { 28 + // O-3: wrapPoints monotonic 29 + for (let j = 1; j < entry.wrapPoints.length; j++) { 30 + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); 31 + } 32 + // O-6: totalSubRows 33 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); 34 + // O-7: subrow bounds 35 + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); 36 + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); 37 + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); 38 + } 39 + 40 + // O-8: total visible ≤ rows 41 + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); 42 + expect(totalVisible).toBeLessThanOrEqual(10); 43 + }); 44 + 45 + it("Structural estimation non-negative — totalEstimatedVisualRows ≥ 0 after random ops", () => { 46 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10, maxLines: 50 }); 47 + for (let i = 0; i < 100; i++) { 48 + v.appendLine("x".repeat(i % 40)); 49 + } 50 + v.scrollBy(-30); 51 + v.resize(15, 10); 52 + expect(v.totalEstimatedVisualRows).toBeGreaterThanOrEqual(0); 53 + }); 54 + 55 + it("Structural estimation zero-empty — both fields 0 on empty buffer", () => { 56 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 57 + expect(v.totalEstimatedVisualRows).toBe(0); 58 + expect(v.currentEstimatedVisualRow).toBe(0); 59 + }); 60 + 61 + it("Structural estimation positive-nonempty — totalEstimatedVisualRows > 0 when lineCount > 0", () => { 62 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 63 + v.appendLine("x"); 64 + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); 65 + }); 66 + 67 + it("Structural currentEstimate inequality — valid range when lineCount > 0", () => { 68 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 }); 69 + for (let i = 0; i < 50; i++) v.appendLine("a".repeat(i % 30)); 70 + v.scrollBy(-20); 71 + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); 72 + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); 73 + }); 74 + 75 + it("Reference-behavior exact formula — estimation matches ceil(displayWidth/columns)", () => { 76 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); 77 + let before = v.totalEstimatedVisualRows; 78 + v.appendLine("a".repeat(15)); // displayWidth=15, ceil(15/10)=2 79 + expect(v.totalEstimatedVisualRows - before).toBe(2); 80 + }); 81 + });
+185
virtualizer/test/resize.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) { i += skip; continue; } 30 + result += text[i]; 31 + i++; 32 + } 33 + return result; 34 + } 35 + 36 + function visibleWidth(text: string): number { 37 + let stripped = stripAnsi(text); 38 + let w = 0; 39 + for (let i = 0; i < stripped.length; i++) { 40 + let cp = stripped.codePointAt(i)!; 41 + if (cp > 0xffff) { i++; w += 2; } 42 + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; 43 + else if (cp < 0x20) w += 0; 44 + else w += 1; 45 + } 46 + return w; 47 + } 48 + 49 + describe("C.RESIZE — resize", () => { 50 + it("C.RESIZE.wrap-cache-invalidated — wrap points recomputed after column change", () => { 51 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 52 + v.appendLine("a".repeat(100)); 53 + let vp1 = v.resolveViewport(); 54 + expect(vp1.entries[0].totalSubRows).toBe(2); // ceil(100/80)=2 55 + 56 + v.resize(50, 24); 57 + let vp2 = v.resolveViewport(); 58 + expect(vp2.entries[0].totalSubRows).toBe(2); // ceil(100/50)=2, but with exact wrapping 59 + // Verify wrap points are different from width-80 60 + expect(vp2.entries[0].wrapPoints).not.toEqual(vp1.entries[0].wrapPoints); 61 + }); 62 + 63 + it("C.RESIZE.anchor-subrow-clamped — anchorSubRow clamped to new wrap count", () => { 64 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 24 }); 65 + v.appendLine("a".repeat(100)); // 5 sub-rows at width 20 66 + v.scrollBy(4); // anchorSubRow=4 67 + expect(v.anchorSubRow).toBe(4); 68 + 69 + v.resize(50, 24); // now 2 sub-rows → clamp to 1 70 + expect(v.anchorSubRow).toBe(1); 71 + }); 72 + 73 + it("C.RESIZE.bottom-follow-preserved — isAtBottom survives resize", () => { 74 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 75 + v.appendLine("hello"); 76 + expect(v.isAtBottom).toBe(true); 77 + v.resize(40, 24); 78 + expect(v.isAtBottom).toBe(true); 79 + }); 80 + 81 + it("C.RESIZE.row-only-no-invalidation — row-only change does not affect wrapping", () => { 82 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 83 + v.appendLine("a".repeat(100)); 84 + let vp1 = v.resolveViewport(); 85 + let total1 = v.totalEstimatedVisualRows; 86 + let anchor1 = v.anchorLineIndex; 87 + 88 + v.resize(80, 40); 89 + expect(v.totalEstimatedVisualRows).toBe(total1); 90 + expect(v.anchorLineIndex).toBe(anchor1); 91 + }); 92 + 93 + it("C.RESIZE.viewport-correct-after-resize — all output invariants hold after resize", () => { 94 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 95 + v.appendLine("short"); 96 + v.appendLine("a".repeat(100)); 97 + v.appendLine("文字".repeat(20)); 98 + 99 + v.resize(40, 24); 100 + let vp = v.resolveViewport(); 101 + 102 + // O-1: entries ordered 103 + for (let i = 1; i < vp.entries.length; i++) { 104 + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); 105 + } 106 + 107 + for (let entry of vp.entries) { 108 + // O-6: totalSubRows 109 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); 110 + // O-7: subrow bounds 111 + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); 112 + // O-9: sliced width within columns 113 + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); 114 + for (let slice of slices) { 115 + expect(visibleWidth(slice)).toBeLessThanOrEqual(40); 116 + } 117 + } 118 + 119 + // O-8: visible rows within budget 120 + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); 121 + expect(totalVisible).toBeLessThanOrEqual(24); 122 + }); 123 + 124 + it("C.RESIZE.estimation-fields-valid-after-resize — estimation constraints hold", () => { 125 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 126 + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); 127 + 128 + v.resize(40, 24); 129 + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); 130 + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); 131 + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); 132 + }); 133 + 134 + it("C.RESIZE.displayWidth-unchanged — cached displayWidth unchanged across resize", () => { 135 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 136 + let idx = v.appendLine("hello"); 137 + let before = v.getLineDisplayWidth(idx); 138 + v.resize(40, 24); 139 + expect(v.getLineDisplayWidth(idx)).toBe(before); 140 + }); 141 + 142 + it("C.RESIZE.empty-buffer — resize on empty buffer does not error", () => { 143 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 144 + v.resize(40, 24); 145 + let vp = v.resolveViewport(); 146 + expect(vp.entries.length).toBe(0); 147 + expect(vp.totalEstimatedVisualRows).toBe(0); 148 + }); 149 + }); 150 + 151 + describe("G.RESIZE — resize golden fixtures", () => { 152 + it("G.RESIZE.narrow-to-wide — wrapping removed when columns increase", () => { 153 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); 154 + v.appendLine("a".repeat(80)); 155 + let vp1 = v.resolveViewport(); 156 + expect(vp1.entries[0].totalSubRows).toBe(2); 157 + 158 + v.resize(80, 24); 159 + let vp2 = v.resolveViewport(); 160 + expect(vp2.entries[0].totalSubRows).toBe(1); 161 + expect(vp2.entries[0].wrapPoints).toEqual([]); 162 + }); 163 + 164 + it("G.RESIZE.wide-to-narrow — wrapping added when columns decrease", () => { 165 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 166 + v.appendLine("a".repeat(80)); 167 + let vp1 = v.resolveViewport(); 168 + expect(vp1.entries[0].totalSubRows).toBe(1); 169 + 170 + v.resize(20, 24); 171 + let vp2 = v.resolveViewport(); 172 + expect(vp2.entries[0].totalSubRows).toBe(4); 173 + expect(vp2.entries[0].wrapPoints.length).toBe(3); 174 + }); 175 + }); 176 + 177 + describe("C.APPEND.caches-displayWidth (deferred from PR 2)", () => { 178 + it("C.APPEND.caches-displayWidth — displayWidth unchanged after resize", () => { 179 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 180 + let idx = v.appendLine("abc"); 181 + expect(v.getLineDisplayWidth(idx)).toBe(3); 182 + v.resize(2, 24); 183 + expect(v.getLineDisplayWidth(idx)).toBe(3); 184 + }); 185 + });
+40 -2
virtualizer/virtualizer.ts
··· 135 135 return newLineIndex; 136 136 } 137 137 138 - resize(_columns: number, _rows: number): void { 139 - throw new Error("not implemented"); 138 + /** 139 + * resize(columns, rows) — §10.3 140 + * 141 + * On column-width change: clear wrap cache, recompute estimation, 142 + * clamp anchor sub-row, recompute currentEstimatedVisualRow. 143 + * On row-only change: just update rows. 144 + */ 145 + resize(columns: number, rows: number): void { 146 + if (columns !== this._columns) { 147 + // Column width changed — invalidate wrap cache (INV-5) 148 + this._wrapCache.clear(); 149 + 150 + // Recompute totalEstimatedVisualRows with new column width 151 + let newTotal = 0; 152 + let baseIndex = this._ringBuffer.baseIndex; 153 + for (let i = baseIndex; i < baseIndex + this._ringBuffer.lineCount; i++) { 154 + let entry = this._ringBuffer.get(i); 155 + if (!entry) break; 156 + newTotal += Math.max(1, Math.ceil(entry.displayWidth / columns)); 157 + } 158 + this._totalEstimatedVisualRows = newTotal; 159 + 160 + // Clamp anchor sub-row at new width 161 + if (this._ringBuffer.lineCount > 0) { 162 + let anchorEntry = this._ringBuffer.get(this._anchorLineIndex); 163 + if (anchorEntry) { 164 + let newEstimate = Math.max(1, Math.ceil(anchorEntry.displayWidth / columns)); 165 + if (this._anchorSubRow >= newEstimate) { 166 + this._anchorSubRow = newEstimate - 1; 167 + } 168 + } 169 + } 170 + 171 + this._columns = columns; 172 + 173 + // Recompute currentEstimatedVisualRow 174 + this._recomputeCurrentEstimate(); 175 + } 176 + 177 + this._rows = rows; 140 178 } 141 179 142 180 /**