Skip to content

Commit 7462e0d

Browse files
committed
✨ add @clayterm/virtualizer v1 (PRs 1–3)
Implement the virtualizer package for viewport virtualization over large terminal text output. This covers PRs 1–3 of the implementation plan: - PR 1: Export createDisplayWidth from renderer (WASM per-codepoint wcwidth, R.WIDTH.* tests) - PR 2: Virtualizer core — appendLine, resolveViewport, ring buffer, ANSI scanner, wrap walker, getLineDisplayWidth (~66 tests) - PR 3: scrollBy + scrollToFraction with all deferred scroll-dependent tests (~92 tests total) resize() still throws "not implemented" — deferred to PR 4.
1 parent c1e987f commit 7462e0d

26 files changed

Lines changed: 1939 additions & 0 deletions

mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./term.ts";
33
export * from "./input.ts";
44
export * from "./settings.ts";
55
export * from "./termcodes.ts";
6+
export { createDisplayWidth } from "./width.ts";

src/clayterm.c

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* output — pointer to output byte buffer
88
* length — length of output byte buffer
99
* measure — Clay text measurement callback
10+
* display_width — per-codepoint wcwidth sum over a UTF-8 string
1011
*/
1112

1213
#include "clayterm.h"
@@ -656,3 +657,32 @@ void measure(int ret, int txt) {
656657
dims[0] = (float)w;
657658
dims[1] = 1.0f;
658659
}
660+
661+
/* ── display_width — per-codepoint wcwidth sum ───────────────────── */
662+
663+
/**
664+
* Compute the display width of a UTF-8 string by summing max(0, wcwidth(cp))
665+
* for each Unicode codepoint. No ANSI skipping — pure per-codepoint sum.
666+
*
667+
* @param str Pointer to a UTF-8 encoded string (not necessarily null-terminated).
668+
* @param len Byte length of the string.
669+
* @return Total display width (non-negative).
670+
*/
671+
int display_width(const char *str, int len) {
672+
int w = 0;
673+
const char *p = str;
674+
int rem = len;
675+
while (rem > 0) {
676+
uint32_t cp;
677+
int n = utf8_decode(&cp, p);
678+
if (n <= 0) {
679+
n = 1;
680+
}
681+
int cw = wcwidth(cp);
682+
if (cw > 0)
683+
w += cw;
684+
p += n;
685+
rem -= n;
686+
}
687+
return w;
688+
}

test/width.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { beforeEach, describe, expect, it } from "./suite.ts";
2+
import { createDisplayWidth } from "../width.ts";
3+
4+
describe("createDisplayWidth", () => {
5+
let displayWidth: (text: string) => number;
6+
7+
beforeEach(async () => {
8+
displayWidth = await createDisplayWidth();
9+
});
10+
11+
it("R.WIDTH.ascii — ASCII characters each have width 1", () => {
12+
expect(displayWidth("hello")).toBe(5);
13+
});
14+
15+
it("R.WIDTH.cjk — CJK characters each have width 2", () => {
16+
expect(displayWidth("文字")).toBe(4);
17+
});
18+
19+
it("R.WIDTH.combining — combining marks have width 0", () => {
20+
expect(displayWidth("e\u0301")).toBe(1);
21+
});
22+
23+
it("R.WIDTH.zwj-emoji — ZWJ emoji sequence uses per-codepoint wcwidth", () => {
24+
expect(displayWidth("👨‍👩‍👧‍👦")).toBe(8);
25+
});
26+
27+
it("R.WIDTH.additivity — width of concatenation equals sum of widths", () => {
28+
let pairs: [string, string][] = [
29+
["hello", "world"],
30+
["文", "字"],
31+
["abc", "文字"],
32+
["e\u0301", "hello"],
33+
];
34+
for (let [a, b] of pairs) {
35+
expect(displayWidth(a + b)).toBe(displayWidth(a) + displayWidth(b));
36+
}
37+
});
38+
39+
it("R.WIDTH.empty-string — empty string has width 0", () => {
40+
expect(displayWidth("")).toBe(0);
41+
});
42+
43+
it("R.WIDTH.zero-width — zero-width characters have width 0", () => {
44+
expect(displayWidth("\u200B")).toBe(0);
45+
expect(displayWidth("\u200D")).toBe(0);
46+
});
47+
});

