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 = + `${rows}
`; +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(); + +// Each resolved style's box model, by the object the style keeps until +// it changes. +const boxModels = new WeakMap(); + +/** + * An element's margins, borders and padding, in cells. The same object + * until the element's style changes, so a caller copies it to change it. + */ +/** + * Called with every style value read while set: the element, what was + * read, and the value. A computation that caches its result records what + * it read, and is invalidated when any of it reads differently. + */ +type StyleReadRecorder = ( + element: Element, + read: string, + value: string, +) => void; + +let readRecorder: StyleReadRecorder | null = null; + +// A box model is recorded as one read, not the values it is made of, and +// by the record it is: the same until the element's style changes. +const BOX_MODEL_READ = "box-model"; +const boxModelIds = new WeakMap(); +let nextBoxModelId = 0; + +function getBoxModelId(model: CSSValues.BoxModel): string { + let id = boxModelIds.get(model); + if (id === undefined) { + id = String(nextBoxModelId++); + boxModelIds.set(model, id); + } + return id; +} + export function getBoxModel(element: Element): CSSValues.BoxModel { + const document = ownerDocumentOf(element); + const cascade = document ? documentCascades.get(document) : undefined; + const key = cascade?.getStyleKey(element) ?? null; + let model = key === null ? undefined : boxModels.get(key); + if (model === undefined) { + // Recorded as one read, cached or not, rather than as the values + // that make it up. + model = recordStyleReads(() => {}, () => readBoxModel(element)); + if (key !== null) { + boxModels.set(key, model); + } + } + readRecorder?.(element, BOX_MODEL_READ, getBoxModelId(model)); + return model; +} + +function readBoxModel(element: Element): CSSValues.BoxModel { // The engine's own read: the cascade's declaration directly, without // the author path's resolved-value work. const length = (property: string, vertical: boolean, signed = false) => @@ -443,11 +440,6 @@ export function getBoxModel(element: Element): CSSValues.BoxModel { }; } -// 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(); - // What list-style-type spells, quoted the way a content value is // written. Null outside a list. function getDefaultMarkerContent(hostElement: Element): string | null { @@ -510,7 +502,7 @@ function getListGutterWidth(listElement: Element): number { continue; } const marker = cascade - ? cascade.getMarkerContent(child) + ? getMarkerContent(cascade, child) : CSSValues.withMarkerSeparator(getListMarker(child, listElement)); if (!marker) { continue; @@ -557,10 +549,6 @@ function createCSSNamespace(document: Document): typeof CSSNamespace { const inlineStyles = new WeakMap(); -const kComputedValue = Symbol("computedValue"); -const kCustomProperties = Symbol("customProperties"); -const kSync = Symbol("sync"); - // Declarations are stored as longhands. A shorthand write expands and a // shorthand read reconstructs. An element-owned block and the `style` // attribute are one store seen from two sides: a property write @@ -589,7 +577,7 @@ interface CSSStyleDeclarationSlots { block: CSSValues.DeclarationBlock | null; // How many numeric index properties currently name a declaration. indexed: number; - [kSync]: Step; + _sync: Step; } interface CSSStyleDeclaration { @@ -635,7 +623,7 @@ class CSSStyleDeclaration { syncViewIndices(this); return slots.viewOf.length; } - slots[kSync]!(); + slots._sync!(); return slots.declarations.length; } @@ -644,7 +632,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.cssText; } - slots[kSync]!(); + slots._sync!(); return serializeDeclarations(this); } @@ -654,7 +642,7 @@ class CSSStyleDeclaration { slots.viewOf.cssText = text; return; } - slots[kSync]!(); + slots._sync!(); slots.declarations = []; slots.byName.clear(); for (const declaration of CSSValues.parseDeclarationText(text ?? "")) { @@ -677,7 +665,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.item(index); } - slots[kSync]!(); + slots._sync!(); return slots.declarations[index]?.name ?? ""; } @@ -687,7 +675,7 @@ class CSSStyleDeclaration { syncViewIndices(this); return slots.viewOf[Symbol.iterator](); } - slots[kSync]!(); + slots._sync!(); return slots.declarations.map((entry) => entry.name)[Symbol.iterator](); } @@ -696,7 +684,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.getPropertyValue(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const declared = findDeclaration(this, name); if (declared) { @@ -711,7 +699,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.getPropertyPriority(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const declared = findDeclaration(this, name); if (declared) { @@ -733,7 +721,7 @@ class CSSStyleDeclaration { slots.viewOf.setProperty(property, value, priority); return; } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); if (!isSupportedDeclaration(this, name)) { return; @@ -764,7 +752,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.removeProperty(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const previous = this.getPropertyValue(name); let changed = removeDeclaration(this, name); @@ -781,7 +769,7 @@ class CSSStyleDeclaration { * Reparse the `style` attribute if it changed since this object last wrote * it. */ - [kSync]?(this: Slots): void { + _sync?(this: Slots): void { const slots = this; if (!slots.element) { return; @@ -818,7 +806,7 @@ function getDeclarationBlock( style: CSSStyleDeclaration, ): CSSValues.DeclarationBlock { const styleSlots = _(style); - styleSlots[kSync]!(); + styleSlots._sync!(); if (styleSlots.declarations.length === 0) { return EMPTY_DECLARATIONS; } @@ -1159,21 +1147,32 @@ for (const property of CSS_PROPERTIES) { } } -const pageViews = new WeakMap(); +const pageViews = new WeakMap>(); // The engine resolves a computed style through classes of its own, which // a page must not find in a prototype chain. What a page gets is a plain -// CSSStyleProperties that reads through to the engine's, one per -// declaration, so asking again returns the same object. -function getPageView(source: CSSStyleDeclaration): CSSStyleProperties { - let view = pageViews.get(source); +// CSSStyleProperties that reads through to the engine's, one per element +// and pseudo-element, so asking again returns the same object. A restyle +// gives the element a new declaration, and the view reads through to it, +// keeping the indices it already defined. +function getPageView( + source: CSSStyleDeclaration, + element: Element, + pseudoElement: string, +): CSSStyleProperties { + let views = pageViews.get(element); + if (views === undefined) { + views = new Map(); + pageViews.set(element, views); + } + let view = views.get(pseudoElement); if (view === undefined) { view = Object.create(CSSStyleProperties.prototype) as CSSStyleProperties; seal(view, CSSStyleProperties, CSSStyleDeclaration); - _(view).viewOf = source; _(view).indexed = 0; - pageViews.set(source, view); + views.set(pseudoElement, view); } + _(view).viewOf = source; syncViewIndices(view); return view; } @@ -1676,6 +1675,27 @@ class CSSStyleRule extends CSSGroupingRule { } } +// A sheet opens with @layer statements, then @import rules, then +// @namespace rules, and either of the last two anywhere else is dropped, +// as css-cascade-5 and css-namespaces-3 place them. +const PRELUDE_LAYERS = 0; +const PRELUDE_IMPORTS = 1; +const PRELUDE_NAMESPACES = 2; +const PRELUDE_OVER = 3; + +function getPreludeStage(rule: CSSRule): number { + if (rule instanceof CSSLayerStatementRule) { + return PRELUDE_LAYERS; + } + if (rule instanceof CSSImportRule) { + return PRELUDE_IMPORTS; + } + if (rule instanceof CSSNamespaceRule) { + return PRELUDE_NAMESPACES; + } + return PRELUDE_OVER; +} + function getSheetNamespaces( sheet: CSSStyleSheet | null, ): SelectorNamespaces | undefined { @@ -1683,7 +1703,10 @@ function getSheetNamespaces( return undefined; } const namespaces: SelectorNamespaces = {default: null, prefixes: new Map()}; - for (const rule of Array.from(sheet.cssRules)) { + for (const rule of sheet.cssRules) { + if (getPreludeStage(rule) === PRELUDE_OVER) { + break; + } if (!(rule instanceof CSSNamespaceRule)) { continue; } @@ -2777,7 +2800,7 @@ interface CSSStyleSheetSlots { title: string | null; // The owner node's text CSSStyleSheet sheet last parsed. text: string | null; - [kSync]: Step; + _sync: Step; } interface CSSStyleSheet { @@ -2815,7 +2838,7 @@ class CSSStyleSheet { get cssRules(): CSSRuleList { const slots = _(this); - slots[kSync]!(); + slots._sync!(); return slots.ruleList; } @@ -2876,7 +2899,7 @@ class CSSStyleSheet { throw typeError("insertRule requires a rule", this); } const slots = _(this); - slots[kSync]!(); + slots._sync!(); if (index > slots.rules.length) { throw domException( `Cannot insert at index ${index}`, @@ -2906,7 +2929,7 @@ class CSSStyleSheet { throw typeError("deleteRule requires an index", this); } const slots = _(this); - slots[kSync]!(); + slots._sync!(); if (index >= slots.rules.length) { throw domException( `Cannot delete at index ${index}`, @@ -2943,7 +2966,7 @@ class CSSStyleSheet { } removeRule(index = 0): void { - _(this)[kSync]!(); + _(this)._sync!(); if (index >= this.cssRules.length) { throw domException( `Cannot delete at index ${index}`, @@ -2985,7 +3008,7 @@ class CSSStyleSheet { } // Reparse the owner element's text if it changed. - [kSync]?(this: Slots): void { + _sync?(this: Slots): void { const slots = this; const node = slots.ownerNode; if (!node || node.tagName !== "STYLE") { @@ -3254,6 +3277,7 @@ function convertRules( run = []; } }; + let stage = PRELUDE_LAYERS; for (const node of nodes) { if (node.type === "Declaration") { if (nestedContext) { @@ -3266,6 +3290,15 @@ function convertRules( if (!rule) { continue; } + const ruleStage = getPreludeStage(rule); + if (ruleStage !== PRELUDE_LAYERS && ruleStage !== PRELUDE_OVER) { + if (parentRule !== null || ruleStage < stage) { + continue; + } + stage = ruleStage; + } else if (stage !== PRELUDE_LAYERS || ruleStage === PRELUDE_OVER) { + stage = PRELUDE_OVER; + } if (rule instanceof CSSNamespaceRule) { if (rule.prefix === "") { namespaces.default = rule.namespaceURI; @@ -3831,7 +3864,7 @@ function getUAStyleSheet(): CSSStyleSheet { interface MeasuredSlots { element: Element; cascade: Cascade | null; - [kComputedValue](property: string): string; + _computedValue(property: string): string; } interface MeasuredDeclaration { @@ -3853,6 +3886,44 @@ export function getComputedValue( element: Element, property: string, pseudoElement = "", +): string { + const value = readComputedValue(element, property, pseudoElement); + readRecorder?.( + element, + pseudoElement === "" ? property : `${pseudoElement}|${property}`, + value, + ); + return value; +} + +export function recordStyleReads( + recorder: StyleReadRecorder, + compute: () => T, +): T { + const outer = readRecorder; + readRecorder = recorder; + try { + return compute(); + } finally { + readRecorder = outer; + } +} + +/** What a recorded read reads now. */ +export function readStyleAgain(element: Element, read: string): string { + if (read === BOX_MODEL_READ) { + return getBoxModelId(getBoxModel(element)); + } + const bar = read.indexOf("|"); + return bar === -1 + ? readComputedValue(element, read, "") + : readComputedValue(element, read.slice(bar + 1), read.slice(0, bar)); +} + +function readComputedValue( + element: Element, + property: string, + pseudoElement: string, ): string { // A pseudo-element node's style is its host's declaration for the // pseudo-element it fills. It matches no selector of its own. @@ -3876,10 +3947,10 @@ export function getComputedValue( return ""; } return pseudoElement - ? _(getPseudoDeclaration(cascade, element, pseudoElement))[kComputedValue]( + ? _(getPseudoDeclaration(cascade, element, pseudoElement))._computedValue( property, ) - : cascade.computedSlotsFor(element)[kComputedValue](property); + : computedSlotsFor(cascade, element)._computedValue(property); } // A box with nothing to contain or transform: no box at all, or a plain @@ -3936,10 +4007,6 @@ function hasContainment(element: Element, kinds: Set): boolean { .some((keyword) => kinds.has(keyword)); } -function hasPaintContainment(element: Element): boolean { - return hasContainment(element, PAINT_CONTAINMENT); -} - export function hasLayoutOrPaintContainment(element: Element): boolean { return hasContainment(element, LAYOUT_OR_PAINT_CONTAINMENT); } @@ -3950,7 +4017,7 @@ export function getUsedOverflow(element: Element, axis: "x" | "y"): string { const overflow = getComputedValue(element, axis === "x" ? "overflow-x" : "overflow-y") || getComputedValue(element, "overflow"); - return overflow === "visible" && hasPaintContainment(element) + return overflow === "visible" && hasContainment(element, PAINT_CONTAINMENT) ? "clip" : overflow; } @@ -4029,10 +4096,6 @@ function defineLonghandIndices(prototype: object): void { } } -const kSyncResolved = Symbol("syncResolved"); -const kUsedValue = Symbol("usedValue"); -const kBaseValue = Symbol("baseValue"); - // Writing a computed style is an error, not a no-op. It throws the // document's own DOMException, since one from another global is not // what an author catches. @@ -4182,7 +4245,7 @@ function getResolvedValues( if (cascade.getStyleKey(element) === null) { return null; } - return cascade.computedSlotsFor(element).resolved; + return computedSlotsFor(cascade, element).resolved; } function getSharedResolved( @@ -4266,11 +4329,10 @@ interface ComputedStyleDeclarationSlots extends CSSStyleDeclarationSlots { // for the paint style to be derived again. styleVersion: object; custom: string[] | null; - [kCustomProperties]: Step; - [kUsedValue]: Step; - [kBaseValue]: Step; - [kSyncResolved]: Step; - [kComputedValue]: Step; + _usedValue: Step; + _baseValue: Step; + _syncResolved: Step; + _computedValue: Step; } interface ComputedStyleDeclaration { @@ -4327,16 +4389,16 @@ class ComputedStyleDeclaration extends CSSStyleProperties { } const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(slots)) { - slots[kSyncResolved](); + slots._syncResolved(); } // A flow-relative longhand resolves as the physical longhand it maps // to: same slot, same measurement, same result. property = toPhysicalProperty(this, property); if (slots.cascade && USED_VALUE_PROPERTIES.has(property)) { - return slots[kUsedValue](property); + return slots._usedValue(property); } if (slots.cascade && MIN_SIZE_PROPERTIES.has(property)) { - return getResolvedMinSize(this, slots[kComputedValue](property)); + return getResolvedMinSize(this, slots._computedValue(property)); } if (slots.cascade && USED_TRACK_PROPERTIES.has(property)) { const tracks = usedGridTracks( @@ -4356,9 +4418,9 @@ class ComputedStyleDeclaration extends CSSStyleProperties { this.getPropertyValue(longhand), ); } - let computed = slots[kComputedValue](property); + let computed = slots._computedValue(property); if (AUTO_COLOR_PROPERTIES.has(property) && computed === "auto") { - computed = slots[kComputedValue]("color"); + computed = slots._computedValue("color"); } // A system color computes as its keyword, and reads back as the color // it stands for here. @@ -4397,32 +4459,10 @@ class ComputedStyleDeclaration extends CSSStyleProperties { return [...CSS_LONGHANDS, ...getCustomNames(this)][Symbol.iterator](); } - [kCustomProperties](this: Slots): string[] { - const names: string[] = []; - for (const rule of this.cssRules) { - for (const name of Object.keys(rule.declarations)) { - if (name.startsWith("--") && !names.includes(name)) { - names.push(name); - } - } - } - for (const name of Object.keys( - getInlineDeclarations(ownerOf(this)).declarations, - )) { - if (name.startsWith("--") && !names.includes(name)) { - names.push(name); - } - } - return names; - } - // Measured through the same flush a geometry read takes, and memoized // behind it, so a property-heavy caller measures once per layout. The // memo belongs to the cascade, which lets a flush drop every one. - [kUsedValue]( - this: Slots, - property: string, - ): string { + _usedValue(this: Slots, property: string): string { const slots = this; const cascade = slots.cascade!; const used = getUsedValues(cascade, ownerOf(this)); @@ -4431,7 +4471,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { return memoized; } - const computed = slots[kComputedValue](property); + const computed = slots._computedValue(property); const value = measureUsedValue(ownerOf(this), property, computed); used.set(property, value); return value; @@ -4440,17 +4480,14 @@ class ComputedStyleDeclaration extends CSSStyleProperties { // The cascade's value before a running transition overrides it, meaning // the after-change style. An interpolated value moves every frame and // must never enter the memo. - [kBaseValue]( - this: Slots, - property: string, - ): string { + _baseValue(this: Slots, property: string): string { const slots = this; let value = slots.resolved.get(property); if (value === undefined) { const longhands = CSSValues.getLonghands(property); value = longhands ? CSSValues.resolveShorthand(property, longhands, (longhand) => - slots[kBaseValue](longhand), + slots._baseValue(longhand), ) : getAbsolutizedValue( ownerOf(this), @@ -4463,7 +4500,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { // Reads call this only when the cascade no longer vouches for this // declaration. It runs under every property read of every element. - [kSyncResolved](this: Slots): void { + _syncResolved(this: Slots): void { const slots = this; if (!slots.cascade) { return; @@ -4490,21 +4527,21 @@ class ComputedStyleDeclaration extends CSSStyleProperties { processTransitionStyle( slots.cascade, slots.element, - (property) => slots[kBaseValue](property), + (property) => slots._baseValue(property), "", ); } - [kComputedValue]( + _computedValue( this: Slots, property: string, ): string { const slots = this; const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(this)) { - slots[kSyncResolved](); + slots._syncResolved(); } - const value = slots[kBaseValue](property); + const value = slots._baseValue(property); const cascade = slots.cascade; if (cascade !== null && cascade.activeTransitions.size > 0) { const transitional = getTransitionValue( @@ -4521,6 +4558,27 @@ class ComputedStyleDeclaration extends CSSStyleProperties { } } +function getDeclaredCustomNames( + slots: Slots, +): string[] { + const names: string[] = []; + for (const rule of slots.cssRules) { + for (const name of Object.keys(rule.declarations)) { + if (name.startsWith("--") && !names.includes(name)) { + names.push(name); + } + } + } + for (const name of Object.keys( + getInlineDeclarations(ownerOf(slots)).declarations, + )) { + if (name.startsWith("--") && !names.includes(name)) { + names.push(name); + } + } + return names; +} + // The cascade's declaration, interned, and absolutized against this // element when the interned entry says only an element can resolve it. function getAbsolutizedValue( @@ -4575,7 +4633,7 @@ function getLengthContext( const font = CSSValues.getFontSize( own ? parent ? getComputedValue(parent, "font-size") : "" - : declarationSlots[kComputedValue]("font-size"), + : declarationSlots._computedValue("font-size"), initial, ); const root = getRootFontSize(declaration, own); @@ -4654,7 +4712,7 @@ function getRootFontSize( !root || (ownFontSize && root === declarationSlots.element) ? "" : root === declarationSlots.element - ? declarationSlots[kComputedValue]("font-size") + ? declarationSlots._computedValue("font-size") : getComputedValue(root, "font-size"), getCellSize(declarationSlots.element).height, ); @@ -4670,7 +4728,7 @@ function toPhysicalProperty( return ( CSSValues.getPhysicalProperty( property, - _(declaration)[kComputedValue]("direction"), + _(declaration)._computedValue("direction"), ) ?? property ); } @@ -4685,7 +4743,7 @@ function measureUsedValue( // A border with no style draws nothing and takes no space, whatever // width it declares. if (property.startsWith("border-") && property.endsWith("-width")) { - const style = _(declaration)[kComputedValue]( + const style = _(declaration)._computedValue( `${property.slice(0, -"-width".length)}-style`, ); if (!style || style === "none" || style === "hidden") { @@ -4716,7 +4774,7 @@ function measureUsedValue( const border = vertical ? rect.height : rect.width; // The used value of width is the box box-sizing names, so a // border-box element reads its border box (css-sizing-3 §3.1). - if (_(declaration)[kComputedValue]("box-sizing") === "border-box") { + if (_(declaration)._computedValue("box-sizing") === "border-box") { return CSSValues.getUsedLength(Math.max(0, border)); } const edges = @@ -4786,7 +4844,7 @@ function getUsedInset( const opposite = OPPOSITE_INSET[property]; const other = CSSValues.getInsetLength( - _(declaration)[kComputedValue](opposite), + _(declaration)._computedValue(opposite), basis, ); // A relatively positioned box is offset from where it already was, so @@ -4947,7 +5005,7 @@ function getResolvedMinSize( return "0px"; } } - if (declarationSlots[kComputedValue]("aspect-ratio") !== "auto") { + if (declarationSlots._computedValue("aspect-ratio") !== "auto") { return "auto"; } const parent = flatParentElement(declarationSlots.element); @@ -5209,7 +5267,7 @@ function resolvePropertyValue( if ( property === "text-align" && CSSValues.getLegacyAlignment(value) !== null && - /^(?:inline-)?table$/.test(declarationSlots[kComputedValue]("display")) + /^(?:inline-)?table$/.test(declarationSlots._computedValue("display")) ) { return "start"; } @@ -5270,7 +5328,7 @@ function resolveCascadedValue( // on. return property === "color" ? (resolveFromParent(declaration, "color") ?? "") - : _(declaration)[kComputedValue]("color"); + : _(declaration)._computedValue("color"); } return value; } @@ -5291,7 +5349,7 @@ function resolvePropertyValueRaw( name === property || CSSValues.getPhysicalProperty( name, - (direction ??= declarationSlots[kComputedValue]("direction")), + (direction ??= declarationSlots._computedValue("direction")), ) === property; const inline = getInlineDeclarations(declaration); @@ -5426,7 +5484,7 @@ function getCustomNames(computed: ComputedStyleDeclaration): string[] { const computedSlots = _(computed); const current = computedSlots.cascade?.currentDeclarations; if (current !== undefined && !current.has(computedSlots)) { - computedSlots[kSyncResolved](); + computedSlots._syncResolved(); } if (computedSlots.custom) { return computedSlots.custom; @@ -5438,7 +5496,8 @@ function getCustomNames(computed: ComputedStyleDeclaration): string[] { element = flatParentElement(element) ) { const declaration = computedSlots.cascade?.declarationFor(element); - for (const name of _maybe(declaration)?.[kCustomProperties]() ?? []) { + const slots = _maybe(declaration); + for (const name of slots ? getDeclaredCustomNames(slots) : []) { names.add(name); } } @@ -5461,10 +5520,10 @@ interface PseudoStyleDeclarationSlots extends CSSStyleDeclarationSlots { cascade: Cascade | null; nodeResolved: Map; boxView: MeasuredDeclaration | null; - [kSyncResolved]: Step; - [kBaseValue]: Step; - [kUsedValue]: Step; - [kComputedValue]: Step; + _syncResolved: Step; + _baseValue: Step; + _usedValue: Step; + _computedValue: Step; } interface PseudoStyleDeclaration { @@ -5511,12 +5570,12 @@ class PseudoStyleDeclaration extends CSSStyleProperties { const slots = _(this); const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(slots)) { - slots[kSyncResolved](); + slots._syncResolved(); } let value = slots.nodeResolved.get(property); if (value === undefined) { value = - slots[kBaseValue](property) || + slots._baseValue(property) || CSSValues.getComputedValueEntry( property, getInitialStyle(null, property), @@ -5533,13 +5592,13 @@ class PseudoStyleDeclaration extends CSSStyleProperties { flushCascadeStyle(slots.cascade); } const computed = - slots[kComputedValue](property) || + slots._computedValue(property) || CSSValues.getComputedValueEntry( property, getInitialStyle(null, property), ); if (slots.cascade && USED_VALUE_PROPERTIES.has(property)) { - return slots[kUsedValue](property, computed); + return slots._usedValue(property, computed); } return computed; } @@ -5560,7 +5619,7 @@ class PseudoStyleDeclaration extends CSSStyleProperties { return CSS_LONGHANDS[index] ?? ""; } - [kSyncResolved](this: Slots): void { + _syncResolved(this: Slots): void { // Before the work, because resolving below reads back through this // declaration. const slots = this; @@ -5587,21 +5646,21 @@ class PseudoStyleDeclaration extends CSSStyleProperties { processTransitionStyle( slots.cascade, slots.element, - (property) => slots[kBaseValue](property), + (property) => slots._baseValue(property), slots.pseudoElement, ); } } // The cascade's declarations alone, with no transition overriding them. - [kBaseValue](this: Slots, property: string): string { + _baseValue(this: Slots, property: string): string { const slots = this; let value = slots.resolved.get(property); if (value === undefined) { const longhands = CSSValues.getLonghands(property); value = longhands && slots.pseudoDeclarations[property] === undefined ? CSSValues.serializeShorthandValue(property, longhands, (longhand) => - slots[kBaseValue](longhand) || CSS_INITIAL_VALUES[longhand] || "") + slots._baseValue(longhand) || CSS_INITIAL_VALUES[longhand] || "") : CSSValues.getComputedValueEntry( property, slots.pseudoDeclarations[property] ?? "", @@ -5615,7 +5674,7 @@ class PseudoStyleDeclaration extends CSSStyleProperties { // through the same arithmetic as an element's metrics. A pseudo-element // never given a node uses the percentage resolution below, against the // box it would be in. - [kUsedValue]( + _usedValue( this: Slots, property: string, computed: string, @@ -5667,16 +5726,16 @@ class PseudoStyleDeclaration extends CSSStyleProperties { // The engine's read. An empty result means no rule reached the // pseudo-element, which is what the ::selection and ::marker painters // check. - [kComputedValue]( + _computedValue( this: Slots, property: string, ): string { const slots = this; const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(this)) { - slots[kSyncResolved](); + slots._syncResolved(); } - const value = slots[kBaseValue](property); + const value = slots._baseValue(property); const transitional = getPseudoTransitionValue(ownerOf(this), property); return transitional ?? value; } @@ -5724,7 +5783,7 @@ function getBoxView( adoptSlots(measured, { element: node, cascade: declarationSlots.cascade, - [kComputedValue]: (property: string): string => + _computedValue: (property: string): string => declaration.nodeValue(property), }); view = measured as MeasuredDeclaration; @@ -5986,6 +6045,7 @@ interface ParsedCSSRule { // hasAnchors keeps them, and the pseudo-classes inside it. hasAnchors?: readonly string[]; hasStates?: readonly string[]; + hasSiblings?: boolean; // Cascade ORIGIN, the tier above specificity. Every author rule beats // every UA rule, which lets `input::placeholder { color }` beat the UA @@ -6062,9 +6122,9 @@ function shouldCreatePseudoElement( ): boolean { if (pseudoType === "::marker") { const computedStyle = cascade.declarationFor(element); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); const listStylePosition = - _(computedStyle)[kComputedValue]("list-style-position") || "outside"; + _(computedStyle)._computedValue("list-style-position") || "outside"; if (display === "list-item" && listStylePosition !== "outside") { return true; @@ -6146,7 +6206,7 @@ export class Cascade { // The slots of every declaration resolved against the current cascade. // Dropping one, or replacing the set, sends it back through - // kSyncResolved on its next read. Weak, so a declaration nobody holds + // _syncResolved on its next read. Weak, so a declaration nobody holds // costs nothing. currentDeclarations: WeakSet; @@ -6211,6 +6271,18 @@ export class Cascade { pseudoRulesByType: Map; counterRulesExist: boolean; listItemRulesExist: boolean; + // The elements whose generated content reads a counter, and the + // counters each element declared when its scope was built. A counter + // one element changes renumbers the content of others, which nothing + // restyles, so a change sets countersStale and those elements restyle. + counterUsers: Set; + countersStale: boolean; + // Each element's counters where its subtree starts and where it ends, + // the inputs they were counted from, and whether any were counted. + counterSets: WeakMap; + counterEnds: WeakMap; + counterKeys: WeakMap; + countersCounted: boolean; // Whether any rule is scoped, which is what adds proximity to the sort. scopedRulesExist: boolean; @@ -6221,6 +6293,9 @@ export class Cascade { // change inside an element that has none of them leaves every :has() // as it was. hasAnchors: Set; + // Whether a :has() argument starts with + or ~, which an element's + // earlier siblings can match from, and not only its ancestors. + hasSiblingRules: boolean; // The engine reads this to decide whether the terminal must report // pointer motion. A sheet that never tests :hover cannot show it, and @@ -6232,8 +6307,6 @@ export class Cascade { // apply with no MutationObserver attached. parsedStyleSheetCount: number; - counterScopes: WeakMap; - // The transition gate is STICKY. It opens the first time anything // declares a transition and never closes, so a document with none pays // two checks per style change event. Snapshots live in a WeakMap. Only @@ -6326,13 +6399,19 @@ export class Cascade { this.pseudoRulesByType = new Map(); this.counterRulesExist = false; this.listItemRulesExist = false; + this.counterUsers = new Set(); + this.countersStale = false; + this.counterSets = new WeakMap(); + this.counterEnds = new WeakMap(); + this.counterKeys = new WeakMap(); + this.countersCounted = false; this.scopedRulesExist = false; this.hasRulesExist = false; this.hasAnchors = new Set(); + this.hasSiblingRules = false; this.hasStates = new Set(); this.hoverRulesDeclared = false; this.parsedStyleSheetCount = -1; - this.counterScopes = new WeakMap(); this.flushing = false; this.usedValues = new WeakMap(); this.hasResults = null; @@ -6363,6 +6442,8 @@ export class Cascade { getPageView( getResolvedStyle(this, element, pseudoElt) as unknown as CSSStyleDeclaration, + element, + pseudoElt ?? "", ) as unknown as globalThis.CSSStyleDeclaration, ) as typeof window.getComputedStyle; @@ -6376,11 +6457,17 @@ export class Cascade { return; } this.shadowRoots.add(root); + noteFlatTreeChange(this); // Incrementally. Rebuilding every sheet per UA shadow tree upgrade made // a document of n UA shadow trees reparse everything n times. syncShadowRoot(this, root); } + // A slot took other nodes, which now inherit through it. + slotsChanged(): void { + noteFlatTreeChange(this); + } + // A root filled with its content after it registered, as a declarative // shadow root is, has sheets its registration did not see. syncShadowRoot(root: ShadowRoot): void { @@ -6391,236 +6478,6 @@ export class Cascade { } } - handleMutations(mutations: MutationRecord[]): void { - const Node = this.window.Node; - let shouldSyncStylesheets = false; - let removedElements = false; - const changedParents = new Set(); - - // A :has() anchor sits above what changed it, or before it among its - // siblings for :has(+ x) and :has(~ x), and the rule can style the - // anchor's descendants too. So each such element a :has() could be - // tested on restyles, with everything under it. - if (this.hasRulesExist) { - const anchors = new Set(); - for (const mutation of mutations) { - const start = nodeTypeOf(mutation.target) === 1 - ? (mutation.target as Element) - : mutation.target.parentElement; - for ( - let ancestor: Element | null = start; - ancestor; - ancestor = flatParentElement(ancestor) - ) { - for ( - let element: Element | null = ancestor; - element !== null; - element = element.previousElementSibling - ) { - if (!anchors.has(element) && couldAnchorHas(this, element)) { - anchors.add(element); - invalidateSubtree(this, element); - } - } - } - } - } - - // A root's sheets sync once a batch, after its records: each sync - // restyles the whole tree, and a page that fills its sheets a rule at - // a time sent a record per rule. - const rootsToSync = new Set(); - for (const mutation of mutations) { - if (mutation.type === "childList") { - // A `; + dom.document.body.innerHTML = html; + await nextFrame(dom); + if (change) { + change(dom.document); + await nextFrame(dom); + } + const lines = terminal.getScreenContents() + .split("\n") + .map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()) + .filter((line) => line !== ""); + dom.dispose(); + return lines; +} + +const NUMBERED = + "body { counter-reset: n } .n { counter-increment: n } " + + ".n::before { content: counter(n) \". \" }"; + +test("siblings count on from one another", async () => { + expect( + await render( + NUMBERED, + "
a
b
c
", + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a counter takes its value from the element just before", async () => { + expect( + await render( + NUMBERED, + "
a
b
" + + "
c
", + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a reset reaches the siblings after it", async () => { + expect( + await render( + NUMBERED + " .r { counter-reset: n 10 }", + "
a
b
c
", + ), + ).toEqual(["1. a", "b", "11. c"]); +}); + +test("a counter made inside a sibling stays inside it", async () => { + expect( + await render( + ".r { counter-reset: m 5 } .m::before { content: counter(m) \" \" }", + "
a
b
", + ), + ).toEqual(["a", "0 b"]); +}); + +test("counter-set sets the value the next step counts from", async () => { + expect( + await render( + NUMBERED + " .s { counter-set: n 7 }", + "
a
b
c
", + ), + ).toEqual(["1. a", "b", "8. c"]); +}); + +test("a box not shown does not count", async () => { + expect( + await render( + NUMBERED, + "
a
b
" + + "
c
", + ), + ).toEqual(["1. a", "2. c"]); +}); + +test("a counter declared later renumbers what follows it", async () => { + expect( + await render( + NUMBERED, + "
a
b
c
", + (document) => document.getElementById("b").classList.add("n"), + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a box shown again counts again", async () => { + expect( + await render( + NUMBERED, + "
a
b
" + + "
c
", + (document) => document.getElementById("b").style.display = "block", + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a parent hidden and shown again counts its children again", async () => { + expect( + await render( + NUMBERED, + "
a
" + + "
b
c
", + (document) => document.getElementById("p").style.display = "block", + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a list item's value sets the list-item counter", async () => { + expect( + await render( + "li::marker { content: counter(list-item) \") \" } " + + "ol { list-style-position: inside; padding: 0 }", + "
  1. a
  2. b
  3. c
", + ), + ).toEqual(["4) a", "9) b", "10) c"]); +}); + +test("a ::before counts, as its element's first child", async () => { + expect( + await render( + "body { counter-reset: section } " + + "h2::before { counter-increment: section; " + + "content: counter(section) \". \" } h2 { margin: 0 }", + "

a

b

c

", + ), + ).toEqual(["1. a", "2. b", "3. c"]); +}); + +test("a ::after counts, as its element's last child", async () => { + expect( + await render( + NUMBERED + " .a::after { counter-increment: n 10; content: \"\" }", + "
a
b
c
", + ), + ).toEqual(["1. a", "b", "12. c"]); +}); + +test("counters() joins every counter of the name in scope", async () => { + expect( + await render( + "ol { counter-reset: item; list-style: none; padding: 0 } " + + "li { counter-increment: item } " + + "li::before { content: counters(item, \".\") \" \" }", + "
  1. a
    1. b
    2. c
  2. d
", + ), + ).toEqual(["1 a", "1.1 b", "1.2 c", "2 d"]); +}); + +test("a string that spells counter() is only text", async () => { + expect( + await render( + NUMBERED + " .s::before { content: \"counter(n) \" }", + "
a
", + ), + ).toEqual(["counter(n) a"]); +}); + +test("a counter passes through a shadow tree and a slot", async () => { + const terminal = new MockProcess({cols: 30, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.head.innerHTML = ``; + dom.document.body.innerHTML = + "
a
b
" + + "
c
"; + const root = + dom.document.getElementById("host")!.attachShadow({mode: "open"}); + root.innerHTML = + "" + + "
inner
"; + await nextFrame(dom); + await nextFrame(dom); + const lines = terminal.getScreenContents() + .split("\n") + .map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()) + .filter((line) => line !== ""); + expect(lines).toEqual(["1. a", "2. inner", "3. b", "4. c"]); + dom.dispose(); +}); diff --git a/tests/cssom.test.ts b/tests/cssom.test.ts index b81dfcbb..7ecc2f30 100644 --- a/tests/cssom.test.ts +++ b/tests/cssom.test.ts @@ -547,3 +547,57 @@ test("text the CSS parsers cannot judge passes through as authored", () => { dom.dispose(); }); + +test("an @import or @namespace out of its place at the top of a sheet is dropped", () => { + const {dom} = makeDOM(); + const names = (text: string): string[] => { + const style = dom.document.createElement("style"); + style.textContent = text; + dom.document.head.append(style); + const rules = [...style.sheet!.cssRules].map((rule) => + rule.constructor.name); + style.remove(); + return rules; + }; + expect(names("a {} @import url(x.css); @namespace url(y); b {}")) + .toEqual(["CSSStyleRule", "CSSStyleRule"]); + expect(names("@namespace url(y); @import url(x.css);")) + .toEqual(["CSSNamespaceRule"]); + expect(names("@layer a; @import url(x.css); @namespace url(y);")) + .toEqual(["CSSLayerStatementRule", "CSSImportRule", "CSSNamespaceRule"]); + expect(names("@import url(x.css); @layer a; @namespace url(y);")) + .toEqual(["CSSImportRule", "CSSLayerStatementRule"]); + expect(names("@media all { @import url(x.css); @namespace url(y); }")) + .toEqual(["CSSMediaRule"]); + dom.dispose(); +}); + +test("a namespace prefix declared after a style rule does not apply", async () => { + const {dom} = makeDOM("

text

"); + const style = dom.document.createElement("style"); + style.textContent = + "@namespace h url(http://www.w3.org/1999/xhtml); h|p { color: red } " + + "b {} @namespace q url(http://www.w3.org/1999/xhtml); q|p { color: blue }"; + dom.document.head.append(style); + await nextFrame(dom); + expect( + dom.document.defaultView!.getComputedStyle( + dom.document.querySelector("p")!, + ).color, + ).toBe("rgb(255, 0, 0)"); + expect(style.sheet!.cssRules.length).toBe(3); + dom.dispose(); +}); + +test("a type selector matches its element through the rules filed by name", () => { + const {dom} = makeDOM( + "" + + "

text

", + ); + const view = dom.document.defaultView!; + const color = (selector: string): string => + view.getComputedStyle(dom.document.querySelector(selector)!).color; + expect(color("p")).toBe("rgb(255, 0, 0)"); + expect(color("#f")).toBe("rgb(0, 0, 255)"); + dom.dispose(); +}); diff --git a/tests/document-mode.test.ts b/tests/document-mode.test.ts index 5ad5eba8..b83b16b1 100644 --- a/tests/document-mode.test.ts +++ b/tests/document-mode.test.ts @@ -523,3 +523,18 @@ test("hit-testing finds text in the root's boxes when the body is inline", async .toBe(true); dom.dispose(); }); + +test("an element outside the document cannot go fullscreen", async () => { + const terminal = new MockProcess({cols: 40, rows: 8}); + const dom = new TermDOM({transport: terminal.transport}); + await nextFrame(dom); + const heard: string[] = []; + dom.document.addEventListener("fullscreenerror", (event) => + heard.push(event.type)); + const stage = dom.document.createElement("div"); + await expect(stage.requestFullscreen()).rejects.toThrow(TypeError); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(heard).toEqual(["fullscreenerror"]); + expect(dom.document.fullscreenElement).toBe(null); + dom.dispose(); +}); diff --git a/tests/dom-dispatch.test.ts b/tests/dom-dispatch.test.ts index b8129e81..e7a02614 100644 --- a/tests/dom-dispatch.test.ts +++ b/tests/dom-dispatch.test.ts @@ -10,7 +10,8 @@ */ import {expect, test} from "@b9g/libuild/test"; -import {createWindow, type Document} from "../src/internal/dom.ts"; +import type {Document} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; // The interfaces script sees are a window's, so the events the tests // dispatch are built with a window's constructors. diff --git a/tests/dom-misc.test.ts b/tests/dom-misc.test.ts index 74158bf3..742d8f5e 100644 --- a/tests/dom-misc.test.ts +++ b/tests/dom-misc.test.ts @@ -5,7 +5,8 @@ */ import {expect, test} from "@b9g/libuild/test"; -import {createWindow, type Document, HTMLElement} from "../src/internal/dom.ts"; +import {type Document, HTMLElement} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; // The realm the tests reach constructors through: a window of this DOM // exposes them, as the platform does. diff --git a/tests/dom.test.ts b/tests/dom.test.ts index abb7d173..198c8146 100644 --- a/tests/dom.test.ts +++ b/tests/dom.test.ts @@ -10,7 +10,6 @@ import {expect, test} from "@b9g/libuild/test"; import { - createWindow, type Document, CustomEvent as DOMCustomEvent, Event as DOMEvent, @@ -20,6 +19,7 @@ import { Text, Window, } from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; // The door a test document comes through. The parser is the one that hands // a document the realm's custom element registry, as it does the engine's. diff --git a/tests/element-from-point-shadow.test.ts b/tests/element-from-point-shadow.test.ts index 6434b8ed..954586de 100644 --- a/tests/element-from-point-shadow.test.ts +++ b/tests/element-from-point-shadow.test.ts @@ -27,3 +27,34 @@ test("elementFromPoint retargets against the tree it is asked from", async () => ]); dom.dispose(); }); + +test("elementFromPoint finds a box an inline-block lays out low on the page", async () => { + const dom = new TermDOM({ + transport: new MockProcess({cols: 40, rows: 12}).transport, + }); + const {document} = dom; + document.body.innerHTML = + "
1
2
3
4
5
6
" + + "
" + + "
first
second
"; + await nextFrame(dom); + expect(document.elementFromPoint(1, 6)!.id).toBe("a"); + expect(document.elementFromPoint(1, 7)!.id).toBe("b"); + dom.dispose(); +}); + +test("elementFromPoint finds content that overflows an inline-block", async () => { + const dom = new TermDOM({ + transport: new MockProcess({cols: 40, rows: 12}).transport, + }); + const {document} = dom; + document.body.innerHTML = + "
1
2
3
4
5
6
" + + "
" + + "
first
second
third
" + + "
"; + await nextFrame(dom); + expect(document.elementFromPoint(1, 7)!.id).toBe("b"); + expect(document.elementFromPoint(1, 8)!.id).toBe("c"); + dom.dispose(); +}); diff --git a/tests/exchange-events.test.ts b/tests/exchange-events.test.ts index 27784aa1..93f62fbb 100644 --- a/tests/exchange-events.test.ts +++ b/tests/exchange-events.test.ts @@ -1,7 +1,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; import {MockProcess, nextFrame, until} from "./test-utils"; function filler(rows: number): string { diff --git a/tests/flex.test.ts b/tests/flex.test.ts index b2a582d9..825be7f0 100644 --- a/tests/flex.test.ts +++ b/tests/flex.test.ts @@ -1,6 +1,12 @@ import {describe, expect, test} from "@b9g/libuild/test"; -import {LayoutNode, toValue} from "../src/internal/layoutsolver.ts"; +import { + getComputedHeight, + getComputedWidth, + insertChild, + LayoutNode, + toValue, +} from "../src/internal/layoutsolver.ts"; /** * Spec tests for the layout engine, driven directly rather than through the @@ -25,7 +31,7 @@ function node(): LayoutNode { function box(parent: LayoutNode, index = parent.children.length): LayoutNode { const child = node(); - parent.insertChild(child, index); + insertChild(parent, child, index); return child; } @@ -36,10 +42,10 @@ function rect(n: LayoutNode): { height: number; } { return { - left: n.result.left, - top: n.result.top, - width: n.getComputedWidth(), - height: n.getComputedHeight(), + left: n.grid.left, + top: n.grid.top, + width: getComputedWidth(n), + height: getComputedHeight(n), }; } diff --git a/tests/form-associated.test.ts b/tests/form-associated.test.ts index 08b5c6d8..cae7490a 100644 --- a/tests/form-associated.test.ts +++ b/tests/form-associated.test.ts @@ -1,6 +1,6 @@ import {expect, test} from "@b9g/libuild/test"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; test("a form-associated custom element is disabled by its attribute or a fieldset, and says so", () => { const window = createWindow(""); diff --git a/tests/formdata.test.ts b/tests/formdata.test.ts index 68f4cb37..b8941ec2 100644 --- a/tests/formdata.test.ts +++ b/tests/formdata.test.ts @@ -1,6 +1,6 @@ import {expect, test} from "@b9g/libuild/test"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; test("a FormData holds its entries in order and reads them back by name", () => { const window = createWindow(""); diff --git a/tests/fuzz-pages.ts b/tests/fuzz-pages.ts new file mode 100644 index 00000000..54a5116e --- /dev/null +++ b/tests/fuzz-pages.ts @@ -0,0 +1,527 @@ +/** + * Pages and changes for tests/incremental.test.ts, kept apart so a + * failing seed can be replayed and shrunk outside the test runner. A + * change is chosen as an action naming its target by id, so the same + * actions replay on a fresh page. + */ +import {TermDOM} from "../src/index.ts"; +import {MockProcess, nextFrame} from "./test-utils.ts"; + +export const COLS = 60; +export const ROWS = 30; +// Cells 7 by 15 pixels put pixel lengths on fractions of a cell. +export const CELL = {width: 7, height: 15}; + +export interface Page { + name: string; + head: string; + body: string; + classes: string[]; + styles: Array<[string, (random: Random) => string]>; + attributes: Array<[string, (random: Random) => string]>; + // Rules a change can add to or take from the page's own sheet. + rules: string[]; +} + +const LAYOUT: Page = { + name: "layout", + head: ``, + body: `
+

Some text span and more words here

+
one
two words
three
+
cell a
cell b has longer text that may wrap
+
  1. first
  2. second
  3. third
+
xy y
+

inline block tail of the line

+
above
below
+
`, + classes: [ + "row", + "col", + "grid", + "box", + "bg", + "b", + "hide", + "wide", + "m", + "abs", + "ib", + "center", + "n", + ], + styles: [ + ["margin-left", (r: Random): string => `${r.int(25)}px`], + ["margin-top", (r: Random): string => `${r.int(40)}px`], + ["padding-top", (r: Random): string => `${r.int(30)}px`], + ["width", (r: Random): string => `${10 + r.int(90)}%`], + ["width", (r: Random): string => `${20 + r.int(300)}px`], + [ + "color", + (r: Random): string => `rgb(${r.int(255)}, ${r.int(255)}, ${r.int(255)})`, + ], + [ + "background-color", + (r: Random): string => r.pick(["transparent", "rgb(90, 0, 0)"]), + ], + [ + "display", + (r: Random): string => + r.pick(["block", "none", "flex", "inline-block", "inline"]), + ], + ["order", (r: Random): string => String(r.int(3))], + [ + "flex-direction", + (r: Random): string => r.pick(["row", "column", "row-reverse"]), + ], + ["text-align", (r: Random): string => r.pick(["left", "center", "right"])], + [ + "position", + (r: Random): string => r.pick(["static", "relative", "absolute"]), + ], + ["top", (r: Random): string => `${r.int(30)}px`], + ["visibility", (r: Random): string => r.pick(["visible", "hidden"])], + [ + "border", + (r: Random): string => r.pick(["none", "1px solid", "1px double"]), + ], + ["z-index", (r: Random): string => String(r.int(3))], + ["outline", (r: Random): string => r.pick(["none", "1px solid"])], + ], + attributes: [], + rules: [], +}; + +const FLOW: Page = { + name: "flow", + head: ``, + body: `
+

sticky head

one

two

three

four

+
a long line of text that the box cuts with an ellipsis
+
contents child

block in contents

+
a
b
c
d
+

before text

+ + +
fixed bar
+
pre  formatted
+   text
+
`, + classes: [ + "scroll", + "clip", + "contents", + "sticky", + "fixed", + "ell", + "grid3", + "span2", + "before", + "upper", + "rtl", + "indent", + "nowrap", + "pre", + "box", + ], + styles: [ + ["height", (r: Random): string => `${15 + r.int(90)}px`], + ["width", (r: Random): string => `${30 + r.int(70)}%`], + ["overflow", (r: Random): string => r.pick(["visible", "hidden", "auto"])], + [ + "white-space", + (r: Random): string => r.pick(["normal", "nowrap", "pre", "pre-wrap"]), + ], + ["direction", (r: Random): string => r.pick(["ltr", "rtl"])], + ["text-transform", (r: Random): string => r.pick(["none", "uppercase"])], + [ + "grid-column", + (r: Random): string => r.pick(["auto", "span 2", "1 / 3", "3"]), + ], + [ + "display", + (r: Random): string => + r.pick(["block", "contents", "none", "grid", "inline"]), + ], + ["margin-top", (r: Random): string => `${r.int(40)}px`], + [ + "color", + (r: Random): string => `rgb(${r.int(255)}, ${r.int(255)}, ${r.int(255)})`, + ], + ["position", (r: Random): string => r.pick(["static", "sticky", "fixed"])], + ["text-indent", (r: Random): string => `${r.int(5)}ch`], + ], + attributes: [ + ["data-x", (r: Random): string => String(r.int(99))], + [ + "value", + (r: Random): string => + r.pick(["", "new value", "a much longer field value"]), + ], + ["rows", (r: Random): string => String(1 + r.int(4))], + ], + rules: [ + ".rule-a { color: rgb(200, 0, 0) }", + "p { margin: 1px 0 }", + ".before::before { content: \"<\" attr(data-x) \"> \" }", + ], +}; + +// What boxes that lay out their own content hold: a sticky box in an +// inline-block in a scroller, held against the scroller's bottom with no +// scrolling, content past an inline-block's own height, an inline-table, +// counters ::before steps, and cells with no table around them. +const CONTEXTS: Page = { + name: "contexts", + head: ``, + body: `
+
head text
stuck foot
one
two
three
four
five
+

before

first
second
after

+

table abb tail

+
heading
heading
+
x
yy
+
`, + classes: [ + "ib", + "scroll", + "stick", + "rel", + "abs", + "short", + "it", + "row", + "cell", + "n", + "box", + ], + styles: [ + ["height", (r: Random): string => `${15 + r.int(90)}px`], + ["width", (r: Random): string => `${20 + r.int(80)}%`], + [ + "display", + (r: Random): string => + r.pick([ + "block", + "inline-block", + "inline-table", + "table-cell", + "table-row", + "none", + ]), + ], + [ + "position", + (r: Random): string => + r.pick(["static", "relative", "sticky", "absolute"]), + ], + ["bottom", (r: Random): string => `${r.int(30)}px`], + ["margin-top", (r: Random): string => `${r.int(40)}px`], + ["overflow", (r: Random): string => r.pick(["visible", "hidden", "auto"])], + ], + attributes: [], + rules: [ + ".n::before { content: counter(n) \" \" }", + ".ib { border: 1px solid }", + ], +}; + +export const PAGES = [LAYOUT, FLOW, CONTEXTS]; + +export const WORDS = [ + "alpha", + "be", + "gamma delta", + "e", + "zeta eta theta", + "iota", +]; + +export class Random { + state: number; + constructor(seed: number) { + this.state = seed; + } + + next(): number { + this.state = (this.state + 0x6d2b79f5) | 0; + let t = this.state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + int(max: number): number { + return Math.floor(this.next() * (max + 1)); + } + + pick(items: readonly T[]): T { + return items[Math.floor(this.next() * items.length)]; + } +} + +export type Action = + {kind: "class"; id: string; token: string} | + {kind: "style"; id: string; property: string; value: string} | + {kind: "attribute"; id: string; name: string; value: string} | + {kind: "text"; id: string; words: string} | + { + kind: "insert"; + parent: string; + tag: string; + id: string; + words: string; + before: number; + } | + {kind: "remove"; id: string} | + {kind: "rule"; rule: string}; + +export function mount(): {dom: TermDOM; terminal: MockProcess} { + const terminal = new MockProcess({cols: COLS, rows: ROWS}); + const dom = new TermDOM({transport: terminal.transport, cellSize: CELL}); + return {dom, terminal}; +} + +// Every element under the root gets an id, so an action can name it. +export function setUp(document: any, page: Page): void { + document.head.innerHTML = page.head; + document.body.innerHTML = page.body; + let count = 0; + for (const element of document.getElementById("root").querySelectorAll("*")) { + if (!element.id) { + element.id = `k${count++}`; + } + } +} + +export function choose( + document: any, + page: Page, + random: Random, +): Action | null { + const root = document.getElementById("root"); + const elements = [...root.querySelectorAll("*")]; + const element: any = elements.length > 0 ? random.pick(elements) : root; + switch (random.int(page.attributes.length > 0 ? 7 : 5)) { + case 0: + return {kind: "class", id: element.id, token: random.pick(page.classes)}; + case 1: + case 2: { + const [property, value] = random.pick(page.styles); + return {kind: "style", id: element.id, property, value: value(random)}; + } + case 3: + return {kind: "text", id: element.id, words: random.pick(WORDS)}; + case 4: { + const tag = random.pick(["p", "span", "div", "li", "b"]); + const id = `x${random.int(9999)}`; + const words = random.pick(WORDS); + const parent: any = random.pick([root, ...elements]); + const before = random.int(parent.childNodes.length); + return {kind: "insert", parent: parent.id, tag, id, words, before}; + } + case 5: + return element === root ? null : {kind: "remove", id: element.id}; + case 6: { + const [name, value] = random.pick(page.attributes); + return {kind: "attribute", id: element.id, name, value: value(random)}; + } + default: + return page.rules.length > 0 + ? {kind: "rule", rule: random.pick(page.rules)} + : null; + } +} + +export function apply(document: any, action: Action): void { + switch (action.kind) { + case "class": + document.getElementById(action.id)?.classList.toggle(action.token); + return; + case "style": + document.getElementById(action.id)?.style + .setProperty(action.property, action.value); + return; + case "attribute": + document + .getElementById(action.id) + ?.setAttribute(action.name, action.value); + return; + case "text": { + const element = document.getElementById(action.id); + if (!element) { + return; + } + const text = [...element.childNodes].find((node: any) => + node.nodeType === 3 && node.data.trim() !== "", + ); + if (text) { + (text as any).data = action.words; + } else { + element.append(action.words); + } + return; + } + case "insert": { + const parent = document.getElementById(action.parent); + if (!parent || document.getElementById(action.id)) { + return; + } + const added = document.createElement(action.tag); + added.id = action.id; + added.textContent = action.words; + parent.insertBefore(added, parent.childNodes[action.before] ?? null); + return; + } + case "remove": + document.getElementById(action.id)?.remove(); + return; + case "rule": { + // A rule present is taken out, and one absent is added. + const sheet = document.getElementById("sheet"); + const text: string = sheet.textContent; + sheet.textContent = text.includes(action.rule) + ? text.replace(action.rule, "") + : `${text}\n${action.rule}`; + return; + } + } +} + +export function record( + page: Page, + seed: number, + steps: number, +): Promise { + return (async () => { + const random = new Random(seed); + const {dom} = mount(); + setUp(dom.document, page); + await nextFrame(dom); + const actions: Action[] = []; + for (let step = 0; step < steps; step++) { + const action = choose(dom.document, page, random); + if (action) { + actions.push(action); + apply(dom.document, action); + } + } + dom.dispose(); + return actions; + })(); +} + +export function rects(document: any): string[] { + return [...document.querySelectorAll("*")].map((element: any) => { + const r = element.getBoundingClientRect(); + return `${element.localName}#${element.id} ${r.x},${r.y} ${r.width}x${r.height}`; + }); +} + +// A copy of the page's nodes, not its markup, which the parser would +// rearrange where the changes put an element it would not. +export async function fresh( + page: any, +): Promise<{screen: string; rects: string[]}> { + const {dom, terminal} = mount(); + dom.document.documentElement.replaceChildren( + ...[...page.documentElement.childNodes].map((node: any) => + dom.document.importNode(node, true), + ), + ); + await nextFrame(dom); + const result = { + screen: terminal.getScreenContents(), + rects: rects(dom.document), + }; + dom.dispose(); + return result; +} + +/** + * Where the page, changed a frame at a time, first stops matching the + * same page rendered fresh: the actions up to that point and what + * differs, or null when it never does. + */ +export async function firstMismatch( + page: Page, + actions: Action[], +): Promise { + const {dom, terminal} = mount(); + setUp(dom.document, page); + await nextFrame(dom); + for (let index = 0; index < actions.length; index++) { + apply(dom.document, actions[index]); + await nextFrame(dom); + const expected = await fresh(dom.document); + const got = rects(dom.document); + const screen = terminal.getScreenContents(); + const differences: string[] = []; + for (let i = 0; i < Math.max(got.length, expected.rects.length); i++) { + if (got[i] !== expected.rects[i]) { + differences.push(`rect ${got[i]}, fresh ${expected.rects[i]}`); + } + } + const rows = screen.split("\n"); + const freshRows = expected.screen.split("\n"); + for (let i = 0; i < rows.length; i++) { + if (rows[i] !== freshRows[i]) { + differences.push( + `row ${i} ${JSON.stringify(rows[i])}, fresh ${ + JSON.stringify(freshRows[i]) + }`, + ); + } + } + if (differences.length > 0) { + dom.dispose(); + return `${page.name} after ${ + JSON.stringify(actions.slice(0, index + 1)) + }\n${differences.slice(0, 12).join("\n")}`; + } + } + dom.dispose(); + return null; +} diff --git a/tests/highlights.test.ts b/tests/highlights.test.ts index 34b8f076..0b208145 100644 --- a/tests/highlights.test.ts +++ b/tests/highlights.test.ts @@ -111,7 +111,7 @@ test("the registry is a map, and re-registering a name moves it last", () => { expect([...registry.keys()]).toEqual(["second", "first"]); const names: string[] = []; - registry.forEach((value: unknown, key: string, parent: unknown) => { + registry.forEach((_value: unknown, key: string, parent: unknown) => { names.push(key); expect(parent).toBe(registry); }); diff --git a/tests/images.test.ts b/tests/images.test.ts index 6429ccbd..c9f12547 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -13,6 +13,7 @@ import {expect, test} from "@b9g/libuild/test"; import { type Bitmap, decodeImage, + decodeImageForPage, decodeImageOffThread, encodePNG, keepAtMost, @@ -23,7 +24,6 @@ import { resolveImageURL, retainDecoder, sampleBitmap, - setDecodeTimeout, setDecodeWorkerURL, sniffImageType, } from "../src/internal/images.ts"; @@ -495,7 +495,6 @@ withWorker( "an image past its time fails alone, and the images behind it decode", async () => { try { - setDecodeTimeout(1000); setDecodeWorkerURL( fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), ); @@ -506,12 +505,11 @@ withWorker( decodeImageOffThread(bytesOf("png-rgb.png")), ]), ).toEqual([ - "The image took more than 1000 ms to decode", + "The image took more than 10000 ms to decode", "1 wide", "1 wide", ]); } finally { - setDecodeTimeout(10_000); setDecodeWorkerURL(null); } }, @@ -521,7 +519,6 @@ withWorker( "a decode aborted while it waits is dropped, and the line moves on", async () => { try { - setDecodeTimeout(1000); setDecodeWorkerURL( fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), ); @@ -541,7 +538,7 @@ withWorker( ]; abort.abort(new Error("The source changed")); expect(await outcomes(decodes)).toEqual([ - "The image took more than 1000 ms to decode", + "The image took more than 10000 ms to decode", "The source changed", "1 wide", ]); @@ -550,7 +547,6 @@ withWorker( decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal), ).rejects.toThrow("The source changed"); } finally { - setDecodeTimeout(10_000); setDecodeWorkerURL(null); } }, @@ -627,15 +623,9 @@ test("a decode on this thread stops past its time", async () => { data[i] = (i * 2654435761) >>> 24; } const png = encodePNG({width, height: width, data}); - try { - setDecodeTimeout(0); - await expect(decodeImageOffThread(png)).rejects.toThrow("too long"); - setDecodeTimeout(10_000); - const bitmap = await decodeImageOffThread(png); - expect(bitmap.naturalWidth ?? bitmap.width).toBe(width); - } finally { - setDecodeTimeout(10_000); - } + await expect(decodeImageForPage(png, Date.now())).rejects.toThrow("too long"); + const bitmap = await decodeImageOffThread(png); + expect(bitmap.naturalWidth ?? bitmap.width).toBe(width); }); test("an image past MAX_KEPT_PIXELS is kept averaged down, with its natural size", () => { diff --git a/tests/img.test.ts b/tests/img.test.ts index b997715d..f6151ac0 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -270,7 +270,9 @@ test("an image sits on a line with the text around it", async () => { await nextFrame(dom); // A frame drawn while it loaded showed its wider loading box, whose // last cell was erased. - expect(rowText(terminal, 0).trimEnd()).toBe("before ▄▄ after"); + // Its bottom edge is its baseline, which the text sits on. + expect(rowText(terminal, 0).trimEnd()).toBe(" ▄▄"); + expect(rowText(terminal, 1).trimEnd()).toBe("before ▄▄ after"); // The image's computed display stays inline, as in a browser. expect( dom.window.getComputedStyle(document.querySelector("img")!).display, diff --git a/tests/import-graph.test.ts b/tests/import-graph.test.ts new file mode 100644 index 00000000..3ff065ed --- /dev/null +++ b/tests/import-graph.test.ts @@ -0,0 +1,78 @@ +/** + * No module of the engine imports, at run time, a module that imports it + * back. A cycle decides which module finishes loading first by whoever + * the application reaches first, and a class that extends one from the + * other side of a cycle is undefined when its module runs. Type-only + * imports erase, so they may point anywhere. + */ +import {readdirSync, readFileSync} from "node:fs"; +import {dirname, join, normalize} from "node:path"; +import {fileURLToPath} from "node:url"; + +import {expect, test} from "@b9g/libuild/test"; + +const src = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); + +function modules(directory: string): string[] { + return readdirSync(directory, {withFileTypes: true}).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return modules(path); + } + return path.endsWith(".ts") ? [path] : []; + }); +} + +function runtimeImports(file: string): string[] { + const text = readFileSync(file, "utf8"); + const found: string[] = []; + const statements = + /^(?:import|export)\s+(type\s+)?([^;]*?)\s+from\s+"(\.[^"]+)";/gms; + for (const [, typeOnly, clause, specifier] of text.matchAll(statements)) { + if (typeOnly) { + continue; + } + const named = clause.match(/\{([^}]*)\}/); + const rest = clause.replace(/\{[^}]*\}/, "").replace(/,/g, "").trim(); + if (named && rest === "") { + const members = named[1].split(",").map((member) => member.trim()) + .filter(Boolean); + if (members.every((member) => member.startsWith("type "))) { + continue; + } + } + found.push(normalize(join(dirname(file), specifier))); + } + return found; +} + +test("no module of the engine imports one that imports it back", () => { + const graph = new Map( + modules(src).map((file) => [file, runtimeImports(file)]), + ); + const cycles: string[] = []; + const state = new Map(); + const stack: string[] = []; + const visit = (file: string): void => { + state.set(file, "visiting"); + stack.push(file); + for (const next of graph.get(file) ?? []) { + if (state.get(next) === "visiting") { + const loop = stack.slice(stack.indexOf(next)).concat(next); + cycles.push( + loop.map((path) => path.slice(src.length + 1)).join(" -> "), + ); + } else if (!state.has(next) && graph.has(next)) { + visit(next); + } + } + stack.pop(); + state.set(file, "done"); + }; + for (const file of graph.keys()) { + if (!state.has(file)) { + visit(file); + } + } + expect(cycles).toEqual([]); +}); diff --git a/tests/import-order.test.ts b/tests/import-order.test.ts index 980b5cab..4682bd1b 100644 --- a/tests/import-order.test.ts +++ b/tests/import-order.test.ts @@ -1,12 +1,11 @@ /** - * cssvalues, cssselectors, dom and cssom form an import cycle, so whichever - * of them an application reaches first is evaluated before the others' - * tables exist. A module-level constant that calls across the cycle throws + * An application can reach cssvalues before anything else of the engine, + * and a module-level constant that needs a module not yet evaluated throws * a TDZ ReferenceError for that first importer and nobody else, which is * why the entry point never saw it. * - * The import is the whole test: nothing else here reaches the cycle, so - * cssvalues is the module that evaluates it. + * The import is the whole test: nothing else here is imported, so + * cssvalues is the first module evaluated. */ import {expect, test} from "@b9g/libuild/test"; diff --git a/tests/incremental.test.ts b/tests/incremental.test.ts new file mode 100644 index 00000000..3a891234 --- /dev/null +++ b/tests/incremental.test.ts @@ -0,0 +1,193 @@ +/** + * Whatever a page went through, what it shows must be what the same + * page shows freshly rendered. Every cache layout, style and paint keep + * across frames is a way for the first to drift from the second: a box + * not laid out again, rows not repainted, a style or a measure read from + * before a change. Each run applies seeded random changes to a page + * built from the features those caches cover, and after every change + * compares the screen and every element's rect with a fresh render of + * the page's markup. Cells are 7 by 15 pixels, so lengths in pixels + * fall on fractions of a cell. + */ +import {expect, test} from "@b9g/libuild/test"; + +import { + firstMismatch, + fresh, + mount, + PAGES, + record, + rects, +} from "./fuzz-pages.ts"; +import {nextFrame} from "./test-utils.ts"; + +const [LAYOUT, FLOW] = PAGES; + +for (const page of PAGES) { + for (let seed = 1; seed <= 24; seed++) { + test(`seeded changes to the ${page.name} page render as a fresh page does: seed ${seed}`, async () => { + expect(await firstMismatch(page, await record(page, seed, 12))).toBe( + null, + ); + }); + } +} + +// Seeds that once found a bug, run as long as they did then: a grid cell +// rounded again from rounded values (1012, 1066, 1093), restyles inside +// an inline-block repainting rows counted from its own top (1020, 1193, +// 1200, 1260), an inline-block's width its measure did not hear of +// (1481), a positioned row group zeroed by its table when stray content +// joined it (1140), a positioned row's absolute cell (1941), an +// absolute row placed from rounded offsets (2029), and a row made an +// inline-block whose percentage width a cached size did not hear of +// (1637, 2045). +for (const seed of [ + 1012, + 1020, + 1066, + 1093, + 1140, + 1193, + 1200, + 1260, + 1481, + 1637, + 1941, + 2029, + 2045, +]) { + test(`seeded changes to the layout page render as a fresh page does: seed ${seed}`, async () => { + expect(await firstMismatch(LAYOUT, await record(LAYOUT, seed, 25))).toBe( + null, + ); + }); +} + +// The same for the flow page: an input's shown value its attributes set +// (3006), a class restyling a form control's shadow children (3003), a +// sticky box asked for its shift in the middle of a solve (3130), and a +// sticky shift moving rows its rect did not (3134, 3262). +for (const seed of [3003, 3006, 3130, 3134, 3262]) { + test(`seeded changes to the flow page render as a fresh page does: seed ${seed}`, async () => { + expect(await firstMismatch(FLOW, await record(FLOW, seed, 25))).toBe(null); + }); +} + +// Each change, applied a frame apart, against the page drawn fresh after +// each one. +async function replay( + html: string, + changes: Array<(document: any) => void>, +): Promise { + const {dom, terminal} = mount(); + dom.document.head.innerHTML = LAYOUT.head; + dom.document.body.innerHTML = html; + await nextFrame(dom); + for (const [index, apply] of changes.entries()) { + apply(dom.document); + await nextFrame(dom); + const expected = await fresh(dom.document); + expect(`after change ${index}\n${rects(dom.document).join("\n")}`) + .toBe(`after change ${index}\n${expected.rects.join("\n")}`); + expect(`after change ${index}\n${terminal.getScreenContents()}`) + .toBe(`after change ${index}\n${expected.screen}`); + } + dom.dispose(); +} + +test("a cell taken out of flow leaves its table", async () => { + await replay( + "
xy y
", + [(document) => document.getElementById("a").style.position = "absolute"], + ); +}); + +test("a positioned row group keeps its positioned cells laid out", async () => { + await replay( + "" + + "
xy y
", + [ + (document) => document.getElementById("b").classList.add("abs"), + (document) => document.getElementById("g").classList.add("abs"), + (document) => document.getElementById("t").append("stray"), + ], + ); +}); + +test("an inline-block grows with a child made wider", async () => { + await replay( + "
one
two
", + [ + (document) => document.getElementById("f").classList.add("ib"), + (document) => document.getElementById("a").style.width = "87px", + ], + ); +}); + +test("an inline-block lays out a block put inside its inline content", async () => { + await replay("
above
", [ + (document) => { + const span = document.createElement("span"); + span.id = "s"; + span.textContent = "gamma delta"; + document.getElementById("c").prepend(span); + }, + (document) => document.getElementById("c").classList.add("ib"), + (document) => { + const p = document.createElement("p"); + p.textContent = "e"; + document.getElementById("s").append(p); + }, + ]); +}); + +test("a flex item made an inline-block takes the width it is given", async () => { + await replay("
one
two words
", [ + (document) => document.getElementById("a").classList.add("ib"), + (document) => document.getElementById("a").style.width = "23%", + ]); +}); + +test("an untouched textarea shows the text children it is given", async () => { + await replay("", [ + (document) => document.getElementById("a").firstChild.data = "alpha", + (document) => document.getElementById("a").append(" beta"), + (document) => { + const span = document.createElement("span"); + span.textContent = "not its text"; + document.getElementById("a").prepend(span); + }, + ]); +}); + +test("a row made a containing block lays out its positioned cell", async () => { + await replay("
xy y
", [ + (document) => document.getElementById("b").classList.add("abs"), + (document) => document.getElementById("r").style.position = "relative", + ]); +}); + +test("a block in an inline that dissolves leaves no box behind", async () => { + await replay("
inline

block

", [ + (document) => document.getElementById("p").style.display = "contents", + ]); +}); + +test("a positioned cell in an inline-block paints on the rows it is on", async () => { + await replay( + "
1
2
3
4
5
" + + "
6
7
8
" + + "
xy y
", + [ + (document) => document.getElementById("t").style.display = "inline-block", + (document) => document.getElementById("b").style.position = "relative", + (document) => { + const block = document.createElement("div"); + block.textContent = "e"; + document.getElementById("b").prepend(block); + }, + (document) => document.getElementById("g").style.marginTop = "24px", + ], + ); +}); diff --git a/tests/inline-block.test.ts b/tests/inline-block.test.ts index 3894040a..6a5891c1 100644 --- a/tests/inline-block.test.ts +++ b/tests/inline-block.test.ts @@ -574,3 +574,57 @@ test("whitespace between columns takes no room at font-size 0", () => { } dom.dispose(); }); + +// css2 §10.8.1: an atomic inline sits with its baseline on the line's, so +// the text beside it lines up with its last line of text, an +// inline-table's first, or the bottom edge of a box that clips. +async function lineRows(html: string): Promise { + const terminal = new MockProcess({cols: 30, rows: 6}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.body.innerHTML = html; + await nextFrame(dom); + const rows = terminal.getPlainText() + .split("\n") + .map((line) => line.replace(/\s+$/, "")) + .filter((line) => line !== ""); + dom.dispose(); + return rows; +} + +test("text beside an inline-block sits on its last line", async () => { + expect( + await lineRows( + "before
one
" + + "
two
after", + ), + ).toEqual([" one", "before two after"]); +}); + +test("vertical-align: top puts an inline-block's top on the line's", async () => { + expect( + await lineRows( + "before " + + "
one
two
after", + ), + ).toEqual(["before one after", " two"]); +}); + +test("text beside an inline-table sits on its first row", async () => { + expect( + await lineRows( + "before " + + "" + + "one" + + "two after", + ), + ).toEqual(["before one after", " two"]); +}); + +test("text beside an inline-block that clips sits on its bottom edge", async () => { + expect( + await lineRows( + "before " + + "
one
two
three
after", + ), + ).toEqual([" one", " two", "before three after"]); +}); diff --git a/tests/inspector.test.ts b/tests/inspector.test.ts index 65b9be7d..ad5c0491 100644 --- a/tests/inspector.test.ts +++ b/tests/inspector.test.ts @@ -4,7 +4,8 @@ import {inspect} from "node:util"; import {expect, test} from "@b9g/libuild/test"; -import {createWindow, type Element} from "../src/internal/dom.ts"; +import type {Element} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; function asElement(value: unknown): Element { return value as Element; diff --git a/tests/layout-invalidation.test.ts b/tests/layout-invalidation.test.ts index 4c5c93ff..b0580a37 100644 --- a/tests/layout-invalidation.test.ts +++ b/tests/layout-invalidation.test.ts @@ -509,7 +509,8 @@ test("a block arriving under a box that measures as a unit rebuilds it", async ( const host = document.getElementById("s")!; host.insertBefore(block, host.firstChild); await nextFrame(dom); - expect(lines().slice(0, 2)).toEqual(["NB", "A"]); + // The inline-block's baseline is its last line, which the text sits on. + expect(lines().slice(0, 2)).toEqual(["N", "AB"]); dom.dispose(); }); @@ -852,3 +853,64 @@ test("many children of one container changed in one frame all repaint", async () } dom.dispose(); }); + +test("a subtree changed while out of the document lays out its changes when it returns", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const dom = new TermDOM({transport: terminal.transport}); + const {document} = dom; + document.body.innerHTML = + "

first

second

after

"; + await nextFrame(dom); + const box = document.getElementById("box")!; + box.remove(); + await nextFrame(dom); + expect(dom.renderANSI()).not.toContain("first"); + box.querySelector("#a")!.textContent = "changed and much longer"; + const span = document.createElement("span"); + span.textContent = "added"; + box.prepend(span); + box.lastElementChild!.remove(); + document.body.prepend(box); + await nextFrame(dom); + const lines = dom.renderANSI().split("\n").map((line) => + line.replace(/\x1b\[[0-9;:]*m/g, "").trimEnd()); + expect(lines.slice(0, 3)).toEqual([ + "added", + "changed and much longer", + "after", + ]); + dom.dispose(); +}); + +test("pseudo-elements built while a tree is detached show once it is inserted", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const dom = new TermDOM({transport: terminal.transport}); + const {document} = dom; + document.body.innerHTML = + ""; + await nextFrame(dom); + const box = document.createElement("div"); + const list = document.createElement("ul"); + box.append(list); + const item = document.createElement("li"); + item.textContent = "item"; + list.append(item); + const tag = document.createElement("p"); + tag.className = "tag"; + tag.textContent = "tagged"; + box.append(tag); + const warm = document.createElement("div"); + warm.className = "warm"; + const moved = document.createElement("span"); + moved.textContent = "moved"; + box.append(moved); + warm.append(moved); + box.append(warm); + document.body.append(box); + await nextFrame(dom); + const text = terminal.getPlainText(); + expect(text).toContain("• item"); + expect(text).toContain("> tagged"); + expect(text).toContain("moved"); + dom.dispose(); +}); diff --git a/tests/line-clearing.test.ts b/tests/line-clearing.test.ts index f7e69add..afd17b1f 100644 --- a/tests/line-clearing.test.ts +++ b/tests/line-clearing.test.ts @@ -1,7 +1,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {Framebuffer} from "../src/internal/framebuffer.ts"; +import {drawText, Framebuffer, replaced} from "../src/internal/framebuffer.ts"; import {MockProcess, nextFrame, renderFrame} from "./test-utils.js"; test("line clearing removes terminal artifacts from previous commands", async () => { @@ -144,7 +144,7 @@ test("a screen reset clears stale rows without a screen-qualifying erase", async {offset: 0, cursorRow: 0, regionRows: 5}, (ctx) => { for (let i = 0; i < 5; i++) { - ctx.drawText(`stale row ${i}`, 0, i); + drawText(ctx, `stale row ${i}`, 0, i); } }, ), @@ -153,13 +153,13 @@ test("a screen reset clears stale rows without a screen-qualifying erase", async // The resize path resets the screen; the new frame is shorter and leaves // row 1 blank. - framebuffer.replaced(0); + replaced(framebuffer, 0); const out = renderFrame( framebuffer, {offset: 0, cursorRow: 0, regionRows: 3}, (ctx) => { - ctx.drawText("fresh top", 0, 0); - ctx.drawText("fresh mid", 0, 2); + drawText(ctx, "fresh top", 0, 0); + drawText(ctx, "fresh mid", 0, 2); }, ); expect(out).not.toContain("\x1b[2J"); diff --git a/tests/margin-collapsing.test.ts b/tests/margin-collapsing.test.ts index 708e5ac7..d7c4832a 100644 --- a/tests/margin-collapsing.test.ts +++ b/tests/margin-collapsing.test.ts @@ -251,3 +251,17 @@ test("a trailing self-collapsing child pushes the gap outside its parent", async expect(after.top - wrap.bottom).toBe(2); dom.dispose(); }); + +// The root element's margins collapse with nothing (css2 §8.3.1): not its +// children's, and not out of the initial containing block, which has +// nowhere to put them. +test("the root element's margins stay its own", async () => { + const {dom} = await layout( + "

x

", + "", + ); + const html = getRect(dom, dom.document.documentElement); + const p = getRect(dom, dom.document.getElementById("p")!); + expect([html.top, p.top]).toEqual([2, 5]); + dom.dispose(); +}); diff --git a/tests/mathml.test.ts b/tests/mathml.test.ts index 47e3f03e..9b1e2e6b 100644 --- a/tests/mathml.test.ts +++ b/tests/mathml.test.ts @@ -5,7 +5,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; import { captureRawOutput, inputDispatched, diff --git a/tests/membrane.test.ts b/tests/membrane.test.ts index 872d2c86..215fbe2e 100644 --- a/tests/membrane.test.ts +++ b/tests/membrane.test.ts @@ -1,17 +1,12 @@ /** * What a page, or the program embedding TermDOM, can reach holds nothing of - * the engine's: no symbol-keyed state or steps on any object or prototype - * but the runtime's own. Where the runtime has a class, the page gets it; - * where it has none, the sealed one stands in. + * the engine's: no internal state or steps on any object or prototype but + * the runtime's own. Where the runtime has a class, the page gets it. */ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import { - SealedAbortController, - SealedAbortSignal, - SealedDOMException, -} from "../src/internal/dom.ts"; +import {SealedAbortController, SealedAbortSignal} from "../src/internal/dom.ts"; import {MockProcess} from "./test-utils.js"; function create(html = ""): TermDOM { @@ -153,28 +148,6 @@ test("a window's DOMException is the runtime's where the runtime has one", () => dom.dispose(); }); -test("the sealed DOMException is an Error with WebIDL's members", () => { - const dom = create(); - const DOMException = - SealedDOMException as unknown as typeof globalThis.DOMException; - const error = new DOMException("gone", "NotFoundError"); - expect(error).toBeInstanceOf(Error); - expect(Object.getPrototypeOf(DOMException.prototype)).toBe(Error.prototype); - expect([error.name, error.message, error.code]).toEqual([ - "NotFoundError", - "gone", - 8, - ]); - expect(new DOMException().name).toBe("Error"); - expect(new DOMException("x", "Unlisted").code).toBe(0); - expect([DOMException.NOT_FOUND_ERR, error.NOT_FOUND_ERR]).toEqual([8, 8]); - expect(String(error)).toBe("NotFoundError: gone"); - expect(Object.prototype.toString.call(error)).toBe("[object DOMException]"); - const name = Object.getOwnPropertyDescriptor(DOMException.prototype, "name")!; - expect(() => name.get!.call({})).toThrow(TypeError); - dom.dispose(); -}); - function document(dom: TermDOM): Document { return dom.document; } diff --git a/tests/mouse.test.ts b/tests/mouse.test.ts index 0d38e8b7..4e6c3e94 100644 --- a/tests/mouse.test.ts +++ b/tests/mouse.test.ts @@ -13,15 +13,15 @@ const ENABLE = "\x1b[?1002h\x1b[?1006h"; const DISABLE = "\x1b[?1006l\x1b[?1002l"; /** Feed bytes as the terminal would, and wait for the read side to see them. */ -function send(proc: MockProcess, data: string): Promise { +async function send(proc: MockProcess, data: string): Promise { (proc.stdin as unknown as {emit(e: string, d: Buffer): void}).emit( "data", Buffer.from(data), ); + await inputDispatched(); // The frame the input asks for starts in the next task, and writes in // the one after. - return new Promise((resolve) => - setTimeout(() => setTimeout(resolve, 0), 0)); + await new Promise((resolve) => setTimeout(() => setTimeout(resolve, 0), 0)); } function makeDocumentModeApp(lines = 30): { diff --git a/tests/offset-geometry.test.ts b/tests/offset-geometry.test.ts index b1f2ebbe..4a38addb 100644 --- a/tests/offset-geometry.test.ts +++ b/tests/offset-geometry.test.ts @@ -235,3 +235,17 @@ test("offsetParent walks the flat tree and answers only shadow-including ancesto expect(fixed.offsetParent).toBe(null); dom.dispose(); }); + +test("an image's x and y are its box's edges from the document's origin", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.body.innerHTML = + "

line

aa x

"; + await nextFrame(dom); + const image = dom.document.querySelector("img")!; + const rect = image.getBoundingClientRect(); + expect([image.x, image.y]).toEqual([rect.left, rect.top]); + expect([image.x, image.y]).not.toEqual([0, 0]); + expect(new dom.window.Image().x).toBe(0); + dom.dispose(); +}); diff --git a/tests/partial-repaint.test.ts b/tests/partial-repaint.test.ts new file mode 100644 index 00000000..e898a797 --- /dev/null +++ b/tests/partial-repaint.test.ts @@ -0,0 +1,399 @@ +/** + * A frame repaints only the rows that changed, and every way a page can + * change has to name its rows. Each test makes one change after a first + * frame and holds the result to two oracles: the partial-frame check, + * which paints the frame whole beside it, and the terminal itself, whose + * screen must not change when the page is then repainted from scratch. + * The tests also count the frames the check compared, so a change that + * repaints whole cannot pass for one that repaints rows. + */ +import {expect, test} from "@b9g/libuild/test"; + +import {TermDOM} from "../src/index.ts"; +import {invalidate, repaintAll} from "../src/internal/framebuffer.ts"; +import {partialFrameCheck} from "../src/internal/painter.ts"; +import {_} from "../src/internal/slots.ts"; +import {MockProcess, nextFrame} from "./test-utils.ts"; + +async function repaint( + html: string, + change: (document: any, window: any) => void | Promise, + {head = "", cols = 40, rows = 12} = {}, +): Promise<{partial: number; terminal: MockProcess; dom: TermDOM}> { + const terminal = new MockProcess({cols, rows}); + const dom = new TermDOM({transport: terminal.transport}); + if (head) { + dom.document.head.innerHTML = head; + } + dom.document.body.innerHTML = html; + await nextFrame(dom); + await nextFrame(dom); + const before = partialFrameCheck.checked; + await change(dom.document, dom.window); + await nextFrame(dom); + const partial = partialFrameCheck.checked - before; + + const shown = terminal.getScreenContents(); + const slots = _(dom as any) as any; + repaintAll(slots.framebuffer); + invalidate(slots.framebuffer); + await nextFrame(dom); + expect(shown).toBe(terminal.getScreenContents()); + return {partial, terminal, dom}; +} + +const lines = (count: number, prefix = "line") => + Array.from({length: count}, (_, i) => `

${prefix} ${i}

`).join(""); + +test("a z-index swap repaints the boxes it reorders", async () => { + const {partial, dom} = await repaint( + lines(3) + + "
aaaa
" + + "
bbbb
", + (document) => { + document.getElementById("a").style.zIndex = "3"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("visibility hides a word in place", async () => { + const {partial, dom} = await repaint( + lines(4) + "

keep this word

", + (document) => { + document.getElementById("s").style.visibility = "hidden"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("an outline drawn outside its box repaints the rows it reaches", async () => { + const {partial, dom} = await repaint( + lines(3) + "
boxed
" + lines(3), + (document) => { + const d = document.getElementById("d"); + d.style.outline = "1px solid"; + d.style.outlineOffset = "1px"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("an inherited color reaches every descendant's rows", async () => { + const {partial, dom} = await repaint( + lines(2) + "

one two

three

", + (document) => { + document.getElementById("d").style.color = "rgb(0, 128, 0)"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a custom property on the root recolors text deep below it", async () => { + const {dom} = await repaint( + lines(2) + "

deep

", + (document) => { + document.documentElement.style.setProperty("--c", "rgb(255, 0, 0)"); + }, + { + head: "", + }, + ); + dom.dispose(); +}); + +test("an ordered list renumbers below an item taken out", async () => { + const {dom} = await repaint( + "
    " + + Array.from({length: 5}, (_, i) => `
  1. item
  2. `).join("") + + "
", + (document) => { + document.getElementById("i1").remove(); + }, + ); + dom.dispose(); +}); + +test("an ordered list renumbers when its start changes", async () => { + const {dom} = await repaint( + "
  1. a
  2. b
  3. c
", + (document) => { + document.getElementById("o").setAttribute("start", "7"); + }, + ); + dom.dispose(); +}); + +test("generated content follows the attribute it reads", async () => { + const {dom} = await repaint( + lines(2) + "

count

", + (document) => { + document.getElementById("p").setAttribute("data-n", "2"); + }, + {head: ""}, + ); + dom.dispose(); +}); + +test("text edited to the same length repaints its row", async () => { + const {partial, dom} = await repaint( + lines(3) + "

abcdef

" + lines(3), + (document) => { + document.getElementById("p").firstChild.data = "uvwxyz"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("an input's value set by property repaints the field", async () => { + const {dom} = await repaint( + lines(2) + "", + (document) => { + document.getElementById("i").value = "new value"; + }, + ); + dom.dispose(); +}); + +test("the last child leaving a box of fixed height clears its rows", async () => { + const {partial, dom} = await repaint( + "

first

last

" + + lines(2), + (document) => { + document.getElementById("last").remove(); + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a plain box hidden with display: none clears its text", async () => { + const {partial, dom} = await repaint( + lines(2) + "

gone

soon

" + lines(2), + (document) => { + document.getElementById("d").style.display = "none"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a border removed beside another unjoins their corners", async () => { + const {partial, dom} = await repaint( + lines(2) + + "
" + + "a
b
" + + lines(2), + (document) => { + document.getElementById("a").style.border = "none"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a selection made from script highlights, then collapses", async () => { + const {dom} = await repaint( + lines(2) + "

select these words

" + lines(2), + async (document, window) => { + const range = document.createRange(); + range.setStart(document.getElementById("p").firstChild, 7); + range.setEnd(document.getElementById("p").firstChild, 12); + window.getSelection().addRange(range); + }, + ); + dom.dispose(); +}); + +test("a highlight registered and removed repaints its text", async () => { + const {dom} = await repaint( + lines(2) + "

highlight me

", + (document, window) => { + const range = document.createRange(); + range.selectNodeContents(document.getElementById("p")); + window.CSS.highlights.set("mark", new window.Highlight(range)); + }, + {head: ""}, + ); + dom.dispose(); +}); + +test("a canvas drawn on repaints its cells", async () => { + const {dom} = await repaint( + lines(2) + "", + (document) => { + const ctx = document.getElementById("c").getContext("2d"); + ctx.fillStyle = "red"; + ctx.fillRect(0, 0, 40, 24); + }, + ); + dom.dispose(); +}); + +test("a table cell's background repaints the cell", async () => { + const {partial, dom} = await repaint( + lines(2) + "
ab
", + (document) => { + document.getElementById("c").style.background = "blue"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("an inline-block's text changes inside its line", async () => { + const {partial, dom} = await repaint( + lines(2) + + "

before box after

", + (document) => { + document.getElementById("s").firstChild.data = "bix"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("text-align moves a block's lines without moving the block", async () => { + const {partial, dom} = await repaint( + lines(2) + "

centered later

" + lines(2), + (document) => { + document.getElementById("p").style.textAlign = "center"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a class that bolds a word repaints its row", async () => { + const {partial, dom} = await repaint( + lines(3) + "

one two three

", + (document) => { + document.getElementById("s").className = "b"; + }, + {head: ""}, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a fixed bar changes while the document is scrolled", async () => { + const {dom} = await repaint( + "
bar one
" + + lines(40), + async (document, window) => { + window.scrollTo(0, 10); + await nextFrame({window, document}); + document.getElementById("bar").firstChild.data = "bar two"; + }, + ); + dom.dispose(); +}); + +test("a sticky header in a scrolled box keeps its place", async () => { + const {dom} = await repaint( + "
" + + "
head
" + + lines(20) + + "
", + (document) => { + document.getElementById("s").scrollTop = 4; + }, + ); + dom.dispose(); +}); + +test("a transition repaints each of its frames", async () => { + const {dom} = await repaint( + lines(2) + + "

fading

", + async (document, window) => { + document.getElementById("p").style.color = "rgb(255, 255, 255)"; + for (let i = 0; i < 4; i++) { + await nextFrame({window, document}); + } + }, + ); + dom.dispose(); +}); + +test("a counter set on one element renumbers the ones after it", async () => { + const {dom} = await repaint( + "
a
b
c
", + (document) => { + document.getElementById("a").style.counterIncrement = "n 5"; + }, + { + head: + "", + }, + ); + dom.dispose(); +}); + +test("a sticky header moves when the box it sticks in shrinks", async () => { + const {partial, dom} = await repaint( + "
head
q
" + + "
", + async (document, window) => { + window.scrollTo(0, 6); + await nextFrame({window, document}); + document.getElementById("q").innerHTML = "q
".repeat(12); + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a box that stops painting as it moves clears its old rows", async () => { + const {partial, dom} = await repaint( + lines(6) + + "
bar
", + (document) => { + document.getElementById("bar").classList.remove("tall"); + }, + {head: ""}, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("an outlined box with no height paints no row", async () => { + const {partial, dom} = await repaint( + "
one
text
between
" + + "
after
", + (document) => { + document.getElementById("p").textContent = "changed"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); + +test("a change in what overflows an inline-block repaints its rows", async () => { + const {partial, dom} = await repaint( + lines(6) + + "" + + "
first
second
" + + "
third
" + + lines(3, "after"), + (document) => { + document.getElementById("c").textContent = "changed"; + }, + ); + expect(partial).toBeGreaterThan(0); + dom.dispose(); +}); diff --git a/tests/selection-modify.test.ts b/tests/selection-modify.test.ts index 97519715..44925ae0 100644 --- a/tests/selection-modify.test.ts +++ b/tests/selection-modify.test.ts @@ -8,7 +8,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; import {MockProcess, nextFrame} from "./test-utils.js"; // The door a test document comes through: a document of this DOM, in the diff --git a/tests/selectors.test.ts b/tests/selectors.test.ts index d71f290a..7bd716f6 100644 --- a/tests/selectors.test.ts +++ b/tests/selectors.test.ts @@ -12,7 +12,6 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; import {parseSelectorList} from "../src/internal/cssselectors.ts"; import { - createWindow, type Document, DOMException, type Element, @@ -20,6 +19,7 @@ import { setDocumentFocusVisible, setHoveredElement, } from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; import {MockProcess, nextFrame} from "./test-utils.js"; function tree(html: string, url = "about:blank"): Document { diff --git a/tests/serialization.test.ts b/tests/serialization.test.ts index 99beb1f6..5e84754f 100644 --- a/tests/serialization.test.ts +++ b/tests/serialization.test.ts @@ -10,7 +10,8 @@ */ import {expect, test} from "@b9g/libuild/test"; -import {createWindow, type Document} from "../src/internal/dom.ts"; +import type {Document} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; // The door a test document comes through. The parser is the one that hands // a document the realm's custom element registry, as it does the engine's. diff --git a/tests/shadow-dom.test.ts b/tests/shadow-dom.test.ts index 7f22bc3b..546f3aa5 100644 --- a/tests/shadow-dom.test.ts +++ b/tests/shadow-dom.test.ts @@ -11,7 +11,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; import { findCollector, inputDispatched, diff --git a/tests/sticky-position.test.ts b/tests/sticky-position.test.ts index 2240bda2..27b2ff2e 100644 --- a/tests/sticky-position.test.ts +++ b/tests/sticky-position.test.ts @@ -161,3 +161,23 @@ test("an absolute box ignores a scroller outside its containing block", async () expect(dom.document.elementFromPoint(1, 2)).toBe(tag); dom.dispose(); }); + +test("a sticky heading an inline-block lays out holds the scroller around it", async () => { + const terminal = new MockProcess({cols: 30, rows: 8}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.body.innerHTML = + "
top
" + + "" + + makeSections(5) + + "
"; + await nextFrame(dom); + dom.document.getElementById("pane")!.scrollTop = 2; + await nextFrame(dom); + const rows = terminal.getVisibleText().split("\n"); + expect(rows[1]).toContain("Heading 0"); + expect(rows[2]).toContain("s0 row 2"); + expect(dom.document.getElementById("h0")!.getBoundingClientRect().top).toBe( + 1, + ); + dom.dispose(); +}); diff --git a/tests/style-caches.test.ts b/tests/style-caches.test.ts new file mode 100644 index 00000000..0ca4e363 --- /dev/null +++ b/tests/style-caches.test.ts @@ -0,0 +1,208 @@ +import {expect, test} from "@b9g/libuild/test"; + +import {TermDOM} from "../src/index.ts"; +import {getBoxModel, getDeclaredDisplay} from "../src/internal/cssom.ts"; +import {MockProcess, nextFrame} from "./test-utils.js"; + +async function mount(html: string): Promise<{dom: TermDOM; document: any}> { + const terminal = new MockProcess({cols: 40, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.body.innerHTML = html; + await nextFrame(dom); + return {dom, document: dom.document}; +} + +test("the box model is the same object until the element's style changes", async () => { + const {dom, document} = + await mount("
a
b
"); + const a = document.getElementById("a"); + const first = getBoxModel(a); + expect(getBoxModel(a)).toBe(first); + document.getElementById("b").style.color = "red"; + await nextFrame(dom); + expect(getBoxModel(a)).toBe(first); + dom.dispose(); +}); + +test("the box model follows an inline style change", async () => { + const {dom, document} = + await mount("
a
"); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(2); + a.style.paddingLeft = "5px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(5); + a.removeAttribute("style"); + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft === 0).toBe(true); + dom.dispose(); +}); + +test("the box model follows a class that changes which rule matches", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([0, 0]); + a.classList.add("wide"); + await nextFrame(dom); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([4, 1]); + a.classList.remove("wide"); + await nextFrame(dom); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([0, 0]); + dom.dispose(); +}); + +test("the box model follows a stylesheet edited in place", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(1); + document.getElementById("s").textContent = "div { padding-left: 3px }"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(3); + const sheet = document.getElementById("s").sheet; + sheet.cssRules[0].style.paddingLeft = "6px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(6); + dom.dispose(); +}); + +test("the box model follows an inherited font size its lengths are in", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(2); + document.getElementById("p").style.fontSize = "3px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(3); + dom.dispose(); +}); + +test("the declared display follows a class and an edited stylesheet", async () => { + const {dom, document} = await mount( + "a", + ); + const a = document.getElementById("a"); + expect(getDeclaredDisplay(a)).toBe("inline"); + a.className = "row"; + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("flex"); + document.getElementById("s").textContent = ".row { display: grid }"; + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("grid"); + a.removeAttribute("class"); + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("inline"); + dom.dispose(); +}); + +test("a list marker's color follows its ::marker rule", async () => { + const {dom, document} = await mount( + "
  • item
", + ); + const bulletColor = (): string => { + const output = dom.renderANSI(); + const bullet = output.indexOf("•"); + return output.slice(0, bullet); + }; + expect(bulletColor()).toContain("38;2;0;128;0"); + document.getElementById("s").textContent = + "li::marker { color: rgb(255, 0, 0) }"; + await nextFrame(dom); + expect(bulletColor()).toContain("38;2;255;0;0"); + document.querySelector("li").style.fontWeight = "bold"; + document.getElementById("s").textContent = + "li::marker { color: rgb(0, 0, 255) }"; + await nextFrame(dom); + expect(bulletColor()).toContain("38;2;0;0;255"); + dom.dispose(); +}); + +test("a list marker's text follows a ::marker rule added after the first frame", async () => { + const {dom, document} = + await mount("
  • item
"); + expect(dom.renderANSI()).toContain("•"); + document.getElementById("s").textContent = "li::marker { content: '> ' }"; + await nextFrame(dom); + expect(dom.renderANSI()).toContain("> item"); + document.getElementById("s").textContent = + "ul li::marker { content: '- ' } li::marker { content: '* ' } " + + "@layer a { ul li::marker { content: '+ ' } }"; + await nextFrame(dom); + expect(dom.renderANSI()).toContain("- item"); + dom.dispose(); +}); + +test("appending a child leaves its earlier siblings' styles alone", async () => { + const {dom, document} = + await mount("
a
"); + const first = document.getElementById("first"); + const span = first.firstElementChild; + const boxes = [getBoxModel(first), getBoxModel(span)]; + const next = document.createElement("div"); + next.textContent = "b"; + document.getElementById("log").append(next); + await nextFrame(dom); + expect([getBoxModel(first), getBoxModel(span)]).toEqual(boxes); + expect(getBoxModel(first)).toBe(boxes[0]); + expect(getBoxModel(span)).toBe(boxes[1]); + dom.dispose(); +}); + +test("a sibling rule that reaches descendants follows a child placed first", async () => { + const {dom, document} = await mount( + "" + + "
  • a
", + ); + expect(getBoxModel(document.getElementById("sa")).paddingLeft).toBe(2); + const b = document.createElement("li"); + b.innerHTML = "b"; + document.querySelector("ul").prepend(b); + await nextFrame(dom); + expect(getBoxModel(document.getElementById("sb")).paddingLeft).toBe(2); + expect(getBoxModel(document.getElementById("sa")).paddingLeft === 0) + .toBe(true); + dom.dispose(); +}); + +test("only a details element's first summary is its marker", async () => { + const {dom, document} = + await mount("
oldbody
"); + const view = dom.window; + const display = (id: string): string => + view.getComputedStyle(document.getElementById(id)).display; + expect(display("old")).toBe("list-item"); + const summary = document.createElement("summary"); + summary.id = "new"; + summary.textContent = "new"; + document.querySelector("details").prepend(summary); + await nextFrame(dom); + expect([display("new"), display("old")]).toEqual(["list-item", "block"]); + dom.dispose(); +}); + +test("generated content renumbers when an enclosing element's counter changes", async () => { + const {dom, document} = await mount( + "" + + "
c
", + ); + const text = () => dom.renderANSI().replace(/\x1b\[[0-9;]*m/g, ""); + expect(text()).toContain("2. c"); + document.getElementById("a").className = "n"; + await nextFrame(dom); + expect(text()).toContain("3. c"); + document.getElementById("b").style.counterIncrement = "n 5"; + await nextFrame(dom); + expect(text()).toContain("7. c"); + document.getElementById("a").className = ""; + await nextFrame(dom); + expect(text()).toContain("6. c"); + dom.dispose(); +}); diff --git a/tests/stylesheet.test.ts b/tests/stylesheet.test.ts index a8c39a12..762f077c 100644 --- a/tests/stylesheet.test.ts +++ b/tests/stylesheet.test.ts @@ -1,6 +1,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {getBoxModel} from "../src/internal/cssom.ts"; import { getPseudoHost, getPseudoName, @@ -622,3 +623,49 @@ test(":has() restyles its anchor, its siblings and what it styles below when a c expect(color(".title")).toBe("rgb(1, 2, 3)"); termdom.dispose(); }); + +test("a :has() that reads only down the tree restyles a changed item's ancestors, not the items before it", async () => { + const terminal = new MockProcess(); + const termdom = new TermDOM({transport: terminal.transport}); + const {document} = termdom; + document.body.innerHTML = + "
    " + + Array.from({length: 5}, (_, i) => `
  • ${i}
  • `) + .join("") + + "
"; + await nextFrame(termdom); + const items = [...document.querySelectorAll("li")]; + const before = items.map((item) => getBoxModel(item)); + document.getElementById("s3")!.classList.add("on"); + await nextFrame(termdom); + const after = items.map((item) => getBoxModel(item)); + // The box model is kept until its element restyles. + expect(after.map((model, i) => model === before[i])).toEqual([ + true, + true, + true, + false, + true, + ]); + expect(termdom.window.getComputedStyle(items[3]).color) + .toBe("rgb(255, 0, 0)"); + termdom.dispose(); +}); + +// A sheet can come in inside what is added, a "; + const body = dom.document.createElement("body"); + body.innerHTML = "
x
"; + dom.document.documentElement.replaceChildren(head, body); + await nextFrame(dom); + expect(dom.document.getElementById("d")!.getBoundingClientRect().width) + .toBe(10); + dom.dispose(); +}); diff --git a/tests/tables.test.ts b/tests/tables.test.ts index 373c9a96..6e8142ac 100644 --- a/tests/tables.test.ts +++ b/tests/tables.test.ts @@ -838,3 +838,81 @@ test("table-layout:fixed does nothing to a table whose width is auto", async () expect(box("table").width).toBeLessThan(10); dom.dispose(); }); + +// CSS 2.1 §17.2.1, rule 3: table parts a block holds are wrapped in an +// anonymous table, one for each unbroken sequence, so cells sit side by +// side and stray rows share their columns. +test("cells in a block share an anonymous table", async () => { + const {box, rows, dom} = await render( + "
a
" + + "
bb

after

", + ); + expect(box("#a").top).toBe(box("#b").top); + expect(box("#b").left).toBe(box("#a").left + box("#a").width); + expect(rows[0]).toBe("abb"); + dom.dispose(); +}); + +test("rows in a block share the columns of one anonymous table", async () => { + const row = (a: string, b: string, id: string) => + `
${a}` + + `
${b}
`; + const {box, dom} = await render( + "
" + + row("a", "x", "first") + + "\n " + + row("cccc", "y", "second") + + "
", + ); + expect(box("#first").left).toBe(box("#second").left); + dom.dispose(); +}); + +test("text a script puts in a table gets a row of its own", async () => { + const {box, dom, document} = + await render("
x
"); + document.getElementById("t")!.append("loose"); + await nextFrame(dom); + const group = box("#g"); + expect(box("#t").height).toBe(group.height + 1); + expect(dom.document.body.textContent).toContain("loose"); + dom.dispose(); +}); + +test("a row group spans its own rows", async () => { + const {box, dom} = await render( + "" + + "
h
one
two
", + ); + expect(box("#h").height).toBe(box("tr", 0).height); + expect(box("#b").top).toBe(box("tr", 1).top); + expect(box("#b").height).toBe( + box("tr", 2).top + box("tr", 2).height - box("tr", 1).top, + ); + dom.dispose(); +}); + +test("an absolute box after an anonymous table starts below it", async () => { + const {box, dom} = await render( + "
" + + "
a
" + + "
bb
" + + "X
", + ); + expect(box("#abs").top).toBe(box("#a").top + box("#a").height); + dom.dispose(); +}); + +test("an inline-table sits on the line with the text around it", async () => { + const {box, dom} = await render( + "

before " + + "" + + "a" + + "bb" + + " after

", + ); + expect(box("#t").top).toBe(box("p").top); + expect(box("#t").left).toBe("before ".length); + expect(box("#b").left).toBe(box("#a").left + box("#a").width); + dom.dispose(); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6a5b91ef..55059188 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -12,11 +12,31 @@ import { type TerminalTransport, transportFromProcess, } from "../src/index.ts"; -import {framePainted} from "../src/internal/dom.ts"; +import {styleCheck} from "../src/internal/cssom.ts"; +import {controlCheck, framePainted} from "../src/internal/dom.ts"; import type {ColorDepth} from "../src/internal/exchange.ts"; -import {Framebuffer} from "../src/internal/framebuffer.ts"; +import { + beginFrame, + drawText, + endFrame, + Framebuffer, +} from "../src/internal/framebuffer.ts"; +import {layoutCheck} from "../src/internal/layout.ts"; +import {partialFrameCheck} from "../src/internal/painter.ts"; import {getStringWidth} from "../src/internal/text.ts"; +// A frame that repaints only the rows that changed must match one painted +// whole, and every test's frames are held to that. +partialFrameCheck.enabled = true; +// And every solve must place each box where a solve that forgot every +// cache would. +layoutCheck.enabled = true; +// And every style value served must be what the cascade resolves with +// every cache dropped. +styleCheck.enabled = true; +// And every form control must show its state before a frame redraws it. +controlCheck.enabled = true; + const {Terminal} = xtermPkg; type Terminal = InstanceType; @@ -346,7 +366,7 @@ export class MockProcess extends EventEmitter implements ProcessLike { this.terminal.cols, this.colorDepth, ); - const context = framebuffer.beginFrame({offset: 0}); + const context = beginFrame(framebuffer, {offset: 0}); for (let row = 0; row < this.terminal.rows; row++) { const line = buffer.getLine(buffer.viewportY + row); @@ -374,7 +394,7 @@ export class MockProcess extends EventEmitter implements ProcessLike { // color modes carry over, so defaults stay the terminal's own. const hasExplicitFg = cell.getFgColorMode() !== 0; const hasExplicitBg = cell.getBgColorMode() !== 0; - context.drawText(chars, outputCol, row, { + drawText(context, chars, outputCol, row, { fg: hasExplicitFg ? cell.getFgColor() : undefined, bg: hasExplicitBg ? cell.getBgColor() : undefined, bold: !!cell.isBold(), @@ -390,7 +410,7 @@ export class MockProcess extends EventEmitter implements ProcessLike { // The frame emitter withholds the final row's line ending (the screen // has nothing below it); the oracle's callers split on lines. - return stripControlCodes(framebuffer.endFrame() + "\r\n"); + return stripControlCodes(endFrame(framebuffer) + "\r\n"); } } @@ -489,13 +509,13 @@ export function stripControlCodes(ansi: string): string { * is a caller with one painter and one frame. */ export function renderFrame( - renderer: {beginFrame(options: any): any; endFrame(): string}, - options: {offset: number} & Record, + renderer: Framebuffer, + options: Parameters[1], draw: (ctx: any) => void, ): string { - const context = renderer.beginFrame(options); + const context = beginFrame(renderer, options); draw(context); - return renderer.endFrame(); + return endFrame(renderer); } /** The same, for a frame that stands alone. */ diff --git a/tests/textarea.test.ts b/tests/textarea.test.ts index 645d13cb..76650a1a 100644 --- a/tests/textarea.test.ts +++ b/tests/textarea.test.ts @@ -162,6 +162,30 @@ test("the caret parks at the content origin of an empty textarea", async () => { dom.dispose(); }); +// In a flex row the textarea's measure counts its border in its height, +// and its layout node holds no border on that axis. Its content box is +// still inside the border. +test("the caret parks inside the border of an empty textarea in a flex row", async () => { + const terminal = new MockProcess({rows: 8, cols: 40}); + const dom = new TermDOM({transport: terminal.transport}); + dom.attach(); + await new Promise((r) => setTimeout(r, 0)); + const {document} = dom; + document.body.innerHTML = + "
line one
" + + "
Body
"; + document.querySelector("textarea")!.focus(); + await nextFrame(dom); + await nextFrame(dom); + + // Border row 1, content row 2; "Body", border and padding, column 6. + const buffer = (terminal as any).terminal.buffer.active; + expect(buffer.cursorY).toBe(2); + expect(buffer.cursorX).toBe(6); + dom.dispose(); +}); + test("the caret parks at the multiline position; arrows move between lines", async () => { const terminal = new MockProcess({rows: 8, cols: 40}); const dom = new TermDOM({transport: terminal.transport}); diff --git a/tests/user-select.test.ts b/tests/user-select.test.ts index 87aff0b2..20b8395c 100644 --- a/tests/user-select.test.ts +++ b/tests/user-select.test.ts @@ -118,8 +118,8 @@ test("toString is the rendered text, not the range's DOM text", async () => { await nextFrame(dom); await type(terminal, press(1, 1)); - await type(terminal, drag(39, 5)); - await type(terminal, release(39, 5)); + await type(terminal, drag(39, 6)); + await type(terminal, release(39, 6)); await nextFrame(dom); const text = dom.window.getSelection()!.toString(); expect(text).toContain("sum"); diff --git a/tests/webidl.test.ts b/tests/webidl.test.ts index fb56f131..328e5ad1 100644 --- a/tests/webidl.test.ts +++ b/tests/webidl.test.ts @@ -6,6 +6,7 @@ */ import {expect, test} from "@b9g/libuild/test"; +import {DOM_INTERFACES} from "../src/generated/domidl.ts"; import {TermDOM} from "../src/index.ts"; function setup(): {window: any; document: any} { @@ -212,3 +213,32 @@ test("an Illegal invocation carries no cause", () => { expect((error as Error).cause).toBe(undefined); } }); + +test("no interface a page can reach shows an internal member", () => { + const {window} = setup(); + const shown: string[] = []; + for (const name of Object.getOwnPropertyNames(window)) { + const value = window[name]; + if (typeof value !== "function" || !/^[A-Z]/.test(name)) { + continue; + } + for (const target of [value, value.prototype]) { + if (target == null) { + continue; + } + for (const key of Object.getOwnPropertyNames(target)) { + if (key.startsWith("_")) { + shown.push(`${name}.${key}`); + } + } + } + } + expect(shown).toEqual([]); +}); + +test("no member the specs name starts with an underscore, which internal steps use", () => { + const named = Object.entries(DOM_INTERFACES).flatMap(([name, idl]) => + idl.members.map(([key]) => `${name}.${key}`)) + .filter((member) => member.split(".")[1].startsWith("_")); + expect(named).toEqual([]); +}); diff --git a/tests/whitespace.test.ts b/tests/whitespace.test.ts index f673296a..786a564e 100644 --- a/tests/whitespace.test.ts +++ b/tests/whitespace.test.ts @@ -792,3 +792,27 @@ test("a nowrap span keeps itself whole and lets the paragraph wrap", () => { .toEqual(["affirmed the suit against", "Jack Poulson was without merit."]); dom.dispose(); }); + +// U+00A0 is not collapsible white space (css-text-3 §4.1.1), so a line +// holding only one is not empty. Outlook writes every blank line as +//

 

. +test("a paragraph holding only a non-breaking space is one line tall", async () => { + const terminal = new MockProcess({cols: 40, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + const paragraph = (inner: string) => `

${inner}

`; + for (const inner of [ + " ", + " ", + " ", + "   ", + ]) { + dom.document.body.innerHTML = + paragraph("a") + paragraph(inner) + paragraph("b"); + await nextFrame(dom); + expect( + [...dom.document.querySelectorAll("p")] + .map((p) => p.getBoundingClientRect().height), + ).toEqual([1, 1, 1]); + } + dom.dispose(); +}); diff --git a/tests/wide-cluster.test.ts b/tests/wide-cluster.test.ts index 47bbb69e..f43082e0 100644 --- a/tests/wide-cluster.test.ts +++ b/tests/wide-cluster.test.ts @@ -1,16 +1,21 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {Framebuffer} from "../src/internal/framebuffer.ts"; +import { + beginFrame, + drawText, + endFrame, + Framebuffer, +} from "../src/internal/framebuffer.ts"; import {MockProcess, nextFrame, stripControlCodes} from "./test-utils.js"; test("a cell written inside a wide cluster's span is dropped, not emitted", () => { const framebuffer = new Framebuffer(2, 12, "rgb"); - const ctx = framebuffer.beginFrame({offset: 0}); - ctx.drawText("काा", 2, 0); - ctx.drawText("ab", 3, 0); - ctx.drawText("cd", 6, 0); - const frame = stripControlCodes(framebuffer.endFrame()); + const ctx = beginFrame(framebuffer, {offset: 0}); + drawText(ctx, "काा", 2, 0); + drawText(ctx, "ab", 3, 0); + drawText(ctx, "cd", 6, 0); + const frame = stripControlCodes(endFrame(framebuffer)); expect(frame).toContain("काा"); expect(frame).toContain("cd"); expect(frame).not.toContain("a"); @@ -35,14 +40,14 @@ test("text that replaces wide characters on a fill rewrites every column", () => // written, so a frame may skip neither. const framebuffer = new Framebuffer(1, 20, "rgb"); const white = {bg: 0x1000000 | 0xffffff, fg: 0x1000000}; - let ctx = framebuffer.beginFrame({offset: 0}); - ctx.drawText(" ".repeat(20), 0, 0, white); - ctx.drawText("日本語のテキストです", 0, 0, white); - framebuffer.endFrame(); - ctx = framebuffer.beginFrame({offset: 0}); - ctx.drawText(" ".repeat(20), 0, 0, white); - ctx.drawText("a", 0, 0, white); - const update = framebuffer.endFrame(); + let ctx = beginFrame(framebuffer, {offset: 0}); + drawText(ctx, " ".repeat(20), 0, 0, white); + drawText(ctx, "日本語のテキストです", 0, 0, white); + endFrame(framebuffer); + ctx = beginFrame(framebuffer, {offset: 0}); + drawText(ctx, " ".repeat(20), 0, 0, white); + drawText(ctx, "a", 0, 0, white); + const update = endFrame(framebuffer); expect(update).not.toMatch(/\x1b\[\d*C/); expect(update.replace(/\x1b(?:\[[?\d;]*[A-Za-z]|[78])|\r/g, "")).toBe( "a" + " ".repeat(19), diff --git a/tests/width-measurement.test.ts b/tests/width-measurement.test.ts index 8513dc86..281f1e4f 100644 --- a/tests/width-measurement.test.ts +++ b/tests/width-measurement.test.ts @@ -15,7 +15,12 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; import type {Exchange} from "../src/internal/exchange.ts"; -import {Framebuffer} from "../src/internal/framebuffer.ts"; +import { + beginFrame, + drawText, + endFrame, + Framebuffer, +} from "../src/internal/framebuffer.ts"; import {getStringWidth, recordClusterAdvance} from "../src/internal/text.ts"; import { captureRawOutput, @@ -132,11 +137,11 @@ function emit( ): string { const framebuffer = new Framebuffer(rows, cols, "rgb"); framebuffer.measurer = measurer; - const context = framebuffer.beginFrame({offset: 0}); + const context = beginFrame(framebuffer, {offset: 0}); for (const [index, cluster] of cells) { - context.drawText(cluster, index % cols, Math.floor(index / cols)); + drawText(context, cluster, index % cols, Math.floor(index / cols)); } - return framebuffer.endFrame(); + return endFrame(framebuffer); } function settle(ms = 60): Promise { diff --git a/tests/window-named-access.test.ts b/tests/window-named-access.test.ts index e6511bd1..8abfce87 100644 --- a/tests/window-named-access.test.ts +++ b/tests/window-named-access.test.ts @@ -5,7 +5,7 @@ */ import {expect, test} from "@b9g/libuild/test"; -import {createWindow} from "../src/internal/dom.ts"; +import {createWindow} from "../src/internal/window.ts"; function open(html: string): {window: any; document: any} { const window = createWindow(html) as any; diff --git a/tests/zero-width.test.ts b/tests/zero-width.test.ts index 59486a6c..22f0dbe8 100644 --- a/tests/zero-width.test.ts +++ b/tests/zero-width.test.ts @@ -5,7 +5,7 @@ */ import {expect, test} from "@b9g/libuild/test"; -import {Framebuffer} from "../src/internal/framebuffer.ts"; +import {drawText, Framebuffer} from "../src/internal/framebuffer.ts"; import {renderStatic} from "./test-utils.js"; test("a zero-width cluster takes no cell and the line still ends", () => { @@ -14,7 +14,7 @@ test("a zero-width cluster takes no cell and the line still ends", () => { framebuffer, {rows: 1, lineEnding: "\n"}, (ctx) => { - ctx.drawText("a­b​c", 0, 0); + drawText(ctx, "a­b​c", 0, 0); }, ); expect(output.replace(/\x1b\[[0-9;]*m/g, "")).toBe("abc\n"); diff --git a/tsconfig.json b/tsconfig.json index 98c2ac55..9dd4e8d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,11 @@ "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noImplicitOverride": true }, - "include": ["src", "tests", "fuzz", "examples", "scripts"], + "include": ["src", "tests", "fuzz", "examples", "scripts", "bench", "node_modules/@b9g/libuild/litest.d.ts"], // The drift ledger instantiates every class against lib.dom, which is // twenty seconds and a gigabyte the ordinary typecheck does not need. // scripts/lib-dom-drift.ts checks it in a program of its own.