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; } }