virtualizer/ansi-scanner.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* If text[pos] starts an ESC-initiated CSI or OSC sequence, return the
3+
* byte length of the full sequence (inclusive). Otherwise return 0.
4+
*
5+
* CSI: ESC [ ... <final byte 0x40–0x7E>
6+
* OSC: ESC ] ... <BEL (0x07) or ST (ESC \)>
7+
*/
8+
export function skipAnsiSequence(text: string, pos: number): number {
9+
if (text.charCodeAt(pos) !== 0x1b) return 0;
10+
if (pos + 1 >= text.length) return 0;
11+
12+
let next = text.charCodeAt(pos + 1);
13+
14+
// CSI: ESC [
15+
if (next === 0x5b) {
16+
let i = pos + 2;
17+
while (i < text.length) {
18+
let ch = text.charCodeAt(i);
19+
if (ch >= 0x40 && ch <= 0x7e) {
20+
return i - pos + 1;
21+
}
22+
i++;
23+
}
24+
// Unterminated CSI — consume what we have
25+
return i - pos;
26+
}
27+
28+
// OSC: ESC ]
29+
if (next === 0x5d) {
30+
let i = pos + 2;
31+
while (i < text.length) {
32+
let ch = text.charCodeAt(i);
33+
// BEL terminator
34+
if (ch === 0x07) {
35+
return i - pos + 1;
36+
}
37+
// ST terminator: ESC backslash
38+
if (ch === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c) {
39+
return i - pos + 2;
40+
}
41+
i++;
42+
}
43+
// Unterminated OSC — consume what we have
44+
return i - pos;
45+
}
46+
47+
return 0;
48+
}

virtualizer/deno.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@clayterm/virtualizer",
3+
"license": "MIT",
4+
"exports": { ".": "./mod.ts" },
5+
"imports": {
6+
"@std/testing": "jsr:@std/testing@1",
7+
"@std/expect": "jsr:@std/expect@1"
8+
}
9+
}

virtualizer/deno.lock

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

virtualizer/mod.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { Virtualizer } from "./virtualizer.ts";
2+
export type {
3+
ResolvedViewport,
4+
ViewportEntry,
5+
VirtualizerOptions,
6+
} from "./types.ts";

