From bb48b92b309e3963a89519754ebdc0d3d18011cf Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 11 Apr 2026 07:04:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix=20anchor=20eviction=20in=20s?= =?UTF-8?q?ingle-line=20buffer=20+=20document=20columns=20precondition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- specs/clayterm-virtualizer-spec.md | 8 +++++++- virtualizer/test/eviction.test.ts | 19 +++++++++++++++++++ virtualizer/test/viewport.test.ts | 3 ++- virtualizer/test/wrap-golden.test.ts | 9 +++++++-- virtualizer/types.ts | 8 ++++++++ virtualizer/virtualizer.ts | 13 ++++++------- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/specs/clayterm-virtualizer-spec.md b/specs/clayterm-virtualizer-spec.md index 3c2e88c..eac3fa3 100644 --- a/specs/clayterm-virtualizer-spec.md +++ b/specs/clayterm-virtualizer-spec.md @@ -216,11 +216,17 @@ The current test suite checks the following output invariants through public API - `totalSubRows === wrapPoints.length + 1` - `firstSubRow` and `visibleSubRows` stay within bounds - total visible sub-rows do not exceed the viewport row budget -- every visible slice fits within `columns` +- every visible slice fits within `columns` (when `columns` ≥ the maximum single-glyph width returned by `measureWidth`; see §8) - output can be reconstructed from `text + wrapPoints` ## 8. Current Implementation Notes And Known Gaps +### Columns ≥ max glyph width precondition + +`columns` must be at least as wide as the widest single glyph that `measureWidth` can return. With a standard `wcwidth`-based provider, CJK ideographs are width 2, so `columns` must be ≥ 2. When this precondition is violated (e.g. `columns: 1` with CJK text), the wrapping algorithm cannot split a single glyph and it overflows its sub-row. The O-9 invariant ("every visible slice fits within `columns`") does not hold in that case. The wrap-golden test suite documents the overflow behavior for reference but the configuration is unsupported. + +### Other notes + These notes document the current branch shape rather than defining desired future contract. - The renderer-side width provider is exposed as `createDisplayWidth(): Promise<(text: string) => number>`, not a directly callable synchronous top-level export. diff --git a/virtualizer/test/eviction.test.ts b/virtualizer/test/eviction.test.ts index 47a12e3..36ad9d2 100644 --- a/virtualizer/test/eviction.test.ts +++ b/virtualizer/test/eviction.test.ts @@ -119,4 +119,23 @@ describe("C.EVICT — basic eviction (non-scroll subset)", () => { v.appendLine("D"); // evicts A(0), anchor clamped to 1 expect(v.currentEstimatedVisualRow).toBe(0); }); + + it("R.EVICT.maxLines1-not-at-bottom — anchor clamps to surviving line when isAtBottom is false", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // lineIndex 0, anchor 0, isAtBottom true + v.scrollBy(-1); // isAtBottom false, anchor still 0 + expect(v.isAtBottom).toBe(false); + + v.appendLine("B"); // evicts A(0), inserts B(1) + // Anchor must clamp to the surviving line (1), not stay on evicted (0) + expect(v.anchorLineIndex).toBe(1); + expect(v.anchorSubRow).toBe(0); + expect(v.lineCount).toBe(1); + + // resolveViewport must return the surviving line, not empty + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(1); + expect(vp.entries[0].lineIndex).toBe(1); + expect(vp.entries[0].text).toBe("B"); + }); }); diff --git a/virtualizer/test/viewport.test.ts b/virtualizer/test/viewport.test.ts index 1d98488..1fe07f5 100644 --- a/virtualizer/test/viewport.test.ts +++ b/virtualizer/test/viewport.test.ts @@ -146,9 +146,10 @@ describe("C.VIEWPORT — resolveViewport output invariants", () => { expect(totalVisible).toBeLessThanOrEqual(5); }); - it("C.VIEWPORT.sliced-width-within-columns — each sub-row fits within columns", () => { + it("C.VIEWPORT.sliced-width-within-columns — each sub-row fits within columns (requires columns ≥ max glyph width)", () => { let v = makeVirtualizer(10, 24); v.appendLine("abcdefghijklmnopqrstuvwxyz"); + v.appendLine("abc文def字ghi"); // includes width-2 CJK glyphs let vp = v.resolveViewport(); for (let entry of vp.entries) { let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); diff --git a/virtualizer/test/wrap-golden.test.ts b/virtualizer/test/wrap-golden.test.ts index 23ece6e..0c29c0b 100644 --- a/virtualizer/test/wrap-golden.test.ts +++ b/virtualizer/test/wrap-golden.test.ts @@ -82,13 +82,18 @@ describe("G.WRAP — wrapping golden fixtures", () => { expect(entry.totalSubRows).toBe(1); }); - it("G.WRAP.wide-char-wider-than-columns — no wrap point at 0", () => { + // The following two tests document behavior when columns < max glyph + // width, which is an unsupported configuration. Individual glyphs may + // overflow their sub-row; the O-9 invariant does not apply. See the + // JSDoc on VirtualizerOptions.columns. + + it("G.WRAP.wide-char-wider-than-columns — unsupported: glyph overflows, no wrap at 0", () => { let entry = resolve("文", 1); expect(entry.wrapPoints).toEqual([]); expect(entry.totalSubRows).toBe(1); }); - it("G.WRAP.multiple-wide-chars-at-columns-one — each on own row", () => { + it("G.WRAP.multiple-wide-chars-at-columns-one — unsupported: each glyph gets own row", () => { let entry = resolve("文字", 1); expect(entry.wrapPoints).toEqual([1]); expect(entry.totalSubRows).toBe(2); diff --git a/virtualizer/types.ts b/virtualizer/types.ts index e0e741d..6fb6bb6 100644 --- a/virtualizer/types.ts +++ b/virtualizer/types.ts @@ -1,6 +1,14 @@ export interface VirtualizerOptions { measureWidth: (text: string) => number; maxLines?: number; + /** + * Viewport width in columns. Must be ≥ the maximum width that + * `measureWidth` returns for any single glyph. When `columns` is + * narrower than a glyph (e.g. `columns: 1` with CJK characters of + * width 2), the glyph cannot be split and will overflow its sub-row. + * The O-9 invariant ("every visible slice fits within columns") does + * not hold in that case. + */ columns: number; rows: number; } diff --git a/virtualizer/virtualizer.ts b/virtualizer/virtualizer.ts index d0be700..d1a06c2 100644 --- a/virtualizer/virtualizer.ts +++ b/virtualizer/virtualizer.ts @@ -105,13 +105,12 @@ export class Virtualizer { if (this._anchorLineIndex > evictedLineIndex) { this._currentEstimatedVisualRow -= evictedEstimate; } else if (this._anchorLineIndex === evictedLineIndex) { - if (this._ringBuffer.lineCount > 1) { - // Clamp anchor to next line - this._anchorLineIndex = evictedLineIndex + 1; - this._anchorSubRow = 0; - this._currentEstimatedVisualRow = 0; - } - // If lineCount === 1 (maxLines=1): transient empty, resolved by step 3 + // Clamp anchor to the next surviving line. For maxLines=1 this + // pre-targets the line about to be inserted in step 3, whose + // lineIndex is always evictedLineIndex + 1 (monotonic counter). + this._anchorLineIndex = evictedLineIndex + 1; + this._anchorSubRow = 0; + this._currentEstimatedVisualRow = 0; } } -- 2.51.2