diff --git a/mod.ts b/mod.ts index 8862d13..7924f6a 100644 --- a/mod.ts +++ b/mod.ts @@ -3,3 +3,4 @@ export * from "./term.ts"; export * from "./input.ts"; export * from "./settings.ts"; export * from "./termcodes.ts"; +export { createDisplayWidth } from "./width.ts"; diff --git a/src/clayterm.c b/src/clayterm.c index a9bedf6..ab11910 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -7,6 +7,7 @@ * output — pointer to output byte buffer * length — length of output byte buffer * measure — Clay text measurement callback + * display_width — per-codepoint wcwidth sum over a UTF-8 string */ #include "clayterm.h" @@ -656,3 +657,32 @@ void measure(int ret, int txt) { dims[0] = (float)w; dims[1] = 1.0f; } + +/* ── display_width — per-codepoint wcwidth sum ───────────────────── */ + +/** + * Compute the display width of a UTF-8 string by summing max(0, wcwidth(cp)) + * for each Unicode codepoint. No ANSI skipping — pure per-codepoint sum. + * + * @param str Pointer to a UTF-8 encoded string (not necessarily null-terminated). + * @param len Byte length of the string. + * @return Total display width (non-negative). + */ +int display_width(const char *str, int len) { + int w = 0; + const char *p = str; + int rem = len; + while (rem > 0) { + uint32_t cp; + int n = utf8_decode(&cp, p); + if (n <= 0) { + n = 1; + } + int cw = wcwidth(cp); + if (cw > 0) + w += cw; + p += n; + rem -= n; + } + return w; +} diff --git a/test/width.test.ts b/test/width.test.ts new file mode 100644 index 0000000..195f270 --- /dev/null +++ b/test/width.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createDisplayWidth } from "../width.ts"; + +describe("createDisplayWidth", () => { + let displayWidth: (text: string) => number; + + beforeEach(async () => { + displayWidth = await createDisplayWidth(); + }); + + it("R.WIDTH.ascii — ASCII characters each have width 1", () => { + expect(displayWidth("hello")).toBe(5); + }); + + it("R.WIDTH.cjk — CJK characters each have width 2", () => { + expect(displayWidth("文字")).toBe(4); + }); + + it("R.WIDTH.combining — combining marks have width 0", () => { + expect(displayWidth("e\u0301")).toBe(1); + }); + + it("R.WIDTH.zwj-emoji — ZWJ emoji sequence uses per-codepoint wcwidth", () => { + expect(displayWidth("👨‍👩‍👧‍👦")).toBe(8); + }); + + it("R.WIDTH.additivity — width of concatenation equals sum of widths", () => { + let pairs: [string, string][] = [ + ["hello", "world"], + ["文", "字"], + ["abc", "文字"], + ["e\u0301", "hello"], + ]; + for (let [a, b] of pairs) { + expect(displayWidth(a + b)).toBe(displayWidth(a) + displayWidth(b)); + } + }); + + it("R.WIDTH.empty-string — empty string has width 0", () => { + expect(displayWidth("")).toBe(0); + }); + + it("R.WIDTH.zero-width — zero-width characters have width 0", () => { + expect(displayWidth("\u200B")).toBe(0); + expect(displayWidth("\u200D")).toBe(0); + }); +}); diff --git a/virtualizer/ansi-scanner.ts b/virtualizer/ansi-scanner.ts new file mode 100644 index 0000000..209b4fc --- /dev/null +++ b/virtualizer/ansi-scanner.ts @@ -0,0 +1,48 @@ +/** + * If text[pos] starts an ESC-initiated CSI or OSC sequence, return the + * byte length of the full sequence (inclusive). Otherwise return 0. + * + * CSI: ESC [ ... + * OSC: ESC ] ... + */ +export function skipAnsiSequence(text: string, pos: number): number { + if (text.charCodeAt(pos) !== 0x1b) return 0; + if (pos + 1 >= text.length) return 0; + + let next = text.charCodeAt(pos + 1); + + // CSI: ESC [ + if (next === 0x5b) { + let i = pos + 2; + while (i < text.length) { + let ch = text.charCodeAt(i); + if (ch >= 0x40 && ch <= 0x7e) { + return i - pos + 1; + } + i++; + } + // Unterminated CSI — consume what we have + return i - pos; + } + + // OSC: ESC ] + if (next === 0x5d) { + let i = pos + 2; + while (i < text.length) { + let ch = text.charCodeAt(i); + // BEL terminator + if (ch === 0x07) { + return i - pos + 1; + } + // ST terminator: ESC backslash + if (ch === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c) { + return i - pos + 2; + } + i++; + } + // Unterminated OSC — consume what we have + return i - pos; + } + + return 0; +} diff --git a/virtualizer/deno.json b/virtualizer/deno.json new file mode 100644 index 0000000..9a89e67 --- /dev/null +++ b/virtualizer/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@clayterm/virtualizer", + "license": "MIT", + "exports": { ".": "./mod.ts" }, + "imports": { + "@std/testing": "jsr:@std/testing@1", + "@std/expect": "jsr:@std/expect@1" + } +} diff --git a/virtualizer/deno.lock b/virtualizer/deno.lock new file mode 100644 index 0000000..98a73db --- /dev/null +++ b/virtualizer/deno.lock @@ -0,0 +1,42 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/assert@^1.0.17": "1.0.19", + "jsr:@std/expect@1": "1.0.17", + "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/testing@1": "1.0.17" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/expect@1.0.17": { + "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", + "dependencies": [ + "jsr:@std/assert@^1.0.14", + "jsr:@std/internal@^1.0.10" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/assert@^1.0.17", + "jsr:@std/internal@^1.0.12" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/expect@1", + "jsr:@std/testing@1" + ] + } +} diff --git a/virtualizer/mod.ts b/virtualizer/mod.ts new file mode 100644 index 0000000..a915955 --- /dev/null +++ b/virtualizer/mod.ts @@ -0,0 +1,6 @@ +export { Virtualizer } from "./virtualizer.ts"; +export type { + ResolvedViewport, + ViewportEntry, + VirtualizerOptions, +} from "./types.ts"; diff --git a/virtualizer/ring-buffer.ts b/virtualizer/ring-buffer.ts new file mode 100644 index 0000000..eda6c20 --- /dev/null +++ b/virtualizer/ring-buffer.ts @@ -0,0 +1,68 @@ +export interface LineEntry { + text: string; + displayWidth: number; + lineIndex: number; +} + +export interface AppendResult { + lineIndex: number; + evicted?: { displayWidth: number; lineIndex: number }; +} + +export class RingBuffer { + private _items: (LineEntry | undefined)[]; + private _capacity: number; + private _head: number; + private _count: number; + private _nextIndex: number; + + constructor(capacity: number) { + this._capacity = capacity; + this._items = new Array(capacity); + this._head = 0; + this._count = 0; + this._nextIndex = 0; + } + + get capacity(): number { + return this._capacity; + } + + get lineCount(): number { + return this._count; + } + + get baseIndex(): number { + if (this._count === 0) return this._nextIndex; + return this._items[this._head]!.lineIndex; + } + + append(text: string, displayWidth: number): AppendResult { + let lineIndex = this._nextIndex++; + let evicted: { displayWidth: number; lineIndex: number } | undefined; + + if (this._count === this._capacity) { + let evictedEntry = this._items[this._head]!; + evicted = { + displayWidth: evictedEntry.displayWidth, + lineIndex: evictedEntry.lineIndex, + }; + this._head = (this._head + 1) % this._capacity; + this._count--; + } + + let slot = (this._head + this._count) % this._capacity; + this._items[slot] = { text, displayWidth, lineIndex }; + this._count++; + + return { lineIndex, evicted }; + } + + get(lineIndex: number): LineEntry | undefined { + if (this._count === 0) return undefined; + let base = this._items[this._head]!.lineIndex; + let offset = lineIndex - base; + if (offset < 0 || offset >= this._count) return undefined; + return this._items[(this._head + offset) % this._capacity]; + } +} diff --git a/virtualizer/test/ansi-golden.test.ts b/virtualizer/test/ansi-golden.test.ts new file mode 100644 index 0000000..1086329 --- /dev/null +++ b/virtualizer/test/ansi-golden.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("G.ANSI — ANSI golden fixtures", () => { + it("G.ANSI.simple-sgr — SGR sequence does not add sub-rows", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mred\x1b[0m"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(3); + }); + + it("G.ANSI.sgr-at-wrap-boundary — wrap occurs at visible char boundary, not inside SGR", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + v.appendLine("abcde\x1b[31mfghij"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // Visible: "abcde" (5) then "fghij" (5). CSI "\x1b[31m" at indices 5–9 (5 chars). + // Wrap after 5 visible chars → wrap point at index 10 (start of 'f', after CSI) + expect(entry.wrapPoints).toEqual([10]); + let slices = [entry.text.slice(0, 10), entry.text.slice(10)]; + expect(slices[0]).toBe("abcde\x1b[31m"); + expect(slices[1]).toBe("fghij"); + }); + + it("G.ANSI.osc-with-bel — OSC with BEL terminator", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x07visible"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("G.ANSI.osc-with-st — OSC with ST terminator", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x1b\\visible"); + let vp = v.resolveViewport(); + expect(vp.entries[0].totalSubRows).toBe(1); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("G.ANSI.nested-csi-in-wrapped-line — multiple CSI in wrapping line, no wrap inside CSI", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 }); + // "a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me" — 5 visible chars: a, b, c, d, e + v.appendLine("a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // 5 visible chars at columns 3 → 2 sub-rows + expect(entry.totalSubRows).toBe(2); + // No wrap point should fall inside any CSI sequence + let text = entry.text; + for (let wp of entry.wrapPoints) { + expect(text.charCodeAt(wp)).not.toBe(0x5b); // not '[' + // wp should not be between ESC and final byte + if (wp > 0 && text.charCodeAt(wp - 1) === 0x1b) { + // wp right after ESC — that's inside the sequence + expect(true).toBe(false); + } + } + }); +}); diff --git a/virtualizer/test/ansi.test.ts b/virtualizer/test/ansi.test.ts new file mode 100644 index 0000000..06870e9 --- /dev/null +++ b/virtualizer/test/ansi.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("C.ANSI — ANSI handling", () => { + it("C.ANSI.csi-skipped-in-width — CSI bytes do not contribute to displayWidth", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("C.ANSI.osc-skipped-in-width — OSC payload does not contribute to displayWidth", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b]0;title\x07visible"); + expect(v.getLineDisplayWidth(idx)).toBe(7); + }); + + it("C.ANSI.wrap-point-not-inside-csi — no wrapPoint falls inside a CSI sequence", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + // "abcde\x1b[31mfghij" — 5 visible chars, then CSI at string index 5–9, then 5 more visible + let text = "abcde\x1b[31mfghij"; + v.appendLine(text); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // CSI bytes are at string indices 5–9 — no wrap point should fall inside the CSI + for (let wp of entry.wrapPoints) { + expect(wp < 5 || wp >= 10).toBe(true); + } + }); + + it("C.ANSI.wrap-point-not-inside-osc — no wrapPoint falls inside an OSC sequence", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + // 5 visible chars, then OSC, then 5 more visible chars + let text = "abcde\x1b]0;title\x07fghij"; + v.appendLine(text); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + // OSC spans from index 5 to 14 (inclusive of BEL) — no wrap point inside + for (let wp of entry.wrapPoints) { + expect(wp < 5 || wp >= 15).toBe(true); + } + }); + + it("C.ANSI.unrecognized-not-skipped — unrecognized ESC sequences are not skipped", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); + // SS3 (ESC O) is not CSI/OSC — should not be skipped + v.appendLine("\x1bOA"); + let joined = recorded.join(""); + expect(joined).toContain("O"); + expect(joined).toContain("A"); + }); + + it("C.ANSI.escapes-contribute-zero-width — multiple CSI sequences contribute zero width", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31m\x1b[42mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("R.ANSI.call-discipline — measureWidth never receives ESC or CSI/OSC body bytes", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 40, rows: 24 }); + v.appendLine("\x1b[1m\x1b[31mhello\x1b[0m \x1b]0;title\x07world"); + v.resolveViewport(); + for (let arg of recorded) { + expect(arg.includes("\x1b")).toBe(false); + } + }); + + it("C.ANSI.no-style-state-across-lines — prior line SGR does not affect next line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("\x1b[31mred"); + let idx2 = v.appendLine("plain"); + expect(v.getLineDisplayWidth(idx2)).toBe(5); + }); +}); diff --git a/virtualizer/test/append.test.ts b/virtualizer/test/append.test.ts new file mode 100644 index 0000000..c8e0000 --- /dev/null +++ b/virtualizer/test/append.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.APPEND — appendLine", () => { + it("C.APPEND.stores-text — text is retrievable via resolveViewport", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("hello world"); + let vp = v.resolveViewport(); + expect(vp.entries[0].text).toBe("hello world"); + }); + + it("C.APPEND.increments-estimated-total — totalEstimatedVisualRows increases correctly", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + let before = v.totalEstimatedVisualRows; + v.appendLine("hello"); // width 5, ceil(5/10) = 1 + expect(v.totalEstimatedVisualRows - before).toBe(1); + }); + + it("C.APPEND.bottom-follow-advances-anchor — anchor tracks newest line when at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.isAtBottom).toBe(true); + let idx0 = v.appendLine("line 0"); + expect(v.anchorLineIndex).toBe(idx0); + let idx1 = v.appendLine("line 1"); + expect(v.anchorLineIndex).toBe(idx1); + let idx2 = v.appendLine("line 2"); + expect(v.anchorLineIndex).toBe(idx2); + + let vp = v.resolveViewport(); + let lastEntry = vp.entries[vp.entries.length - 1]; + expect(lastEntry.lineIndex).toBe(idx2); + }); + + it("C.APPEND.does-not-invalidate-existing-wrap-cache — cached wrap points survive new appends", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + v.appendLine("abcdefghij"); // wraps at 5 + let vp1 = v.resolveViewport(); + let wp1 = vp1.entries[0].wrapPoints.slice(); + + v.appendLine("more text"); + let vp2 = v.resolveViewport(); + // The first line should still be in the viewport with same wrap points + let entry = vp2.entries.find((e) => e.text === "abcdefghij"); + if (entry) { + expect(entry.wrapPoints).toEqual(wp1); + } + }); + + it("C.APPEND.identity-counter-independent-of-buffer — indices always increase", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); + let a = v.appendLine("A"); // 0 + let b = v.appendLine("B"); // 1 + let c = v.appendLine("C"); // 2, evicts A + let d = v.appendLine("D"); // 3, evicts B + expect([a, b, c, d]).toEqual([0, 1, 2, 3]); + }); + + it("C.APPEND.no-bottom-follow-when-scrolled-up — anchor stays when not at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); + // anchor at 9 (bottom-follow). scrollBy(-5) → anchor at 4. + v.scrollBy(-5); + let anchorBefore = v.anchorLineIndex; + expect(anchorBefore).toBe(4); + let vp1 = v.resolveViewport(); + let entries1 = vp1.entries.map((e) => e.lineIndex); + // rows=3, forward walk from 4: [4,5,6] (full) + expect(entries1).toEqual([4, 5, 6]); + + // Append 3 more lines — anchor should not move, viewport stays full at [4,5,6] + v.appendLine("extra 1"); + v.appendLine("extra 2"); + v.appendLine("extra 3"); + + expect(v.anchorLineIndex).toBe(anchorBefore); + let vp2 = v.resolveViewport(); + let entries2 = vp2.entries.map((e) => e.lineIndex); + expect(entries2).toEqual(entries1); + }); +}); diff --git a/virtualizer/test/empty.test.ts b/virtualizer/test/empty.test.ts new file mode 100644 index 0000000..ed4b7e6 --- /dev/null +++ b/virtualizer/test/empty.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.EMPTY — empty buffer", () => { + it("C.EMPTY.construction-state — initial state is empty and at bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.lineCount).toBe(0); + expect(v.totalEstimatedVisualRows).toBe(0); + expect(v.currentEstimatedVisualRow).toBe(0); + expect(v.isAtBottom).toBe(true); + }); + + it("C.EMPTY.resolve-returns-empty — resolveViewport on empty buffer returns empty entries", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + expect(vp.currentEstimatedVisualRow).toBe(0); + expect(vp.isAtBottom).toBe(true); + }); + + it("C.EMPTY.first-append-uses-counter — first appendLine returns 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.appendLine("first")).toBe(0); + }); + + it("C.EMPTY.identity-counter-not-reset-after-eviction — counter continues after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + expect(v.baseIndex).toBe(2); + }); + + it("C.EMPTY.scrollBy-noop — scrollBy on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollBy(5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); + + it("C.EMPTY.scrollToFraction-noop — scrollToFraction on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollToFraction(0.5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); +}); diff --git a/virtualizer/test/eviction.test.ts b/virtualizer/test/eviction.test.ts new file mode 100644 index 0000000..47a12e3 --- /dev/null +++ b/virtualizer/test/eviction.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.EVICT — basic eviction (non-scroll subset)", () => { + it("C.EVICT.triggers-at-capacity — lineCount stays at maxLines", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); + v.appendLine("B"); + v.appendLine("C"); + v.appendLine("D"); + expect(v.lineCount).toBe(3); + }); + + it("C.EVICT.removes-oldest — oldest line is evicted", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.appendLine("D"); // 3, evicts A + let vp = v.resolveViewport(); + let indices = vp.entries.map((e) => e.lineIndex); + expect(indices).toEqual([1, 2, 3]); + }); + + it("C.EVICT.baseIndex-advances — baseIndex tracks eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 2 }); + v.appendLine("A"); expect(v.baseIndex).toBe(0); + v.appendLine("B"); expect(v.baseIndex).toBe(0); + v.appendLine("C"); expect(v.baseIndex).toBe(1); + v.appendLine("D"); expect(v.baseIndex).toBe(2); + }); + + it("C.EVICT.estimation-decremented — total estimate accounts for eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24, maxLines: 3 }); + v.appendLine("hello"); // 5 chars, 1 row + v.appendLine("world"); // 5 chars, 1 row + v.appendLine("three"); // 5 chars, 1 row + expect(v.totalEstimatedVisualRows).toBe(3); + v.appendLine("four!"); // evicts "hello", total stays 3 + expect(v.totalEstimatedVisualRows).toBe(3); + }); + + it("C.EVICT.maxLines-one — single-line buffer works correctly", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 1 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + expect(v.lineCount).toBe(1); + expect(v.baseIndex).toBe(1); + let vp = v.resolveViewport(); + expect(vp.entries[0].lineIndex).toBe(1); + }); + + it("C.EVICT.anchor-survives-when-newer — anchor not affected by eviction of older line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); // indices 0–4, anchor at 4 + v.scrollBy(-1); // anchor at 3, isAtBottom=false + expect(v.anchorLineIndex).toBe(3); + + v.appendLine("line 5"); // evicts index 0 + expect(v.anchorLineIndex).toBe(3); // unchanged + let vp = v.resolveViewport(); + expect(vp.entries[0].lineIndex).toBeGreaterThanOrEqual(1); + }); + + it("C.EVICT.viewport-stable-when-anchor-survives — viewport unchanged after eviction of older line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 2, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // anchor at 4 (bottom-follow). scrollBy(-1) → anchor at 3. + v.scrollBy(-1); + expect(v.anchorLineIndex).toBe(3); + // rows=2, forward from 3: [3,4] (full) + let vp1 = v.resolveViewport(); + let entries1 = vp1.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); + expect(entries1).toEqual([ + { lineIndex: 3, text: "line 3" }, + { lineIndex: 4, text: "line 4" }, + ]); + + v.appendLine("line 5"); // evicts index 0, anchor still at 3 + let vp2 = v.resolveViewport(); + let entries2 = vp2.entries.map((e) => ({ lineIndex: e.lineIndex, text: e.text })); + expect(entries2).toEqual(entries1); + }); + + it("C.EVICT.anchor-clamps-when-evicted — anchor clamped to next surviving line", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2, anchor at 2 via bottom-follow + v.scrollBy(-2); // anchor at 0, isAtBottom=false + expect(v.anchorLineIndex).toBe(0); + + v.appendLine("D"); // evicts A(0), anchor was at 0 → clamp to 1 + expect(v.anchorLineIndex).toBe(1); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.EVICT.currentEstimate-decremented-when-anchor-survives — currentEstimate adjusts for eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 5 }); + for (let i = 0; i < 5; i++) v.appendLine("x"); // each 1 row + v.scrollBy(-1); // anchor at 3 + let before = v.currentEstimatedVisualRow; + + v.appendLine("y"); // evicts index 0 (1 row) + // currentEstimate should decrease by 1 (evicted line's estimate) + expect(v.currentEstimatedVisualRow).toBe(before - 1); + }); + + it("C.EVICT.currentEstimate-zero-when-anchor-evicted — currentEstimate resets to 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.scrollBy(-2); // anchor at 0 + v.appendLine("D"); // evicts A(0), anchor clamped to 1 + expect(v.currentEstimatedVisualRow).toBe(0); + }); +}); diff --git a/virtualizer/test/fixtures/real-world-ansi.ts b/virtualizer/test/fixtures/real-world-ansi.ts new file mode 100644 index 0000000..4ef5190 --- /dev/null +++ b/virtualizer/test/fixtures/real-world-ansi.ts @@ -0,0 +1,25 @@ +// Real-world ANSI fixture strings captured from common terminal programs. + +// ls --color output (directory and file listing with SGR) +export const LS_COLOR = + "\x1b[0m\x1b[01;34mnode_modules\x1b[0m \x1b[01;32mpackage.json\x1b[0m \x1b[00msrc\x1b[0m \x1b[01;32mtsconfig.json\x1b[0m"; + +// git diff --color output (added/removed lines) +export const GIT_DIFF_ADD = + "\x1b[32m+export function createDisplayWidth(): Promise<(text: string) => number> {\x1b[m"; +export const GIT_DIFF_REMOVE = + "\x1b[31m-// TODO: implement width calculation\x1b[m"; +export const GIT_DIFF_HEADER = + "\x1b[1mdiff --git a/width.ts b/width.ts\x1b[m"; + +// gcc diagnostics with bold and color +export const GCC_ERROR = + "\x1b[1m\x1b[35msrc/main.c:42:5:\x1b[0m \x1b[1m\x1b[31merror:\x1b[0m \x1b[1mexpected ';' after expression\x1b[0m"; + +// npm output with multiple SGR parameters +export const NPM_WARN = + "\x1b[33m\x1b[1mnpm\x1b[22m \x1b[39m\x1b[33mWARN\x1b[39m \x1b[35mdeprecated\x1b[39m request@2.88.2: request has been deprecated"; + +// OSC title-set (window title) followed by visible text +export const OSC_TITLE = + "\x1b]0;user@host:~/project\x07$ ls -la"; diff --git a/virtualizer/test/fraction.test.ts b/virtualizer/test/fraction.test.ts new file mode 100644 index 0000000..99b10e3 --- /dev/null +++ b/virtualizer/test/fraction.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(_text: string): number { + return 1; +} + +describe("C.FRACTION — scrollToFraction", () => { + it("C.FRACTION.zero-scrolls-to-top", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0); + expect(v.anchorLineIndex).toBe(v.baseIndex); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.FRACTION.one-scrolls-to-bottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(1); + expect(v.isAtBottom).toBe(true); + }); + + it("C.FRACTION.half-anchor-in-valid-range", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); + }); + + it("R.FRACTION.half-lands-near-middle — 100 equal-width lines, fraction 0.5 lands at line 50", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine("x"); // each line width 1, 1 sub-row + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBe(v.baseIndex + 50); + }); + + it("C.FRACTION.empty-buffer-noop", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollToFraction(0.5); + expect(v.lineCount).toBe(0); + expect(v.isAtBottom).toBe(true); + }); + + it("C.FRACTION.subrow-clamped — anchorSubRow is in valid range", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + // Mix of short and long lines + for (let i = 0; i < 50; i++) { + v.appendLine(i % 3 === 0 ? "a".repeat(25) : "short"); + } + v.scrollToFraction(0.7); + let entry = v.getLineDisplayWidth(v.anchorLineIndex); + if (entry !== undefined) { + let estimate = Math.max(1, Math.ceil(entry / 10)); + expect(v.anchorSubRow).toBeGreaterThanOrEqual(0); + expect(v.anchorSubRow).toBeLessThan(estimate); + } + }); + + it("C.FRACTION.currentEstimate-updated — currentEstimatedVisualRow reflects walk", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 50; i++) v.appendLine("x"); + v.scrollToFraction(0.5); + // Manually verify: 50 lines each 1 sub-row, target = floor(0.5*50) = 25 + // Walk 25 lines → accumulated = 25, anchorSubRow = 0 + // currentEstimatedVisualRow = 25 + 0 = 25 + expect(v.currentEstimatedVisualRow).toBe(25); + }); + + it("C.FRACTION.zero-after-eviction — fraction 0 goes to baseIndex after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0); + expect(v.anchorLineIndex).toBe(v.baseIndex); // 30, not 0 + }); + + it("C.FRACTION.mid-after-eviction — fraction 0.5 in valid range after eviction", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24, maxLines: 50 }); + for (let i = 0; i < 80; i++) v.appendLine(`line ${i}`); + v.scrollToFraction(0.5); + expect(v.anchorLineIndex).toBeGreaterThanOrEqual(v.baseIndex); + expect(v.anchorLineIndex).toBeLessThanOrEqual(v.baseIndex + v.lineCount - 1); + }); +}); diff --git a/virtualizer/test/invariants.test.ts b/virtualizer/test/invariants.test.ts new file mode 100644 index 0000000..2856d8a --- /dev/null +++ b/virtualizer/test/invariants.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function mockMeasureWidth(text: string): number { + let w = 0; + for (let i = 0; i < text.length; i++) { + let cp = text.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +describe("C.INV1 — monotonic identity", () => { + it("C.INV1.monotonic-identity — each lineIndex strictly greater than previous", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24 }); + let indices: number[] = []; + for (let i = 0; i < 10; i++) { + indices.push(v.appendLine(`line ${i}`)); + } + for (let i = 1; i < indices.length; i++) { + expect(indices[i]).toBeGreaterThan(indices[i - 1]); + } + }); + + it("C.INV1.identity-survives-eviction — surviving lines keep their lineIndex", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 3 }); + v.appendLine("A"); // 0 + v.appendLine("B"); // 1 + v.appendLine("C"); // 2 + v.appendLine("D"); // 3 — evicts A(0) + expect(v.baseIndex).toBe(1); + let vp = v.resolveViewport(); + let lineIndices = vp.entries.map((e) => e.lineIndex); + expect(lineIndices).toContain(1); + expect(lineIndices).toContain(2); + expect(lineIndices).toContain(3); + }); + + it("C.INV1.identity-never-reused — each returned index is unique even with eviction", () => { + let v = new Virtualizer({ measureWidth: mockMeasureWidth, columns: 80, rows: 24, maxLines: 1 }); + let indices = [ + v.appendLine("A"), + v.appendLine("B"), + v.appendLine("C"), + v.appendLine("D"), + ]; + expect(indices).toEqual([0, 1, 2, 3]); + expect(new Set(indices).size).toBe(4); + }); +}); diff --git a/virtualizer/test/real-world-ansi.test.ts b/virtualizer/test/real-world-ansi.test.ts new file mode 100644 index 0000000..3f2d3cf --- /dev/null +++ b/virtualizer/test/real-world-ansi.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; +import * as fixtures from "./fixtures/real-world-ansi.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { i += skip; continue; } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +function assertAllOutputInvariants( + v: Virtualizer, + columns: number, +) { + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-3: wrapPoints monotonic + for (let j = 1; j < entry.wrapPoints.length; j++) { + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); + } + for (let wp of entry.wrapPoints) { + expect(wp).toBeGreaterThan(0); + expect(wp).toBeLessThan(entry.text.length); + + // O-4: not in surrogate + let prev = entry.text.charCodeAt(wp - 1); + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); + + // O-5: not in escape + let i = 0; + while (i < entry.text.length) { + let skip = skipAnsiSequence(entry.text, i); + if (skip > 0) { + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + + // O-6: totalSubRows = wrapPoints.length + 1 + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + + // O-7: subrow bounds + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + + // O-9: sliced width within columns + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice)).toBeLessThanOrEqual(columns); + } + } + + // O-8: visible rows within budget + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(v.rows); +} + +let allFixtures = Object.entries(fixtures); + +describe("Real-world ANSI fixtures", () => { + for (let [name, text] of allFixtures) { + it(`${name} at columns 80`, () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine(text); + assertAllOutputInvariants(v, 80); + }); + + it(`${name} at columns 40`, () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); + v.appendLine(text); + assertAllOutputInvariants(v, 40); + }); + } +}); diff --git a/virtualizer/test/scroll.test.ts b/virtualizer/test/scroll.test.ts new file mode 100644 index 0000000..79b8303 --- /dev/null +++ b/virtualizer/test/scroll.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("C.SCROLL — scrollBy", () => { + it("C.SCROLL.down-one-row — scrolling down shifts viewport", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 3 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // After 5 appends with bottom-follow, anchor at line 4. + // scrollBy(-4) to move anchor to line 0. + v.scrollBy(-4); + expect(v.anchorLineIndex).toBe(0); + let vp1 = v.resolveViewport(); + expect(vp1.entries.map((e) => e.lineIndex)).toEqual([0, 1, 2]); + + v.scrollBy(1); + let vp2 = v.resolveViewport(); + expect(vp2.entries.map((e) => e.lineIndex)).toEqual([1, 2, 3]); + }); + + it("C.SCROLL.up-past-top-clamps — scrolling past top clamps to baseIndex", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + v.scrollBy(-100); + expect(v.anchorLineIndex).toBe(v.baseIndex); + expect(v.anchorSubRow).toBe(0); + }); + + it("C.SCROLL.down-past-bottom-clamps-and-sets-isAtBottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + // anchor at 4 (bottom-follow), isAtBottom=true + v.scrollBy(-3); // anchor at line 1 + expect(v.isAtBottom).toBe(false); + v.scrollBy(100); + expect(v.isAtBottom).toBe(true); + }); + + it("C.SCROLL.up-clears-isAtBottom — scrolling up clears isAtBottom", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 5; i++) v.appendLine(`line ${i}`); + expect(v.isAtBottom).toBe(true); + v.scrollBy(-1); + expect(v.isAtBottom).toBe(false); + }); + + it("C.SCROLL.empty-buffer-noop — scrollBy on empty buffer is a no-op", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.scrollBy(5); + expect(v.isAtBottom).toBe(true); + expect(v.lineCount).toBe(0); + }); + + it("C.SCROLL.wrapping-lines-counted — sub-rows are counted when scrolling", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + v.appendLine("abcdefghijklmnopqrstuvwxy"); // 25 chars, 3 sub-rows (index 0) + v.appendLine("hello"); // 5 chars, 1 sub-row (index 1) + // After append, anchor at line 1 (bottom-follow). + // scrollBy(-3) to go back 3 visual rows: + // from line 1 sub-row 0 → back 1 to line 0 sub-row 2 → back 1 to sub-row 1 → back 1 to sub-row 0 + v.scrollBy(-3); + expect(v.anchorLineIndex).toBe(0); + expect(v.anchorSubRow).toBe(0); + + // scrollBy(2) → sub-row 0 → 1 → 2 + v.scrollBy(2); + expect(v.anchorLineIndex).toBe(0); + expect(v.anchorSubRow).toBe(2); + }); + + it("C.SCROLL.currentEstimate-nondecreasing-on-scroll-down", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); + v.scrollBy(-10); + let prev = v.currentEstimatedVisualRow; + v.scrollBy(1); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); + }); + + it("C.SCROLL.nonincreasing-on-scroll-up", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i}`); + v.scrollBy(-10); + let prev = v.currentEstimatedVisualRow; + v.scrollBy(-1); + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); + }); + + it("C.SCROLL.monotonicity-under-exact-estimate-mismatch — wide chars maintain monotonicity", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 }); + for (let i = 0; i < 20; i++) { + v.appendLine("文字abc文d"); // mixed wide/narrow + } + v.scrollBy(-50); // scroll near top + + // Forward: each step >= previous + let prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 10; i++) { + v.scrollBy(1); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + + // Backward: each step <= previous + prev = v.currentEstimatedVisualRow; + for (let i = 0; i < 10; i++) { + v.scrollBy(-1); + expect(v.currentEstimatedVisualRow).toBeLessThanOrEqual(prev); + prev = v.currentEstimatedVisualRow; + } + }); +}); diff --git a/virtualizer/test/suite.ts b/virtualizer/test/suite.ts new file mode 100644 index 0000000..334d8a2 --- /dev/null +++ b/virtualizer/test/suite.ts @@ -0,0 +1,2 @@ +export { beforeEach, describe, it } from "@std/testing/bdd"; +export { expect } from "@std/expect"; diff --git a/virtualizer/test/viewport.test.ts b/virtualizer/test/viewport.test.ts new file mode 100644 index 0000000..1d98488 --- /dev/null +++ b/virtualizer/test/viewport.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string, measureWidth: (t: string) => number): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + w += measureWidth(String.fromCodePoint(cp)); + if (charLen === 2) i++; + } + return w; +} + +describe("C.VIEWPORT — resolveViewport output invariants", () => { + function makeVirtualizer(columns = 20, rows = 5) { + return new Virtualizer({ measureWidth: charMeasure, columns, rows }); + } + + it("C.VIEWPORT.entries-ordered — entries have strictly increasing lineIndex", () => { + let v = makeVirtualizer(); + for (let i = 0; i < 10; i++) v.appendLine(`line ${i}`); + let vp = v.resolveViewport(); + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + }); + + it("C.VIEWPORT.text-is-original — entry text is the original string", () => { + let v = makeVirtualizer(); + let original = "hello world"; + v.appendLine(original); + let vp = v.resolveViewport(); + expect(vp.entries[0].text).toBe(original); + }); + + it("C.VIEWPORT.wrapPoints-monotonic — wrapPoints are strictly increasing in (0, text.length)", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghijklmno"); // 15 chars, wraps at 5, 10 + let vp = v.resolveViewport(); + let wp = vp.entries[0].wrapPoints; + for (let i = 0; i < wp.length; i++) { + expect(wp[i]).toBeGreaterThan(0); + expect(wp[i]).toBeLessThan(15); + if (i > 0) expect(wp[i]).toBeGreaterThan(wp[i - 1]); + } + }); + + it("C.VIEWPORT.wrapPoints-not-in-surrogate — wrapPoints don't split surrogate pairs", () => { + let v = makeVirtualizer(3); + // Emoji are surrogate pairs in UTF-16: each is 2 code units + v.appendLine("a😀b😀c"); + let vp = v.resolveViewport(); + let text = vp.entries[0].text; + for (let wp of vp.entries[0].wrapPoints) { + if (wp > 0) { + let prev = text.charCodeAt(wp - 1); + // prev should not be a high surrogate (0xD800–0xDBFF) + expect(prev >= 0xd800 && prev <= 0xdbff).toBe(false); + } + } + }); + + it("C.VIEWPORT.wrapPoints-not-in-escape — wrapPoints don't fall inside ANSI sequences", () => { + let v = makeVirtualizer(5); + v.appendLine("abcde\x1b[31mfghij\x1b[0mklmno"); + let vp = v.resolveViewport(); + let text = vp.entries[0].text; + for (let wp of vp.entries[0].wrapPoints) { + // Check that wp is not inside an ANSI sequence + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + // wp should not be in (i, i+skip) + expect(wp <= i || wp >= i + skip).toBe(true); + i += skip; + } else { + i++; + } + } + } + }); + + it("C.VIEWPORT.totalSubRows-equals-wrapPoints-plus-one", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghij"); // wraps once at 5 → 2 sub-rows + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + } + }); + + it("C.VIEWPORT.subrow-bounds — firstSubRow and visibleSubRows are valid", () => { + let v = makeVirtualizer(5); + v.appendLine("abcdefghijklmno"); + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + } + }); + + it("C.VIEWPORT.visible-rows-within-budget — total visible sub-rows ≤ rows", () => { + let v = makeVirtualizer(10, 5); + for (let i = 0; i < 20; i++) v.appendLine(`line ${i} with some text`); + let vp = v.resolveViewport(); + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(5); + }); + + it("C.VIEWPORT.sliced-width-within-columns — each sub-row fits within columns", () => { + let v = makeVirtualizer(10, 24); + v.appendLine("abcdefghijklmnopqrstuvwxyz"); + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice, charMeasure)).toBeLessThanOrEqual(10); + } + } + }); + + it("C.VIEWPORT.self-contained — viewport can be reconstructed from text + wrapPoints alone", () => { + let v = makeVirtualizer(10, 24); + v.appendLine("hello world, this is a long line of text"); + let vp = v.resolveViewport(); + for (let entry of vp.entries) { + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + expect(slices.length).toBe(entry.totalSubRows); + } + }); + + it("C.VIEWPORT.estimation-fields-present — all estimation fields defined", () => { + let v = makeVirtualizer(); + v.appendLine("test"); + let vp = v.resolveViewport(); + expect(vp.totalEstimatedVisualRows).not.toBe(undefined); + expect(vp.currentEstimatedVisualRow).not.toBe(undefined); + expect(vp.isAtBottom).not.toBe(undefined); + }); + + it("C.VIEWPORT.estimation-inequality — currentEstimatedVisualRow in valid range", () => { + let v = makeVirtualizer(); + v.appendLine("test"); + let vp = v.resolveViewport(); + expect(vp.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(vp.currentEstimatedVisualRow).toBeLessThan(vp.totalEstimatedVisualRows); + + // Empty case + let v2 = makeVirtualizer(); + let vp2 = v2.resolveViewport(); + expect(vp2.totalEstimatedVisualRows).toBe(0); + expect(vp2.currentEstimatedVisualRow).toBe(0); + }); + + it("C.VIEWPORT.empty-buffer — empty resolves to empty entries", () => { + let v = makeVirtualizer(); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + expect(vp.currentEstimatedVisualRow).toBe(0); + expect(vp.isAtBottom).toBe(true); + }); +}); diff --git a/virtualizer/test/width.test.ts b/virtualizer/test/width.test.ts new file mode 100644 index 0000000..555f94f --- /dev/null +++ b/virtualizer/test/width.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +describe("C.WIDTH — width usage", () => { + it("C.WIDTH.ansi-stripped-before-measure — measureWidth never sees ANSI bytes", () => { + let recorded: string[] = []; + let recordingMeasure = (text: string): number => { + recorded.push(text); + return 1; + }; + let v = new Virtualizer({ measureWidth: recordingMeasure, columns: 80, rows: 24 }); + v.appendLine("\x1b[31mhello\x1b[0m"); + for (let arg of recorded) { + expect(arg).not.toContain("\x1b"); + expect(arg).not.toContain("["); + expect(arg).not.toContain("3"); + expect(arg).not.toContain("1"); + expect(arg).not.toContain("m"); + } + // Only visible chars passed + let joined = recorded.join(""); + expect(joined).toBe("hello"); + }); + + it("C.WIDTH.displayWidth-matches-visible-content — cached width reflects visible chars only", () => { + let measureWidth = (_text: string): number => 1; + let v = new Virtualizer({ measureWidth, columns: 80, rows: 24 }); + let idx = v.appendLine("\x1b[31mhello\x1b[0m"); + expect(v.getLineDisplayWidth(idx)).toBe(5); + }); + + it("C.WIDTH.additivity-in-wrapping — each sub-row fits within columns", () => { + let measureWidth = (text: string): number => { + let cp = text.codePointAt(0)!; + return cp >= 0x4e00 && cp <= 0x9fff ? 2 : 1; + }; + let v = new Virtualizer({ measureWidth, columns: 10, rows: 24 }); + v.appendLine("abcdefghijklmnopqrstuvwxyz"); + let vp = v.resolveViewport(); + let entry = vp.entries[0]; + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + let w = 0; + for (let i = 0; i < slice.length; i++) { + w += measureWidth(slice[i]); + } + expect(w).toBeLessThanOrEqual(10); + } + }); +}); + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} diff --git a/virtualizer/test/wrap-golden.test.ts b/virtualizer/test/wrap-golden.test.ts new file mode 100644 index 0000000..a14af3a --- /dev/null +++ b/virtualizer/test/wrap-golden.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("G.WRAP — wrapping golden fixtures", () => { + function resolve(text: string, columns: number) { + let v = new Virtualizer({ measureWidth: charMeasure, columns, rows: 24 }); + v.appendLine(text); + let vp = v.resolveViewport(); + return vp.entries[0]; + } + + it("G.WRAP.ascii-exact-fit — 10 ASCII chars at columns 10", () => { + let entry = resolve("abcdefghij", 10); + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.ascii-one-over — 11 ASCII chars at columns 10", () => { + let entry = resolve("abcdefghijk", 10); + expect(entry.wrapPoints).toEqual([10]); + expect(entry.totalSubRows).toBe(2); + }); + + it("G.WRAP.cjk-boundary — wide char forced to next row when only 1 column left", () => { + // "abc文d" — widths 1,1,1,2,1 = 6 total + // At columns 5: abc fills 3, 文 needs 2, 3+2=5 fits. So no wrap. + // Wait — re-read plan: wrap before 文 at index 3 → [3] + // Let me check: columns=5, "abc" = 3 cols, "文" = 2 cols, 3+2=5 ≤ 5, so it fits. + // Plan says wrapPoint at [3]. But 3+2=5 is exact fit. Let me re-check the plan... + // Plan says: "abc文d" (widths 1,1,1,2,1=6) columns 5, wrap before 文 at string index 3 → [3] + // But with columns=5, "abc" uses 3, then "文" needs 2, and 3+2=5 ≤ 5. It fits! + // The total is 6 (including 'd'), so 'd' at width 6 > 5 causes wrap. + // So: "abc文" = 5 cols in row 1, "d" = 1 col in row 2 + // The wrap happens before 'd'. 文 is at string index 3 (1 char), d is at index 4. + // So wrapPoint = [4], not [3]. + // + // But the plan explicitly states [3]. Let me re-read: + // "abc文d" (widths 1,1,1,2,1=6), columns 5 + // Plan says: "wrap before 文 at string index 3 → [3]" + // This would mean: row 1 = "abc" (3 cols), row 2 = "文d" (3 cols) + // But 3 + 2 = 5 ≤ 5 — 文 fits on row 1! + // Unless the plan intended columns=4? + // + // The test fixture is from the plan, so I'll test what the algorithm actually does + // and verify it's correct behavior. With columns=5, "abc文" fits (width 5). + // The wrap happens before "d" wouldn't happen either since 5+1=6 > 5, wrap before d at index 4. + // + // Actually wait: the wrap point is where we'd exceed columns. + // a(1) b(2) c(3) 文(5) — fits exactly. d would be col 6 > 5 → wrap before d. + // d is at string index 4 (文 is one char at index 3). So wrapPoint = [4]. + // + // The plan example seems wrong about columns=5 producing [3]. + // With columns=4: a(1) b(2) c(3) 文 needs 2, 3+2=5>4 → wrap before 文 at index 3. [3]. ✓ + // I'll test with columns=4 to match the plan's expected output. + let entry = resolve("abc文d", 4); + expect(entry.wrapPoints).toEqual([3]); + expect(entry.totalSubRows).toBe(2); + }); + + it("G.WRAP.cjk-exact-fit — CJK chars fit exactly", () => { + let entry = resolve("文字", 4); // width 4 + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.cjk-exact-fit-at-boundary — mixed content exactly fills columns", () => { + let entry = resolve("abc文", 5); // widths 1+1+1+2=5 + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); + + it("G.WRAP.empty-line — empty string produces no wrap", () => { + let entry = resolve("", 80); + expect(entry.wrapPoints).toEqual([]); + expect(entry.totalSubRows).toBe(1); + }); +}); diff --git a/virtualizer/types.ts b/virtualizer/types.ts new file mode 100644 index 0000000..e0e741d --- /dev/null +++ b/virtualizer/types.ts @@ -0,0 +1,22 @@ +export interface VirtualizerOptions { + measureWidth: (text: string) => number; + maxLines?: number; + columns: number; + rows: number; +} + +export interface ViewportEntry { + lineIndex: number; + text: string; + wrapPoints: number[]; + totalSubRows: number; + firstSubRow: number; + visibleSubRows: number; +} + +export interface ResolvedViewport { + entries: ViewportEntry[]; + totalEstimatedVisualRows: number; + currentEstimatedVisualRow: number; + isAtBottom: boolean; +} diff --git a/virtualizer/virtualizer.ts b/virtualizer/virtualizer.ts new file mode 100644 index 0000000..22fd18e --- /dev/null +++ b/virtualizer/virtualizer.ts @@ -0,0 +1,388 @@ +import { RingBuffer } from "./ring-buffer.ts"; +import { computeDisplayWidth, computeWrapPoints } from "./wrap-walker.ts"; +import type { + ResolvedViewport, + ViewportEntry, + VirtualizerOptions, +} from "./types.ts"; + +export class Virtualizer { + private _ringBuffer: RingBuffer; + private _wrapCache: Map; + private _measureWidth: (text: string) => number; + private _columns: number; + private _rows: number; + private _anchorLineIndex: number; + private _anchorSubRow: number; + private _isAtBottom: boolean; + private _totalEstimatedVisualRows: number; + private _currentEstimatedVisualRow: number; + + constructor(options: VirtualizerOptions) { + let maxLines = options.maxLines ?? 10_000; + this._ringBuffer = new RingBuffer(maxLines); + this._wrapCache = new Map(); + this._measureWidth = options.measureWidth; + this._columns = options.columns; + this._rows = options.rows; + this._anchorLineIndex = 0; + this._anchorSubRow = 0; + this._isAtBottom = true; + this._totalEstimatedVisualRows = 0; + this._currentEstimatedVisualRow = 0; + } + + // Read-only observable state + get lineCount(): number { + return this._ringBuffer.lineCount; + } + + get baseIndex(): number { + return this._ringBuffer.baseIndex; + } + + get columns(): number { + return this._columns; + } + + get rows(): number { + return this._rows; + } + + get totalEstimatedVisualRows(): number { + return this._totalEstimatedVisualRows; + } + + get currentEstimatedVisualRow(): number { + return this._currentEstimatedVisualRow; + } + + get isAtBottom(): boolean { + return this._isAtBottom; + } + + get anchorLineIndex(): number { + return this._anchorLineIndex; + } + + get anchorSubRow(): number { + return this._anchorSubRow; + } + + private _estimateVisualRows(displayWidth: number): number { + return Math.max(1, Math.ceil(displayWidth / this._columns)); + } + + /** + * appendLine(text) — §10.2, 6-step control flow: + * + * 1. Compute displayWidth (always) + * 2. If at capacity: evict oldest line + * 3. Store the line in ring buffer (always) + * 4. Increment totalEstimatedVisualRows (always) + * 5. If isAtBottom: advance anchor (always) + * 6. Return lineIndex (always) + */ + appendLine(text: string): number { + // Step 1: Compute displayWidth + let displayWidth = computeDisplayWidth(text, this._measureWidth); + + // Step 2: If at capacity, evict before insertion + if ( + this._ringBuffer.lineCount === this._ringBuffer.capacity + ) { + let evictedLineIndex = this._ringBuffer.baseIndex; + let evictedEntry = this._ringBuffer.get(evictedLineIndex)!; + let evictedEstimate = this._estimateVisualRows(evictedEntry.displayWidth); + + // Remove evicted line's wrap cache entry (§11.3 step 4) + this._wrapCache.delete(evictedLineIndex); + + // Decrement total by evicted line's estimate + this._totalEstimatedVisualRows -= evictedEstimate; + + // Handle anchor per §11.4 + 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 + } + } + + // Step 3: Store the line + let result = this._ringBuffer.append(text, displayWidth); + let newLineIndex = result.lineIndex; + + // Step 4: Increment totalEstimatedVisualRows + let newEstimate = this._estimateVisualRows(displayWidth); + this._totalEstimatedVisualRows += newEstimate; + + // Step 5: If isAtBottom, advance anchor + if (this._isAtBottom) { + this._anchorLineIndex = newLineIndex; + this._anchorSubRow = 0; + this._currentEstimatedVisualRow = + this._totalEstimatedVisualRows - newEstimate; + } + + // Step 6: Return lineIndex + return newLineIndex; + } + + resize(_columns: number, _rows: number): void { + throw new Error("not implemented"); + } + + /** + * scrollBy(deltaVisualRows) — §10.4 + * + * Walk forward (positive) or backward (negative) from anchor through + * exact sub-rows. Clamp at buffer boundaries. Update isAtBottom and + * currentEstimatedVisualRow. + */ + scrollBy(deltaVisualRows: number): void { + if (this._ringBuffer.lineCount === 0) return; + + let remaining = deltaVisualRows; + + if (remaining > 0) { + // Scroll down (forward) + while (remaining > 0) { + let entry = this._ringBuffer.get(this._anchorLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(this._anchorLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + let availableInLine = totalSubRows - this._anchorSubRow - 1; + + if (remaining <= availableInLine) { + this._anchorSubRow += remaining; + remaining = 0; + } else { + // Move to next line + let nextEntry = this._ringBuffer.get(this._anchorLineIndex + 1); + if (!nextEntry) { + // Clamp at bottom of current line + this._anchorSubRow = totalSubRows - 1; + remaining = 0; + } else { + remaining -= availableInLine + 1; + this._anchorLineIndex++; + this._anchorSubRow = 0; + } + } + } + + // Check if we're at the very bottom + let lastLineIndex = this._ringBuffer.baseIndex + this._ringBuffer.lineCount - 1; + if (this._anchorLineIndex === lastLineIndex) { + let lastEntry = this._ringBuffer.get(lastLineIndex)!; + let lastWrap = this._getWrapPoints(lastLineIndex, lastEntry.text); + let lastTotalSubRows = lastWrap.length + 1; + if (this._anchorSubRow === lastTotalSubRows - 1) { + this._isAtBottom = true; + } + } + } else if (remaining < 0) { + // Scroll up (backward) + if (this._isAtBottom) { + this._isAtBottom = false; + } + + remaining = -remaining; // work with positive count + + while (remaining > 0) { + if (this._anchorSubRow >= remaining) { + this._anchorSubRow -= remaining; + remaining = 0; + } else { + remaining -= this._anchorSubRow; + // Move to previous line + let prevEntry = this._ringBuffer.get(this._anchorLineIndex - 1); + if (!prevEntry) { + // Clamp at top + this._anchorSubRow = 0; + remaining = 0; + } else { + this._anchorLineIndex--; + let prevWrap = this._getWrapPoints(this._anchorLineIndex, prevEntry.text); + let prevTotalSubRows = prevWrap.length + 1; + this._anchorSubRow = prevTotalSubRows - 1; + remaining -= 1; // consumed one row entering this line's last sub-row + } + } + } + } + + // Update currentEstimatedVisualRow: sum estimates for all lines before anchor + anchorSubRow + this._recomputeCurrentEstimate(); + } + + /** + * scrollToFraction(fraction) — §10.5 + * + * Map fraction to an estimated visual row, then walk from baseIndex + * to find the corresponding anchor position. + */ + scrollToFraction(fraction: number): void { + if (this._ringBuffer.lineCount === 0) return; + + let target = Math.min( + Math.floor(fraction * this._totalEstimatedVisualRows), + Math.max(this._totalEstimatedVisualRows - 1, 0), + ); + + let accumulated = 0; + let baseIndex = this._ringBuffer.baseIndex; + let lastLineIndex = baseIndex + this._ringBuffer.lineCount - 1; + + for (let i = baseIndex; i <= lastLineIndex; i++) { + let entry = this._ringBuffer.get(i)!; + let estimate = this._estimateVisualRows(entry.displayWidth); + + if (accumulated + estimate > target) { + this._anchorLineIndex = i; + this._anchorSubRow = Math.min(target - accumulated, estimate - 1); + break; + } + + accumulated += estimate; + + if (i === lastLineIndex) { + // fraction >= 1 or rounding landed past end + this._anchorLineIndex = i; + this._anchorSubRow = estimate - 1; + } + } + + // Determine isAtBottom + let lastEntry = this._ringBuffer.get(lastLineIndex)!; + let lastEstimate = this._estimateVisualRows(lastEntry.displayWidth); + this._isAtBottom = + this._anchorLineIndex === lastLineIndex && + this._anchorSubRow === lastEstimate - 1; + + this._recomputeCurrentEstimate(); + } + + private _recomputeCurrentEstimate(): void { + let estimate = 0; + let baseIndex = this._ringBuffer.baseIndex; + for (let i = baseIndex; i < this._anchorLineIndex; i++) { + let entry = this._ringBuffer.get(i); + if (!entry) break; + estimate += this._estimateVisualRows(entry.displayWidth); + } + estimate += this._anchorSubRow; + this._currentEstimatedVisualRow = estimate; + } + + getLineDisplayWidth(lineIndex: number): number | undefined { + return this._ringBuffer.get(lineIndex)?.displayWidth; + } + + private _getWrapPoints(lineIndex: number, text: string): number[] { + let cached = this._wrapCache.get(lineIndex); + if (cached !== undefined) return cached; + let wp = computeWrapPoints(text, this._columns, this._measureWidth); + this._wrapCache.set(lineIndex, wp); + return wp; + } + + resolveViewport(): ResolvedViewport { + if (this._ringBuffer.lineCount === 0) { + return { + entries: [], + totalEstimatedVisualRows: 0, + currentEstimatedVisualRow: 0, + isAtBottom: true, + }; + } + + let forwardEntries: ViewportEntry[] = []; + let rowsBudget = this._rows; + let currentLineIndex = this._anchorLineIndex; + let startSubRow = this._anchorSubRow; + + // Walk forward from anchor, filling viewport rows + while (rowsBudget > 0) { + let entry = this._ringBuffer.get(currentLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(currentLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + + let firstSubRow = startSubRow; + let availableSubRows = totalSubRows - firstSubRow; + let visibleSubRows = Math.min(availableSubRows, rowsBudget); + + forwardEntries.push({ + lineIndex: currentLineIndex, + text: entry.text, + wrapPoints, + totalSubRows, + firstSubRow, + visibleSubRows, + }); + + rowsBudget -= visibleSubRows; + currentLineIndex++; + startSubRow = 0; + } + + // Backfill: if forward walk didn't fill the viewport, walk backward + let backEntries: ViewportEntry[] = []; + if (rowsBudget > 0) { + // First, expand anchor line's visible sub-rows upward if possible + if (this._anchorSubRow > 0 && forwardEntries.length > 0) { + let anchor = forwardEntries[0]; + let fillAbove = Math.min(this._anchorSubRow, rowsBudget); + forwardEntries[0] = { + ...anchor, + firstSubRow: anchor.firstSubRow - fillAbove, + visibleSubRows: anchor.visibleSubRows + fillAbove, + }; + rowsBudget -= fillAbove; + } + + // Walk backward through earlier lines + let backLineIndex = this._anchorLineIndex - 1; + while (rowsBudget > 0 && backLineIndex >= this._ringBuffer.baseIndex) { + let entry = this._ringBuffer.get(backLineIndex); + if (!entry) break; + + let wrapPoints = this._getWrapPoints(backLineIndex, entry.text); + let totalSubRows = wrapPoints.length + 1; + let visibleSubRows = Math.min(totalSubRows, rowsBudget); + let firstSubRow = totalSubRows - visibleSubRows; + + backEntries.push({ + lineIndex: backLineIndex, + text: entry.text, + wrapPoints, + totalSubRows, + firstSubRow, + visibleSubRows, + }); + + rowsBudget -= visibleSubRows; + backLineIndex--; + } + } + + let entries = [...backEntries.reverse(), ...forwardEntries]; + + return { + entries, + totalEstimatedVisualRows: this._totalEstimatedVisualRows, + currentEstimatedVisualRow: this._currentEstimatedVisualRow, + isAtBottom: this._isAtBottom, + }; + } +} diff --git a/virtualizer/wrap-walker.ts b/virtualizer/wrap-walker.ts new file mode 100644 index 0000000..0334cf0 --- /dev/null +++ b/virtualizer/wrap-walker.ts @@ -0,0 +1,66 @@ +import { skipAnsiSequence } from "./ansi-scanner.ts"; + +/** + * Compute the display width of a line, skipping recognized ANSI sequences + * and calling measureWidth only on visible characters. + */ +export function computeDisplayWidth( + text: string, + measureWidth: (text: string) => number, +): number { + let width = 0; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + // Handle surrogate pairs + let cp = text.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + width += measureWidth(String.fromCodePoint(cp)); + i += charLen; + } + return width; +} + +/** + * Compute wrap points for a line at the given column width. + * Returns strictly increasing UTF-16 offsets where wraps occur. + * + * - Wide chars (width 2) with only 1 column left → wrap before. + * - Never splits surrogate pairs. + * - Never splits inside recognized ANSI sequences. + */ +export function computeWrapPoints( + text: string, + columns: number, + measureWidth: (text: string) => number, +): number[] { + let wrapPoints: number[] = []; + let col = 0; + let i = 0; + + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { + i += skip; + continue; + } + + let cp = text.codePointAt(i)!; + let charLen = cp > 0xffff ? 2 : 1; + let w = measureWidth(String.fromCodePoint(cp)); + + if (w > 0 && col + w > columns) { + wrapPoints.push(i); + col = 0; + } + + col += w; + i += charLen; + } + + return wrapPoints; +} diff --git a/width.ts b/width.ts new file mode 100644 index 0000000..5839752 --- /dev/null +++ b/width.ts @@ -0,0 +1,41 @@ +import { compiled } from "./wasm.ts"; + +export async function createDisplayWidth(): Promise<(text: string) => number> { + let memory = new WebAssembly.Memory({ initial: 4 }); + + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction() {}, + queryScrollOffsetFunction(ret: number) { + let v = new DataView(memory.buffer); + v.setFloat32(ret, 0, true); + v.setFloat32(ret + 4, 0, true); + }, + }, + }); + + let exports = instance.exports as unknown as { + __heap_base: WebAssembly.Global; + display_width(ptr: number, len: number): number; + }; + + let heap = exports.__heap_base.value as number; + let encoder = new TextEncoder(); + + return function displayWidth(text: string): number { + let encoded = encoder.encode(text); + let len = encoded.byteLength; + + // Grow memory if needed to fit the encoded string + let needed = heap + len; + let pages = Math.ceil(needed / 65536); + let current = memory.buffer.byteLength / 65536; + if (pages > current) { + memory.grow(pages - current); + } + + new Uint8Array(memory.buffer, heap, len).set(encoded); + return exports.display_width(heap, len); + }; +}