virtualizer/ring-buffer.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
export interface LineEntry {
2+
text: string;
3+
displayWidth: number;
4+
lineIndex: number;
5+
}
6+
7+
export interface AppendResult {
8+
lineIndex: number;
9+
evicted?: { displayWidth: number; lineIndex: number };
10+
}
11+
12+
export class RingBuffer {
13+
private _items: (LineEntry | undefined)[];
14+
private _capacity: number;
15+
private _head: number;
16+
private _count: number;
17+
private _nextIndex: number;
18+
19+
constructor(capacity: number) {
20+
this._capacity = capacity;
21+
this._items = new Array(capacity);
22+
this._head = 0;
23+
this._count = 0;
24+
this._nextIndex = 0;
25+
}
26+
27+
get capacity(): number {
28+
return this._capacity;
29+
}
30+
31+
get lineCount(): number {
32+
return this._count;
33+
}
34+
35+
get baseIndex(): number {
36+
if (this._count === 0) return this._nextIndex;
37+
return this._items[this._head]!.lineIndex;
38+
}
39+
40+
append(text: string, displayWidth: number): AppendResult {
41+
let lineIndex = this._nextIndex++;
42+
let evicted: { displayWidth: number; lineIndex: number } | undefined;
43+
44+
if (this._count === this._capacity) {
45+
let evictedEntry = this._items[this._head]!;
46+
evicted = {
47+
displayWidth: evictedEntry.displayWidth,
48+
lineIndex: evictedEntry.lineIndex,
49+
};
50+
this._head = (this._head + 1) % this._capacity;
51+
this._count--;
52+
}
53+
54+
let slot = (this._head + this._count) % this._capacity;
55+
this._items[slot] = { text, displayWidth, lineIndex };
56+
this._count++;
57+
58+
return { lineIndex, evicted };
59+
}
60+
61+
get(lineIndex: number): LineEntry | undefined {
62+
if (this._count === 0) return undefined;
63+
let base = this._items[this._head]!.lineIndex;
64+
let offset = lineIndex - base;
65+
if (offset < 0 || offset >= this._count) return undefined;
66+
return this._items[(this._head + offset) % this._capacity];
67+
}
68+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import { Virtualizer } from "../mod.ts";
3+
4+
function charMeasure(text: string): number {
5+
let cp = text.codePointAt(0)!;
6+
if (cp >= 0x4e00 && cp <= 0x9fff) return 2;
7+
if (cp < 0x20) return 0;
8+
return 1;
9+
}
10+
11+
describe("G.ANSI — ANSI golden fixtures", () => {
12+
it("G.ANSI.simple-sgr — SGR sequence does not add sub-rows", () => {
13+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
14+
let idx = v.appendLine("\x1b[31mred\x1b[0m");
15+
let vp = v.resolveViewport();
16+
expect(vp.entries[0].totalSubRows).toBe(1);
17+
expect(v.getLineDisplayWidth(idx)).toBe(3);
18+
});
19+
20+
it("G.ANSI.sgr-at-wrap-boundary — wrap occurs at visible char boundary, not inside SGR", () => {
21+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 5, rows: 24 });
22+
v.appendLine("abcde\x1b[31mfghij");
23+
let vp = v.resolveViewport();
24+
let entry = vp.entries[0];
25+
// Visible: "abcde" (5) then "fghij" (5). CSI "\x1b[31m" at indices 5–9 (5 chars).
26+
// Wrap after 5 visible chars → wrap point at index 10 (start of 'f', after CSI)
27+
expect(entry.wrapPoints).toEqual([10]);
28+
let slices = [entry.text.slice(0, 10), entry.text.slice(10)];
29+
expect(slices[0]).toBe("abcde\x1b[31m");
30+
expect(slices[1]).toBe("fghij");
31+
});
32+
33+
it("G.ANSI.osc-with-bel — OSC with BEL terminator", () => {
34+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
35+
let idx = v.appendLine("\x1b]0;title\x07visible");
36+
let vp = v.resolveViewport();
37+
expect(vp.entries[0].totalSubRows).toBe(1);
38+
expect(v.getLineDisplayWidth(idx)).toBe(7);
39+
});
40+
41+
it("G.ANSI.osc-with-st — OSC with ST terminator", () => {
42+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
43+
let idx = v.appendLine("\x1b]0;title\x1b\\visible");
44+
let vp = v.resolveViewport();
45+
expect(vp.entries[0].totalSubRows).toBe(1);
46+
expect(v.getLineDisplayWidth(idx)).toBe(7);
47+
});
48+
49+
it("G.ANSI.nested-csi-in-wrapped-line — multiple CSI in wrapping line, no wrap inside CSI", () => {
50+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 3, rows: 24 });
51+
// "a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me" — 5 visible chars: a, b, c, d, e
52+
v.appendLine("a\x1b[1mb\x1b[0mc\x1b[32md\x1b[0me");
53+
let vp = v.resolveViewport();
54+
let entry = vp.entries[0];
55+
// 5 visible chars at columns 3 → 2 sub-rows
56+
expect(entry.totalSubRows).toBe(2);
57+
// No wrap point should fall inside any CSI sequence
58+
let text = entry.text;
59+
for (let wp of entry.wrapPoints) {
60+
expect(text.charCodeAt(wp)).not.toBe(0x5b); // not '['
61+
// wp should not be between ESC and final byte
62+
if (wp > 0 && text.charCodeAt(wp - 1) === 0x1b) {
63+
// wp right after ESC — that's inside the sequence
64+
expect(true).toBe(false);
65+
}
66+
}
67+
});
68+
});

0 commit comments

Comments
 (0)