diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
new file mode 100644
index 00000000..ee1f1171
--- /dev/null
+++ b/.github/workflows/bench.yml
@@ -0,0 +1,32 @@
+name: Benchmarks
+
+on:
+ pull_request:
+
+# Shared runners are too noisy to fail a pull request on a number, so the
+# comparison is a report in the job summary, not a check.
+jobs:
+ compare:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - uses: actions/setup-node@v5
+ with:
+ node-version: 26
+
+ - run: bun install --frozen-lockfile
+
+ - name: Compare with the base branch
+ run: |
+ {
+ echo "## Benchmarks: median CPU time, base against this pull request"
+ echo
+ node bench/run.ts --runs 3 --against "origin/${{ github.base_ref }}" --markdown
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/bench/editor.ts b/bench/editor.ts
new file mode 100644
index 00000000..042a89c3
--- /dev/null
+++ b/bench/editor.ts
@@ -0,0 +1,49 @@
+/**
+ * CodeMirror as the example runs it, on a 400-line file, with the cursor
+ * near the top.
+ */
+import {installGlobals, mount, nextFrame} from "./harness.ts";
+
+export async function mountEditor(): Promise<{proc: any; dom: any; view: any}> {
+ const {proc, dom} = await mount(100, 40, {cellSize: "auto"});
+ installGlobals(dom);
+ const {EditorState} = await import("@codemirror/state");
+ const {EditorView, keymap, lineNumbers} = await import("@codemirror/view");
+ const {defaultKeymap, history, historyKeymap} = await import(
+ "@codemirror/commands",
+ );
+ const {javascript} = await import("@codemirror/lang-javascript");
+ const {defaultHighlightStyle, syntaxHighlighting} = await import(
+ "@codemirror/language",
+ );
+
+ const {document} = dom;
+ document.body.innerHTML = "
Editor
";
+ const doc = Array.from(
+ {length: 100},
+ (_, i) =>
+ [
+ `function step${i}(value: number, rect: {x: number; y: number}): number {`,
+ `\tconst scaled = value * ${i + 1} + rect.x; // step ${i}`,
+ "\treturn Math.max(scaled, rect.y);",
+ "}",
+ ].join("\n"),
+ ).join("\n");
+ const view = new EditorView({
+ state: EditorState.create({
+ doc,
+ extensions: [
+ lineNumbers(),
+ history(),
+ javascript({typescript: true}),
+ syntaxHighlighting(defaultHighlightStyle),
+ keymap.of([...defaultKeymap, ...historyKeymap]),
+ ],
+ }),
+ parent: document.getElementById("editor"),
+ });
+ view.focus();
+ view.dispatch({selection: {anchor: doc.indexOf("\n", 200)}});
+ await nextFrame(dom);
+ return {proc, dom, view};
+}
diff --git a/bench/harness.ts b/bench/harness.ts
new file mode 100644
index 00000000..23c27ee3
--- /dev/null
+++ b/bench/harness.ts
@@ -0,0 +1,161 @@
+/**
+ * What every scenario shares. A scenario runs against the checkout named
+ * by its first argument, so the runner can point the same scenario at two
+ * checkouts, and prints one JSON line per measurement.
+ */
+import {spawn} from "node:child_process";
+import {readFileSync} from "node:fs";
+import {availableParallelism} from "node:os";
+
+export const root = process.argv[2];
+
+/** The bench's own checkout, which every input is read from. */
+export const benchRoot = new URL("..", import.meta.url).pathname;
+
+const index = await import(`${root}/src/index.ts`);
+const utils = await import(`${root}/tests/test-utils.ts`);
+const painter = await import(`${root}/src/internal/painter.ts`);
+// The test utilities check every partial frame against a whole one, which
+// is not what a user's frame costs.
+if (painter.partialFrameCheck !== undefined) {
+ painter.partialFrameCheck.enabled = false;
+}
+const cssom = await import(`${root}/src/internal/cssom.ts`);
+if (cssom.styleCheck !== undefined) {
+ cssom.styleCheck.enabled = false;
+}
+const layout = await import(`${root}/src/internal/layout.ts`);
+if (layout.layoutCheck !== undefined) {
+ layout.layoutCheck.enabled = false;
+}
+
+export const TermDOM = index.TermDOM;
+export const installGlobals = index.installGlobals;
+export const MockProcess = utils.MockProcess;
+export const nextFrame: (dom: unknown) => Promise = utils.nextFrame;
+
+export function readBenchFile(path: string): string {
+ return readFileSync(`${benchRoot}${path}`, "utf8");
+}
+
+export async function mount(
+ cols = 100,
+ rows = 40,
+ options: object = {},
+): Promise<{proc: any; dom: any}> {
+ const proc = new MockProcess({cols, rows});
+ const dom = new TermDOM({...options, transport: proc.transport});
+ dom.attach();
+ await nextFrame(dom);
+ return {proc, dom};
+}
+
+/**
+ * The terminal becomes a sink once the page is up, so what is measured
+ * is the engine and not the emulator reading its output.
+ */
+export function silence(proc: any): void {
+ proc.stdout.write = (_chunk: unknown, enc?: unknown, cb?: unknown) => {
+ const callback = typeof enc === "function" ? enc : cb;
+ if (typeof callback === "function") {
+ (callback as () => void)();
+ }
+ return true;
+ };
+}
+
+/** CPU time, so the time a frame waits for its turn is not counted. */
+export function cpuMs(): number {
+ const {user, system} = process.cpuUsage();
+ return (user + system) / 1000;
+}
+
+export function report(label: string, times: number[]): void {
+ const sorted = [...times].sort((a, b) => a - b);
+ const median = sorted[Math.floor(sorted.length / 2)];
+ console.log(JSON.stringify({label, median}));
+}
+
+export async function measure(
+ label: string,
+ steps: number,
+ step: (i: number) => Promise | void,
+ warm = 10,
+): Promise {
+ for (let i = 0; i < warm; i++) {
+ await step(i);
+ }
+ const times: number[] = [];
+ for (let i = warm; i < warm + steps; i++) {
+ const start = cpuMs();
+ await step(i);
+ times.push(cpuMs() - start);
+ }
+ report(label, times);
+}
+
+/**
+ * Reports wall times as a measurement, and prints their percentiles, which
+ * a median alone hides, to stderr.
+ */
+export function describeLatency(label: string, times: number[]): void {
+ report(label, times);
+ const sorted = [...times].sort((a, b) => a - b);
+ const at = (q: number): string =>
+ sorted[Math.floor(sorted.length * q)].toFixed(1);
+ console.error(
+ `${label}: p50 ${at(0.5)} p90 ${at(0.9)} p99 ${at(0.99)} max ${sorted.at(-1)!.toFixed(1)}`,
+ );
+}
+
+/**
+ * Times each key from reaching the program to its frame reaching the
+ * terminal, typed at a person's pace.
+ */
+export function keyLatency(proc: any): (key: string) => Promise {
+ let wrote: (() => void) | null = null;
+ proc.stdout.write = (_chunk: unknown, enc?: unknown, cb?: unknown) => {
+ wrote?.();
+ const callback = typeof enc === "function" ? enc : cb;
+ if (typeof callback === "function") {
+ (callback as () => void)();
+ }
+ return true;
+ };
+ return async (key) => {
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const painted = new Promise((resolve) => {
+ wrote = resolve;
+ });
+ const start = performance.now();
+ proc.stdin.simulateResponse(key);
+ await painted;
+ wrote = null;
+ return performance.now() - start;
+ };
+}
+
+/** Runs the work with an idle machine, every core busy, and two spinners a core. */
+export async function underLoad(
+ label: string,
+ work: () => Promise,
+): Promise {
+ describeLatency(`${label}, idle`, await work());
+ for (const [load, perCore] of [
+ ["every core busy", 1],
+ ["two spinners a core", 2],
+ ] as const) {
+ const spinners = Array.from(
+ {length: availableParallelism() * perCore},
+ () => spawn(process.execPath, ["-e", "for (;;) {}"], {stdio: "ignore"}),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ try {
+ describeLatency(`${label}, ${load}`, await work());
+ } finally {
+ for (const spinner of spinners) {
+ spinner.kill();
+ }
+ }
+ }
+}
diff --git a/bench/run.ts b/bench/run.ts
new file mode 100644
index 00000000..4562a2da
--- /dev/null
+++ b/bench/run.ts
@@ -0,0 +1,145 @@
+/**
+ * Runs the scenarios in bench/scenarios, each in a process of its own,
+ * and prints the median CPU time of each measurement.
+ *
+ * node bench/run.ts [--runs N] [--only a,b] [--against REF] [--markdown]
+ *
+ * --against checks REF out into a temporary worktree and runs the same
+ * scenarios against it, alternating with this checkout, so both sides
+ * share whatever the machine was doing.
+ */
+import {spawnSync} from "node:child_process";
+import {mkdtempSync, readdirSync, rmSync, symlinkSync} from "node:fs";
+import {tmpdir} from "node:os";
+import {join} from "node:path";
+
+const benchRoot = new URL("..", import.meta.url).pathname;
+const scenarioDirectory = new URL("scenarios/", import.meta.url).pathname;
+
+function option(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? undefined : process.argv[index + 1];
+}
+
+const runs = Number(option("--runs") ?? 3);
+const only = option("--only")?.split(",");
+const against = option("--against");
+const markdown = process.argv.includes("--markdown");
+
+const scenarios = readdirSync(scenarioDirectory)
+ .filter((name) => name.endsWith(".ts"))
+ .map((name) => name.slice(0, -3))
+ .filter((name) => only === undefined || only.includes(name));
+
+function git(...args: string[]): void {
+ const result = spawnSync("git", args, {cwd: benchRoot, stdio: "inherit"});
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(" ")} failed`);
+ }
+}
+
+function runScenario(name: string, root: string): Map {
+ const result = spawnSync(
+ process.execPath,
+ [...process.execArgv, join(scenarioDirectory, `${name}.ts`), root],
+ {
+ cwd: root,
+ encoding: "utf8",
+ timeout: 600_000,
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ const medians = new Map();
+ for (const line of result.stdout.split("\n")) {
+ if (line.startsWith("{")) {
+ const {label, median} = JSON.parse(line);
+ medians.set(label, median);
+ }
+ }
+ if (medians.size === 0) {
+ console.error(`${name} on ${root} reported nothing:\n${result.stderr}`);
+ }
+ return medians;
+}
+
+function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length / 2)];
+}
+
+const roots: Array<{name: string; path: string}> = [];
+let worktree: string | undefined;
+if (against !== undefined) {
+ worktree = mkdtempSync(join(tmpdir(), "termdom-bench-"));
+ git("worktree", "add", "--detach", worktree, against);
+ symlinkSync(join(benchRoot, "node_modules"), join(worktree, "node_modules"));
+ roots.push({name: against, path: worktree});
+}
+roots.push({name: "this checkout", path: benchRoot.replace(/\/$/, "")});
+
+const samples = new Map>();
+try {
+ for (let run = 0; run < runs; run++) {
+ for (const scenario of scenarios) {
+ for (const root of roots) {
+ for (const [label, value] of runScenario(scenario, root.path)) {
+ let byRoot = samples.get(label);
+ if (byRoot === undefined) {
+ byRoot = new Map();
+ samples.set(label, byRoot);
+ }
+ const values = byRoot.get(root.name) ?? [];
+ values.push(value);
+ byRoot.set(root.name, values);
+ }
+ }
+ }
+ console.error(`run ${run + 1} of ${runs} done`);
+ }
+} finally {
+ if (worktree !== undefined) {
+ rmSync(join(worktree, "node_modules"));
+ git("worktree", "remove", "--force", worktree);
+ }
+}
+
+const header = [
+ "Measurement",
+ ...roots.map((root) => `${root.name} (ms)`),
+ ...(roots.length === 2 ? ["Change"] : []),
+];
+const rows = [...samples].map(([label, byRoot]) => {
+ const values = roots.map((root) => {
+ const values = byRoot.get(root.name);
+ return values === undefined ? NaN : median(values);
+ });
+ const cells = [label, ...values.map((value) => value.toFixed(2))];
+ if (values.length === 2) {
+ const change = (values[1] - values[0]) / values[0] * 100;
+ cells.push(`${change > 0 ? "+" : ""}${change.toFixed(0)}%`);
+ }
+ return cells;
+});
+
+if (markdown) {
+ console.log(`| ${header.join(" | ")} |`);
+ console.log(
+ `|${header.map((_, i) => i === 0 ? " --- " : " ---: ").join("|")}|`,
+ );
+ for (const row of rows) {
+ console.log(`| ${row.join(" | ")} |`);
+ }
+} else {
+ const widths = header.map((_, i) =>
+ Math.max(header[i].length, ...rows.map((row) => row[i].length)));
+ const format = (cells: string[]): string =>
+ cells
+ .map((cell, i) =>
+ i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i]),
+ )
+ .join(" ");
+ console.log(format(header));
+ for (const row of rows) {
+ console.log(format(row));
+ }
+}
diff --git a/bench/scenarios/chat.ts b/bench/scenarios/chat.ts
new file mode 100644
index 00000000..7e2307a5
--- /dev/null
+++ b/bench/scenarios/chat.ts
@@ -0,0 +1,83 @@
+import {
+ installGlobals,
+ measure,
+ mount,
+ nextFrame,
+ silence,
+} from "../harness.ts";
+
+const {proc, dom} = await mount();
+installGlobals(dom);
+const React = await import("react");
+const {createRoot} = await import("react-dom/client");
+const {flushSync} = await import("react-dom");
+const h = React.createElement;
+
+dom.document.head.innerHTML = ``;
+
+interface Message {
+ id: number;
+ who: string;
+ text: string;
+}
+
+let append: (message: Message) => void = () => {};
+
+function App(): React.ReactElement {
+ const [messages, setMessages] = React.useState([]);
+ append = (message) => setMessages((list) => [...list, message]);
+ return h(
+ "div",
+ null,
+ h(
+ "div",
+ {className: "log"},
+ messages.map((message) =>
+ h(
+ "div",
+ {key: message.id, className: `msg ${message.who}`},
+ h("span", {className: "who"}, message.who === "you" ? "You" : "Bot"),
+ " ",
+ h(
+ "span",
+ {className: "time"},
+ `12:${String(message.id % 60).padStart(2, "0")}`,
+ ),
+ h("span", {className: "body"}, message.text),
+ )),
+ ),
+ h(
+ "div",
+ {className: "prompt"},
+ h("span", null, "› "),
+ h("textarea", {rows: 1}),
+ ),
+ );
+}
+
+const reactRoot = createRoot(dom.document.body);
+flushSync(() => reactRoot.render(h(App)));
+await nextFrame(dom);
+silence(proc);
+
+const words = (
+ "the quick brown fox jumps over the lazy dog while a terminal renders the dom"
+).split(" ");
+let id = 0;
+await measure("react chat: append a message", 200, async () => {
+ const text = Array.from({length: 12 + (id % 20)}, (_, k) => words[(id + k) %
+ words.length]).join(" ");
+ flushSync(() => append({id, who: id % 2 ? "bot" : "you", text}));
+ id++;
+ await nextFrame(dom);
+});
+dom.dispose();
diff --git a/bench/scenarios/dom-ops.ts b/bench/scenarios/dom-ops.ts
new file mode 100644
index 00000000..50c3232c
--- /dev/null
+++ b/bench/scenarios/dom-ops.ts
@@ -0,0 +1,22 @@
+import {measure, TermDOM} from "../harness.ts";
+
+const term = new TermDOM();
+const {document} = term;
+await measure(
+ "dom: build, render and remove 20000 spans",
+ 15,
+ () => {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ for (let k = 0; k < 20000; k++) {
+ const span = document.createElement("span");
+ span.className = `c${k % 7}`;
+ span.textContent = "t";
+ root.appendChild(span);
+ }
+ root.remove();
+ term.renderANSI();
+ },
+ 3,
+);
+term.dispose();
diff --git a/bench/scenarios/editor-latency.ts b/bench/scenarios/editor-latency.ts
new file mode 100644
index 00000000..5596b8da
--- /dev/null
+++ b/bench/scenarios/editor-latency.ts
@@ -0,0 +1,23 @@
+import {mountEditor} from "../editor.ts";
+import {keyLatency, underLoad} from "../harness.ts";
+
+const {proc, dom} = await mountEditor();
+const press = keyLatency(proc);
+const text = "const value = compute(element, rect);";
+let typed = 0;
+
+async function type(keys: number): Promise {
+ const times: number[] = [];
+ for (let i = 0; i < keys; i++) {
+ const key = typed % text.length === text.length - 1
+ ? "\r"
+ : text[typed % text.length];
+ times.push(await press(key));
+ typed++;
+ }
+ return times;
+}
+
+await type(20);
+await underLoad("codemirror: key to frame", () => type(100));
+dom.dispose();
diff --git a/bench/scenarios/editor.ts b/bench/scenarios/editor.ts
new file mode 100644
index 00000000..2fe0a9c8
--- /dev/null
+++ b/bench/scenarios/editor.ts
@@ -0,0 +1,25 @@
+import {mountEditor} from "../editor.ts";
+import {measure, nextFrame, silence} from "../harness.ts";
+
+const {proc, dom, view} = await mountEditor();
+silence(proc);
+
+async function press(data: string): Promise {
+ const before = view.state.doc.length;
+ proc.stdin.simulateResponse(data);
+ for (let i = 0; i < 200 && view.state.doc.length === before; i++) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ await nextFrame(dom);
+}
+
+const text = "const value = compute(element, rect);";
+let typed = 0;
+await measure("codemirror: type a character", 120, async () => {
+ await press(text[typed % text.length]);
+ typed++;
+ if (typed % text.length === 0) {
+ await press("\r");
+ }
+});
+dom.dispose();
diff --git a/bench/scenarios/emails.ts b/bench/scenarios/emails.ts
new file mode 100644
index 00000000..fe0ac74f
--- /dev/null
+++ b/bench/scenarios/emails.ts
@@ -0,0 +1,18 @@
+import {readdirSync} from "node:fs";
+
+import {benchRoot, measure, readBenchFile, TermDOM} from "../harness.ts";
+
+const directory = "tests/fixtures/email";
+const emails = readdirSync(`${benchRoot}${directory}`)
+ .filter((name) => name.endsWith(".html"))
+ .map((name) => readBenchFile(`${directory}/${name}`));
+await measure(
+ "emails: parse and render every fixture",
+ 7,
+ () => {
+ for (const html of emails) {
+ new TermDOM({html}).renderANSI();
+ }
+ },
+ 2,
+);
diff --git a/bench/scenarios/latency.ts b/bench/scenarios/latency.ts
new file mode 100644
index 00000000..45bb9df5
--- /dev/null
+++ b/bench/scenarios/latency.ts
@@ -0,0 +1,24 @@
+import {keyLatency, mount, nextFrame, underLoad} from "../harness.ts";
+
+const {proc, dom} = await mount();
+dom.document.body.innerHTML = "";
+const field = dom.document.getElementById("field");
+field.focus();
+await nextFrame(dom);
+const press = keyLatency(proc);
+
+async function type(keys: number): Promise {
+ const times: number[] = [];
+ for (let i = 0; i < keys; i++) {
+ times.push(await press(String.fromCharCode(97 + (i % 26))));
+ if (field.value.length > 60) {
+ field.value = "";
+ await nextFrame(dom);
+ }
+ }
+ return times;
+}
+
+await type(20);
+await underLoad("latency: key to frame", () => type(100));
+dom.dispose();
diff --git a/bench/scenarios/scroll.ts b/bench/scenarios/scroll.ts
new file mode 100644
index 00000000..13932e8b
--- /dev/null
+++ b/bench/scenarios/scroll.ts
@@ -0,0 +1,25 @@
+import {marked} from "marked";
+
+import {measure, mount, nextFrame, readBenchFile, silence} from "../harness.ts";
+
+const html = marked.parse(readBenchFile("README.md"), {async: false});
+const {proc, dom} = await mount();
+dom.document.body.innerHTML = `${html}`;
+await nextFrame(dom);
+silence(proc);
+const view = dom.window;
+let direction = 1;
+
+async function scroll(rows: number): Promise {
+ const max = dom.document.body.scrollHeight - view.innerHeight;
+ const next = view.scrollY + rows * direction;
+ if (next > max || next < 0) {
+ direction = -direction;
+ }
+ view.scrollBy(0, rows * direction);
+ await nextFrame(dom);
+}
+
+await measure("scroll: one row", 100, () => scroll(1));
+await measure("scroll: one page", 60, () => scroll(40));
+dom.dispose();
diff --git a/bench/scenarios/startup.ts b/bench/scenarios/startup.ts
new file mode 100644
index 00000000..db087fc0
--- /dev/null
+++ b/bench/scenarios/startup.ts
@@ -0,0 +1,26 @@
+import {marked} from "marked";
+
+import {
+ cpuMs,
+ MockProcess,
+ nextFrame,
+ readBenchFile,
+ report,
+ TermDOM,
+} from "../harness.ts";
+
+const html = marked.parse(readBenchFile("README.md"), {async: false});
+const times: number[] = [];
+for (let i = 0; i < 25; i++) {
+ const proc = new MockProcess({cols: 100, rows: 40});
+ const start = cpuMs();
+ const dom = new TermDOM({transport: proc.transport});
+ dom.document.body.innerHTML = `${html}`;
+ dom.attach();
+ await nextFrame(dom);
+ if (i >= 5) {
+ times.push(cpuMs() - start);
+ }
+ dom.dispose();
+}
+report("startup: README to first frame", times);
diff --git a/bench/scenarios/table.ts b/bench/scenarios/table.ts
new file mode 100644
index 00000000..b8a70449
--- /dev/null
+++ b/bench/scenarios/table.ts
@@ -0,0 +1,16 @@
+import {measure, TermDOM} from "../harness.ts";
+
+const rows = Array.from(
+ {length: 1000},
+ (_, i) => `| ${i} | row ${i} | x |
`,
+).join("");
+const html =
+ ``;
+await measure(
+ "table: render 1000 rows",
+ 7,
+ () => {
+ new TermDOM({html}).renderANSI();
+ },
+ 2,
+);
diff --git a/bench/scenarios/toggle.ts b/bench/scenarios/toggle.ts
new file mode 100644
index 00000000..30fa1c1b
--- /dev/null
+++ b/bench/scenarios/toggle.ts
@@ -0,0 +1,24 @@
+import {measure, TermDOM} from "../harness.ts";
+
+const term = new TermDOM();
+const {document} = term;
+document.body.innerHTML =
+ "" +
+ "" +
+ Array.from(
+ {length: 500},
+ (_, i) => `- item ${i}
`,
+ ).join("") +
+ "
";
+term.renderANSI();
+let next = 0;
+await measure(
+ "toggle: a class under li:has(), 500 items",
+ 200,
+ () => {
+ document.getElementById(`s${next++ % 500}`).classList.toggle("on");
+ term.renderANSI();
+ },
+ 50,
+);
+term.dispose();
diff --git a/bun.lock b/bun.lock
index 09da77fa..75da0cb4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,7 +14,7 @@
"devDependencies": {
"@b9g/crank": "^0.7.12",
"@b9g/eslint-config": "^0.1.10",
- "@b9g/libuild": "^0.2.26",
+ "@b9g/libuild": "^0.2.27",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
@@ -61,7 +61,7 @@
"@b9g/eslint-config": ["@b9g/eslint-config@0.1.10", "", { "peerDependencies": { "@eslint/js": ">=9", "@stylistic/eslint-plugin": ">=3", "@typescript-eslint/eslint-plugin": ">=8", "@typescript-eslint/parser": ">=8", "eslint": ">=9", "eslint-plugin-acrocase": ">=0.0.1", "eslint-plugin-crank": ">=0.2.2", "eslint-plugin-esfold": ">=0.1.2" } }, "sha512-5uVBZvX+K4XoGilegJ5/tEH4Mzg5gf+/XdOF6I3QJxTX/5iOoyTuMChIKWHPI3oxBAHzbLHYxFeHoNrp7q2gRQ=="],
- "@b9g/libuild": ["@b9g/libuild@0.2.26", "", { "dependencies": { "@b9g/async-context": "^0.2.1", "commander": "^15.0.0", "esbuild": "^0.28.1", "expect": "^30.4.1", "glob": "^13.0.6", "pretty-format": "^30.4.1" }, "peerDependencies": { "playwright": "^1.40.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["playwright", "typescript"], "bin": { "libuild": "cli.js" } }, "sha512-8eZT6nJ+PL5ILlowL64kBntd+PPrH7DpTJHyWazhL6ik9kQwmQpQFJgVtcu27G4FE0BPqtlSYyJMbLPNVE1KlA=="],
+ "@b9g/libuild": ["@b9g/libuild@0.2.27", "", { "dependencies": { "@b9g/async-context": "^0.2.1", "c8": "^12.0.0", "commander": "^15.0.0", "esbuild": "^0.28.1", "expect": "^30.4.1", "glob": "^13.0.6", "pretty-format": "^30.4.1" }, "peerDependencies": { "playwright": "^1.40.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["playwright", "typescript"], "bin": { "libuild": "cli.js" } }, "sha512-WpPtO642xQ1GKx8nrbOM0e2s2eCJ2vP5lLJMMxOEgahSxwkuJ321v3sWrf5TCv5Dl6Kig0VH46LybkSCDcnGHQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -73,6 +73,8 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
+ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
+
"@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="],
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
@@ -171,6 +173,8 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+ "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
+
"@jest/diff-sequences": ["@jest/diff-sequences@30.5.0", "", {}, "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg=="],
"@jest/expect-utils": ["@jest/expect-utils@30.5.2", "", { "dependencies": { "@jest/get-type": "30.5.0" } }, "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q=="],
@@ -297,6 +301,8 @@
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
+ "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="],
+
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"arabic-persian-reshaper": ["arabic-persian-reshaper@1.0.1", "", {}, "sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg=="],
@@ -319,12 +325,16 @@
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
+ "c8": ["c8@12.0.0", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^18.0.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg=="],
+
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
+ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
@@ -335,6 +345,8 @@
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
@@ -351,10 +363,14 @@
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.11.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ=="],
@@ -405,6 +421,12 @@
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
+ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
+
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -419,6 +441,8 @@
"htm": ["htm@3.1.1", "", {}, "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ=="],
+ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+
"ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -431,6 +455,12 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
+
+ "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
+
+ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
+
"jest-diff": ["jest-diff@30.5.2", "", { "dependencies": { "@jest/diff-sequences": "30.5.0", "@jest/get-type": "30.5.0", "chalk": "^4.1.2", "pretty-format": "30.5.1" } }, "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ=="],
"jest-matcher-utils": ["jest-matcher-utils@30.5.2", "", { "dependencies": { "@jest/get-type": "30.5.0", "chalk": "^4.1.2", "jest-diff": "30.5.2", "pretty-format": "30.5.1" } }, "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ=="],
@@ -465,6 +495,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
+
"marked": ["marked@18.0.14", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A=="],
"marked-highlight": ["marked-highlight@2.2.4", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="],
@@ -533,6 +565,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -541,12 +575,18 @@
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
+ "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="],
+
+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
"style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
+ "test-exclude": ["test-exclude@8.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^13.0.6", "minimatch": "^10.2.2" } }, "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ=="],
+
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -565,6 +605,8 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
+
"vue": ["vue@3.5.43", "", { "dependencies": { "@vue/compiler-dom": "3.5.43", "@vue/compiler-sfc": "3.5.43", "@vue/runtime-dom": "3.5.43", "@vue/server-renderer": "3.5.43", "@vue/shared": "3.5.43" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
@@ -575,6 +617,14 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
+
+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
+ "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="],
+
+ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
@@ -589,6 +639,8 @@
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
"css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
@@ -601,6 +653,12 @@
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
+ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
+ "yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
+
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}
diff --git a/eslint.config.js b/eslint.config.js
index fad21ad7..ec170b67 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -20,7 +20,7 @@ export default [
{
// Scripts and examples print to the terminal as their job. URL is a
// runtime global everywhere the scripts run.
- files: ["scripts/**", "examples/**"],
+ files: ["scripts/**", "examples/**", "bench/**"],
languageOptions: {globals: {URL: "readonly"}},
rules: {"no-console": "off"},
},
diff --git a/examples/codemirror.ts b/examples/codemirror.ts
index 723fe8c1..257f7384 100644
--- a/examples/codemirror.ts
+++ b/examples/codemirror.ts
@@ -8,14 +8,14 @@
// CodeMirror edits the document through the DOM: it renders lines into a
// contenteditable element, listens for beforeinput and watches the tree
// with a MutationObserver, and reads the selection back through
-// getSelection(). Nothing here is adapted for the terminal except the
-// theme, which turns its pixel paddings into cells.
+// getSelection(). The terminal reports how many pixels a cell measures,
+// so CodeMirror's pixel sizes and its viewport culling work as they do in
+// a browser. Nothing here is adapted for the terminal but the colors.
import {readFileSync} from "node:fs";
import {installGlobals, TermDOM} from "@b9g/termdom";
-import type {EditorView as View} from "@codemirror/view";
-const term = new TermDOM();
+const term = new TermDOM({cellSize: "auto"});
term.attach();
const {document} = term;
@@ -30,7 +30,7 @@ const {javascript} = await import("@codemirror/lang-javascript");
const {defaultHighlightStyle, syntaxHighlighting} = await import(
"@codemirror/language",
);
-const {EditorSelection, EditorState} = await import("@codemirror/state");
+const {EditorState} = await import("@codemirror/state");
const {EditorView, keymap, lineNumbers} = await import("@codemirror/view");
document.head.innerHTML = `
@@ -51,53 +51,20 @@ const theme = EditorView.theme(
// The focused editor edits text, so it shows the terminal cursor, not
// the dotted outline the base theme draws around the wrapper.
"&.cm-focused": {outline: "none"},
- ".cm-scroller": {lineHeight: "1"},
- ".cm-content": {padding: "0", caretColor: "white"},
+ ".cm-content": {caretColor: "white"},
".cm-line": {padding: "0 1ch"},
".cm-gutters": {
backgroundColor: "#101418",
color: "#606060",
borderRight: "1px solid #303030",
- minWidth: "4ch",
- },
- ".cm-lineNumbers .cm-gutterElement": {
- padding: "0 1ch 0 0",
- minWidth: "3ch",
},
".cm-activeLine": {backgroundColor: "#1a2028"},
".cm-activeLineGutter": {backgroundColor: "#1a2028"},
+ ".cm-lineNumbers .cm-gutterElement": {padding: "0 1ch"},
},
{dark: true},
);
-// CodeMirror's own line moves probe half a text height at a time, and a
-// line here is one cell tall, so half of it rounds to nothing. These
-// probe half a cell past the line's edge, which is the next row.
-function moveLine(forward: boolean, extend: boolean): (view: View) => boolean {
- return (view: View): boolean => {
- const {selection} = view.state;
- const ranges = selection.ranges.map((range) => {
- const moved = view.moveVertically(
- range,
- forward,
- view.defaultLineHeight / 2,
- );
- return extend ? EditorSelection.range(range.anchor, moved.head) : moved;
- });
- view.dispatch({
- selection: EditorSelection.create(ranges, selection.mainIndex),
- scrollIntoView: true,
- userEvent: "select",
- });
- return true;
- };
-}
-
-const lineKeys = keymap.of([
- {key: "ArrowUp", run: moveLine(false, false), shift: moveLine(false, true)},
- {key: "ArrowDown", run: moveLine(true, false), shift: moveLine(true, true)},
-]);
-
const sample = [
"function greet(name) {",
" const hour = new Date().getHours();",
@@ -119,7 +86,6 @@ const view = new EditorView({
EditorState.tabSize.of(2),
lineNumbers(),
history(),
- lineKeys,
keymap.of([...defaultKeymap, ...historyKeymap]),
javascript(),
syntaxHighlighting(defaultHighlightStyle),
diff --git a/examples/solitaire.ts b/examples/solitaire.ts
index 99c1f1b2..d6a5e5c1 100644
--- a/examples/solitaire.ts
+++ b/examples/solitaire.ts
@@ -528,9 +528,9 @@ function sheet(): string {
${css(TIERS.compact)}
@media (min-width: ${ROOMY_MIN_WIDTH}ch) { ${css(TIERS.roomy)} }
- @media (min-width: ${GRAND_MIN_WIDTH}ch) and (min-height: ${GRAND_MIN_HEIGHT}) { ${css(TIERS.grand)} }
+ @media (min-width: ${GRAND_MIN_WIDTH}ch) and (min-height: ${GRAND_MIN_HEIGHT}px) { ${css(TIERS.grand)} }
/* A short terminal spends its rows on the cards. */
- @media (max-height: ${HINT_MIN_HEIGHT - 1}) { .hint { display: none; } }
+ @media (max-height: ${HINT_MIN_HEIGHT - 1}px) { .hint { display: none; } }
`;
}
@@ -1360,7 +1360,7 @@ function *App(this: Context) {
// re-renders the faces to the size the CSS just snapped to.
const roomy = term.window.matchMedia(`(min-width: ${ROOMY_MIN_WIDTH}ch)`);
const grand = term.window.matchMedia(
- `(min-width: ${GRAND_MIN_WIDTH}ch) and (min-height: ${GRAND_MIN_HEIGHT})`,
+ `(min-width: ${GRAND_MIN_WIDTH}ch) and (min-height: ${GRAND_MIN_HEIGHT}px)`,
);
const retier = () => this.refresh();
roomy.addEventListener("change", retier);
diff --git a/package.json b/package.json
index 38f4d476..024bce45 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"LICENSE"
],
"scripts": {
+ "bench": "node bench/run.ts",
"build": "libuild build",
"lint": "eslint .",
"prepublishOnly": "echo 'ERROR: Cannot publish from root directory. Use libuild to publish or stage the package.' && exit 1",
@@ -58,7 +59,7 @@
"devDependencies": {
"@b9g/crank": "^0.7.12",
"@b9g/eslint-config": "^0.1.10",
- "@b9g/libuild": "^0.2.26",
+ "@b9g/libuild": "^0.2.27",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
diff --git a/scripts/generate-dom-idl.ts b/scripts/generate-dom-idl.ts
index 3d83d0f0..a0841225 100644
--- a/scripts/generate-dom-idl.ts
+++ b/scripts/generate-dom-idl.ts
@@ -18,7 +18,7 @@ import {fileURLToPath} from "node:url";
import {parse} from "webidl2";
-import {getWindowInterfaceNames} from "../src/internal/dom.ts";
+import {getWindowClasses} from "../src/internal/window.ts";
const require = createRequire(import.meta.url);
const idlDir = dirname(require.resolve("@webref/idl/package.json"));
@@ -311,7 +311,7 @@ for (const definition of definitions) {
}
}
-const windowInterfaces = new Set(getWindowInterfaceNames());
+const windowInterfaces = new Set(Object.keys(getWindowClasses()));
const exposed = new Set(
definitions
.filter(
diff --git a/src/index.ts b/src/index.ts
index e068971b..8bfb9cef 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,19 +3,20 @@ import "./internal/inspector.ts";
import type {CanvasCellGridContext} from "./internal/canvas.ts";
import {
Cascade,
- type CellSize,
- type CellSizeSetting,
- getCellSize,
- UNIT_CELL,
+ checkStylesFromScratch,
+ dispose,
+ hoverRulesExist,
+ styleCheck,
} from "./internal/cssom.ts";
import {
applyMutations,
attachDocument,
+ checkFormControls,
clampScrollOffsets,
clearHighlights,
clearWindowTimers,
constructInternal,
- createWindow,
+ controlCheck,
disconnectObservers,
dispatchAsUserAgent,
DOMException,
@@ -24,7 +25,6 @@ import {
type EventTargetSlots,
flushLayout,
flushObservers,
- getCellSizeSetting,
getGraphicsSettings,
getProcessTimers,
type GraphicsSetting,
@@ -38,29 +38,77 @@ import {
runFrameCallbacks,
runScrollSteps,
setDocumentVisible,
+ syncFormControls,
syncMediaQueries,
takeScrollShift,
type Window,
} from "./internal/dom.ts";
+import {installEditing} from "./internal/editing.ts";
import {
+ abandonColorDepthQuery,
+ abandonColorSchemeQuery,
+ clearScreen,
+ cursorToRow,
DEFAULT_ROWS,
+ deferResizeEvent,
+ detectAnchor,
type ColorDepth as EmittedColorDepth,
+ eraseBelow,
Exchange,
+ flushExchange,
+ initializeCursorDetection,
+ negotiateBidi,
+ negotiateCellPixels,
+ negotiateColorDepth,
+ negotiateColorScheme,
+ negotiateGraphemeClusters,
+ negotiateOverline,
+ queryDeviceAttributes,
+ reclaimModes,
+ releaseModes,
+ reserveRows,
+ restoreEngagedModes,
+ scrubProbeEcho,
+ setDisplayType,
+ startExchange,
type TerminalCloseInfo,
type TerminalResizeEvent,
type TerminalSize,
type TerminalTransport,
transportFromProcess,
+ writeLines,
} from "./internal/exchange.ts";
-import {Framebuffer} from "./internal/framebuffer.ts";
+import {
+ beginFrame,
+ endFrame,
+ Framebuffer,
+ rebind,
+ repaintAll,
+ scrollTo,
+} from "./internal/framebuffer.ts";
import {
releaseDecoder,
retainDecoder,
setDecodeWorkerURL,
} from "./internal/images.ts";
-import {Input} from "./internal/input.ts";
-import {Layout} from "./internal/layout.ts";
-import {Painter} from "./internal/painter.ts";
+import {Input, resolvePendingHover} from "./internal/input.ts";
+import {
+ checkLayoutFromScratch,
+ framePainted,
+ invalidateTextMeasurement,
+ Layout,
+ layoutCheck,
+ takeDamageBands,
+} from "./internal/layout.ts";
+import {
+ checkPartialFrame,
+ paint,
+ Painter,
+ paintsAsBefore,
+ partialFrameCheck,
+ resolveDocumentScrollBand,
+ resolveScrollShift,
+} from "./internal/painter.ts";
import {
answerRequest,
checkInlineHandler,
@@ -71,10 +119,18 @@ import {
setWindowFetch,
} from "./internal/resources.ts";
import {_, type internals, moveLateMembers} from "./internal/slots.ts";
-import {queueMicrotask, setTimeout} from "./internal/timers.ts";
+import {queueMicrotask, queueTask, setTimeout} from "./internal/timers.ts";
+import {getCellSizeSetting} from "./internal/tree.ts";
+import {
+ type CellSize,
+ type CellSizeSetting,
+ getCellSize,
+ UNIT_CELL,
+} from "./internal/units.ts";
+import {createWindow} from "./internal/window.ts";
export type {CanvasCellGridContext} from "./internal/canvas.ts";
-export type {CellSize} from "./internal/cssom.ts";
+export type {CellSize} from "./internal/units.ts";
export {transportFromProcess, transportFromSSH} from "./internal/exchange.ts";
export {
ExtendableEvent,
@@ -559,6 +615,7 @@ export class TermDOM extends EventTarget {
reportUncaught(this, error);
},
});
+ installEditing(exchange);
// The document had no size until it was attached, so an @media rule
// in the markup was read against a zero-width viewport. Attaching is
// a resize, and flips @media results the same way.
@@ -655,11 +712,11 @@ export class TermDOM extends EventTarget {
}
started = true;
- slots.exchange.start(slots.input);
+ startExchange(slots.exchange, slots.input);
if (slots.transport.interactive) {
- slots.exchange.setDisplayType("bracketedPaste", true);
+ setDisplayType(slots.exchange, "bracketedPaste", true);
// So dispose can restore the title.
- slots.exchange.setDisplayType("titleStack", true);
+ setDisplayType(slots.exchange, "titleStack", true);
if (this.document.title) {
void slots.exchange.setTitle(this.document.title);
}
@@ -669,7 +726,7 @@ export class TermDOM extends EventTarget {
// The terminal's background, asked first so the first frame can
// wait for it.
const schemeBefore = slots.exchange.colorScheme;
- void slots.exchange.negotiateColorScheme().then(() => {
+ void negotiateColorScheme(slots.exchange).then(() => {
if (isAttached(this) && slots.exchange.colorScheme !== schemeBefore) {
slots.cascade.syncStylesheets();
syncMediaQueries(this.document);
@@ -679,9 +736,9 @@ export class TermDOM extends EventTarget {
// What colors the terminal shows, when the program leaves it to the
// terminal to say. Asked before DA1, like the background.
const depthSettled = slots.colorDepth === undefined
- ? slots.exchange.negotiateColorDepth().then((depth) => {
+ ? negotiateColorDepth(slots.exchange).then((depth) => {
if (isAttached(this) && depth !== slots.framebuffer.colorDepth) {
- slots.framebuffer.rebind(depth);
+ rebind(slots.framebuffer, depth);
syncMediaQueries(this.document);
void render(this);
}
@@ -689,34 +746,34 @@ export class TermDOM extends EventTarget {
: Promise.resolve();
// A terminal answers in the order it was asked, so once it answers
// DA1, one that said nothing of its background never will.
- const answered = slots.exchange.queryDeviceAttributes();
- slots.exchange.initializeCursorDetection();
+ const answered = queryDeviceAttributes(slots.exchange);
+ initializeCursorDetection(slots.exchange);
// A terminal that answers nothing is not waited on past the cursor
// question the anchor already waits for.
const cursor = slots.exchange.cursorDetectionPending;
const schemeSettled = (
cursor === null ? answered : Promise.race([answered, cursor])
).then(() => {
- slots.exchange.abandonColorSchemeQuery();
- slots.exchange.abandonColorDepthQuery();
+ abandonColorSchemeQuery(slots.exchange);
+ abandonColorDepthQuery(slots.exchange);
});
- void slots.exchange.negotiateBidi();
- void slots.exchange.negotiateGraphemeClusters();
+ void negotiateBidi(slots.exchange);
+ void negotiateGraphemeClusters(slots.exchange);
// Math draws its bars as overlines once the terminal has agreed
// to them, so what was measured before the answer measures again.
- void slots.exchange.negotiateOverline().then((agreed) => {
+ void negotiateOverline(slots.exchange).then((agreed) => {
if (agreed && isAttached(this)) {
- slots.layout.invalidateTextMeasurement();
+ invalidateTextMeasurement(slots.layout);
void render(this);
}
});
- slots.exchange.scrubProbeEcho();
+ scrubProbeEcho(slots.exchange);
// After the erase, which is for the mode probes' echo. What was
// painted against the guessed cell repaints when the answer lands.
// When the cell sizes the page, the first frame waits for it, so
// the page never lays out against the guess.
const measuresCell = getCellSizeSetting(this.document) === "auto";
- const cellAnswered = slots.exchange.negotiateCellPixels().then(
+ const cellAnswered = negotiateCellPixels(slots.exchange).then(
(cellChanged) => {
if (!isAttached(this)) {
return;
@@ -741,10 +798,10 @@ export class TermDOM extends EventTarget {
if (measuresCell) {
// A font zoom resizes the grid, and the cell with it. The page
// hears of it once, when the cell is known.
- slots.exchange.deferResizeEvent();
+ deferResizeEvent(slots.exchange);
slots.exchange.addEventListener("terminalresize", (event) => {
const {sizeChanged} = event as TerminalResizeEvent;
- void slots.exchange.negotiateCellPixels().then((cellChanged) => {
+ void negotiateCellPixels(slots.exchange).then((cellChanged) => {
if (!isAttached(this)) {
return;
}
@@ -919,7 +976,7 @@ export class TermDOM extends EventTarget {
// Leaving the alternate screen puts the cursor where the switch saved
// it, on the flow content's bottom row. Step below it, or the shell's
// next line lands on ours.
- slots.exchange.restoreEngagedModes();
+ restoreEngagedModes(slots.exchange);
dropFullscreen(this.document);
slots.hoverReportingEnabled = false;
slots.mouseReportingEnabled = false;
@@ -941,13 +998,13 @@ export class TermDOM extends EventTarget {
void slots.staticSibling.dispose();
slots.staticSibling = null;
}
- slots.cascade.dispose();
+ dispose(slots.cascade);
slots.layout.dispose();
clearHighlights(this.document);
disconnectObservers(this.document);
clearWindowTimers(slots.window);
slots.disposal = Promise.all([
- slots.exchange.flush(),
+ flushExchange(slots.exchange),
drained,
...slots.lifetimes,
]).then(() => {});
@@ -980,19 +1037,19 @@ async function suspendTermDOM(termDOM: TermDOM): Promise {
flushDocument(termDOM);
termDOMSlots.sealed = true;
}
- termDOMSlots.exchange.releaseModes();
- await termDOMSlots.exchange.flush();
+ releaseModes(termDOMSlots.exchange);
+ await flushExchange(termDOMSlots.exchange);
await suspend.call(termDOMSlots.transport);
termDOMSlots.suspended = false;
if (termDOMSlots.lifecycle !== "attached") {
return;
}
- termDOMSlots.exchange.reclaimModes();
+ reclaimModes(termDOMSlots.exchange);
if (termDOM.document.title) {
void termDOMSlots.exchange.setTitle(termDOM.document.title);
}
if (fullscreen) {
- termDOMSlots.framebuffer.repaintAll();
+ repaintAll(termDOMSlots.framebuffer);
}
setDocumentVisible(termDOM.document, true);
void render(termDOM);
@@ -1118,7 +1175,7 @@ function writeHeldErrors(termDOM: TermDOM): void {
return;
}
termDOMSlots.unwrittenErrors = (termDOMSlots.unwrittenErrors ?? "") + text;
- void termDOMSlots.exchange.writeLines(text).then(() => {
+ void writeLines(termDOMSlots.exchange, text).then(() => {
termDOMSlots.unwrittenErrors = null;
if (termDOMSlots.heldErrors.length === 0) {
sessionsHoldingErrors.delete(termDOM);
@@ -1179,7 +1236,7 @@ function syncMouseReporting(termDOM: TermDOM): void {
return;
}
termDOMSlots.mouseReportingEnabled = wanted;
- termDOMSlots.exchange.setDisplayType("mouseCapture", wanted);
+ setDisplayType(termDOMSlots.exchange, "mouseCapture", wanted);
}
/**
@@ -1191,12 +1248,12 @@ function syncHoverReporting(termDOM: TermDOM): void {
const wanted =
termDOMSlots.mouseReportingEnabled &&
(hoverListenerCount(termDOM.document) > 0 ||
- termDOMSlots.cascade.hoverRulesExist());
+ hoverRulesExist(termDOMSlots.cascade));
if (wanted === termDOMSlots.hoverReportingEnabled) {
return;
}
termDOMSlots.hoverReportingEnabled = wanted;
- termDOMSlots.exchange.setDisplayType("motionReporting", wanted);
+ setDisplayType(termDOMSlots.exchange, "motionReporting", wanted);
}
// An image's or a canvas's natural size in cells is its pixels over the
@@ -1277,8 +1334,11 @@ async function render(termDOM: TermDOM): Promise {
try {
// After the task that asked, and every microtask it queued, as a
// browser renders: an edit and whatever answers it in its own
- // microtasks are one frame, not a frame of the half-done edit.
- await new Promise((resolve) => setTimeout(resolve, 0));
+ // microtasks are one frame, not a frame of the half-done edit. A
+ // task the runtime runs at once, not a timer, which sleeps the
+ // process and, on a loaded machine, waits for the scheduler to
+ // give it the processor back.
+ await new Promise((resolve) => queueTask(resolve));
do {
termDOMSlots.renderQueued = false;
if (termDOMSlots.lifecycle === "attaching") {
@@ -1307,7 +1367,13 @@ async function render(termDOM: TermDOM): Promise {
// microtasks alone, and input and timers would never get a turn.
const wait =
termDOMSlots.lastFrameAt + FRAME_INTERVAL_MS - performance.now();
- await new Promise((resolve) => setTimeout(resolve, Math.max(0, wait)));
+ await new Promise((resolve) => {
+ if (wait > 0) {
+ setTimeout(resolve, wait);
+ } else {
+ queueTask(resolve);
+ }
+ });
if (!isAttached(termDOM) || termDOMSlots.suspended) {
break;
}
@@ -1426,7 +1492,7 @@ function printStatic(termDOM: TermDOM): void {
applyMutations(termDOM.document);
const termDOMSlots = _(termDOM);
termDOMSlots.layout.performLayout();
- termDOMSlots.layout.framePainted();
+ framePainted(termDOMSlots.layout);
termDOMSlots.unprinted = true;
sessionsUnprinted.add(termDOM);
installExitHook();
@@ -1472,9 +1538,9 @@ function flushDocument(termDOM: TermDOM): void {
// old frame held below. Never a full ED from the top. tmux archives a
// fully erased screen into the scrollback, which put the document there
// twice.
- void termDOMSlots.exchange.cursorToRow(top + 1);
- void termDOMSlots.exchange.writeLines(output);
- void termDOMSlots.exchange.eraseBelow();
+ void cursorToRow(termDOMSlots.exchange, top + 1);
+ void writeLines(termDOMSlots.exchange, output);
+ void eraseBelow(termDOMSlots.exchange);
}
/** The document as ANSI: colors and line breaks, no cursor controls, no modes. */
@@ -1484,7 +1550,7 @@ function renderStatic(termDOM: TermDOM, lineEnding: "\n" | "\r\n"): string {
return termDOMSlots.framebuffer.renderStatic(
termDOMSlots.layout.documentPaintHeight(),
lineEnding,
- (context) => termDOMSlots.painter.paint(context),
+ (context) => paint(termDOMSlots.painter, context),
);
}
@@ -1498,14 +1564,14 @@ async function renderInteractive(termDOM: TermDOM): Promise {
const termDOMSlots = _(termDOM);
if (termDOMSlots.sealed) {
termDOMSlots.sealed = false;
- termDOMSlots.framebuffer.scrollTo(0);
- termDOMSlots.framebuffer.repaintAll();
+ scrollTo(termDOMSlots.framebuffer, 0);
+ repaintAll(termDOMSlots.framebuffer);
// detectAnchor reads a reply, so the listener must be attached.
if (termDOMSlots.transport.interactive) {
termDOM.attach();
// A terminal that does not answer leaves the anchor where it was,
// as at attach, and dispose() gives the question up.
- await termDOMSlots.exchange.detectAnchor(NaN);
+ await detectAnchor(termDOMSlots.exchange, NaN);
if (termDOMSlots.lifecycle === "disposed") {
return;
}
@@ -1526,25 +1592,36 @@ async function renderInteractive(termDOM: TermDOM): Promise {
const wantAlt = isFullscreen(termDOM);
if (wantAlt !== termDOMSlots.onAlternateScreen) {
termDOMSlots.onAlternateScreen = wantAlt;
- termDOMSlots.exchange.setDisplayType("altScreen", wantAlt);
+ setDisplayType(termDOMSlots.exchange, "altScreen", wantAlt);
if (wantAlt) {
- termDOMSlots.exchange.setDisplayType("cursorHidden", true);
- void termDOMSlots.exchange.clearScreen();
+ setDisplayType(termDOMSlots.exchange, "cursorHidden", true);
+ void clearScreen(termDOMSlots.exchange);
}
// Drop the diff model, or this frame patches one screen against the
// other's content.
- termDOMSlots.framebuffer.repaintAll();
+ repaintAll(termDOMSlots.framebuffer);
syncMouseReporting(termDOM);
syncHoverReporting(termDOM);
}
// First, so a hover listener's mutations join the records taken below.
- termDOMSlots.input.resolvePendingHover();
+ resolvePendingHover(termDOMSlots.input);
+ if (import.meta.litest && controlCheck.enabled) {
+ checkFormControls(termDOM.document);
+ }
+ syncFormControls(termDOM.document);
applyMutations(termDOM.document);
runFocusFixup(termDOM.document);
+ // The checks run under test only, and the build strips them.
+ if (import.meta.litest && styleCheck.enabled) {
+ checkStylesFromScratch(termDOMSlots.cascade);
+ }
termDOMSlots.layout.performLayout();
+ if (import.meta.litest && layoutCheck.enabled) {
+ checkLayoutFromScratch(termDOMSlots.layout);
+ }
clampScrollOffsets(termDOM.document);
revealPendingCaret(termDOM.document);
@@ -1579,13 +1656,14 @@ async function renderInteractive(termDOM: TermDOM): Promise {
: termDOMSlots.layout.documentPaintHeight();
const regionHeight = Math.min(contentHeight, termDOMSlots.framebuffer.rows);
- const top = fullscreen ? 0 : termDOMSlots.exchange.reserveRows(regionHeight);
+ const top = fullscreen ? 0 : reserveRows(termDOMSlots.exchange, regionHeight);
if (!fullscreen) {
// Through scrollTo, so the journal's delta is what the screen is about
// to be shifted by.
const maxScroll = Math.max(0, contentHeight - regionHeight);
- termDOMSlots.framebuffer.scrollTo(
+ scrollTo(
+ termDOMSlots.framebuffer,
Math.min(termDOMSlots.framebuffer.scrollTop, maxScroll),
);
}
@@ -1593,25 +1671,42 @@ async function renderInteractive(termDOM: TermDOM): Promise {
// The document scroll has nothing to move in fullscreen. A scroll box
// inside it still does, under DECSTBM margins.
const shift =
- termDOMSlots.painter.resolveScrollShift(regionHeight, journalled) ??
+ resolveScrollShift(termDOMSlots.painter, regionHeight, journalled) ??
(fullscreen || termDOMSlots.framebuffer.journal.frameScroll === 0
? null
- : termDOMSlots.painter.resolveDocumentScrollBand(
+ : resolveDocumentScrollBand(
+ termDOMSlots.painter,
regionHeight,
termDOMSlots.framebuffer.journal.frameScroll,
));
// Read after the clamp, which adds to the journal.
const clamped = termDOMSlots.framebuffer.journal;
- const context = termDOMSlots.framebuffer.beginFrame({
+ const damage = takeDamageBands(
+ termDOMSlots.layout,
+ -termDOMSlots.framebuffer.scrollTop,
+ (element, key) => paintsAsBefore(termDOMSlots.painter, element, key),
+ );
+ const context = beginFrame(termDOMSlots.framebuffer, {
offset: -termDOMSlots.framebuffer.scrollTop,
cursorRow: top,
regionRows: top + regionHeight,
delta: shift ? shift.delta : fullscreen ? 0 : clamped.frameScroll,
shift: shift ?? undefined,
+ // A box scrolled moves rows no box reports.
+ bands: termDOMSlots.framebuffer.wholeDamage || journalled !== null
+ ? undefined
+ : damage,
});
- termDOMSlots.painter.paint(context);
- const ansi = termDOMSlots.framebuffer.endFrame();
- termDOMSlots.layout.framePainted();
+ paint(termDOMSlots.painter, context);
+ if (import.meta.litest && context.partial && partialFrameCheck.enabled) {
+ checkPartialFrame(
+ termDOMSlots.painter,
+ context,
+ termDOMSlots.framebuffer.prevFrame?.caret ?? null,
+ );
+ }
+ const ansi = endFrame(termDOMSlots.framebuffer);
+ framePainted(termDOMSlots.layout);
// The cursor stays hidden while a frame paints and between frames. It
// is parked for resize bookkeeping, and a cursor blinking there is not
@@ -1621,10 +1716,11 @@ async function renderInteractive(termDOM: TermDOM): Promise {
if (!fullscreen) {
termDOMSlots.flowPainted = true;
}
- termDOMSlots.exchange.setDisplayType("cursorHidden", true);
+ setDisplayType(termDOMSlots.exchange, "cursorHidden", true);
await termDOMSlots.exchange.write(ansi);
}
- termDOMSlots.exchange.setDisplayType(
+ setDisplayType(
+ termDOMSlots.exchange,
"cursorHidden",
!termDOMSlots.framebuffer.caretVisible,
);
@@ -1698,9 +1794,6 @@ function hasRuntimeGlobal(name: string): boolean {
if (!(name in globalThis)) {
return false;
}
- if (name !== "localStorage" && name !== "sessionStorage") {
- return true;
- }
const emitWarning = process.emitWarning;
process.emitWarning = () => {};
try {
diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts
index 68c39233..2b3b533a 100644
--- a/src/internal/canvas.ts
+++ b/src/internal/canvas.ts
@@ -22,6 +22,12 @@ import {
import {
type CellContext,
type CellStyle,
+ clearGrid,
+ clearRange,
+ drawBox,
+ drawLine,
+ drawRect,
+ drawText,
LINE_STYLES,
type LineStyle,
readCell,
@@ -42,7 +48,6 @@ import {
type internals,
seal,
type Slots,
- type Step,
} from "./slots.ts";
import {getStringWidth, graphemeSegmenter, toVisualOrder} from "./text.ts";
@@ -115,9 +120,9 @@ export class ImageData {
width = Math.floor(dataOrWidth);
height = Math.floor(widthOrHeight);
if (!(width > 0) || !(height > 0)) {
- throw domException(
- "IndexSizeError",
+ throw new DOMException(
"The width and height must be positive",
+ "IndexSizeError",
);
}
if (width * height > MAX_IMAGE_PIXELS) {
@@ -132,16 +137,16 @@ export class ImageData {
if (
!(width > 0) || data.length === 0 || data.length % (4 * width) !== 0
) {
- throw domException(
- "IndexSizeError",
+ throw new DOMException(
"The data does not hold whole rows of that width",
+ "IndexSizeError",
);
}
const rows = data.length / (4 * width);
if (height !== undefined && Math.floor(height) !== rows) {
- throw domException(
- "IndexSizeError",
+ throw new DOMException(
"The data does not hold that many rows",
+ "IndexSizeError",
);
}
height = rows;
@@ -156,8 +161,6 @@ export class ImageData {
declareSealedRoot(ImageData);
-const kColorAt = Symbol("colorAt");
-
export interface ImageBitmapSlots {
bitmapData: Bitmap | null;
}
@@ -201,9 +204,9 @@ export function createImageBitmapFrom(
): ImageBitmap {
const bitmap = readDrawable(source);
if (bitmap === null) {
- throw domException(
- "InvalidStateError",
+ throw new DOMException(
"The image has nothing to copy yet",
+ "InvalidStateError",
);
}
const x = Math.floor(sx ?? 0);
@@ -211,7 +214,7 @@ export function createImageBitmapFrom(
const width = Math.floor(sw ?? naturalWidthOf(bitmap));
const height = Math.floor(sh ?? naturalHeightOf(bitmap));
if (width <= 0 || height <= 0) {
- throw domException("IndexSizeError", "The crop rectangle is empty");
+ throw new DOMException("The crop rectangle is empty", "IndexSizeError");
}
if (bitmap.width === naturalWidthOf(bitmap)) {
const copy = createBitmap(width, height);
@@ -283,14 +286,6 @@ function copyRect(
}
}
-// The platform's DOMException where there is one, which every runtime
-// this engine supports has.
-function domException(name: string, message: string): Error {
- const Constructor = (globalThis as {DOMException?: typeof DOMException})
- .DOMException;
- return Constructor ? new Constructor(message, name) : new Error(message);
-}
-
// ---------------------------------------------------------------------------
// Colors.
@@ -436,8 +431,6 @@ interface Subpath {
closed: boolean;
}
-const kSubpaths = Symbol("subpaths");
-
class PathBuilder {
subpaths: Subpath[];
last: [number, number] | null;
@@ -559,7 +552,7 @@ class PathBuilder {
return;
}
if (rx < 0 || ry < 0) {
- throw domException("IndexSizeError", "The radius is negative");
+ throw new DOMException("The radius is negative", "IndexSizeError");
}
// HTML's sweep rules: a full turn or more draws the whole ellipse,
// and otherwise the sweep is brought into (0, 2π) in its direction.
@@ -614,7 +607,7 @@ class PathBuilder {
return;
}
if (radius < 0) {
- throw domException("IndexSizeError", "The radius is negative");
+ throw new DOMException("The radius is negative", "IndexSizeError");
}
this.ensure(x1, y1);
const [x0, y0] = this.last!;
@@ -786,7 +779,6 @@ interface DOMPointInit {
export interface Path2DSlots {
builder: PathBuilder;
- [kSubpaths]: Step;
}
export interface Path2D {
@@ -805,10 +797,6 @@ export class Path2D {
}
}
- get [kSubpaths](): Subpath[] {
- return (this as unknown as Slots).builder.subpaths;
- }
-
addPath(path: Path2D, transform?: Partial): void {
const m: Matrix = [
transform?.a ?? 1,
@@ -1457,7 +1445,6 @@ function pathPolygons(subpaths: Subpath[]): number[][] {
export interface CanvasGradientSlots {
stops: Array<{offset: number; color: RGBA}>;
position: (x: number, y: number) => number;
- [kColorAt]: Step;
}
export interface CanvasGradient {
@@ -1474,11 +1461,11 @@ export class CanvasGradient {
addColorStop(offset: number, color: string): void {
if (!Number.isFinite(offset) || offset < 0 || offset > 1) {
- throw domException("IndexSizeError", "The offset is outside 0 to 1");
+ throw new DOMException("The offset is outside 0 to 1", "IndexSizeError");
}
const parsed = parseCanvasColor(String(color));
if (parsed === null) {
- throw domException("SyntaxError", "The color does not parse");
+ throw new DOMException("The color does not parse", "SyntaxError");
}
// A stop at an equal offset goes after the ones there already.
const slots = _(this);
@@ -1488,44 +1475,47 @@ export class CanvasGradient {
}
slots.stops.splice(index, 0, {offset, color: parsed});
}
+}
- /** The color at a user-space point, or null where the gradient has none. */
- [kColorAt](this: Slots, x: number, y: number): RGBA | null {
- const slots = this;
- const stops = slots.stops;
- if (stops.length === 0) {
- return {r: 0, g: 0, b: 0, a: 0};
- }
- const t = slots.position(x, y);
- if (Number.isNaN(t)) {
- return null;
- }
- if (t <= stops[0].offset) {
- return stops[0].color;
- }
- const last = stops[stops.length - 1];
- if (t >= last.offset) {
- return last.color;
- }
- for (let i = 1; i < stops.length; i++) {
- const a = stops[i - 1];
- const b = stops[i];
- if (t <= b.offset) {
- const span = b.offset - a.offset;
- const u = span === 0 ? 1 : (t - a.offset) / span;
- // HTML interpolates the four channels as they are, without
- // premultiplying.
- const mix = (p: number, q: number) => p + (q - p) * u;
- return {
- r: mix(a.color.r, b.color.r),
- g: mix(a.color.g, b.color.g),
- b: mix(a.color.b, b.color.b),
- a: mix(a.color.a, b.color.a),
- };
- }
- }
+/** The color at a user-space point, or null where the gradient has none. */
+function getGradientColor(
+ slots: Slots,
+ x: number,
+ y: number,
+): RGBA | null {
+ const stops = slots.stops;
+ if (stops.length === 0) {
+ return {r: 0, g: 0, b: 0, a: 0};
+ }
+ const t = slots.position(x, y);
+ if (Number.isNaN(t)) {
+ return null;
+ }
+ if (t <= stops[0].offset) {
+ return stops[0].color;
+ }
+ const last = stops[stops.length - 1];
+ if (t >= last.offset) {
return last.color;
}
+ for (let i = 1; i < stops.length; i++) {
+ const a = stops[i - 1];
+ const b = stops[i];
+ if (t <= b.offset) {
+ const span = b.offset - a.offset;
+ const u = span === 0 ? 1 : (t - a.offset) / span;
+ // HTML interpolates the four channels as they are, without
+ // premultiplying.
+ const mix = (p: number, q: number) => p + (q - p) * u;
+ return {
+ r: mix(a.color.r, b.color.r),
+ g: mix(a.color.g, b.color.g),
+ b: mix(a.color.b, b.color.b),
+ a: mix(a.color.a, b.color.a),
+ };
+ }
+ }
+ return last.color;
}
declareSealedRoot(CanvasGradient);
@@ -1535,7 +1525,6 @@ export interface CanvasPatternSlots {
repeatX: boolean;
repeatY: boolean;
patternTransform: Matrix;
- [kColorAt]: Step;
}
export interface CanvasPattern {
@@ -1564,35 +1553,43 @@ export class CanvasPattern {
transform?.f ?? 0,
];
}
+}
- [kColorAt](this: Slots, x: number, y: number): RGBA {
- const slots = this;
- const inverse = invert(slots.patternTransform);
- if (inverse !== null) {
- [x, y] = apply(inverse, x, y);
- }
- const bitmap = slots.patternBitmap;
- const {width, height, data} = bitmap;
- // The pattern tiles at the image's natural size.
- let px = Math.floor((x * width) / naturalWidthOf(bitmap));
- let py = Math.floor((y * height) / naturalHeightOf(bitmap));
- if (slots.repeatX) {
- px = ((px % width) + width) % width;
- }
- if (slots.repeatY) {
- py = ((py % height) + height) % height;
- }
- if (px < 0 || py < 0 || px >= width || py >= height) {
- return {r: 0, g: 0, b: 0, a: 0};
- }
- const at = (py * width + px) * 4;
- return {
- r: data[at],
- g: data[at + 1],
- b: data[at + 2],
- a: data[at + 3] / 255,
- };
+function getPatternColor(
+ slots: Slots,
+ x: number,
+ y: number,
+): RGBA {
+ const inverse = invert(slots.patternTransform);
+ if (inverse !== null) {
+ [x, y] = apply(inverse, x, y);
+ }
+ const bitmap = slots.patternBitmap;
+ const {width, height, data} = bitmap;
+ // The pattern tiles at the image's natural size.
+ let px = Math.floor((x * width) / naturalWidthOf(bitmap));
+ let py = Math.floor((y * height) / naturalHeightOf(bitmap));
+ if (slots.repeatX) {
+ px = ((px % width) + width) % width;
+ }
+ if (slots.repeatY) {
+ py = ((py % height) + height) % height;
}
+ if (px < 0 || py < 0 || px >= width || py >= height) {
+ return {r: 0, g: 0, b: 0, a: 0};
+ }
+ const at = (py * width + px) * 4;
+ return {r: data[at], g: data[at + 1], b: data[at + 2], a: data[at + 3] / 255};
+}
+
+function getPaintColor(
+ paint: CanvasGradient | CanvasPattern,
+ x: number,
+ y: number,
+): RGBA | null {
+ return paint instanceof CanvasGradient
+ ? getGradientColor(_(paint), x, y)
+ : getPatternColor(_(paint), x, y);
}
declareSealedRoot(CanvasPattern);
@@ -1785,17 +1782,6 @@ interface CanvasHost {
direction(): "ltr" | "rtl";
}
-const kSetMatrix = Symbol("setMatrix");
-const kSubpathsFor = Symbol("subpathsFor");
-const kReadPathArgs = Symbol("readPathArgs");
-const kStrokeStyle = Symbol("strokeStyle");
-const kScale = Symbol("scale");
-const kRectMask = Symbol("rectMask");
-const kPaint = Symbol("paint");
-const kDropText = Symbol("dropText");
-const kCoverText = Symbol("coverText");
-const kDrawText = Symbol("drawText");
-
// The host getContext() is making a context for. A context made any other
// way has none, and its constructor is illegal, as a browser's is.
let constructingHost: CanvasHost | GridHost | null = null;
@@ -1827,16 +1813,6 @@ export interface CanvasRenderingContext2DSlots {
state: State;
stack: State[];
path: PathBuilder;
- [kSetMatrix]: Step;
- [kSubpathsFor]: Step;
- [kReadPathArgs]: Step;
- [kStrokeStyle]: Step;
- [kScale]: Step;
- [kRectMask]: Step;
- [kPaint]: Step;
- [kDropText]: Step;
- [kCoverText]: Step;
- [kDrawText]: Step;
}
export interface CanvasRenderingContext2D {
@@ -2102,21 +2078,22 @@ export class CanvasRenderingContext2D {
scale(x: number, y: number): void {
const slots = _(this);
- slots[kSetMatrix](multiply(slots.state.transform, [x, 0, 0, y, 0, 0]));
+ setMatrix(slots, multiply(slots.state.transform, [x, 0, 0, y, 0, 0]));
}
rotate(angle: number): void {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const slots = _(this);
- slots[kSetMatrix](
+ setMatrix(
+ slots,
multiply(slots.state.transform, [cos, sin, -sin, cos, 0, 0]),
);
}
translate(x: number, y: number): void {
const slots = _(this);
- slots[kSetMatrix](multiply(slots.state.transform, [1, 0, 0, 1, x, y]));
+ setMatrix(slots, multiply(slots.state.transform, [1, 0, 0, 1, x, y]));
}
transform(
@@ -2128,7 +2105,7 @@ export class CanvasRenderingContext2D {
f: number,
): void {
const slots = _(this);
- slots[kSetMatrix](multiply(slots.state.transform, [a, b, c, d, e, f]));
+ setMatrix(slots, multiply(slots.state.transform, [a, b, c, d, e, f]));
}
setTransform(
@@ -2141,9 +2118,9 @@ export class CanvasRenderingContext2D {
): void {
const slots = _(this);
if (typeof a === "number") {
- slots[kSetMatrix]([a, b!, c!, d!, e!, f!]);
+ setMatrix(slots, [a, b!, c!, d!, e!, f!]);
} else {
- slots[kSetMatrix]([
+ setMatrix(slots, [
a?.a ?? 1,
a?.b ?? 0,
a?.c ?? 0,
@@ -2155,7 +2132,7 @@ export class CanvasRenderingContext2D {
}
resetTransform(): void {
- _(this)[kSetMatrix]([1, 0, 0, 1, 0, 0]);
+ setMatrix(_(this), [1, 0, 0, 1, 0, 0]);
}
getTransform(): CanvasMatrix {
@@ -2204,7 +2181,7 @@ export class CanvasRenderingContext2D {
throw new TypeError("The gradient's circles must be finite");
}
if (r0 < 0 || r1 < 0) {
- throw domException("IndexSizeError", "A radius is negative");
+ throw new DOMException("A radius is negative", "IndexSizeError");
}
// The largest t whose circle passes through the point (HTML's
// two-circle gradient), solved as a quadratic.
@@ -2256,7 +2233,10 @@ export class CanvasRenderingContext2D {
const bitmap = readDrawable(image);
const mode = repetition ?? "";
if (!["", "repeat", "repeat-x", "repeat-y", "no-repeat"].includes(mode)) {
- throw domException("SyntaxError", "The repetition is not one HTML names");
+ throw new DOMException(
+ "The repetition is not one HTML names",
+ "SyntaxError",
+ );
}
if (bitmap === null || bitmap.width === 0 || bitmap.height === 0) {
return null;
@@ -2343,15 +2323,15 @@ export class CanvasRenderingContext2D {
fill(pathOrRule?: Path2D | FillRule, rule?: FillRule): void {
const slots = _(this);
- const args = slots[kReadPathArgs](pathOrRule, rule);
+ const args = readPathArgs(pathOrRule, rule);
const bitmap = slots.host.bitmap();
const mask = rasterize(
- pathPolygons(slots[kSubpathsFor](args.path)),
+ pathPolygons(subpathsFor(slots, args.path)),
bitmap.width,
bitmap.height,
args.rule,
);
- slots[kPaint](mask, slots.state.fillStyle);
+ paintMask(slots, mask, slots.state.fillStyle);
}
stroke(path?: Path2D): void {
@@ -2359,23 +2339,23 @@ export class CanvasRenderingContext2D {
const bitmap = slots.host.bitmap();
const mask = rasterize(
strokePolygons(
- slots[kSubpathsFor](path),
- slots[kStrokeStyle](),
- slots[kScale](),
+ subpathsFor(slots, path),
+ getStrokeStyle(slots),
+ getTransformScale(slots),
),
bitmap.width,
bitmap.height,
"nonzero",
);
- slots[kPaint](mask, slots.state.strokeStyle);
+ paintMask(slots, mask, slots.state.strokeStyle);
}
clip(pathOrRule?: Path2D | FillRule, rule?: FillRule): void {
const slots = _(this);
- const args = slots[kReadPathArgs](pathOrRule, rule);
+ const args = readPathArgs(pathOrRule, rule);
const bitmap = slots.host.bitmap();
const mask = rasterize(
- pathPolygons(slots[kSubpathsFor](args.path)),
+ pathPolygons(subpathsFor(slots, args.path)),
bitmap.width,
bitmap.height,
args.rule,
@@ -2415,7 +2395,7 @@ export class CanvasRenderingContext2D {
// The point is in canvas coordinates, unaffected by the transform.
let winding = 0;
let crossings = 0;
- for (const points of pathPolygons(_(this)[kSubpathsFor](path))) {
+ for (const points of pathPolygons(subpathsFor(_(this), path))) {
const count = points.length / 2;
for (let i = 0; i < count; i++) {
const ax = points[i * 2];
@@ -2447,9 +2427,9 @@ export class CanvasRenderingContext2D {
}
const slots = _(this);
for (const points of strokePolygons(
- slots[kSubpathsFor](path),
- slots[kStrokeStyle](),
- slots[kScale](),
+ subpathsFor(slots, path),
+ getStrokeStyle(slots),
+ getTransformScale(slots),
)) {
let inside = false;
const count = points.length / 2;
@@ -2476,7 +2456,7 @@ export class CanvasRenderingContext2D {
return;
}
const slots = _(this);
- slots[kPaint](slots[kRectMask](x, y, w, h), slots.state.fillStyle);
+ paintMask(slots, getRectMask(slots, x, y, w, h), slots.state.fillStyle);
}
strokeRect(x: number, y: number, w: number, h: number): void {
@@ -2489,12 +2469,16 @@ export class CanvasRenderingContext2D {
builder.rect(x, y, w, h);
const bitmap = slots.host.bitmap();
const mask = rasterize(
- strokePolygons(builder.subpaths, slots[kStrokeStyle](), slots[kScale]()),
+ strokePolygons(
+ builder.subpaths,
+ getStrokeStyle(slots),
+ getTransformScale(slots),
+ ),
bitmap.width,
bitmap.height,
"nonzero",
);
- slots[kPaint](mask, slots.state.strokeStyle);
+ paintMask(slots, mask, slots.state.strokeStyle);
}
clearRect(x: number, y: number, w: number, h: number): void {
@@ -2502,7 +2486,7 @@ export class CanvasRenderingContext2D {
return;
}
const slots = _(this);
- const mask = slots[kRectMask](x, y, w, h);
+ const mask = getRectMask(slots, x, y, w, h);
const clip = slots.state.clip;
const data = slots.host.bitmap().data;
for (let i = 0; i < mask.length; i++) {
@@ -2517,7 +2501,7 @@ export class CanvasRenderingContext2D {
data[at] = data[at + 1] = data[at + 2] = 0;
}
}
- slots[kDropText](mask, 0.5);
+ dropText(slots, mask, 0.5);
slots.host.changed();
}
@@ -2655,7 +2639,7 @@ export class CanvasRenderingContext2D {
};
// Large downscales average the source area a pixel covers, so a
// photo drawn small does not alias.
- const scale = slots[kScale]();
+ const scale = getTransformScale(slots);
const shrinkX = Math.abs(sw / (dw * scale));
const shrinkY = Math.abs(sh / (dh * scale));
const average = smooth && (shrinkX > 2 || shrinkY > 2);
@@ -2710,7 +2694,7 @@ export class CanvasRenderingContext2D {
}
}
if (operation === "source-over" || operation === "copy") {
- slots[kDropText](mask, 0.5);
+ dropText(slots, mask, 0.5);
}
slots.host.changed();
}
@@ -2727,7 +2711,7 @@ export class CanvasRenderingContext2D {
throw new TypeError("The rectangle must be finite");
}
if (sw === 0 || sh === 0) {
- throw domException("IndexSizeError", "The rectangle is empty");
+ throw new DOMException("The rectangle is empty", "IndexSizeError");
}
if (sw < 0) {
sx += sw;
@@ -2802,12 +2786,12 @@ export class CanvasRenderingContext2D {
fillText(text: string, x: number, y: number, _maxWidth?: number): void {
const slots = _(this);
- slots[kDrawText](String(text), x, y, slots.state.fillStyle);
+ drawContextText(slots, String(text), x, y, slots.state.fillStyle);
}
strokeText(text: string, x: number, y: number, _maxWidth?: number): void {
const slots = _(this);
- slots[kDrawText](String(text), x, y, slots.state.strokeStyle);
+ drawContextText(slots, String(text), x, y, slots.state.strokeStyle);
}
/** Widths in canvas pixels: a cell's width in pixels for each column. */
@@ -2838,289 +2822,277 @@ export class CanvasRenderingContext2D {
}
drawFocusIfNeeded(): void {}
+}
- [kSetMatrix](this: Slots, m: Matrix): void {
- if (!isFiniteAll(...m)) {
- return;
- }
- const slots = this;
- slots.state.transform = m;
- slots.path.transform = m;
+function setMatrix(slots: Slots, m: Matrix): void {
+ if (!isFiniteAll(...m)) {
+ return;
}
+ slots.state.transform = m;
+ slots.path.transform = m;
+}
- // A Path2D's points are in user space and go through the transform
- // when it is used; the current path's went through as they were added.
- [kSubpathsFor](
- this: Slots,
- path?: Path2D,
- ): Subpath[] {
- const slots = this;
- if (path === undefined) {
- return slots.path.subpaths;
- }
- const builder = new PathBuilder();
- builder.append(_(path)[kSubpaths], slots.state.transform);
- return builder.subpaths;
+// A Path2D's points are in user space and go through the transform
+// when it is used; the current path's went through as they were added.
+function subpathsFor(
+ slots: Slots,
+ path?: Path2D,
+): Subpath[] {
+ if (path === undefined) {
+ return slots.path.subpaths;
}
+ const builder = new PathBuilder();
+ builder.append(_(path).builder.subpaths, slots.state.transform);
+ return builder.subpaths;
+}
- [kReadPathArgs](
- pathOrRule?: Path2D | FillRule,
- maybeRule?: FillRule,
- ): {path: Path2D | undefined; rule: FillRule} {
- if (pathOrRule instanceof Path2D) {
- return {
- path: pathOrRule,
- rule: maybeRule === "evenodd" ? "evenodd" : "nonzero",
- };
- }
+function readPathArgs(
+ pathOrRule?: Path2D | FillRule,
+ maybeRule?: FillRule,
+): {path: Path2D | undefined; rule: FillRule} {
+ if (pathOrRule instanceof Path2D) {
return {
- path: undefined,
- rule: pathOrRule === "evenodd" ? "evenodd" : "nonzero",
+ path: pathOrRule,
+ rule: maybeRule === "evenodd" ? "evenodd" : "nonzero",
};
}
+ return {
+ path: undefined,
+ rule: pathOrRule === "evenodd" ? "evenodd" : "nonzero",
+ };
+}
- [kStrokeStyle](this: Slots): StrokeStyle {
- const slots = this;
- const scale = slots[kScale]();
- return {
- width: slots.state.lineWidth,
- cap: slots.state.lineCap,
- join: slots.state.lineJoin,
- miterLimit: slots.state.miterLimit,
- dash: slots.state.lineDash.map((value) => value * scale),
- dashOffset: slots.state.lineDashOffset * scale,
- };
- }
+function getStrokeStyle(slots: Slots): StrokeStyle {
+ const scale = getTransformScale(slots);
+ return {
+ width: slots.state.lineWidth,
+ cap: slots.state.lineCap,
+ join: slots.state.lineJoin,
+ miterLimit: slots.state.miterLimit,
+ dash: slots.state.lineDash.map((value) => value * scale),
+ dashOffset: slots.state.lineDashOffset * scale,
+ };
+}
- // How much the transform scales lengths, for line widths and dashes.
- [kScale](this: Slots): number {
- const m = this.state.transform;
- return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
- }
+// How much the transform scales lengths, for line widths and dashes.
+function getTransformScale(slots: Slots): number {
+ const m = slots.state.transform;
+ return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
+}
- [kRectMask](
- this: Slots,
- x: number,
- y: number,
- w: number,
- h: number,
- ): Float32Array {
- const builder = new PathBuilder();
- const slots = this;
- builder.transform = slots.state.transform;
- builder.rect(x, y, w, h);
- const bitmap = slots.host.bitmap();
- return rasterize(
- pathPolygons(builder.subpaths),
- bitmap.width,
- bitmap.height,
- "nonzero",
- );
- }
+function getRectMask(
+ slots: Slots,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+): Float32Array {
+ const builder = new PathBuilder();
+ builder.transform = slots.state.transform;
+ builder.rect(x, y, w, h);
+ const bitmap = slots.host.bitmap();
+ return rasterize(
+ pathPolygons(builder.subpaths),
+ bitmap.width,
+ bitmap.height,
+ "nonzero",
+ );
+}
- [kPaint](
- this: Slots,
- mask: Float32Array,
- paint: Paint,
- ): void {
- const slots = this;
- const bitmap = slots.host.bitmap();
- const {width, height, data} = bitmap;
- const operation = slots.state.globalCompositeOperation;
- const unbounded = UNBOUNDED.has(operation);
- const alpha = slots.state.globalAlpha;
- const clip = slots.state.clip;
- const solid = typeof paint === "string" ? parseCanvasColor(paint) : null;
- const inverse = typeof paint === "string"
- ? null
- : invert(slots.state.transform);
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x++) {
- const i = y * width + x;
- const clipCover = clip === null ? 1 : clip[i];
- if (clipCover <= 0) {
- continue;
- }
- const cover = mask[i];
- if (cover <= 0 && !unbounded) {
- continue;
- }
- let color: RGBA | null = solid;
- if (color === null && cover > 0 && inverse !== null) {
- const [ux, uy] = apply(inverse, x + 0.5, y + 0.5);
- color = _((paint as CanvasGradient | CanvasPattern))[kColorAt](
- ux,
- uy,
- );
- }
- const sa = color === null ? 0 : color.a * alpha * cover;
- if (clipCover < 1) {
- // Blend the result back toward the old pixel where the clip
- // only partly covers it.
- const before = [
- data[i * 4],
- data[i * 4 + 1],
- data[i * 4 + 2],
- data[i * 4 + 3],
- ];
- composite(
- data,
- i * 4,
- color?.r ?? 0,
- color?.g ?? 0,
- color?.b ?? 0,
- sa,
- operation,
- );
- for (let c = 0; c < 4; c++) {
- data[i * 4 + c] =
- before[c] + (data[i * 4 + c] - before[c]) * clipCover;
- }
- } else {
- composite(
- data,
- i * 4,
- color?.r ?? 0,
- color?.g ?? 0,
- color?.b ?? 0,
- sa,
- operation,
- );
- }
+function paintMask(
+ slots: Slots,
+ mask: Float32Array,
+ paint: Paint,
+): void {
+ const bitmap = slots.host.bitmap();
+ const {width, height, data} = bitmap;
+ const operation = slots.state.globalCompositeOperation;
+ const unbounded = UNBOUNDED.has(operation);
+ const alpha = slots.state.globalAlpha;
+ const clip = slots.state.clip;
+ const solid = typeof paint === "string" ? parseCanvasColor(paint) : null;
+ const inverse = typeof paint === "string"
+ ? null
+ : invert(slots.state.transform);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const i = y * width + x;
+ const clipCover = clip === null ? 1 : clip[i];
+ if (clipCover <= 0) {
+ continue;
}
- }
- if (operation === "source-over") {
- slots[kCoverText](
- mask,
- (x, y) =>
- solid ??
- (inverse === null
- ? null
- : _((paint as CanvasGradient | CanvasPattern))[kColorAt](
- ...apply(inverse, x + 0.5, y + 0.5),
- )),
- alpha,
- );
- } else if (operation === "copy") {
- slots[kDropText](mask, 0.5);
- }
- slots.host.changed();
- }
-
- // Translucent paint over text blends the text toward the paint, as it
- // would blend the text's pixels in a browser, so drawing a dim fill
- // every frame fades text out over several frames.
- [kCoverText](
- this: Slots,
- mask: Float32Array,
- colorAt: (x: number, y: number) => RGBA | null,
- alpha: number,
- ): void {
- const slots = this;
- const bitmap = slots.host.bitmap();
- if (bitmap.width === 0 || bitmap.height === 0) {
- return;
- }
- slots.text = slots.text.filter((run) => {
- const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x)));
- const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y)));
- const cover = mask[y * bitmap.width + x];
- if (cover <= 0) {
- return true;
+ const cover = mask[i];
+ if (cover <= 0 && !unbounded) {
+ continue;
}
- const paint = colorAt(x, y);
- const amount = paint === null ? 0 : paint.a * alpha * cover;
- run.covered = 1 - (1 - run.covered) * (1 - amount);
- if (run.covered >= 0.5) {
- return false;
+ let color: RGBA | null = solid;
+ if (color === null && cover > 0 && inverse !== null) {
+ const [ux, uy] = apply(inverse, x + 0.5, y + 0.5);
+ color = getPaintColor(paint as CanvasGradient | CanvasPattern, ux, uy);
}
- if (paint !== null) {
- run.color = {
- r: run.color.r + (paint.r - run.color.r) * amount,
- g: run.color.g + (paint.g - run.color.g) * amount,
- b: run.color.b + (paint.b - run.color.b) * amount,
- a: run.color.a,
- };
+ const sa = color === null ? 0 : color.a * alpha * cover;
+ if (clipCover < 1) {
+ // Blend the result back toward the old pixel where the clip
+ // only partly covers it.
+ const before = [
+ data[i * 4],
+ data[i * 4 + 1],
+ data[i * 4 + 2],
+ data[i * 4 + 3],
+ ];
+ composite(
+ data,
+ i * 4,
+ color?.r ?? 0,
+ color?.g ?? 0,
+ color?.b ?? 0,
+ sa,
+ operation,
+ );
+ for (let c = 0; c < 4; c++) {
+ data[i * 4 + c] =
+ before[c] + (data[i * 4 + c] - before[c]) * clipCover;
+ }
+ } else {
+ composite(
+ data,
+ i * 4,
+ color?.r ?? 0,
+ color?.g ?? 0,
+ color?.b ?? 0,
+ sa,
+ operation,
+ );
}
- return true;
- });
- }
-
- // Text under the shape painted last is covered, as it would be
- // in pixels.
- [kDropText](
- this: Slots,
- mask: Float32Array,
- threshold: number,
- ): void {
- const slots = this;
- const bitmap = slots.host.bitmap();
- if (bitmap.width === 0 || bitmap.height === 0) {
- return;
}
- // An anchor on or past an edge (right-aligned text at the canvas's
- // width, say) is read at the nearest pixel inside, so clearing the
- // canvas clears it too.
- slots.text = slots.text.filter((run) => {
- const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x)));
- const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y)));
- return mask[y * bitmap.width + x] < threshold;
- });
}
+ if (operation === "source-over") {
+ coverText(
+ slots,
+ mask,
+ (x, y) =>
+ solid ??
+ (inverse === null
+ ? null
+ : getPaintColor(
+ paint as CanvasGradient | CanvasPattern,
+ ...apply(inverse, x + 0.5, y + 0.5),
+ )),
+ alpha,
+ );
+ } else if (operation === "copy") {
+ dropText(slots, mask, 0.5);
+ }
+ slots.host.changed();
+}
- [kDrawText](
- this: Slots,
- text: string,
- x: number,
- y: number,
- paint: Paint,
- ): void {
- if (!isFiniteAll(x, y)) {
- return;
+// Translucent paint over text blends the text toward the paint, as it
+// would blend the text's pixels in a browser, so drawing a dim fill
+// every frame fades text out over several frames.
+function coverText(
+ slots: Slots,
+ mask: Float32Array,
+ colorAt: (x: number, y: number) => RGBA | null,
+ alpha: number,
+): void {
+ const bitmap = slots.host.bitmap();
+ if (bitmap.width === 0 || bitmap.height === 0) {
+ return;
+ }
+ slots.text = slots.text.filter((run) => {
+ const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x)));
+ const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y)));
+ const cover = mask[y * bitmap.width + x];
+ if (cover <= 0) {
+ return true;
}
- const slots = this;
- const [px, py] = apply(slots.state.transform, x, y);
- const color = typeof paint === "string"
- ? parseCanvasColor(paint)
- : _((paint as CanvasGradient | CanvasPattern))[kColorAt](x, y);
- if (color === null || color.a * slots.state.globalAlpha === 0) {
- return;
+ const paint = colorAt(x, y);
+ const amount = paint === null ? 0 : paint.a * alpha * cover;
+ run.covered = 1 - (1 - run.covered) * (1 - amount);
+ if (run.covered >= 0.5) {
+ return false;
}
- // "inherit" takes the canvas's direction as the text is drawn.
- const direction = slots.state.direction === "inherit"
- ? slots.host.direction()
- : slots.state.direction;
- const rtl = direction === "rtl";
- const align = slots.state.textAlign;
- const resolved = align === "center"
- ? "center"
- : align === "right" ||
- (align === "end" && !rtl) ||
- (align === "start" && rtl)
- ? "right"
- : "left";
- const words = slots.state.font.toLowerCase().split(/\s+/);
- const run: CanvasTextRun = {
- text: toVisualOrder(
- text.replace(/[\t\n\f\r]/g, " "),
- rtl ? "rtl" : "ltr",
- ),
- x: px,
- y: py,
- align: resolved,
- baseline: slots.state.textBaseline,
- color: {...color, a: color.a * slots.state.globalAlpha},
- covered: 0,
- bold: words.some(
- (word) => word === "bold" ||
- word === "bolder" ||
- (/^\d+$/.test(word) && Number(word) >= 600),
- ),
- italic: words.includes("italic") || words.includes("oblique"),
- };
- slots.text.push(run);
- slots.host.changed();
+ if (paint !== null) {
+ run.color = {
+ r: run.color.r + (paint.r - run.color.r) * amount,
+ g: run.color.g + (paint.g - run.color.g) * amount,
+ b: run.color.b + (paint.b - run.color.b) * amount,
+ a: run.color.a,
+ };
+ }
+ return true;
+ });
+}
+
+// Text under the shape painted last is covered, as it would be
+// in pixels.
+function dropText(
+ slots: Slots,
+ mask: Float32Array,
+ threshold: number,
+): void {
+ const bitmap = slots.host.bitmap();
+ if (bitmap.width === 0 || bitmap.height === 0) {
+ return;
+ }
+ // An anchor on or past an edge (right-aligned text at the canvas's
+ // width, say) is read at the nearest pixel inside, so clearing the
+ // canvas clears it too.
+ slots.text = slots.text.filter((run) => {
+ const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x)));
+ const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y)));
+ return mask[y * bitmap.width + x] < threshold;
+ });
+}
+
+function drawContextText(
+ slots: Slots,
+ text: string,
+ x: number,
+ y: number,
+ paint: Paint,
+): void {
+ if (!isFiniteAll(x, y)) {
+ return;
}
+ const [px, py] = apply(slots.state.transform, x, y);
+ const color = typeof paint === "string"
+ ? parseCanvasColor(paint)
+ : getPaintColor(paint as CanvasGradient | CanvasPattern, x, y);
+ if (color === null || color.a * slots.state.globalAlpha === 0) {
+ return;
+ }
+ // "inherit" takes the canvas's direction as the text is drawn.
+ const direction = slots.state.direction === "inherit"
+ ? slots.host.direction()
+ : slots.state.direction;
+ const rtl = direction === "rtl";
+ const align = slots.state.textAlign;
+ const resolved = align === "center"
+ ? "center"
+ : align === "right" ||
+ (align === "end" && !rtl) ||
+ (align === "start" && rtl)
+ ? "right"
+ : "left";
+ const words = slots.state.font.toLowerCase().split(/\s+/);
+ const run: CanvasTextRun = {
+ text: toVisualOrder(text.replace(/[\t\n\f\r]/g, " "), rtl ? "rtl" : "ltr"),
+ x: px,
+ y: py,
+ align: resolved,
+ baseline: slots.state.textBaseline,
+ color: {...color, a: color.a * slots.state.globalAlpha},
+ covered: 0,
+ bold: words.some(
+ (word) => word === "bold" ||
+ word === "bolder" ||
+ (/^\d+$/.test(word) && Number(word) >= 600),
+ ),
+ italic: words.includes("italic") || words.includes("oblique"),
+ };
+ slots.text.push(run);
+ slots.host.changed();
}
declareSealedRoot(CanvasRenderingContext2D);
@@ -3177,10 +3149,6 @@ interface GridHost {
drawsPixels(): boolean;
}
-const kResolveDirection = Symbol("resolveDirection");
-const kTextColor = Symbol("textColor");
-const kRect = Symbol("rect");
-
type GridTextAlign = "start" | "end" | "left" | "right" | "center";
type GridDirection = "ltr" | "rtl" | "inherit";
type GridLineStyle = LineStyle["style"] | "none";
@@ -3220,9 +3188,6 @@ export interface CanvasCellGridContextSlots {
gridHost: GridHost;
gridState: GridState;
gridStack: GridState[];
- [kResolveDirection]: Step;
- [kTextColor]: Step;
- [kRect]: Step;
}
export interface CanvasCellGridContext {
@@ -3412,19 +3377,19 @@ export class CanvasCellGridContext {
fillRect(x: number, y: number, w: number, h: number): void {
const slots = _(this);
- const rect = slots[kRect](x, y, w, h);
+ const rect = getGridRect(x, y, w, h);
const fill = toGridFill(slots.gridState.fillStyle);
if (rect === null || fill === undefined) {
return;
}
const [x0, y0, x1, y1] = rect;
- slots.gridHost.cells().drawRect(x0, y0, x1 - x0, y1 - y0, fill);
+ drawRect(slots.gridHost.cells(), x0, y0, x1 - x0, y1 - y0, fill);
slots.gridHost.changed();
}
clearRect(x: number, y: number, w: number, h: number): void {
const slots = _(this);
- const rect = slots[kRect](x, y, w, h);
+ const rect = getGridRect(x, y, w, h);
if (rect === null) {
return;
}
@@ -3432,7 +3397,7 @@ export class CanvasCellGridContext {
const x0 = Math.max(0, rect[0]);
const x1 = Math.min(cols, rect[2]);
for (let row = Math.max(0, rect[1]); row < Math.min(rows, rect[3]); row++) {
- grid.clearRange(row * cols + x0, row * cols + x1);
+ clearRange(grid, row * cols + x0, row * cols + x1);
}
slots.gridHost.changed();
}
@@ -3442,12 +3407,12 @@ export class CanvasCellGridContext {
return;
}
const slots = _(this);
- const color = slots[kTextColor](slots.gridState.fillStyle);
+ const color = getGridTextColor(slots.gridState.fillStyle);
if (color === undefined || (maxWidth !== undefined && !(maxWidth > 0))) {
return;
}
const cells = slots.gridHost.cells();
- const rtl = slots[kResolveDirection]() === "rtl";
+ const rtl = resolveGridDirection(slots) === "rtl";
const clusters = [
...graphemeSegmenter.segment(
toVisualOrder(
@@ -3501,7 +3466,8 @@ export class CanvasCellGridContext {
inside &&
grid.cluster[index] !== 0 &&
readCell(grid, index)?.inverse === true;
- cells.drawText(
+ drawText(
+ cells,
cluster,
col,
row,
@@ -3529,7 +3495,7 @@ export class CanvasCellGridContext {
return;
}
const slots = _(this);
- const color = slots[kTextColor](slots.gridState.strokeStyle);
+ const color = getGridTextColor(slots.gridState.strokeStyle);
const style = slots.gridState.lineStyle;
if (color === undefined || style === "none" || style === "hidden") {
return;
@@ -3538,13 +3504,13 @@ export class CanvasCellGridContext {
const line: LineStyle = {style, color};
const cells = slots.gridHost.cells();
if (b === d) {
- cells.drawLine(Math.min(a, c), b, Math.max(a, c) + 1, b, {
+ drawLine(cells, Math.min(a, c), b, Math.max(a, c) + 1, b, {
...line,
startCap: a === c ? "square" : undefined,
endCap: a === c ? "square" : undefined,
});
} else if (a === c) {
- cells.drawLine(a, Math.min(b, d), a, Math.max(b, d) + 1, line);
+ drawLine(cells, a, Math.min(b, d), a, Math.max(b, d) + 1, line);
} else {
return;
}
@@ -3554,8 +3520,8 @@ export class CanvasCellGridContext {
/** A box on the cells `w` by `h` from (x, y), in strokeStyle and lineStyle. */
strokeRect(x: number, y: number, w: number, h: number): void {
const slots = _(this);
- const rect = slots[kRect](x, y, w, h);
- const color = slots[kTextColor](slots.gridState.strokeStyle);
+ const rect = getGridRect(x, y, w, h);
+ const color = getGridTextColor(slots.gridState.strokeStyle);
const style = slots.gridState.lineStyle;
if (
rect === null ||
@@ -3568,24 +3534,23 @@ export class CanvasCellGridContext {
const [x0, y0, x1, y1] = rect;
const side: LineStyle = {style, color};
const corner = slots.gridState.lineJoin === "round" ? "round" : undefined;
- slots.gridHost.cells()
- .drawBox(x0, y0, x1 - x0, y1 - y0, {
- top: side,
- right: side,
- bottom: side,
- left: side,
- topLeft: corner,
- topRight: corner,
- bottomRight: corner,
- bottomLeft: corner,
- });
+ drawBox(slots.gridHost.cells(), x0, y0, x1 - x0, y1 - y0, {
+ top: side,
+ right: side,
+ bottom: side,
+ left: side,
+ topLeft: corner,
+ topRight: corner,
+ bottomRight: corner,
+ bottomLeft: corner,
+ });
slots.gridHost.changed();
}
measureText(text: string): {width: number} {
const slots = _(this);
return slots.gridHost.cells()
- .measureText(toVisualOrder(String(text), slots[kResolveDirection]()));
+ .measureText(toVisualOrder(String(text), resolveGridDirection(slots)));
}
/**
@@ -3676,7 +3641,7 @@ export class CanvasCellGridContext {
: null,
);
if (half !== null) {
- cells.drawText(half.char, x, y, half.style);
+ drawText(cells, half.char, x, y, half.style);
}
}
}
@@ -3735,52 +3700,53 @@ export class CanvasCellGridContext {
reset(): void {
const slots = _(this);
- slots.gridHost.cells().grid.clear();
+ clearGrid(slots.gridHost.cells().grid);
slots.gridState = defaultGridState();
slots.gridStack = [];
slots.gridHost.changed();
}
+}
- [kResolveDirection](this: Slots): "ltr" | "rtl" {
- const slots = this;
- const {direction} = slots.gridState;
- return direction === "inherit" ? slots.gridHost.direction() : direction;
- }
+function resolveGridDirection(
+ slots: Slots,
+): "ltr" | "rtl" {
+ const {direction} = slots.gridState;
+ return direction === "inherit" ? slots.gridHost.direction() : direction;
+}
- // A kept color as a glyph's color: a cell color, null for the
- // terminal's own text color, or undefined for one too transparent to
- // show, as a 2d context's text under half opaque does not.
- [kTextColor](value: string): number | null | undefined {
- const system = getSystemColor(value);
- if (system !== null) {
- return system.cell === 0 ? null : system.cell;
- }
- const color = parseCanvasColor(value)!;
- if (color.a < 0.5) {
- return undefined;
- }
- return toCellColor((color.r << 16) | (color.g << 8) | color.b);
+// A kept color as a glyph's color: a cell color, null for the
+// terminal's own text color, or undefined for one too transparent to
+// show, as a 2d context's text under half opaque does not.
+function getGridTextColor(value: string): number | null | undefined {
+ const system = getSystemColor(value);
+ if (system !== null) {
+ return system.cell === 0 ? null : system.cell;
+ }
+ const color = parseCanvasColor(value)!;
+ if (color.a < 0.5) {
+ return undefined;
}
+ return toCellColor((color.r << 16) | (color.g << 8) | color.b);
+}
- [kRect](
- x: number,
- y: number,
- w: number,
- h: number,
- ): [number, number, number, number] | null {
- if (!isFiniteAll(x, y, w, h)) {
- return null;
- }
- if (w < 0) {
- x += w;
- w = -w;
- }
- if (h < 0) {
- y += h;
- h = -h;
- }
- return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
+function getGridRect(
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+): [number, number, number, number] | null {
+ if (!isFiniteAll(x, y, w, h)) {
+ return null;
+ }
+ if (w < 0) {
+ x += w;
+ w = -w;
+ }
+ if (h < 0) {
+ y += h;
+ h = -h;
}
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
}
declareSealedRoot(CanvasCellGridContext);
diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts
index d8631e0c..0cf22ce7 100644
--- a/src/internal/cssom.ts
+++ b/src/internal/cssom.ts
@@ -17,46 +17,36 @@ import * as CSSTree from "./csstree.ts";
import * as CSSValues from "./cssvalues.ts";
import {
appendChildOf,
- attributeOf,
constructInternal,
- containsOf,
dispatchAsUserAgent,
type Document as DOMDocument,
type Element as DOMElement,
type Node as DOMNode,
dropPseudoElement,
ensurePseudoElement,
+ flatChildren,
flatParentElement,
flushLayout,
flushStyle,
getAddedNodes,
- getAttributeList,
- getCellPixels,
- getCellSizeSetting,
getClassTokens,
- getDocumentExchange,
getHighlightRegistry,
getPseudoHost,
getPseudoName,
getRemovedNodes,
getShadowRoot,
getTopLayer,
- hasAttributeOf,
- HTML_NAMESPACE,
isButtonInput,
+ isConnectedNode,
isDocumentElement,
isShadowRoot,
isUAShadowTree,
- localNameOf,
- MATHML_NAMESPACE,
- namespaceOf,
- nodeTypeOf,
- ownerDocumentOf,
pseudoElement,
pseudoElementCount,
releaseRemovedNodes,
reobserveShadowRoot,
setAttributeOf,
+ setStyleHooks,
styleElementCount,
TransitionEvent,
type Window,
@@ -81,6 +71,22 @@ import {
} from "./slots.ts";
import {getStringWidth, splitOnASCIIWhitespace} from "./text.ts";
import {clearTimeout, queueMicrotask, setTimeout} from "./timers.ts";
+import {
+ attributeOf,
+ containsOf,
+ ELEMENT_NODE,
+ getAttributeList,
+ getDocumentExchange,
+ hasAttributeOf,
+ HTML_NAMESPACE,
+ localNameOf,
+ MATHML_NAMESPACE,
+ namespaceOf,
+ nextInTree,
+ nodeTypeOf,
+ ownerDocumentOf,
+} from "./tree.ts";
+import {cellsFromPx, getCellSize, pxFromCells, UNIT_CELL} from "./units.ts";
import {
UA_CONTROL_STYLES,
UA_DOCUMENT_STYLES,
@@ -88,72 +94,6 @@ import {
} from "./useragent.ts";
import {windowMember} from "./webidl.ts";
-// What one terminal cell measures in CSS pixels, per document. Layout
-// counts cells; everything a page writes or reads counts CSS pixels. At
-// the default unit size the two are the same number.
-
-export interface CellSize {
- width: number;
- height: number;
-}
-
-export const UNIT_CELL: Readonly = {width: 1, height: 1};
-
-/**
- * How the page's lengths map to cells: one CSS pixel a cell, the
- * terminal's pixels once it reports them, or a size the program gives.
- */
-export type CellSizeSetting = "unit" | "auto" | Readonly;
-
-export function getCellSize(node: Node | null | undefined): Readonly {
- const setting = node ? getCellSizeSetting(node) : undefined;
- if (setting === undefined || setting === "unit") {
- return UNIT_CELL;
- }
- return setting === "auto" ? getCellPixels(node!)! : setting;
-}
-
-/**
- * The cell an image's pixels divide by. An image's pixels are CSS
- * pixels, so this is the page's cell. Under the unit cell that would be
- * a cell per pixel, so the screen's cell stands in. A document no TermDOM
- * draws has no screen, and draws no image.
- */
-export function getImageCellSize(
- node: Node | null | undefined,
-): Readonly {
- const page = getCellSize(node);
- if (page !== UNIT_CELL) {
- return page;
- }
- return (node && getCellPixels(node)) ?? page;
-}
-
-/**
- * CSS pixels along one axis as cells, fractions kept. Layout rounds the
- * edges of the boxes it places, not each length, so lengths that add up
- * still add up.
- */
-function cellsFromPx(
- px: number,
- vertical: boolean,
- node: Node | null | undefined,
-): number {
- const cell = getCellSize(node);
- const size = vertical ? cell.height : cell.width;
- return px / size;
-}
-
-/** Cells along one axis as CSS pixels. */
-export function pxFromCells(
- cells: number,
- vertical: boolean,
- node: Node | null | undefined,
-): number {
- const cell = getCellSize(node);
- return cells * (vertical ? cell.height : cell.width);
-}
-
// Per-element defaults that are STATE, not stylesheet: the fullscreen
// element's viewport block, a select sized to its widest option label so
// the text control never jumps, the size attribute driving an input's width.
@@ -391,8 +331,65 @@ export function getCellBorderWidths(
};
}
-/** An element's margins, borders and padding, in cells. */
+// The entry point for every read of the cascade from a node, including
+// reads deep inside the cascade itself that have no Cascade in
+// hand.
+const documentCascades = new WeakMap