[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 three edge-case bugs with wide chars at narrow columns

1. resize() now clamps anchorSubRow using exact wrap count instead of
ceil(displayWidth/columns) estimate, which undercounts when boundary
waste from wide characters increases actual sub-row count.

2. _recomputeCurrentEstimate() clamps currentEstimatedVisualRow to
[0, totalEstimatedVisualRows-1] so the scrollbar invariant holds
even when exact wrap counts exceed the estimate.

3. computeWrapPoints() no longer emits wrap point at index 0 when the
first visible character is wider than columns โ€” the character
overflows its row instead of creating an empty first sub-row.

Adds 4 regression tests covering all three fixes.

Taras Mankovski (Apr 11, 2026, 6:44 AM EDT) 90110cdd 4ebaa33c

+49 -8
+15
virtualizer/test/resize.test.ts
··· 70 70 expect(v.anchorSubRow).toBe(1); 71 71 }); 72 72 73 + it("C.RESIZE.anchor-subrow-clamped-exact โ€” uses exact wrapping for clamp, not estimate", () => { 74 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); 75 + // "ๆ–‡" is width 2. At columns=3: 1 fits per row (2+2=4 > 3) โ†’ 10 exact sub-rows 76 + // estimate = ceil(20/3) = 7 77 + v.appendLine("ๆ–‡".repeat(10)); 78 + v.scrollBy(8); // anchorSubRow = 8 (valid in exact range 0-9) 79 + expect(v.anchorSubRow).toBe(8); 80 + 81 + // Resize to columns=5: 2 chars fit per row (2+2=4 โ‰ค 5) โ†’ 5 exact sub-rows 82 + // estimate = ceil(20/5) = 4 83 + // Should clamp to exact(5)-1 = 4, not estimate(4)-1 = 3 84 + v.resize(5, 24); 85 + expect(v.anchorSubRow).toBe(4); 86 + }); 87 + 73 88 it("C.RESIZE.bottom-follow-preserved โ€” isAtBottom survives resize", () => { 74 89 let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); 75 90 v.appendLine("hello");
+10
virtualizer/test/scroll.test.ts
··· 115 115 prev = v.currentEstimatedVisualRow; 116 116 } 117 117 }); 118 + 119 + it("C.ESTIMATE.current-within-total โ€” holds when exact wraps exceed estimate", () => { 120 + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); 121 + // "ๆ–‡" is width 2. At columns=3: 1 per row โ†’ 10 exact sub-rows 122 + // estimate = ceil(20/3) = 7 123 + v.appendLine("ๆ–‡".repeat(10)); 124 + v.scrollBy(9); // scroll to last exact sub-row 125 + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); 126 + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); 127 + }); 118 128 });
+12
virtualizer/test/wrap-golden.test.ts
··· 81 81 expect(entry.wrapPoints).toEqual([]); 82 82 expect(entry.totalSubRows).toBe(1); 83 83 }); 84 + 85 + it("G.WRAP.wide-char-wider-than-columns โ€” no wrap point at 0", () => { 86 + let entry = resolve("ๆ–‡", 1); 87 + expect(entry.wrapPoints).toEqual([]); 88 + expect(entry.totalSubRows).toBe(1); 89 + }); 90 + 91 + it("G.WRAP.multiple-wide-chars-at-columns-one โ€” each on own row", () => { 92 + let entry = resolve("ๆ–‡ๅญ—", 1); 93 + expect(entry.wrapPoints).toEqual([1]); 94 + expect(entry.totalSubRows).toBe(2); 95 + }); 84 96 });
+11 -7
virtualizer/virtualizer.ts
··· 157 157 } 158 158 this._totalEstimatedVisualRows = newTotal; 159 159 160 - // Clamp anchor sub-row at new width 160 + this._columns = columns; 161 + 162 + // Clamp anchor sub-row using exact wrap count at new width 161 163 if (this._ringBuffer.lineCount > 0) { 162 164 let anchorEntry = this._ringBuffer.get(this._anchorLineIndex); 163 165 if (anchorEntry) { 164 - let newEstimate = Math.max(1, Math.ceil(anchorEntry.displayWidth / columns)); 165 - if (this._anchorSubRow >= newEstimate) { 166 - this._anchorSubRow = newEstimate - 1; 166 + let wrapPoints = this._getWrapPoints(this._anchorLineIndex, anchorEntry.text); 167 + let exactSubRows = wrapPoints.length + 1; 168 + if (this._anchorSubRow >= exactSubRows) { 169 + this._anchorSubRow = exactSubRows - 1; 167 170 } 168 171 } 169 172 } 170 - 171 - this._columns = columns; 172 173 173 174 // Recompute currentEstimatedVisualRow 174 175 this._recomputeCurrentEstimate(); ··· 318 319 estimate += this._estimateVisualRows(entry.displayWidth); 319 320 } 320 321 estimate += this._anchorSubRow; 321 - this._currentEstimatedVisualRow = estimate; 322 + this._currentEstimatedVisualRow = Math.min( 323 + estimate, 324 + Math.max(this._totalEstimatedVisualRows - 1, 0), 325 + ); 322 326 } 323 327 324 328 getLineDisplayWidth(lineIndex: number): number | undefined {
+1 -1
virtualizer/wrap-walker.ts
··· 53 53 let charLen = cp > 0xffff ? 2 : 1; 54 54 let w = measureWidth(String.fromCodePoint(cp)); 55 55 56 - if (w > 0 && col + w > columns) { 56 + if (col > 0 && col + w > columns) { 57 57 wrapPoints.push(i); 58 58 col = 0; 59 59 }