[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.

๐Ÿ› fix anchor eviction in single-line buffer + document columns precondition

Fix two blocking issues before merge:

1. When maxLines=1 and isAtBottom=false, evicting the anchor line left
the anchor pointing at a gone lineIndex. resolveViewport() returned
no entries from a non-empty buffer. Removed the `lineCount > 1` guard
so the anchor always clamps to `evictedLineIndex + 1`. Added
regression test R.EVICT.maxLines1-not-at-bottom.

2. Resolved the contradiction between O-9 ("every slice fits within
columns") and the columns=1 + width-2 CJK glyph behavior. Documented
columns โ‰ฅ max glyph width as a precondition on VirtualizerOptions,
updated O-9 test name, wrap-golden test labels, and the spec.

Taras Mankovski (Apr 11, 2026, 7:04 AM EDT) bb48b92b 90110cdd

+49 -11
+7 -1
specs/clayterm-virtualizer-spec.md
··· 216 216 - `totalSubRows === wrapPoints.length + 1` 217 217 - `firstSubRow` and `visibleSubRows` stay within bounds 218 218 - total visible sub-rows do not exceed the viewport row budget 219 - - every visible slice fits within `columns` 219 + - every visible slice fits within `columns` (when `columns` โ‰ฅ the maximum single-glyph width returned by `measureWidth`; see ยง8) 220 220 - output can be reconstructed from `text + wrapPoints` 221 221 222 222 ## 8. Current Implementation Notes And Known Gaps 223 + 224 + ### Columns โ‰ฅ max glyph width precondition 225 + 226 + `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. 227 + 228 + ### Other notes 223 229 224 230 These notes document the current branch shape rather than defining desired future contract. 225 231
+19
virtualizer/test/eviction.test.ts
··· 119 119 v.appendLine("D"); // evicts A(0), anchor clamped to 1 120 120 expect(v.currentEstimatedVisualRow).toBe(0); 121 121 }); 122 + 123 + it("R.EVICT.maxLines1-not-at-bottom โ€” anchor clamps to surviving line when isAtBottom is false", () => { 124 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); 125 + v.appendLine("A"); // lineIndex 0, anchor 0, isAtBottom true 126 + v.scrollBy(-1); // isAtBottom false, anchor still 0 127 + expect(v.isAtBottom).toBe(false); 128 + 129 + v.appendLine("B"); // evicts A(0), inserts B(1) 130 + // Anchor must clamp to the surviving line (1), not stay on evicted (0) 131 + expect(v.anchorLineIndex).toBe(1); 132 + expect(v.anchorSubRow).toBe(0); 133 + expect(v.lineCount).toBe(1); 134 + 135 + // resolveViewport must return the surviving line, not empty 136 + let vp = v.resolveViewport(); 137 + expect(vp.entries.length).toBe(1); 138 + expect(vp.entries[0].lineIndex).toBe(1); 139 + expect(vp.entries[0].text).toBe("B"); 140 + }); 122 141 });
+2 -1
virtualizer/test/viewport.test.ts
··· 146 146 expect(totalVisible).toBeLessThanOrEqual(5); 147 147 }); 148 148 149 - it("C.VIEWPORT.sliced-width-within-columns โ€” each sub-row fits within columns", () => { 149 + it("C.VIEWPORT.sliced-width-within-columns โ€” each sub-row fits within columns (requires columns โ‰ฅ max glyph width)", () => { 150 150 let v = makeVirtualizer(10, 24); 151 151 v.appendLine("abcdefghijklmnopqrstuvwxyz"); 152 + v.appendLine("abcๆ–‡defๅญ—ghi"); // includes width-2 CJK glyphs 152 153 let vp = v.resolveViewport(); 153 154 for (let entry of vp.entries) { 154 155 let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints);
+7 -2
virtualizer/test/wrap-golden.test.ts
··· 82 82 expect(entry.totalSubRows).toBe(1); 83 83 }); 84 84 85 - it("G.WRAP.wide-char-wider-than-columns โ€” no wrap point at 0", () => { 85 + // The following two tests document behavior when columns < max glyph 86 + // width, which is an unsupported configuration. Individual glyphs may 87 + // overflow their sub-row; the O-9 invariant does not apply. See the 88 + // JSDoc on VirtualizerOptions.columns. 89 + 90 + it("G.WRAP.wide-char-wider-than-columns โ€” unsupported: glyph overflows, no wrap at 0", () => { 86 91 let entry = resolve("ๆ–‡", 1); 87 92 expect(entry.wrapPoints).toEqual([]); 88 93 expect(entry.totalSubRows).toBe(1); 89 94 }); 90 95 91 - it("G.WRAP.multiple-wide-chars-at-columns-one โ€” each on own row", () => { 96 + it("G.WRAP.multiple-wide-chars-at-columns-one โ€” unsupported: each glyph gets own row", () => { 92 97 let entry = resolve("ๆ–‡ๅญ—", 1); 93 98 expect(entry.wrapPoints).toEqual([1]); 94 99 expect(entry.totalSubRows).toBe(2);
+8
virtualizer/types.ts
··· 1 1 export interface VirtualizerOptions { 2 2 measureWidth: (text: string) => number; 3 3 maxLines?: number; 4 + /** 5 + * Viewport width in columns. Must be โ‰ฅ the maximum width that 6 + * `measureWidth` returns for any single glyph. When `columns` is 7 + * narrower than a glyph (e.g. `columns: 1` with CJK characters of 8 + * width 2), the glyph cannot be split and will overflow its sub-row. 9 + * The O-9 invariant ("every visible slice fits within columns") does 10 + * not hold in that case. 11 + */ 4 12 columns: number; 5 13 rows: number; 6 14 }
+6 -7
virtualizer/virtualizer.ts
··· 105 105 if (this._anchorLineIndex > evictedLineIndex) { 106 106 this._currentEstimatedVisualRow -= evictedEstimate; 107 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 108 + // Clamp anchor to the next surviving line. For maxLines=1 this 109 + // pre-targets the line about to be inserted in step 3, whose 110 + // lineIndex is always evictedLineIndex + 1 (monotonic counter). 111 + this._anchorLineIndex = evictedLineIndex + 1; 112 + this._anchorSubRow = 0; 113 + this._currentEstimatedVisualRow = 0; 115 114 } 116 115 } 117 116