From 926fd75c3ebdd3530e1229ecaca15013d8499cef Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:27:35 +0400 Subject: [PATCH 01/20] refactor(renderer): replace WeakMap theme propagation with stack --- .../src/renderer/renderToDrawlist/renderTree.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 869f4896..499baf0f 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -116,8 +116,7 @@ export function renderTree( const styleStack: ResolvedTextStyle[] = [inheritedStyle]; const layoutStack: LayoutTree[] = [layoutTree]; const clipStack: (ClipRect | undefined)[] = [undefined]; - const themeByNode = new WeakMap(); - themeByNode.set(tree, theme); + const themeStack: Theme[] = [theme]; while (nodeStack.length > 0) { const nodeOrPop = nodeStack.pop(); @@ -126,6 +125,7 @@ export function renderTree( continue; } if (!nodeOrPop) continue; + const currentTheme = themeStack.pop() ?? theme; const parentStyle = styleStack.pop(); if (!parentStyle) continue; const layoutNode = layoutStack.pop(); @@ -147,7 +147,6 @@ export function renderTree( continue; } - const currentTheme = themeByNode.get(node) ?? theme; let renderTheme = currentTheme; if (vnode.kind === "themed") { const props = vnode.props as { theme?: unknown }; @@ -161,9 +160,7 @@ export function renderTree( const props = vnode.props as { theme?: unknown }; renderTheme = mergeThemeOverride(currentTheme, props.theme); } - for (const child of node.children) { - themeByNode.set(child, renderTheme); - } + const nodeStackLenBeforePush = nodeStack.length; // Depth-first preorder: render node, then its children. switch (vnode.kind) { @@ -409,6 +406,12 @@ export function renderTree( default: break; } + + for (let i = nodeStackLenBeforePush; i < nodeStack.length; i++) { + if (nodeStack[i] !== null) { + themeStack.push(renderTheme); + } + } } if (DEV_MODE && clipStack.length !== 0) { From da8fd8cc3742effb77bd846b7b5cfdaf8678846c Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:46:41 +0400 Subject: [PATCH 02/20] chore(native): sync Zireael v1.3.8-alpha.8 --- packages/native/vendor/zireael/src/util/zr_macros.h | 8 +++++++- vendor/zireael | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/native/vendor/zireael/src/util/zr_macros.h b/packages/native/vendor/zireael/src/util/zr_macros.h index c779ecf7..8f1b6211 100644 --- a/packages/native/vendor/zireael/src/util/zr_macros.h +++ b/packages/native/vendor/zireael/src/util/zr_macros.h @@ -11,11 +11,17 @@ /* Number of elements in a fixed-size array. - Rejects pointer arguments at compile time. + On compilers with GNU builtins, reject pointer arguments at compile time. + MSVC lacks __builtin_types_compatible_p/__typeof__, so fall back to the + size-based form to keep callsites readable and portable. */ +#if defined(_MSC_VER) && !defined(__clang__) +#define ZR_ARRAYLEN(arr) (sizeof(arr) / sizeof((arr)[0])) +#else #define ZR_ARRAYLEN(arr) \ ((sizeof(arr) / sizeof((arr)[0])) + \ 0u * sizeof(char[1 - 2 * !!__builtin_types_compatible_p(__typeof__(arr), __typeof__(&(arr)[0]))])) +#endif /* Generic min/max helpers. diff --git a/vendor/zireael b/vendor/zireael index f60ffe78..435d28bc 160000 --- a/vendor/zireael +++ b/vendor/zireael @@ -1 +1 @@ -Subproject commit f60ffe78afb4cd4391e75cc12e14643e2b96fc1e +Subproject commit 435d28bcd59dd07b78be0f28661ae159cefde753 From 494413e8ab5454e5182d7acc486806f9b624f914 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:36:55 +0400 Subject: [PATCH 03/20] core(drawlist): add buildInto for v2 and v3 builders --- .../__tests__/builder.build-into.test.ts | 72 ++++++++++ packages/core/src/drawlist/builderBase.ts | 131 +++++++++++++----- packages/core/src/drawlist/builder_v2.ts | 4 + packages/core/src/drawlist/builder_v3.ts | 4 + packages/core/src/drawlist/index.ts | 1 + packages/core/src/drawlist/types.ts | 13 +- 6 files changed, 188 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/drawlist/__tests__/builder.build-into.test.ts diff --git a/packages/core/src/drawlist/__tests__/builder.build-into.test.ts b/packages/core/src/drawlist/__tests__/builder.build-into.test.ts new file mode 100644 index 00000000..0152b79f --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.build-into.test.ts @@ -0,0 +1,72 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV2, createDrawlistBuilderV3 } from "../../index.js"; + +describe("DrawlistBuilder buildInto", () => { + test("v2 buildInto(dst) matches build() bytes exactly", () => { + const builder = createDrawlistBuilderV2(); + builder.clear(); + builder.fillRect(0, 0, 8, 4, { bg: "#001122" }); + builder.drawText(2, 1, "v2-build-into", { fg: "#aabbcc", bold: true }); + builder.setCursor({ x: 3, y: 2, shape: 1, visible: true, blink: false }); + + const built = builder.build(); + assert.equal(built.ok, true); + if (!built.ok) return; + + const dst = new Uint8Array(built.bytes.byteLength + 16); + dst.fill(0x7d); + const builtInto = builder.buildInto(dst); + + assert.equal(builtInto.ok, true); + if (!builtInto.ok) return; + assert.equal(builtInto.bytes.byteLength, built.bytes.byteLength); + assert.deepEqual(Array.from(builtInto.bytes), Array.from(built.bytes)); + }); + + test("v3 (drawlist v5) buildInto(dst) matches build() for text, text-run, and graphics", () => { + const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + builder.drawText(1, 2, "hello-v3", { underlineStyle: "dashed", underlineColor: "#ff0000" }); + + const runBlob = builder.addTextRunBlob([ + { text: "run-a", style: { bold: true } }, + { text: "run-b", style: { italic: true } }, + ]); + assert.equal(runBlob, 0); + if (runBlob === null) return; + builder.drawTextRun(4, 5, runBlob); + + const imageBlob = builder.addBlob(new Uint8Array(2 * 2 * 4)); + assert.equal(imageBlob, 1); + if (imageBlob === null) return; + builder.drawImage(6, 7, 2, 2, imageBlob, "rgba", "auto", 0, "contain", 99, 2, 2); + + const built = builder.build(); + assert.equal(built.ok, true); + if (!built.ok) return; + + const dst = new Uint8Array(built.bytes.byteLength + 32); + dst.fill(0x3a); + const builtInto = builder.buildInto(dst); + + assert.equal(builtInto.ok, true); + if (!builtInto.ok) return; + assert.equal(builtInto.bytes.byteLength, built.bytes.byteLength); + assert.deepEqual(Array.from(builtInto.bytes), Array.from(built.bytes)); + }); + + test("buildInto(dst) fails when dst is one byte too small", () => { + const builder = createDrawlistBuilderV2(); + builder.drawText(0, 0, "small-fail"); + builder.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); + + const built = builder.build(); + assert.equal(built.ok, true); + if (!built.ok) return; + + const dst = new Uint8Array(built.bytes.byteLength - 1); + const builtInto = builder.buildInto(dst); + assert.equal(builtInto.ok, false); + if (builtInto.ok) return; + assert.equal(builtInto.error.code, "ZRDL_TOO_LARGE"); + }); +}); diff --git a/packages/core/src/drawlist/builderBase.ts b/packages/core/src/drawlist/builderBase.ts index 470504ba..716f2ee3 100644 --- a/packages/core/src/drawlist/builderBase.ts +++ b/packages/core/src/drawlist/builderBase.ts @@ -500,6 +500,57 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil return { ok: false, error: this.error }; } + const estimatedTotal = this.estimateTotalSize(); + if (estimatedTotal > this.maxDrawlistBytes) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `build: maxDrawlistBytes exceeded (total=${estimatedTotal}, max=${this.maxDrawlistBytes})`, + }, + }; + } + + const outBuf = this.reuseOutputBuffer + ? this.ensureOutputCapacity(estimatedTotal) + : new Uint8Array(estimatedTotal); + if (this.error) { + return { ok: false, error: this.error }; + } + + return this.buildIntoWithVersion(version, outBuf); + } + + protected buildIntoWithVersion(version: number, dst: Uint8Array): DrawlistBuildResult { + if (this.error) { + return { ok: false, error: this.error }; + } + + if (!(dst instanceof Uint8Array)) { + return { + ok: false, + error: { code: "ZRDL_BAD_PARAMS", detail: "buildInto: dst must be a Uint8Array" }, + }; + } + + const prepared = this.prepareBuildLayout(); + if ("ok" in prepared) return prepared; + const layout = prepared; + + if (dst.byteLength < layout.totalSize) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `buildInto: dst is too small (required=${layout.totalSize}, got=${dst.byteLength})`, + }, + }; + } + + return this.writeBuildInto(version, layout, dst); + } + + private prepareBuildLayout(): Layout | DrawlistBuildResult { if ((this.cmdLen & 3) !== 0) { return { ok: false, @@ -519,8 +570,7 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil const stringsSpanOffset = stringsCount === 0 ? 0 : cursor; cursor += stringsSpanBytes; const stringsBytesOffset = stringsCount === 0 ? 0 : cursor; - const stringsBytesLenRaw = this.stringBytesLen; - const stringsBytesLen = stringsCount === 0 ? 0 : align4(stringsBytesLenRaw); + const stringsBytesLen = stringsCount === 0 ? 0 : align4(this.stringBytesLen); cursor += stringsBytesLen; const blobsCount = this.blobSpanOffs.length; @@ -528,13 +578,12 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil const blobsSpanOffset = blobsCount === 0 ? 0 : cursor; cursor += blobsSpanBytes; const blobsBytesOffset = blobsCount === 0 ? 0 : cursor; - const blobsBytesLenRaw = this.blobBytesLen; - const blobsBytesLen = blobsCount === 0 ? 0 : align4(blobsBytesLenRaw); + const blobsBytesLen = blobsCount === 0 ? 0 : align4(this.blobBytesLen); cursor += blobsBytesLen; const totalSize = cursor; - const formatFail = this.validateLayout({ + const layout: Layout = { cmdOffset, cmdBytes, cmdCount, @@ -548,7 +597,9 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil blobsBytesOffset, blobsBytesLen, totalSize, - }); + }; + + const formatFail = this.validateLayout(layout); if (formatFail) return formatFail; if (totalSize > this.maxDrawlistBytes) { @@ -561,36 +612,34 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil }; } - const outBuf = this.reuseOutputBuffer - ? this.ensureOutputCapacity(totalSize) - : new Uint8Array(totalSize); - if (this.error) { - return { ok: false, error: this.error }; - } - const out = this.reuseOutputBuffer ? outBuf.subarray(0, totalSize) : outBuf; + return layout; + } + + private writeBuildInto(version: number, layout: Layout, dst: Uint8Array): DrawlistBuildResult { + const out = dst.subarray(0, layout.totalSize); const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); dv.setUint32(0, ZRDL_MAGIC, true); dv.setUint32(4, version >>> 0, true); dv.setUint32(8, HEADER_SIZE, true); - dv.setUint32(12, totalSize, true); - dv.setUint32(16, cmdOffset, true); - dv.setUint32(20, cmdBytes, true); - dv.setUint32(24, cmdCount, true); - dv.setUint32(28, stringsSpanOffset, true); - dv.setUint32(32, stringsCount, true); - dv.setUint32(36, stringsBytesOffset, true); - dv.setUint32(40, stringsBytesLen, true); - dv.setUint32(44, blobsSpanOffset, true); - dv.setUint32(48, blobsCount, true); - dv.setUint32(52, blobsBytesOffset, true); - dv.setUint32(56, blobsBytesLen, true); + dv.setUint32(12, layout.totalSize, true); + dv.setUint32(16, layout.cmdOffset, true); + dv.setUint32(20, layout.cmdBytes, true); + dv.setUint32(24, layout.cmdCount, true); + dv.setUint32(28, layout.stringsSpanOffset, true); + dv.setUint32(32, layout.stringsCount, true); + dv.setUint32(36, layout.stringsBytesOffset, true); + dv.setUint32(40, layout.stringsBytesLen, true); + dv.setUint32(44, layout.blobsSpanOffset, true); + dv.setUint32(48, layout.blobsCount, true); + dv.setUint32(52, layout.blobsBytesOffset, true); + dv.setUint32(56, layout.blobsBytesLen, true); dv.setUint32(60, 0, true); - out.set(this.cmdBuf.subarray(0, cmdBytes), cmdOffset); + out.set(this.cmdBuf.subarray(0, layout.cmdBytes), layout.cmdOffset); - let spanOff = stringsSpanOffset; - for (let i = 0; i < stringsCount; i++) { + let spanOff = layout.stringsSpanOffset; + for (let i = 0; i < layout.stringsCount; i++) { const off = this.stringSpanOffs[i]; const len = this.stringSpanLens[i]; if (off === undefined || len === undefined) { @@ -605,13 +654,18 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil spanOff += 8; } - out.set(this.stringBytesBuf.subarray(0, stringsBytesLenRaw), stringsBytesOffset); - if (this.reuseOutputBuffer && stringsBytesLen > stringsBytesLenRaw) { - out.fill(0, stringsBytesOffset + stringsBytesLenRaw, stringsBytesOffset + stringsBytesLen); + const stringsBytesLenRaw = this.stringBytesLen; + out.set(this.stringBytesBuf.subarray(0, stringsBytesLenRaw), layout.stringsBytesOffset); + if (layout.stringsBytesLen > stringsBytesLenRaw) { + out.fill( + 0, + layout.stringsBytesOffset + stringsBytesLenRaw, + layout.stringsBytesOffset + layout.stringsBytesLen, + ); } - spanOff = blobsSpanOffset; - for (let i = 0; i < blobsCount; i++) { + spanOff = layout.blobsSpanOffset; + for (let i = 0; i < layout.blobsCount; i++) { const off = this.blobSpanOffs[i]; const len = this.blobSpanLens[i]; if (off === undefined || len === undefined) { @@ -626,9 +680,14 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil spanOff += 8; } - out.set(this.blobBytesBuf.subarray(0, blobsBytesLenRaw), blobsBytesOffset); - if (this.reuseOutputBuffer && blobsBytesLen > blobsBytesLenRaw) { - out.fill(0, blobsBytesOffset + blobsBytesLenRaw, blobsBytesOffset + blobsBytesLen); + const blobsBytesLenRaw = this.blobBytesLen; + out.set(this.blobBytesBuf.subarray(0, blobsBytesLenRaw), layout.blobsBytesOffset); + if (layout.blobsBytesLen > blobsBytesLenRaw) { + out.fill( + 0, + layout.blobsBytesOffset + blobsBytesLenRaw, + layout.blobsBytesOffset + layout.blobsBytesLen, + ); } return { ok: true, bytes: out }; diff --git a/packages/core/src/drawlist/builder_v2.ts b/packages/core/src/drawlist/builder_v2.ts index a46a55f9..3ba0743e 100644 --- a/packages/core/src/drawlist/builder_v2.ts +++ b/packages/core/src/drawlist/builder_v2.ts @@ -47,6 +47,10 @@ class DrawlistBuilderV2Impl extends DrawlistBuilderLegacyBase implements Drawlis this.setCursor({ x: -1, y: -1, shape: 0, visible: false, blink: false }); } + buildInto(dst: Uint8Array): DrawlistBuildResult { + return this.buildIntoWithVersion(ZR_DRAWLIST_VERSION_V2, dst); + } + build(): DrawlistBuildResult { return this.buildWithVersion(ZR_DRAWLIST_VERSION_V2); } diff --git a/packages/core/src/drawlist/builder_v3.ts b/packages/core/src/drawlist/builder_v3.ts index 61853603..3b25c5ab 100644 --- a/packages/core/src/drawlist/builder_v3.ts +++ b/packages/core/src/drawlist/builder_v3.ts @@ -545,6 +545,10 @@ class DrawlistBuilderV3Impl extends DrawlistBuilderBase implements this.maybeFailTooLargeAfterWrite(); } + buildInto(dst: Uint8Array): DrawlistBuildResult { + return this.buildIntoWithVersion(this.drawlistVersion, dst); + } + build(): DrawlistBuildResult { return this.buildWithVersion(this.drawlistVersion); } diff --git a/packages/core/src/drawlist/index.ts b/packages/core/src/drawlist/index.ts index 11f07bac..86ab42a0 100644 --- a/packages/core/src/drawlist/index.ts +++ b/packages/core/src/drawlist/index.ts @@ -21,6 +21,7 @@ export type { DrawlistBuildError, DrawlistBuildErrorCode, DrawlistBuildResult, + DrawlistBuildInto, DrawlistBuilderV1, DrawlistBuilderV2, DrawlistBuilderV3, diff --git a/packages/core/src/drawlist/types.ts b/packages/core/src/drawlist/types.ts index c4088ace..2181a37b 100644 --- a/packages/core/src/drawlist/types.ts +++ b/packages/core/src/drawlist/types.ts @@ -56,6 +56,17 @@ export type DrawlistBuildResult = | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; error: DrawlistBuildError }>; +/** + * Optional capability for serializing directly into a caller-provided buffer. + * + * Semantics: + * - Returns ok=true with a view over the written prefix of `dst`. + * - Returns ok=false if `dst` is too small or build state is invalid. + */ +export interface DrawlistBuildInto { + buildInto(dst: Uint8Array): DrawlistBuildResult; +} + /** * ZRDL v1 drawlist builder interface. * @@ -139,7 +150,7 @@ export type CursorState = Readonly<{ * When v2 is negotiated, the engine handles cursor display internally, * eliminating the need for "fake cursor" glyphs. */ -export interface DrawlistBuilderV2 extends DrawlistBuilderV1 { +export interface DrawlistBuilderV2 extends DrawlistBuilderV1, DrawlistBuildInto { /** * Set cursor position and appearance. Emits OP_SET_CURSOR (opcode 7). * From 183532edfc0e479630e4bd14b51e6a909806a012 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:57:29 +0400 Subject: [PATCH 04/20] =?UTF-8?q?ci:=20optimize=20PR=20pipeline=20?= =?UTF-8?q?=E2=80=94=20concurrency,=20fast=20gate,=20reduced=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add concurrency group to ci.yml and codeql.yml to cancel in-progress runs on re-push (biggest win for runner contention) - Extract lint/typecheck/codegen/portability/unicode into a dedicated `checks` job that gates the matrix — lint failures caught in ~2 min instead of after the full 15-min pipeline - Dynamic matrix: 5 runners on PRs (Linux × Node 18/20/22, macOS × 22, Windows × 22), full 3×3 on push to main - Remove redundant lint/typecheck/codegen/biome-install steps from each matrix cell and the bun job - Remove duplicate docs job (already handled by docs.yml) Net effect on PRs: 13 jobs → 9, ~44% fewer runners, fast-fail on static checks, stale runs cancelled automatically. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 129 +++++++++++++++++------------------ .github/workflows/codeql.yml | 4 ++ 2 files changed, 66 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b27a36e..a5951aba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ on: permissions: contents: read +# Cancel previous runs on the same branch/PR — the single biggest win for +# unblocking queued PRs when authors force-push or push fixups. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: guardrails: name: guardrails @@ -20,17 +26,67 @@ jobs: - name: Run guardrails run: bash scripts/guardrails.sh + # Fast gate: lint, typecheck, codegen, and portability checks are + # platform-independent — run once on Linux/Node 22 instead of 9× in the + # matrix. Failures here prevent the expensive matrix from starting at all. + checks: + name: lint / typecheck / codegen + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install + run: npm ci + + - name: Drawlist codegen guardrail + run: npm run codegen:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Check core portability + run: npm run check:core-portability + + - name: Check Unicode pins (submodule sync) + run: npm run check:unicode + + # Compute matrix: 5 runners on PRs (all Node versions on Linux + latest on + # macOS/Windows), full 3×3 on push to main. + matrix-config: + name: configure matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - id: set + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo 'matrix={"include":[{"os":"ubuntu-latest","node-version":"18"},{"os":"ubuntu-latest","node-version":"20"},{"os":"ubuntu-latest","node-version":"22"},{"os":"macos-latest","node-version":"22"},{"os":"windows-latest","node-version":"22"}]}' >> "$GITHUB_OUTPUT" + else + echo 'matrix={"os":["ubuntu-latest","macos-latest","windows-latest"],"node-version":["18","20","22"]}' >> "$GITHUB_OUTPUT" + fi + ci: + needs: [guardrails, checks, matrix-config] name: node ${{ matrix.node-version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - node-version: ["18", "20", "22"] + matrix: ${{ fromJSON(needs.matrix-config.outputs.matrix) }} steps: - - name: Checkout (with submodules) + - name: Checkout uses: actions/checkout@v4 with: submodules: false @@ -49,29 +105,9 @@ jobs: if: runner.os != 'Linux' run: npm install - - name: Install biome platform binary - if: runner.os != 'Linux' - run: npm install @biomejs/cli-${{ runner.os == 'macOS' && 'darwin' || 'win32' }}-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - - - name: Drawlist codegen guardrail - run: npm run codegen:check - - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - name: Build run: npm run build - - name: Check core portability - run: npm run check:core-portability - - - name: Check Unicode pins (submodule sync) - if: runner.os == 'Linux' - run: npm run check:unicode - - name: Tests run: npm run test @@ -120,10 +156,11 @@ jobs: run: npm run test:native:smoke bun: + needs: [guardrails, checks] name: bun / ubuntu-latest runs-on: ubuntu-latest steps: - - name: Checkout (with submodules) + - name: Checkout uses: actions/checkout@v4 with: submodules: false @@ -141,24 +178,9 @@ jobs: - name: Install run: bun install - - name: Drawlist codegen guardrail - run: bun run codegen:check - - - name: Lint - run: bun run lint - - - name: Typecheck - run: bun run typecheck - - name: Build run: bun run build - - name: Check core portability - run: bun run check:core-portability - - - name: Check Unicode pins (submodule sync) - run: bun run check:unicode - - name: Tests run: bun run test @@ -229,30 +251,3 @@ jobs: .artifacts/bench/ci/compare.md .artifacts/bench/ci/manifest.json .artifacts/bench/ci/profile.json - - docs: - name: docs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: false - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: Install - run: npm ci - - - name: Build packages - run: npm run build - - - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Build docs site - run: npm run docs:build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 87070c53..4ff8422b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,6 +12,10 @@ permissions: actions: read security-events: write +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze-js: name: analyze (js/ts) From f91ab87f015453ea1527eda4e1615253c9c9ce61 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:03:49 +0400 Subject: [PATCH 05/20] =?UTF-8?q?Revert=20"ci:=20optimize=20PR=20pipeline?= =?UTF-8?q?=20=E2=80=94=20concurrency,=20fast=20gate,=20reduced=20matrix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 183532edfc0e479630e4bd14b51e6a909806a012. --- .github/workflows/ci.yml | 129 ++++++++++++++++++----------------- .github/workflows/codeql.yml | 4 -- 2 files changed, 67 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5951aba..9b27a36e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,6 @@ on: permissions: contents: read -# Cancel previous runs on the same branch/PR — the single biggest win for -# unblocking queued PRs when authors force-push or push fixups. -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: guardrails: name: guardrails @@ -26,67 +20,17 @@ jobs: - name: Run guardrails run: bash scripts/guardrails.sh - # Fast gate: lint, typecheck, codegen, and portability checks are - # platform-independent — run once on Linux/Node 22 instead of 9× in the - # matrix. Failures here prevent the expensive matrix from starting at all. - checks: - name: lint / typecheck / codegen - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: false - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: Install - run: npm ci - - - name: Drawlist codegen guardrail - run: npm run codegen:check - - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - - name: Check core portability - run: npm run check:core-portability - - - name: Check Unicode pins (submodule sync) - run: npm run check:unicode - - # Compute matrix: 5 runners on PRs (all Node versions on Linux + latest on - # macOS/Windows), full 3×3 on push to main. - matrix-config: - name: configure matrix - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set.outputs.matrix }} - steps: - - id: set - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo 'matrix={"include":[{"os":"ubuntu-latest","node-version":"18"},{"os":"ubuntu-latest","node-version":"20"},{"os":"ubuntu-latest","node-version":"22"},{"os":"macos-latest","node-version":"22"},{"os":"windows-latest","node-version":"22"}]}' >> "$GITHUB_OUTPUT" - else - echo 'matrix={"os":["ubuntu-latest","macos-latest","windows-latest"],"node-version":["18","20","22"]}' >> "$GITHUB_OUTPUT" - fi - ci: - needs: [guardrails, checks, matrix-config] name: node ${{ matrix.node-version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false - matrix: ${{ fromJSON(needs.matrix-config.outputs.matrix) }} + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: ["18", "20", "22"] steps: - - name: Checkout + - name: Checkout (with submodules) uses: actions/checkout@v4 with: submodules: false @@ -105,9 +49,29 @@ jobs: if: runner.os != 'Linux' run: npm install + - name: Install biome platform binary + if: runner.os != 'Linux' + run: npm install @biomejs/cli-${{ runner.os == 'macOS' && 'darwin' || 'win32' }}-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} + + - name: Drawlist codegen guardrail + run: npm run codegen:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + - name: Build run: npm run build + - name: Check core portability + run: npm run check:core-portability + + - name: Check Unicode pins (submodule sync) + if: runner.os == 'Linux' + run: npm run check:unicode + - name: Tests run: npm run test @@ -156,11 +120,10 @@ jobs: run: npm run test:native:smoke bun: - needs: [guardrails, checks] name: bun / ubuntu-latest runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout (with submodules) uses: actions/checkout@v4 with: submodules: false @@ -178,9 +141,24 @@ jobs: - name: Install run: bun install + - name: Drawlist codegen guardrail + run: bun run codegen:check + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + - name: Build run: bun run build + - name: Check core portability + run: bun run check:core-portability + + - name: Check Unicode pins (submodule sync) + run: bun run check:unicode + - name: Tests run: bun run test @@ -251,3 +229,30 @@ jobs: .artifacts/bench/ci/compare.md .artifacts/bench/ci/manifest.json .artifacts/bench/ci/profile.json + + docs: + name: docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install + run: npm ci + + - name: Build packages + run: npm run build + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build docs site + run: npm run docs:build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4ff8422b..87070c53 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,10 +12,6 @@ permissions: actions: read security-events: write -concurrency: - group: codeql-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: analyze-js: name: analyze (js/ts) From a19b132e14a32297448c69fa6fa1defc02a593a8 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:37:48 +0400 Subject: [PATCH 06/20] EPIC 6: packed style pipeline + Zireael vendor bump --- examples/gallery/src/scenes.ts | 20 +- packages/bench/src/profile-packed-style.ts | 95 ++ packages/core/package.json | 4 + .../stress/fuzz.random-trees.test.ts | 4 +- .../stress/stress.large-trees.test.ts | 18 +- packages/core/src/abi.ts | 5 +- .../__tests__/interpolate.easing.test.ts | 27 +- packages/core/src/animation/interpolate.ts | 18 +- .../__tests__/partialDrawlistEmission.test.ts | 33 +- .../core/src/app/__tests__/rawRender.test.ts | 22 +- packages/core/src/app/createApp.ts | 13 +- packages/core/src/app/rawRenderer.ts | 33 +- packages/core/src/app/widgetRenderer.ts | 39 +- .../app/widgetRenderer/cursorBreadcrumbs.ts | 4 +- packages/core/src/drawApi.ts | 2 +- .../__tests__/builder.alignment.test.ts | 26 +- .../__tests__/builder.build-into.test.ts | 18 +- ..._cursor.test.ts => builder.cursor.test.ts} | 36 +- ..._golden.test.ts => builder.golden.test.ts} | 20 +- ...phics.test.ts => builder.graphics.test.ts} | 44 +- .../drawlist/__tests__/builder.limits.test.ts | 34 +- .../drawlist/__tests__/builder.reset.test.ts | 14 +- .../__tests__/builder.round-trip.test.ts | 44 +- .../__tests__/builder.string-cache.test.ts | 5 +- .../__tests__/builder.string-intern.test.ts | 5 +- .../__tests__/builder.style-encoding.test.ts | 77 +- ...t_run.test.ts => builder.text-run.test.ts} | 10 +- ....test.ts => builder.validate-caps.test.ts} | 24 +- .../__tests__/builder_style_attrs.test.ts | 14 +- .../drawlist/__tests__/writers.gen.test.ts | 8 +- .../drawlist/{builder_v3.ts => builder.ts} | 86 +- packages/core/src/drawlist/builderBase.ts | 188 +-- packages/core/src/drawlist/builder_v1.ts | 19 - packages/core/src/drawlist/builder_v2.ts | 64 - packages/core/src/drawlist/index.ts | 8 +- packages/core/src/drawlist/types.ts | 177 +-- packages/core/src/index.ts | 24 +- .../layout/__tests__/layout.edgecases.test.ts | 2 +- .../__tests__/layout.overflow-scroll.test.ts | 4 +- .../core/src/layout/engine/layoutEngine.ts | 35 +- packages/core/src/perf/perf.ts | 19 +- packages/core/src/pipeline.ts | 35 + .../__tests__/focusIndicators.test.ts | 20 +- .../renderer/__tests__/overlay.edge.test.ts | 4 +- .../__tests__/recipeRendering.test-utils.ts | 18 +- .../renderer/__tests__/render.golden.test.ts | 8 +- .../__tests__/renderer.border.test.ts | 36 +- .../renderer/__tests__/renderer.clip.test.ts | 18 +- .../__tests__/renderer.damage.test.ts | 26 +- .../__tests__/renderer.partial.perf.test.ts | 18 +- .../__tests__/renderer.partial.test.ts | 18 +- .../__tests__/renderer.scrollbar.test.ts | 20 +- .../renderer/__tests__/renderer.text.test.ts | 4 +- .../renderer/__tests__/spinner.golden.test.ts | 4 +- .../__tests__/tableRecipeRendering.test.ts | 6 +- .../__tests__/textStyle.opacity.test.ts | 47 +- .../core/src/renderer/renderToDrawlist.ts | 13 +- .../renderer/renderToDrawlist/boxBorder.ts | 10 +- .../renderer/renderToDrawlist/renderTree.ts | 4 +- .../renderer/renderToDrawlist/simpleVNode.ts | 6 +- .../renderer/renderToDrawlist/textStyle.ts | 136 +- .../src/renderer/renderToDrawlist/types.ts | 4 +- .../renderToDrawlist/widgets/basic.ts | 6 +- .../renderToDrawlist/widgets/collections.ts | 6 +- .../renderToDrawlist/widgets/containers.ts | 32 +- .../renderToDrawlist/widgets/editors.ts | 9 +- .../renderToDrawlist/widgets/files.ts | 4 +- .../renderToDrawlist/widgets/navigation.ts | 6 +- .../renderToDrawlist/widgets/overlays.ts | 22 +- .../widgets/renderCanvasWidgets.ts | 64 +- .../widgets/renderChartWidgets.ts | 55 +- .../widgets/renderFormWidgets.ts | 6 +- .../widgets/renderIndicatorWidgets.ts | 6 +- .../widgets/renderTextWidgets.ts | 19 +- packages/core/src/renderer/shadow.ts | 11 +- packages/core/src/renderer/styles.ts | 50 +- .../commit.fastReuse.regression.test.ts | 6 +- .../runtime/__tests__/hooks.useTheme.test.ts | 4 +- packages/core/src/runtime/commit.ts | 250 +++- packages/core/src/testing/renderer.ts | 17 +- .../theme/__tests__/theme.contrast.test.ts | 22 +- .../src/theme/__tests__/theme.extend.test.ts | 47 +- .../src/theme/__tests__/theme.interop.test.ts | 12 +- .../theme/__tests__/theme.resolution.test.ts | 12 +- .../src/theme/__tests__/theme.switch.test.ts | 22 +- .../core/src/theme/__tests__/theme.test.ts | 13 +- .../theme/__tests__/theme.transition.test.ts | 6 +- packages/core/src/theme/blend.ts | 14 +- packages/core/src/theme/contrast.ts | 12 +- packages/core/src/theme/defaultTheme.ts | 51 +- packages/core/src/theme/extract.ts | 54 +- packages/core/src/theme/interop.ts | 26 +- packages/core/src/theme/resolve.ts | 18 +- packages/core/src/theme/theme.ts | 4 +- packages/core/src/theme/tokens.ts | 68 +- packages/core/src/theme/types.ts | 24 +- packages/core/src/ui/__tests__/themed.test.ts | 22 +- packages/core/src/ui/designTokens.ts | 12 +- packages/core/src/ui/recipes.ts | 12 +- .../__tests__/basicWidgets.render.test.ts | 35 +- .../__tests__/canvas.primitives.test.ts | 28 +- .../src/widgets/__tests__/collections.test.ts | 10 +- .../src/widgets/__tests__/containers.test.ts | 32 +- .../widgets/__tests__/graphics.golden.test.ts | 6 +- .../widgets/__tests__/graphicsWidgets.test.ts | 54 +- .../__tests__/inspectorOverlay.render.test.ts | 4 +- .../src/widgets/__tests__/overlays.test.ts | 18 +- .../widgets/__tests__/overlays.typecheck.ts | 28 +- .../__tests__/renderer.regressions.test.ts | 48 +- .../__tests__/style.attributes.test.ts | 6 +- .../__tests__/style.inheritance.test.ts | 20 +- .../__tests__/style.merge-fuzz.test.ts | 24 +- .../src/widgets/__tests__/style.merge.test.ts | 29 +- .../src/widgets/__tests__/style.utils.test.ts | 20 +- .../src/widgets/__tests__/styleUtils.test.ts | 10 +- .../core/src/widgets/__tests__/styled.test.ts | 6 +- .../src/widgets/__tests__/table.typecheck.ts | 8 +- .../__tests__/widgetRenderSmoke.test.ts | 4 +- packages/core/src/widgets/canvas.ts | 32 +- packages/core/src/widgets/commandPalette.ts | 11 +- packages/core/src/widgets/diffViewer.ts | 15 +- packages/core/src/widgets/field.ts | 3 +- packages/core/src/widgets/heatmap.ts | 82 +- packages/core/src/widgets/logsConsole.ts | 15 +- packages/core/src/widgets/splitPane.ts | 3 +- packages/core/src/widgets/style.ts | 70 +- packages/core/src/widgets/styleUtils.ts | 74 +- packages/core/src/widgets/toast.ts | 13 +- .../translation/propsToVNode.test.ts | 10 +- .../ink-compat/src/_bench_layout_profile.ts | 56 + .../src/runtime/createInkRenderer.ts | 628 ++++++++ packages/ink-compat/src/runtime/render.ts | 240 +-- .../ink-compat/src/translation/colorMap.ts | 30 +- .../src/translation/propsToVNode.ts | 36 +- packages/jsx/src/index.ts | 2 +- packages/native/vendor/VENDOR_COMMIT.txt | 2 +- .../vendor/zireael/include/zr/zr_drawlist.h | 115 +- .../vendor/zireael/include/zr/zr_version.h | 10 +- .../vendor/zireael/src/core/zr_config.c | 6 +- .../vendor/zireael/src/core/zr_drawlist.c | 1320 ++++++++++++----- .../vendor/zireael/src/core/zr_drawlist.h | 37 +- .../vendor/zireael/src/core/zr_engine.c | 104 +- .../zireael/src/core/zr_engine_present.inc | 51 +- .../vendor/zireael/src/core/zr_framebuffer.c | 64 + .../vendor/zireael/src/core/zr_framebuffer.h | 4 + .../native/vendor/zireael/src/core/zr_image.c | 13 + .../native/vendor/zireael/src/core/zr_image.h | 1 + .../node/src/__tests__/config_guards.test.ts | 48 +- packages/node/src/backend/nodeBackend.ts | 26 +- .../node/src/backend/nodeBackendInline.ts | 21 +- packages/node/src/index.ts | 9 +- 151 files changed, 3882 insertions(+), 2480 deletions(-) create mode 100644 packages/bench/src/profile-packed-style.ts rename packages/core/src/drawlist/__tests__/{builder_v2_cursor.test.ts => builder.cursor.test.ts} (89%) rename packages/core/src/drawlist/__tests__/{builder_v1_golden.test.ts => builder.golden.test.ts} (87%) rename packages/core/src/drawlist/__tests__/{builder_v3_graphics.test.ts => builder.graphics.test.ts} (90%) rename packages/core/src/drawlist/__tests__/{builder_v1_text_run.test.ts => builder.text-run.test.ts} (93%) rename packages/core/src/drawlist/__tests__/{builder_v1_validate_caps.test.ts => builder.validate-caps.test.ts} (81%) rename packages/core/src/drawlist/{builder_v3.ts => builder.ts} (89%) delete mode 100644 packages/core/src/drawlist/builder_v1.ts delete mode 100644 packages/core/src/drawlist/builder_v2.ts create mode 100644 packages/core/src/pipeline.ts create mode 100644 packages/ink-compat/src/_bench_layout_profile.ts create mode 100644 packages/ink-compat/src/runtime/createInkRenderer.ts diff --git a/examples/gallery/src/scenes.ts b/examples/gallery/src/scenes.ts index 0e3c0a6b..a9c5ceb3 100644 --- a/examples/gallery/src/scenes.ts +++ b/examples/gallery/src/scenes.ts @@ -415,22 +415,22 @@ export function themedOverrideShowcase(): VNode { { colors: { bg: { - base: { r: 238, g: 242, b: 247 }, - elevated: { r: 232, g: 237, b: 244 }, - subtle: { r: 220, g: 228, b: 238 }, + base: rgb(238, 242, 247), + elevated: rgb(232, 237, 244), + subtle: rgb(220, 228, 238), }, fg: { - primary: { r: 28, g: 36, b: 49 }, - secondary: { r: 63, g: 78, b: 97 }, - muted: { r: 99, g: 113, b: 131 }, - inverse: { r: 245, g: 248, b: 252 }, + primary: rgb(28, 36, 49), + secondary: rgb(63, 78, 97), + muted: rgb(99, 113, 131), + inverse: rgb(245, 248, 252), }, accent: { - primary: { r: 64, g: 120, b: 255 }, + primary: rgb(64, 120, 255), }, border: { - subtle: { r: 187, g: 198, b: 213 }, - default: { r: 157, g: 173, b: 193 }, + subtle: rgb(187, 198, 213), + default: rgb(157, 173, 193), }, }, }, diff --git a/packages/bench/src/profile-packed-style.ts b/packages/bench/src/profile-packed-style.ts new file mode 100644 index 00000000..42bd3b8e --- /dev/null +++ b/packages/bench/src/profile-packed-style.ts @@ -0,0 +1,95 @@ +/** + * Packed-style profiler: validates packed style merge counters and render timing. + * + * Usage: + * REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-packed-style.ts + */ + +import { type TextStyle, type VNode, createApp, perfReset, perfSnapshot, rgb, ui } from "@rezi-ui/core"; +import { BenchBackend } from "./backends.js"; + +const ROWS = 1200; +const WARMUP_ITERS = 20; +const MEASURE_ITERS = 80; + +function makeRowStyle(index: number, tick: number): TextStyle { + return { + ...((index & 1) === 0 ? { bold: true } : { dim: true }), + ...(index % 3 === 0 + ? { + fg: rgb( + (index * 13 + tick * 7) & 0xff, + (index * 17 + tick * 5) & 0xff, + (index * 19 + tick * 3) & 0xff, + ), + } + : {}), + ...(index % 5 === 0 + ? { + bg: rgb( + (index * 11 + tick * 2) & 0xff, + (index * 7 + tick * 13) & 0xff, + (index * 3 + tick * 29) & 0xff, + ), + } + : {}), + ...(index % 7 === 0 ? { underline: true } : {}), + ...(index % 11 === 0 ? { inverse: true } : {}), + }; +} + +function packedStyleTree(tick: number): VNode { + const rows: VNode[] = []; + for (let i = 0; i < ROWS; i++) { + rows.push(ui.text(`row-${String(i).padStart(4, "0")} tick=${tick}`, { style: makeRowStyle(i, tick) })); + } + return ui.column({ p: 0, gap: 0 }, rows); +} + +async function main() { + const backend = new BenchBackend(160, ROWS + 8); + type State = { tick: number }; + const app = createApp({ backend, initialState: { tick: 0 } }); + app.view((state) => packedStyleTree(state.tick)); + + const initialFrame = backend.waitForFrame(); + await app.start(); + await initialFrame; + + for (let i = 0; i < WARMUP_ITERS; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ tick: s.tick + 1 })); + await frameP; + } + + perfReset(); + + for (let i = 0; i < MEASURE_ITERS; i++) { + const frameP = backend.waitForFrame(); + app.update((s) => ({ tick: s.tick + 1 })); + await frameP; + } + + const snap = perfSnapshot(); + const counters = snap.counters; + const merges = counters["style_merges_performed"] ?? 0; + const styleObjects = counters["style_objects_created"] ?? 0; + const packRgbCalls = counters["packRgb_calls"] ?? 0; + const renderAvgUs = ((snap.phases.render?.avg ?? 0) * 1000).toFixed(0); + const drawlistAvgUs = ((snap.phases.drawlist_build?.avg ?? 0) * 1000).toFixed(0); + const reusePct = merges > 0 ? (((merges - styleObjects) / merges) * 100).toFixed(2) : "0.00"; + + console.log("\n=== Packed style profile ===\n"); + console.log(`iters: ${String(MEASURE_ITERS)}`); + console.log(`style_merges_performed: ${String(merges)}`); + console.log(`style_objects_created: ${String(styleObjects)}`); + console.log(`packRgb_calls: ${String(packRgbCalls)}`); + console.log(`style object reuse: ${reusePct}%`); + console.log(`render avg: ${renderAvgUs}µs`); + console.log(`drawlist_build avg: ${drawlistAvgUs}µs`); + + await app.stop(); + app.dispose(); +} + +main().catch(console.error); diff --git a/packages/core/package.json b/packages/core/package.json index 91783eac..5495691e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -99,6 +99,10 @@ "types": "./dist/ui.d.ts", "default": "./dist/ui.js" }, + "./pipeline": { + "types": "./dist/pipeline.d.ts", + "default": "./dist/pipeline.js" + }, "./widgets": { "types": "./dist/widgets/index.d.ts", "default": "./dist/widgets/index.js" diff --git a/packages/core/src/__tests__/stress/fuzz.random-trees.test.ts b/packages/core/src/__tests__/stress/fuzz.random-trees.test.ts index 5ff15b23..8ce516b1 100644 --- a/packages/core/src/__tests__/stress/fuzz.random-trees.test.ts +++ b/packages/core/src/__tests__/stress/fuzz.random-trees.test.ts @@ -1,5 +1,5 @@ import { assert, createRng, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -164,7 +164,7 @@ function runTreeFuzz(seed: number, profile: TreeProfile): void { continue; } - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: commitRes.value.root, layout: layoutRes.value, diff --git a/packages/core/src/__tests__/stress/stress.large-trees.test.ts b/packages/core/src/__tests__/stress/stress.large-trees.test.ts index 4417f5ee..3a6fcbbb 100644 --- a/packages/core/src/__tests__/stress/stress.large-trees.test.ts +++ b/packages/core/src/__tests__/stress/stress.large-trees.test.ts @@ -12,7 +12,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { WidgetRenderer } from "../../app/widgetRenderer.js"; import type { RuntimeBackend } from "../../backend.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { ZrevEvent } from "../../events.js"; import type { VNode } from "../../index.js"; @@ -53,7 +53,7 @@ function nowMs(): number { return perf ? perf.now() : Date.now(); } -class CountingBuilder implements DrawlistBuilderV1 { +class CountingBuilder implements DrawlistBuilder { private opCount = 0; private lastBuiltCount = 0; @@ -95,6 +95,20 @@ class CountingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { this.lastBuiltCount = this.opCount; return { ok: true, bytes: new Uint8Array([this.opCount & 0xff]) }; diff --git a/packages/core/src/abi.ts b/packages/core/src/abi.ts index aa336ad6..0ac2aa36 100644 --- a/packages/core/src/abi.ts +++ b/packages/core/src/abi.ts @@ -20,10 +20,7 @@ export const ZR_ENGINE_ABI_PATCH = 0; * Binary format version pins. */ export const ZR_DRAWLIST_VERSION_V1 = 1; -export const ZR_DRAWLIST_VERSION_V2 = 2; -export const ZR_DRAWLIST_VERSION_V3 = 3; -export const ZR_DRAWLIST_VERSION_V4 = 4; -export const ZR_DRAWLIST_VERSION_V5 = 5; +export const ZR_DRAWLIST_VERSION = ZR_DRAWLIST_VERSION_V1; export const ZR_EVENT_BATCH_VERSION_V1 = 1; // ============================================================================= diff --git a/packages/core/src/animation/__tests__/interpolate.easing.test.ts b/packages/core/src/animation/__tests__/interpolate.easing.test.ts index cb283530..64477f0d 100644 --- a/packages/core/src/animation/__tests__/interpolate.easing.test.ts +++ b/packages/core/src/animation/__tests__/interpolate.easing.test.ts @@ -33,32 +33,29 @@ describe("animation/interpolate", () => { }); test("interpolateRgb interpolates channel values", () => { - assert.deepEqual(interpolateRgb({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }, 0.5), { - r: 128, - g: 128, - b: 128, - }); + assert.equal( + interpolateRgb(((0 << 16) | (0 << 8) | 0), ((255 << 16) | (255 << 8) | 255), 0.5), + ((128 << 16) | (128 << 8) | 128), + ); }); test("interpolateRgb returns endpoints at t=0 and t=1", () => { - const from = { r: 3, g: 40, b: 200 }; - const to = { r: 250, g: 100, b: 0 }; + const from = ((3 << 16) | (40 << 8) | 200); + const to = ((250 << 16) | (100 << 8) | 0); assert.deepEqual(interpolateRgb(from, to, 0), from); assert.deepEqual(interpolateRgb(from, to, 1), to); }); - test("interpolateRgb clamps output channels to byte range integers", () => { - assert.deepEqual( - interpolateRgb({ r: -10, g: 400.4, b: Number.NaN }, { r: -10, g: 400.4, b: Number.NaN }, 1), - { r: 0, g: 255, b: 0 }, - ); + test("interpolateRgb rounds channel interpolation to byte integers", () => { + assert.equal(interpolateRgb(((0 << 16) | (0 << 8) | 0), ((1 << 16) | (1 << 8) | 1), 0.5), ((1 << 16) | (1 << 8) | 1)); + assert.equal(interpolateRgb(((0 << 16) | (0 << 8) | 0), ((2 << 16) | (2 << 8) | 2), 0.5), ((1 << 16) | (1 << 8) | 1)); }); test("interpolateRgbArray returns the requested number of steps", () => { - const steps = interpolateRgbArray({ r: 0, g: 0, b: 0 }, { r: 255, g: 0, b: 0 }, 4); + const steps = interpolateRgbArray(((0 << 16) | (0 << 8) | 0), ((255 << 16) | (0 << 8) | 0), 4); assert.equal(steps.length, 4); - assert.deepEqual(steps[0], { r: 0, g: 0, b: 0 }); - assert.deepEqual(steps[3], { r: 255, g: 0, b: 0 }); + assert.deepEqual(steps[0], ((0 << 16) | (0 << 8) | 0)); + assert.deepEqual(steps[3], ((255 << 16) | (0 << 8) | 0)); }); }); diff --git a/packages/core/src/animation/interpolate.ts b/packages/core/src/animation/interpolate.ts index 863c0e06..cec939b0 100644 --- a/packages/core/src/animation/interpolate.ts +++ b/packages/core/src/animation/interpolate.ts @@ -2,7 +2,7 @@ * packages/core/src/animation/interpolate.ts — Primitive interpolation helpers. */ -import type { Rgb } from "../widgets/style.js"; +import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; /** Clamp a number into [0, 1]. */ export function clamp01(value: number): number { @@ -32,20 +32,20 @@ function clampRgbChannel(channel: number): number { } /** Linear interpolation between two RGB colors. */ -export function interpolateRgb(from: Rgb, to: Rgb, t: number): Rgb { - return Object.freeze({ - r: clampRgbChannel(interpolateNumber(from.r, to.r, t)), - g: clampRgbChannel(interpolateNumber(from.g, to.g, t)), - b: clampRgbChannel(interpolateNumber(from.b, to.b, t)), - }); +export function interpolateRgb(from: Rgb24, to: Rgb24, t: number): Rgb24 { + return rgb( + clampRgbChannel(interpolateNumber(rgbR(from), rgbR(to), t)), + clampRgbChannel(interpolateNumber(rgbG(from), rgbG(to), t)), + clampRgbChannel(interpolateNumber(rgbB(from), rgbB(to), t)), + ); } /** Generate `steps` RGB samples between two colors (inclusive endpoints). */ -export function interpolateRgbArray(from: Rgb, to: Rgb, steps: number): readonly Rgb[] { +export function interpolateRgbArray(from: Rgb24, to: Rgb24, steps: number): readonly Rgb24[] { const count = Math.max(0, Math.trunc(steps)); if (count <= 0) return Object.freeze([]); if (count === 1) return Object.freeze([interpolateRgb(from, to, 0)]); - const samples: Rgb[] = new Array(count); + const samples: Rgb24[] = new Array(count); for (let i = 0; i < count; i++) { samples[i] = interpolateRgb(from, to, i / (count - 1)); } diff --git a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts index 719b29a9..7219e94c 100644 --- a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts +++ b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts @@ -2,8 +2,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { RuntimeBackend } from "../../backend.js"; import type { DrawlistBuildResult, - DrawlistBuilderV1, - DrawlistBuilderV2, + DrawlistBuilder, } from "../../drawlist/index.js"; import type { CursorState } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; @@ -25,7 +24,7 @@ type RecordedOp = | Readonly<{ kind: "setCursor"; state: CursorState }> | Readonly<{ kind: "hideCursor" }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { protected ops: RecordedOp[] = []; private lastBuiltOps: readonly RecordedOp[] = Object.freeze([]); @@ -68,24 +67,36 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(_state: Parameters[0]): void { + this.ops.push({ kind: "setCursor", state: _state }); + } + + hideCursor(): void { + this.ops.push({ kind: "hideCursor" }); + } + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + build(): DrawlistBuildResult { this.lastBuiltOps = this.ops.slice(); return { ok: true, bytes: new Uint8Array([this.ops.length & 0xff]) }; } + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + reset(): void { this.ops = []; } } -class RecordingBuilderV2 extends RecordingBuilder implements DrawlistBuilderV2 { - setCursor(state: CursorState): void { - this.ops.push({ kind: "setCursor", state }); - } - - hideCursor(): void { - this.ops.push({ kind: "hideCursor" }); - } +class RecordingBuilderV2 extends RecordingBuilder implements DrawlistBuilder { + // Kept for historical naming in test cases. } type Framebuffer = Readonly<{ diff --git a/packages/core/src/app/__tests__/rawRender.test.ts b/packages/core/src/app/__tests__/rawRender.test.ts index e0d30d1e..1e36dc65 100644 --- a/packages/core/src/app/__tests__/rawRender.test.ts +++ b/packages/core/src/app/__tests__/rawRender.test.ts @@ -1,10 +1,10 @@ import { assert, test } from "@rezi-ui/testkit"; import type { RuntimeBackend } from "../../backend.js"; -import type { DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuilder } from "../../drawlist/index.js"; import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; import { RawRenderer } from "../rawRenderer.js"; -function makeStubBuilder(bytes: Uint8Array): DrawlistBuilderV1 { +function makeStubBuilder(bytes: Uint8Array): DrawlistBuilder { let built = false; return { clear(): void {}, @@ -20,6 +20,11 @@ function makeStubBuilder(bytes: Uint8Array): DrawlistBuilderV1 { return null; }, drawTextRun(): void {}, + setCursor(): void {}, + hideCursor(): void {}, + setLink(): void {}, + drawCanvas(): void {}, + drawImage(): void {}, reset(): void { built = false; }, @@ -29,6 +34,9 @@ function makeStubBuilder(bytes: Uint8Array): DrawlistBuilderV1 { built = true; return { ok: true, bytes } as const; }, + buildInto() { + return { ok: true, bytes } as const; + }, }; } @@ -83,7 +91,7 @@ test("drawlist build failure maps to ZRUI_DRAWLIST_BUILD_ERROR (#61)", () => { getCaps: () => Promise.resolve(DEFAULT_TERMINAL_CAPS), }; - const badBuilder: DrawlistBuilderV1 = { + const badBuilder: DrawlistBuilder = { clear(): void {}, clearTo(): void {}, fillRect(): void {}, @@ -97,10 +105,18 @@ test("drawlist build failure maps to ZRUI_DRAWLIST_BUILD_ERROR (#61)", () => { return null; }, drawTextRun(): void {}, + setCursor(): void {}, + hideCursor(): void {}, + setLink(): void {}, + drawCanvas(): void {}, + drawImage(): void {}, reset(): void {}, build() { return { ok: false, error: { code: "ZRDL_TOO_LARGE", detail: "cap" } } as const; }, + buildInto() { + return { ok: false, error: { code: "ZRDL_TOO_LARGE", detail: "cap" } } as const; + }, }; const renderer = new RawRenderer({ backend, builder: badBuilder }); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index d8ef395f..d80ec1b6 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -596,20 +596,13 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl const config = resolveAppConfig(opts.config); const backendDrawlistVersion = readBackendDrawlistVersionMarker(backend); - if (backendDrawlistVersion !== null && backendDrawlistVersion < 2) { + if (backendDrawlistVersion !== null && backendDrawlistVersion !== 1) { invalidProps( `backend drawlistVersion=${String( backendDrawlistVersion, - )} is no longer supported. Fix: set backend drawlist version >= 2.`, + )} is invalid. Fix: set backend drawlist version marker to 1.`, ); } - const drawlistVersion: 2 | 3 | 4 | 5 = - backendDrawlistVersion === 2 || - backendDrawlistVersion === 3 || - backendDrawlistVersion === 4 || - backendDrawlistVersion === 5 - ? backendDrawlistVersion - : 5; const backendMaxEventBytes = readBackendPositiveIntMarker( backend, @@ -799,7 +792,6 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl const rawRenderer = new RawRenderer({ backend, - drawlistVersion, maxDrawlistBytes: config.maxDrawlistBytes, ...(opts.config?.drawlistValidateParams === undefined ? {} @@ -809,7 +801,6 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl }); const widgetRenderer = new WidgetRenderer({ backend, - drawlistVersion, maxDrawlistBytes: config.maxDrawlistBytes, rootPadding: config.rootPadding, breakpointThresholds: config.breakpointThresholds, diff --git a/packages/core/src/app/rawRenderer.ts b/packages/core/src/app/rawRenderer.ts index 9017c9d5..7bb448c4 100644 --- a/packages/core/src/app/rawRenderer.ts +++ b/packages/core/src/app/rawRenderer.ts @@ -11,11 +11,7 @@ */ import type { RuntimeBackend } from "../backend.js"; -import { - type DrawlistBuilderV1, - createDrawlistBuilderV2, - createDrawlistBuilderV3, -} from "../drawlist/index.js"; +import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; import { perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import type { DrawFn } from "./types.js"; @@ -51,13 +47,12 @@ function describeThrown(v: unknown): string { */ export class RawRenderer { private readonly backend: RuntimeBackend; - private readonly builder: DrawlistBuilderV1; + private readonly builder: DrawlistBuilder; constructor( opts: Readonly<{ backend: RuntimeBackend; - builder?: DrawlistBuilderV1; - drawlistVersion?: 2 | 3 | 4 | 5; + builder?: DrawlistBuilder; maxDrawlistBytes?: number; drawlistValidateParams?: boolean; drawlistReuseOutputBuffer?: boolean; @@ -81,27 +76,7 @@ export class RawRenderer { this.builder = opts.builder; return; } - const drawlistVersion = opts.drawlistVersion ?? 2; - if ( - drawlistVersion !== 2 && - drawlistVersion !== 3 && - drawlistVersion !== 4 && - drawlistVersion !== 5 - ) { - throw new Error( - `drawlistVersion ${String( - drawlistVersion, - )} is no longer supported; use drawlistVersion 2, 3, 4, or 5.`, - ); - } - if (drawlistVersion >= 3) { - this.builder = createDrawlistBuilderV3({ - ...builderOpts, - drawlistVersion: drawlistVersion === 3 ? 3 : drawlistVersion === 4 ? 4 : 5, - }); - return; - } - this.builder = createDrawlistBuilderV2(builderOpts); + this.builder = createDrawlistBuilder(builderOpts); } /** diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 8311671d..0629e442 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -24,13 +24,7 @@ import type { CursorShape } from "../abi.js"; import { BACKEND_RAW_WRITE_MARKER, type BackendRawWrite, type RuntimeBackend } from "../backend.js"; import { CURSOR_DEFAULTS } from "../cursor/index.js"; -import { - type DrawlistBuilderV1, - type DrawlistBuilderV2, - type DrawlistBuilderV3, - createDrawlistBuilderV2, - createDrawlistBuilderV3, -} from "../drawlist/index.js"; +import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; import type { ZrevEvent } from "../events.js"; import { buildTrie, @@ -544,8 +538,8 @@ function monotonicNowMs(): number { return Date.now(); } -function isV2Builder(builder: DrawlistBuilderV1 | DrawlistBuilderV2): builder is DrawlistBuilderV2 { - return typeof (builder as DrawlistBuilderV2).setCursor === "function"; +function isV2Builder(builder: DrawlistBuilder | DrawlistBuilder): builder is DrawlistBuilder { + return typeof (builder as DrawlistBuilder).setCursor === "function"; } function cloneFocusManagerState(state: FocusManagerState): FocusManagerState { @@ -576,7 +570,7 @@ type ErrorBoundaryState = Readonly<{ */ export class WidgetRenderer { private readonly backend: RuntimeBackend; - private readonly builder: DrawlistBuilderV1 | DrawlistBuilderV2 | DrawlistBuilderV3; + private readonly builder: DrawlistBuilder | DrawlistBuilder | DrawlistBuilder; private readonly cursorShape: CursorShape; private readonly cursorBlink: boolean; private collectRuntimeBreadcrumbs: boolean; @@ -836,8 +830,7 @@ export class WidgetRenderer { constructor( opts: Readonly<{ backend: RuntimeBackend; - builder?: DrawlistBuilderV1 | DrawlistBuilderV2 | DrawlistBuilderV3; - drawlistVersion?: 2 | 3 | 4 | 5; + builder?: DrawlistBuilder; maxDrawlistBytes?: number; drawlistValidateParams?: boolean; drawlistReuseOutputBuffer?: boolean; @@ -893,27 +886,7 @@ export class WidgetRenderer { this.builder = opts.builder; return; } - const drawlistVersion = opts.drawlistVersion ?? 2; - if ( - drawlistVersion !== 2 && - drawlistVersion !== 3 && - drawlistVersion !== 4 && - drawlistVersion !== 5 - ) { - throw new Error( - `drawlistVersion ${String( - drawlistVersion, - )} is no longer supported; use drawlistVersion 2, 3, 4, or 5.`, - ); - } - if (drawlistVersion >= 3) { - this.builder = createDrawlistBuilderV3({ - ...builderOpts, - drawlistVersion: drawlistVersion === 3 ? 3 : drawlistVersion === 4 ? 4 : 5, - }); - return; - } - this.builder = createDrawlistBuilderV2(builderOpts); + this.builder = createDrawlistBuilder(builderOpts); } hasAnimatedWidgets(): boolean { diff --git a/packages/core/src/app/widgetRenderer/cursorBreadcrumbs.ts b/packages/core/src/app/widgetRenderer/cursorBreadcrumbs.ts index f5d4d5a5..cf5dc055 100644 --- a/packages/core/src/app/widgetRenderer/cursorBreadcrumbs.ts +++ b/packages/core/src/app/widgetRenderer/cursorBreadcrumbs.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV2 } from "../../drawlist/index.js"; +import type { DrawlistBuilder } from "../../drawlist/index.js"; import { measureTextCells } from "../../layout/textMeasure.js"; import type { Rect } from "../../layout/types.js"; import type { CursorInfo } from "../../renderer/renderToDrawlist.js"; @@ -62,7 +62,7 @@ type SnapshotRenderedFrameStateParams = Readonly<{ const UTF8_LINE_FEED = 0x0a; -type CursorBuilderLike = Pick; +type CursorBuilderLike = Pick; function isCursorBuilder(builder: unknown): builder is CursorBuilderLike { return ( diff --git a/packages/core/src/drawApi.ts b/packages/core/src/drawApi.ts index 72912225..e3f15ec7 100644 --- a/packages/core/src/drawApi.ts +++ b/packages/core/src/drawApi.ts @@ -13,7 +13,7 @@ import type { TextStyle } from "./widgets/style.js"; * Low-level draw API for raw mode rendering. * * This interface is used via the `app.draw(g => ...)` escape hatch - * and corresponds to the DrawlistBuilderV1 operations. + * and corresponds to the DrawlistBuilder operations. * * All coordinates are in cell units (column, row). * All methods validate inputs and fail deterministically on invalid parameters. diff --git a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts index beb9f073..3ca42bd5 100644 --- a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1 } from "../builder_v1.js"; +import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildResult } from "../types.js"; const HEADER = { @@ -100,9 +100,9 @@ function commandStarts(bytes: Uint8Array, h: ParsedHeader): readonly number[] { return starts; } -describe("DrawlistBuilderV1 - alignment and padding", () => { +describe("DrawlistBuilder - alignment and padding", () => { test("empty drawlist has aligned total size and zero section offsets", () => { - const bytes = expectOk(createDrawlistBuilderV1().build()); + const bytes = expectOk(createDrawlistBuilder().build()); const h = parseHeader(bytes); assert.equal(h.totalSize, HEADER.SIZE); @@ -117,7 +117,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("near-empty clear drawlist keeps command start and section layout aligned", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.clear(); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -131,7 +131,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("all command starts are 4-byte aligned in a mixed stream", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.clear(); b.fillRect(0, 0, 3, 2); b.drawText(1, 1, "abc"); @@ -148,7 +148,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("walking command sizes lands exactly on cmdOffset + cmdBytes", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); const blobIndex = b.addBlob(new Uint8Array([1, 2, 3, 4])); assert.equal(blobIndex, 0); b.clear(); @@ -163,7 +163,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("section offsets are aligned and ordered when strings and blobs exist", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "abc"); const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); assert.equal(blobIndex, 0); @@ -184,7 +184,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("odd-length text: 1-byte string gets 3 zero padding bytes", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -201,7 +201,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("odd-length text: 2-byte string gets 2 zero padding bytes", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "ab"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -218,7 +218,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("odd-length text: 3-byte string gets 1 zero padding byte", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -235,7 +235,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("empty string still has aligned string section with zero raw bytes", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, ""); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -252,7 +252,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("multiple odd-length strings keep contiguous raw spans and aligned tail padding", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); b.drawText(0, 1, "bb"); b.drawText(0, 2, "ccc"); @@ -276,7 +276,7 @@ describe("DrawlistBuilderV1 - alignment and padding", () => { }); test("reuseOutputBuffer keeps odd-string padding zeroed across reset/build cycles", () => { - const b = createDrawlistBuilderV1({ reuseOutputBuffer: true }); + const b = createDrawlistBuilder({ reuseOutputBuffer: true }); b.drawText(0, 0, "abcd"); expectOk(b.build()); diff --git a/packages/core/src/drawlist/__tests__/builder.build-into.test.ts b/packages/core/src/drawlist/__tests__/builder.build-into.test.ts index 0152b79f..08fb38f3 100644 --- a/packages/core/src/drawlist/__tests__/builder.build-into.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.build-into.test.ts @@ -1,12 +1,12 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV2, createDrawlistBuilderV3 } from "../../index.js"; +import { createDrawlistBuilder, rgb } from "../../index.js"; describe("DrawlistBuilder buildInto", () => { - test("v2 buildInto(dst) matches build() bytes exactly", () => { - const builder = createDrawlistBuilderV2(); + test("buildInto(dst) matches build() bytes exactly", () => { + const builder = createDrawlistBuilder(); builder.clear(); - builder.fillRect(0, 0, 8, 4, { bg: "#001122" }); - builder.drawText(2, 1, "v2-build-into", { fg: "#aabbcc", bold: true }); + builder.fillRect(0, 0, 8, 4, { bg: rgb(0, 17, 34) }); + builder.drawText(2, 1, "build-into", { fg: rgb(170, 187, 204), bold: true }); builder.setCursor({ x: 3, y: 2, shape: 1, visible: true, blink: false }); const built = builder.build(); @@ -23,9 +23,9 @@ describe("DrawlistBuilder buildInto", () => { assert.deepEqual(Array.from(builtInto.bytes), Array.from(built.bytes)); }); - test("v3 (drawlist v5) buildInto(dst) matches build() for text, text-run, and graphics", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); - builder.drawText(1, 2, "hello-v3", { underlineStyle: "dashed", underlineColor: "#ff0000" }); + test("buildInto(dst) matches build() for text, text-run, and graphics", () => { + const builder = createDrawlistBuilder(); + builder.drawText(1, 2, "hello", { underlineStyle: "dashed", underlineColor: rgb(255, 0, 0) }); const runBlob = builder.addTextRunBlob([ { text: "run-a", style: { bold: true } }, @@ -55,7 +55,7 @@ describe("DrawlistBuilder buildInto", () => { }); test("buildInto(dst) fails when dst is one byte too small", () => { - const builder = createDrawlistBuilderV2(); + const builder = createDrawlistBuilder(); builder.drawText(0, 0, "small-fail"); builder.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); diff --git a/packages/core/src/drawlist/__tests__/builder_v2_cursor.test.ts b/packages/core/src/drawlist/__tests__/builder.cursor.test.ts similarity index 89% rename from packages/core/src/drawlist/__tests__/builder_v2_cursor.test.ts rename to packages/core/src/drawlist/__tests__/builder.cursor.test.ts index 73852c43..5120598c 100644 --- a/packages/core/src/drawlist/__tests__/builder_v2_cursor.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.cursor.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for DrawlistBuilderV2 SET_CURSOR command encoding. + * Unit tests for DrawlistBuilder SET_CURSOR command encoding. * * Verifies: * - Correct v2 header version @@ -10,8 +10,8 @@ */ import { assert, describe, test } from "@rezi-ui/testkit"; -import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V2 } from "../../abi.js"; -import { createDrawlistBuilderV2 } from "../builder_v2.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1 } from "../../abi.js"; +import { createDrawlistBuilder } from "../builder.js"; function u8(bytes: Uint8Array, off: number): number { return bytes[off] ?? 0; @@ -35,23 +35,23 @@ function i32(bytes: Uint8Array, off: number): number { const HEADER_SIZE = 64; const OP_SET_CURSOR = 7; -describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { - test("basic cursor set produces v2 header", () => { - const b = createDrawlistBuilderV2(); +describe("DrawlistBuilder - SET_CURSOR encoding", () => { + test("basic cursor set produces current header", () => { + const b = createDrawlistBuilder(); b.setCursor({ x: 10, y: 5, shape: 0, visible: true, blink: true }); const res = b.build(); assert.equal(res.ok, true); if (!res.ok) return; - // Verify v2 header + // Verify current header assert.equal(u32(res.bytes, 0), ZRDL_MAGIC, "magic"); - assert.equal(u32(res.bytes, 4), ZR_DRAWLIST_VERSION_V2, "version"); + assert.equal(u32(res.bytes, 4), ZR_DRAWLIST_VERSION_V1, "version"); assert.equal(u32(res.bytes, 8), HEADER_SIZE, "header_size"); }); test("SET_CURSOR command byte layout matches C struct", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 42, y: 17, shape: 2, visible: true, blink: false }); const res = b.build(); @@ -78,7 +78,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { test("cursor shape values: block=0, underline=1, bar=2", () => { for (const shape of [0, 1, 2] as const) { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 0, y: 0, shape, visible: true, blink: false }); const res = b.build(); @@ -91,7 +91,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("x=-1 and y=-1 for 'leave unchanged' semantics", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: -1, y: -1, shape: 0, visible: true, blink: true }); const res = b.build(); @@ -104,7 +104,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("hideCursor emits SET_CURSOR with visible=0", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.hideCursor(); const res = b.build(); @@ -126,7 +126,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { ]; for (const tc of testCases) { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor(tc); const res = b.build(); @@ -139,7 +139,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("SET_CURSOR is 4-byte aligned (total cmd size 20 -> already aligned)", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 1, y: 2, shape: 0, visible: true, blink: true }); const res = b.build(); @@ -151,7 +151,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("multiple SET_CURSOR commands", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); b.setCursor({ x: 10, y: 5, shape: 2, visible: true, blink: false }); const res = b.build(); @@ -178,7 +178,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("SET_CURSOR mixed with other commands", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.clear(); b.setCursor({ x: 5, y: 3, shape: 1, visible: true, blink: true }); b.fillRect(0, 0, 10, 10); @@ -207,7 +207,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("invalid shape value fails with validation enabled", () => { - const b = createDrawlistBuilderV2({ validateParams: true }); + const b = createDrawlistBuilder({ validateParams: true }); // @ts-expect-error - Testing invalid shape b.setCursor({ x: 0, y: 0, shape: 5, visible: true, blink: true }); const res = b.build(); @@ -218,7 +218,7 @@ describe("DrawlistBuilderV2 - SET_CURSOR encoding", () => { }); test("reset clears state for reuse", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 10, y: 20, shape: 0, visible: true, blink: true }); b.reset(); b.setCursor({ x: 1, y: 2, shape: 1, visible: false, blink: false }); diff --git a/packages/core/src/drawlist/__tests__/builder_v1_golden.test.ts b/packages/core/src/drawlist/__tests__/builder.golden.test.ts similarity index 87% rename from packages/core/src/drawlist/__tests__/builder_v1_golden.test.ts rename to packages/core/src/drawlist/__tests__/builder.golden.test.ts index 967263f4..52a7e855 100644 --- a/packages/core/src/drawlist/__tests__/builder_v1_golden.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.golden.test.ts @@ -1,5 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; -import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilderV1 } from "../../index.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; async function load(rel: string): Promise { return readFixture(`zrdl-v1/golden/${rel}`); @@ -49,11 +49,11 @@ function assertHeader( assert.equal(u32(bytes, 60), 0); } -describe("DrawlistBuilderV1 (ZRDL v1) - golden byte fixtures", () => { +describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { test("clear_only.bin", async () => { const expected = await load("clear_only.bin"); - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.clear(); const res = b.build(); assert.equal(res.ok, true); @@ -79,8 +79,8 @@ describe("DrawlistBuilderV1 (ZRDL v1) - golden byte fixtures", () => { test("fill_rect.bin", async () => { const expected = await load("fill_rect.bin"); - const b = createDrawlistBuilderV1(); - b.fillRect(1, 2, 3, 4, { fg: { r: 0, g: 255, b: 0 }, bold: true, underline: true }); + const b = createDrawlistBuilder(); + b.fillRect(1, 2, 3, 4, { fg: ((0 << 16) | (255 << 8) | 0), bold: true, underline: true }); const res = b.build(); assert.equal(res.ok, true); if (!res.ok) return; @@ -105,10 +105,10 @@ describe("DrawlistBuilderV1 (ZRDL v1) - golden byte fixtures", () => { test("draw_text_interned.bin", async () => { const expected = await load("draw_text_interned.bin"); - const b = createDrawlistBuilderV1(); - b.drawText(0, 0, "hello", { fg: { r: 255, g: 255, b: 255 } }); + const b = createDrawlistBuilder(); + b.drawText(0, 0, "hello", { fg: ((255 << 16) | (255 << 8) | 255) }); b.drawText(0, 1, "hello"); - b.drawText(0, 2, "world", { bg: { r: 0, g: 0, b: 255 }, inverse: true }); + b.drawText(0, 2, "world", { bg: ((0 << 16) | (0 << 8) | 255), inverse: true }); const res = b.build(); assert.equal(res.ok, true); if (!res.ok) return; @@ -133,10 +133,10 @@ describe("DrawlistBuilderV1 (ZRDL v1) - golden byte fixtures", () => { test("clip_nested.bin", async () => { const expected = await load("clip_nested.bin"); - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.pushClip(0, 0, 10, 10); b.pushClip(1, 1, 8, 8); - b.fillRect(2, 2, 3, 4, { bg: { r: 255, g: 0, b: 0 }, inverse: true }); + b.fillRect(2, 2, 3, 4, { bg: ((255 << 16) | (0 << 8) | 0), inverse: true }); b.popClip(); b.popClip(); const res = b.build(); diff --git a/packages/core/src/drawlist/__tests__/builder_v3_graphics.test.ts b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts similarity index 90% rename from packages/core/src/drawlist/__tests__/builder_v3_graphics.test.ts rename to packages/core/src/drawlist/__tests__/builder.graphics.test.ts index 26ab586e..861074ac 100644 --- a/packages/core/src/drawlist/__tests__/builder_v3_graphics.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { ZRDL_MAGIC, createDrawlistBuilderV3 } from "../../index.js"; +import { ZRDL_MAGIC, createDrawlistBuilder } from "../../index.js"; const OP_DRAW_TEXT = 3; const OP_DRAW_TEXT_RUN = 6; @@ -102,16 +102,16 @@ function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string } function assertBadParams( - result: ReturnType["build"]>, + result: ReturnType["build"]>, ): void { assert.equal(result.ok, false); if (result.ok) return; assert.equal(result.error.code, "ZRDL_BAD_PARAMS"); } -describe("DrawlistBuilderV3 graphics/link commands", () => { +describe("DrawlistBuilder graphics/link commands", () => { test("encodes v5 header with DRAW_CANVAS and DRAW_IMAGE (links via style ext)", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); builder.setLink("https://example.com", "docs"); builder.drawText(0, 0, "Docs"); @@ -138,7 +138,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("setLink state is encoded into drawText style ext references", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); builder.setLink("https://example.com", "docs"); builder.drawText(1, 2, "Docs"); const built = builder.build(); @@ -163,7 +163,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("setLink(null) clears hyperlink refs for subsequent text", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); builder.setLink("https://example.com", "docs"); builder.drawText(0, 0, "A"); builder.setLink(null); @@ -184,7 +184,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("encodes DRAW_CANVAS payload fields and blob offset/length", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 4 }); + const builder = createDrawlistBuilder(); const blob0 = builder.addBlob(new Uint8Array([1, 2, 3, 4])); const blob1 = builder.addBlob(new Uint8Array(6 * 6 * 4)); assert.equal(blob0, 0); @@ -221,7 +221,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("encodes DRAW_IMAGE payload fields", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(2 * 2 * 4)); assert.equal(blobIndex, 0); if (blobIndex === null) throw new Error("blob index was null"); @@ -257,21 +257,21 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { test("rejects invalid params for setLink, drawCanvas, drawImage, and addBlob", () => { { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); // @ts-expect-error runtime invalid param coverage builder.setLink(123, "id"); assertBadParams(builder.build()); } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); // @ts-expect-error runtime invalid param coverage builder.setLink(null, 123); assertBadParams(builder.build()); } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(8)); if (blobIndex === null) throw new Error("blob index was null"); builder.drawCanvas(0, 0, 1, 1, blobIndex, "braille"); @@ -279,7 +279,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 4 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(8)); if (blobIndex === null) throw new Error("blob index was null"); builder.drawImage(0, 0, 1, 1, blobIndex, "rgba", "auto", 0, "contain", 0); @@ -287,7 +287,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 4 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array([1, 2, 3, 4])); if (blobIndex === null) throw new Error("blob index was null"); // @ts-expect-error runtime invalid param coverage @@ -298,7 +298,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { { const invalidZLayers: readonly unknown[] = [2, -2, Number.NaN, "0"]; for (const invalidZLayer of invalidZLayers) { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(4)); if (blobIndex === null) throw new Error("blob index was null"); builder.drawImage( @@ -320,7 +320,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(4)); if (blobIndex === null) throw new Error("blob index was null"); builder.drawImage(0, 0, 1, 1, blobIndex, "rgba", "blitter", 0, "contain", 0, 1, 1); @@ -328,7 +328,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(4)); if (blobIndex === null) throw new Error("blob index was null"); // @ts-expect-error runtime invalid param coverage @@ -337,7 +337,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array(4)); if (blobIndex === null) throw new Error("blob index was null"); // @ts-expect-error runtime invalid param coverage @@ -346,14 +346,14 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { } { - const builder = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const builder = createDrawlistBuilder(); assert.equal(builder.addBlob(new Uint8Array([1, 2, 3])), null); assertBadParams(builder.build()); } }); test("encodes underline style + underline RGB in drawText v3 style fields", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); builder.drawText(0, 0, "x", { underline: true, underlineStyle: "curly", @@ -371,7 +371,7 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("non-hex underlineColor token string is treated as unset", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); builder.drawText(0, 0, "x", { underline: true, underlineStyle: "curly", @@ -387,13 +387,13 @@ describe("DrawlistBuilderV3 graphics/link commands", () => { }); test("encodes underline style/color in DRAW_TEXT_RUN segment v3 style", () => { - const builder = createDrawlistBuilderV3({ drawlistVersion: 3 }); + const builder = createDrawlistBuilder(); const blobIndex = builder.addTextRunBlob([ { text: "x", style: { underlineStyle: "dashed", - underlineColor: { r: 1, g: 2, b: 3 }, + underlineColor: ((1 << 16) | (2 << 8) | 3), }, }, ]); diff --git a/packages/core/src/drawlist/__tests__/builder.limits.test.ts b/packages/core/src/drawlist/__tests__/builder.limits.test.ts index b8b4ddc8..81aed3de 100644 --- a/packages/core/src/drawlist/__tests__/builder.limits.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.limits.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1 } from "../builder_v1.js"; +import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildErrorCode, DrawlistBuildResult } from "../types.js"; const HEADER = { @@ -75,9 +75,9 @@ function expectError(result: DrawlistBuildResult, code: DrawlistBuildErrorCode): assert.equal(result.error.code, code); } -describe("DrawlistBuilderV1 - limits boundaries", () => { +describe("DrawlistBuilder - limits boundaries", () => { test("maxCmdCount: exactly at limit succeeds", () => { - const b = createDrawlistBuilderV1({ maxCmdCount: 2 }); + const b = createDrawlistBuilder({ maxCmdCount: 2 }); b.clear(); b.clear(); const bytes = expectOk(b.build()); @@ -88,7 +88,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxCmdCount: overflow fails", () => { - const b = createDrawlistBuilderV1({ maxCmdCount: 2 }); + const b = createDrawlistBuilder({ maxCmdCount: 2 }); b.clear(); b.clear(); b.clear(); @@ -97,7 +97,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxStrings: exactly at limit with unique strings succeeds", () => { - const b = createDrawlistBuilderV1({ maxStrings: 2 }); + const b = createDrawlistBuilder({ maxStrings: 2 }); b.drawText(0, 0, "a"); b.drawText(0, 1, "b"); const bytes = expectOk(b.build()); @@ -108,7 +108,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxStrings: interned duplicates do not consume extra slots", () => { - const b = createDrawlistBuilderV1({ maxStrings: 1 }); + const b = createDrawlistBuilder({ maxStrings: 1 }); b.drawText(0, 0, "same"); b.drawText(2, 0, "same"); const bytes = expectOk(b.build()); @@ -119,7 +119,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxStrings: overflow on next unique string fails", () => { - const b = createDrawlistBuilderV1({ maxStrings: 1 }); + const b = createDrawlistBuilder({ maxStrings: 1 }); b.drawText(0, 0, "a"); b.drawText(0, 1, "b"); @@ -127,7 +127,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxStringBytes: exactly-at-limit ASCII payload succeeds", () => { - const b = createDrawlistBuilderV1({ maxStringBytes: 3 }); + const b = createDrawlistBuilder({ maxStringBytes: 3 }); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -142,7 +142,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { test("maxStringBytes: exactly-at-limit UTF-8 payload succeeds", () => { const text = "éa"; const utf8Len = new TextEncoder().encode(text).byteLength; - const b = createDrawlistBuilderV1({ maxStringBytes: utf8Len }); + const b = createDrawlistBuilder({ maxStringBytes: utf8Len }); b.drawText(0, 0, text); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -155,7 +155,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxStringBytes: overflow fails", () => { - const b = createDrawlistBuilderV1({ maxStringBytes: 3 }); + const b = createDrawlistBuilder({ maxStringBytes: 3 }); b.drawText(0, 0, "abcd"); expectError(b.build(), "ZRDL_TOO_LARGE"); @@ -163,7 +163,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { test("maxDrawlistBytes: exactly-at-limit clear-only payload succeeds", () => { const exactLimit = HEADER.SIZE + CMD_SIZE_CLEAR; - const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit }); + const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit }); b.clear(); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -173,7 +173,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { }); test("maxDrawlistBytes: one byte below minimum clear payload fails", () => { - const b = createDrawlistBuilderV1({ maxDrawlistBytes: HEADER.SIZE + CMD_SIZE_CLEAR - 1 }); + const b = createDrawlistBuilder({ maxDrawlistBytes: HEADER.SIZE + CMD_SIZE_CLEAR - 1 }); b.clear(); expectError(b.build(), "ZRDL_TOO_LARGE"); @@ -182,7 +182,7 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { test("maxDrawlistBytes: exact text drawlist boundary succeeds", () => { const textBytes = 3; const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); - const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit }); + const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit }); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); @@ -195,14 +195,14 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { test("maxDrawlistBytes: one byte below text drawlist boundary fails", () => { const textBytes = 3; const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); - const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit - 1 }); + const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit - 1 }); b.drawText(0, 0, "abc"); expectError(b.build(), "ZRDL_TOO_LARGE"); }); test("zero limit values are rejected for each configured cap", () => { - const cases: readonly Readonly<{ opts: Parameters[0] }>[] = [ + const cases: readonly Readonly<{ opts: Parameters[0] }>[] = [ { opts: { maxDrawlistBytes: 0 } }, { opts: { maxCmdCount: 0 } }, { opts: { maxStringBytes: 0 } }, @@ -210,13 +210,13 @@ describe("DrawlistBuilderV1 - limits boundaries", () => { ]; for (const { opts } of cases) { - const b = createDrawlistBuilderV1(opts); + const b = createDrawlistBuilder(opts); expectError(b.build(), "ZRDL_BAD_PARAMS"); } }); test("large-limit smoke: realistic batch stays within limits", () => { - const b = createDrawlistBuilderV1({ + const b = createDrawlistBuilder({ maxDrawlistBytes: 1_000_000, maxCmdCount: 10_000, maxStringBytes: 100_000, diff --git a/packages/core/src/drawlist/__tests__/builder.reset.test.ts b/packages/core/src/drawlist/__tests__/builder.reset.test.ts index 2b53c3ab..d9ed10aa 100644 --- a/packages/core/src/drawlist/__tests__/builder.reset.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.reset.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; const INT32_MAX = 2147483647; @@ -101,7 +101,7 @@ function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string describe("DrawlistBuilder reset behavior", () => { test("v1 reset clears prior commands/strings/blobs for next frame", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([{ text: "A" }, { text: "B" }]); assert.equal(blobIndex, 0); if (blobIndex === null) return; @@ -132,7 +132,7 @@ describe("DrawlistBuilder reset behavior", () => { }); test("v2 reset drops cursor and string state before next frame", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 12, y: 4, shape: 1, visible: true, blink: false }); b.drawText(0, 0, "persist"); const first = b.build(); @@ -161,7 +161,7 @@ describe("DrawlistBuilder reset behavior", () => { }); test("v1 reset clears sticky failure state and restores successful builds", () => { - const b = createDrawlistBuilderV1({ maxStrings: 1 }); + const b = createDrawlistBuilder({ maxStrings: 1 }); b.drawText(0, 0, "a"); b.drawText(0, 1, "b"); const failed = b.build(); @@ -178,7 +178,7 @@ describe("DrawlistBuilder reset behavior", () => { }); test("v2 reset clears sticky failure state and allows cursor commands again", () => { - const b = createDrawlistBuilderV2({ maxCmdCount: 1 }); + const b = createDrawlistBuilder({ maxCmdCount: 1 }); b.setCursor({ x: 1, y: 1, shape: 0, visible: true, blink: true }); b.setCursor({ x: 2, y: 2, shape: 1, visible: true, blink: false }); const failed = b.build(); @@ -200,7 +200,7 @@ describe("DrawlistBuilder reset behavior", () => { }); test("v1 reset reuse remains stable across many frames", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); for (let frame = 0; frame < 128; frame++) { const text = `f${frame}`; @@ -238,7 +238,7 @@ describe("DrawlistBuilder reset behavior", () => { }); test("v2 reset reuse across many frames keeps cursor correctness stable", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); for (let frame = 0; frame < 128; frame++) { const origin = frame % 2 === 0; diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts index 4e2e1535..109e1fae 100644 --- a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -2,9 +2,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, - ZR_DRAWLIST_VERSION_V2, - createDrawlistBuilderV1, - createDrawlistBuilderV2, + createDrawlistBuilder, } from "../../index.js"; const HEADER_SIZE = 64; @@ -210,11 +208,11 @@ function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { describe("DrawlistBuilder round-trip binary readback", () => { test("v1 header magic/version/counts/offsets/byte sizes are exact for mixed commands", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.clear(); b.fillRect(1, 2, 3, 4, { - fg: { r: 0x11, g: 0x22, b: 0x33 }, - bg: { r: 0x44, g: 0x55, b: 0x66 }, + fg: ((0x11 << 16) | (0x22 << 8) | 0x33), + bg: ((0x44 << 16) | (0x55 << 8) | 0x66), bold: true, italic: true, }); @@ -246,10 +244,10 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v1 fillRect command readback preserves geometry and packed style", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.fillRect(-3, 9, 11, 13, { - fg: { r: 1, g: 2, b: 3 }, - bg: { r: 4, g: 5, b: 6 }, + fg: ((1 << 16) | (2 << 8) | 3), + bg: ((4 << 16) | (5 << 8) | 6), bold: true, underline: true, dim: true, @@ -280,10 +278,10 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v1 drawText command readback resolves string span and style fields", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(7, 9, "hello", { - fg: { r: 255, g: 128, b: 1 }, - bg: { r: 2, g: 3, b: 4 }, + fg: ((255 << 16) | (128 << 8) | 1), + bg: ((2 << 16) | (3 << 8) | 4), italic: true, inverse: true, }); @@ -323,7 +321,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v1 clip push/pop commands round-trip with exact payload sizes", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.pushClip(2, 3, 4, 5); b.popClip(); @@ -349,7 +347,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v1 repeated text uses interned string indices deterministically", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(0, 0, "same"); b.drawText(0, 1, "same"); b.drawText(0, 2, "other"); @@ -385,7 +383,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v2 header uses version 2 and correct cmd byte/count totals", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.clear(); b.setCursor({ x: 10, y: 5, shape: 1, visible: true, blink: false }); @@ -395,7 +393,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { const h = readHeader(res.bytes); assert.equal(h.magic, ZRDL_MAGIC); - assert.equal(h.version, ZR_DRAWLIST_VERSION_V2); + assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); assert.equal(h.cmdOffset, 64); assert.equal(h.cmdBytes, 28); assert.equal(h.cmdCount, 2); @@ -404,7 +402,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v2 setCursor readback preserves payload fields and reserved byte", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: -1, y: 123, shape: 2, visible: false, blink: true }); const res = b.build(); @@ -426,7 +424,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v2 multiple cursor commands are emitted in-order", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 1, y: 2, shape: 0, visible: true, blink: true }); b.setCursor({ x: 3, y: 4, shape: 1, visible: true, blink: false }); b.hideCursor(); @@ -469,7 +467,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("cursor edge position (0,0) round-trips exactly", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); const res = b.build(); @@ -487,7 +485,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("cursor edge position (large int32) round-trips exactly", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); const res = b.build(); @@ -505,10 +503,10 @@ describe("DrawlistBuilder round-trip binary readback", () => { }); test("v2 mixed frame keeps aligned sections and expected total byte size", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); b.clear(); b.pushClip(0, 0, 80, 24); - b.fillRect(1, 1, 5, 2, { bg: { r: 7, g: 8, b: 9 }, inverse: true }); + b.fillRect(1, 1, 5, 2, { bg: ((7 << 16) | (8 << 8) | 9), inverse: true }); b.drawText(2, 2, "rt"); b.setCursor({ x: 2, y: 2, shape: 1, visible: true, blink: false }); b.popClip(); @@ -518,7 +516,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { if (!res.ok) return; const h = readHeader(res.bytes); - assert.equal(h.version, ZR_DRAWLIST_VERSION_V2); + assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); assert.equal(h.cmdCount, 6); assert.equal( h.cmdBytes, diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts index 4e83c1f0..8a2c3c47 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; const OP_DRAW_TEXT = 3; @@ -21,8 +21,7 @@ const FACTORIES: readonly Readonly<{ name: string; create(opts?: BuilderOpts): BuilderLike; }>[] = [ - { name: "v1", create: (opts?: BuilderOpts) => createDrawlistBuilderV1(opts) }, - { name: "v2", create: (opts?: BuilderOpts) => createDrawlistBuilderV2(opts) }, + { name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }, ]; function u16(bytes: Uint8Array, off: number): number { diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts index 36bed8d4..f8f4a6c5 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; const OP_DRAW_TEXT = 3; @@ -23,8 +23,7 @@ const FACTORIES: readonly Readonly<{ name: string; create(opts?: BuilderOpts): BuilderLike; }>[] = [ - { name: "v1", create: (opts?: BuilderOpts) => createDrawlistBuilderV1(opts) }, - { name: "v2", create: (opts?: BuilderOpts) => createDrawlistBuilderV2(opts) }, + { name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }, ]; function u16(bytes: Uint8Array, off: number): number { diff --git a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts index 9e4814f0..daeef701 100644 --- a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts @@ -1,10 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { type TextStyle, - createDrawlistBuilderV1, - createDrawlistBuilderV2, - createDrawlistBuilderV3, + createDrawlistBuilder, } from "../../index.js"; +import { DRAW_TEXT_SIZE } from "../writers.gen.js"; +import { DEFAULT_BASE_STYLE, mergeTextStyle } from "../../renderer/renderToDrawlist/textStyle.js"; function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -61,13 +61,11 @@ const ATTR_BITS: ReadonlyArray = ATTRS.map((attr, b const BUILDERS: ReadonlyArray< Readonly<{ - name: "v1" | "v2" | "v3"; - create: typeof createDrawlistBuilderV1; + name: "current"; + create: typeof createDrawlistBuilder; }> > = [ - { name: "v1", create: createDrawlistBuilderV1 }, - { name: "v2", create: createDrawlistBuilderV2 }, - { name: "v3", create: createDrawlistBuilderV3 }, + { name: "current", create: createDrawlistBuilder }, ]; function singleAttrStyle(attr: AttrName): TextStyle { @@ -94,7 +92,7 @@ function packRgb(r: number, g: number, b: number): number { } function encodeViaDrawText( - create: typeof createDrawlistBuilderV1, + create: typeof createDrawlistBuilder, style: TextStyle | undefined, ): Readonly<{ fg: number; bg: number; attrs: number }> { const b = create(); @@ -110,7 +108,7 @@ function encodeViaDrawText( } function encodeViaTextRun( - create: typeof createDrawlistBuilderV1, + create: typeof createDrawlistBuilder, style: TextStyle | undefined, ): Readonly<{ fg: number; bg: number; attrs: number }> { const b = create(); @@ -172,8 +170,8 @@ describe("drawlist style fg/bg and undefined fg/bg encoding", () => { for (const builder of BUILDERS) { test(`${builder.name} drawText encodes fg/bg with attrs`, () => { const encoded = encodeViaDrawText(builder.create, { - fg: { r: 10, g: 20, b: 30 }, - bg: { r: 40, g: 50, b: 60 }, + fg: ((10 << 16) | (20 << 8) | 30), + bg: ((40 << 16) | (50 << 8) | 60), bold: true, inverse: true, }); @@ -194,8 +192,8 @@ describe("drawlist style fg/bg and undefined fg/bg encoding", () => { test(`${builder.name} text-run encodes fg/bg with attrs`, () => { const encoded = encodeViaTextRun(builder.create, { - fg: { r: 1, g: 2, b: 3 }, - bg: { r: 4, g: 5, b: 6 }, + fg: ((1 << 16) | (2 << 8) | 3), + bg: ((4 << 16) | (5 << 8) | 6), dim: true, blink: true, }); @@ -252,3 +250,54 @@ describe("drawlist style attrs exhaustive 256-mask encoding", () => { } } }); + +describe("style merge stress encodes fg/bg/attrs bytes deterministically", () => { + test("many merged styles produce expected drawText style payloads", () => { + const b = createDrawlistBuilder(); + const expected: Array> = []; + + let resolved = DEFAULT_BASE_STYLE; + for (let i = 0; i < 192; i++) { + const override: TextStyle = { + ...(i % 3 === 0 + ? { fg: packRgb((i * 17) & 0xff, (i * 29) & 0xff, (i * 43) & 0xff) } + : {}), + ...(i % 5 === 0 + ? { bg: packRgb((i * 11) & 0xff, (i * 7) & 0xff, (i * 13) & 0xff) } + : {}), + ...(i % 2 === 0 ? { bold: true } : {}), + ...(i % 4 === 0 ? { italic: true } : {}), + ...(i % 6 === 0 ? { underline: true } : {}), + ...(i % 8 === 0 ? { inverse: true } : {}), + ...(i % 10 === 0 ? { dim: true } : {}), + ...(i % 12 === 0 ? { strikethrough: true } : {}), + ...(i % 14 === 0 ? { overline: true } : {}), + ...(i % 16 === 0 ? { blink: true } : {}), + }; + resolved = mergeTextStyle(resolved, override); + expected.push({ + fg: resolved.fg >>> 0, + bg: resolved.bg >>> 0, + attrs: resolved.attrs >>> 0, + }); + b.drawText(i, 0, "x", resolved); + } + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) throw new Error("build failed"); + + const cmdOffset = u32(res.bytes, 16); + const cmdCount = u32(res.bytes, 24); + assert.equal(cmdCount, expected.length); + + for (let i = 0; i < expected.length; i++) { + const exp = expected[i]; + if (!exp) continue; + const off = cmdOffset + i * DRAW_TEXT_SIZE; + assert.equal(u32(res.bytes, off + 28), exp.fg, `fg mismatch at cmd #${String(i)}`); + assert.equal(u32(res.bytes, off + 32), exp.bg, `bg mismatch at cmd #${String(i)}`); + assert.equal(u32(res.bytes, off + 36), exp.attrs, `attrs mismatch at cmd #${String(i)}`); + } + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder_v1_text_run.test.ts b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts similarity index 93% rename from packages/core/src/drawlist/__tests__/builder_v1_text_run.test.ts rename to packages/core/src/drawlist/__tests__/builder.text-run.test.ts index 8bb0e4ef..fc7805e6 100644 --- a/packages/core/src/drawlist/__tests__/builder_v1_text_run.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilderV1 } from "../../index.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -16,13 +16,13 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -describe("DrawlistBuilderV1 (ZRDL v1) - DRAW_TEXT_RUN", () => { +describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { test("emits blob span + DRAW_TEXT_RUN command referencing it", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ - { text: "ABC", style: { fg: { r: 255, g: 0, b: 0 }, bold: true } }, - { text: "DEF", style: { fg: { r: 0, g: 255, b: 0 }, underline: true } }, + { text: "ABC", style: { fg: ((255 << 16) | (0 << 8) | 0), bold: true } }, + { text: "DEF", style: { fg: ((0 << 16) | (255 << 8) | 0), underline: true } }, ]); assert.equal(blobIndex, 0); if (blobIndex === null) return; diff --git a/packages/core/src/drawlist/__tests__/builder_v1_validate_caps.test.ts b/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts similarity index 81% rename from packages/core/src/drawlist/__tests__/builder_v1_validate_caps.test.ts rename to packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts index e801d772..9f532452 100644 --- a/packages/core/src/drawlist/__tests__/builder_v1_validate_caps.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts @@ -1,10 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1 } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; -describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { +describe("DrawlistBuilder (ZRDL v1) - validation and caps", () => { test("invalid params: NaN/Infinity/negative sizes/non-int32 -> ZRDL_BAD_PARAMS", () => { { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.fillRect(Number.NaN, 0, 0, 0); const res = b.build(); assert.equal(res.ok, false); @@ -13,7 +13,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { } { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.pushClip(0, Number.POSITIVE_INFINITY, 0, 0); const res = b.build(); assert.equal(res.ok, false); @@ -22,7 +22,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { } { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.fillRect(0, 0, -1, 0); const res = b.build(); assert.equal(res.ok, false); @@ -31,7 +31,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { } { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.fillRect(0, 0, 1.5, 0); const res = b.build(); assert.equal(res.ok, false); @@ -40,7 +40,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { } { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.fillRect(2147483648, 0, 0, 0); const res = b.build(); assert.equal(res.ok, false); @@ -49,7 +49,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { } { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); // @ts-expect-error runtime bad param test b.drawText(0, 0, 123); const res = b.build(); @@ -60,7 +60,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { }); test("cap: maxCmdCount -> ZRDL_TOO_LARGE (and reset restores usability)", () => { - const b = createDrawlistBuilderV1({ maxCmdCount: 1 }); + const b = createDrawlistBuilder({ maxCmdCount: 1 }); b.clear(); b.clear(); @@ -76,7 +76,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { }); test("cap: maxStrings -> ZRDL_TOO_LARGE (and reset restores usability)", () => { - const b = createDrawlistBuilderV1({ maxStrings: 1 }); + const b = createDrawlistBuilder({ maxStrings: 1 }); b.drawText(0, 0, "a"); b.drawText(0, 1, "b"); @@ -92,7 +92,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { }); test("cap: maxStringBytes -> ZRDL_TOO_LARGE (and reset restores usability)", () => { - const b = createDrawlistBuilderV1({ maxStringBytes: 1 }); + const b = createDrawlistBuilder({ maxStringBytes: 1 }); b.drawText(0, 0, "ab"); // 2 bytes in UTF-8 const res = b.build(); @@ -107,7 +107,7 @@ describe("DrawlistBuilderV1 (ZRDL v1) - validation and caps", () => { }); test("cap: maxDrawlistBytes -> ZRDL_TOO_LARGE (and reset restores usability)", () => { - const b = createDrawlistBuilderV1({ maxDrawlistBytes: 72 }); + const b = createDrawlistBuilder({ maxDrawlistBytes: 72 }); b.fillRect(1, 2, 3, 4); const res = b.build(); diff --git a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts index 655e1750..4ebf3d38 100644 --- a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -20,8 +20,8 @@ function drawTextAttrs(bytes: Uint8Array): number { } describe("drawlist style attrs encode dim", () => { - test("v1 drawText attrs include dim without shifting existing bits", () => { - const b = createDrawlistBuilderV1(); + test("drawText attrs include dim without shifting existing bits", () => { + const b = createDrawlistBuilder(); b.drawText(0, 0, "dim", { dim: true }); const res = b.build(); assert.equal(res.ok, true); @@ -30,8 +30,8 @@ describe("drawlist style attrs encode dim", () => { assert.equal(drawTextAttrs(res.bytes), 1 << 4); }); - test("v2 drawText attrs include dim without shifting existing bits", () => { - const b = createDrawlistBuilderV2(); + test("drawText attrs include dim without shifting existing bits (repeat)", () => { + const b = createDrawlistBuilder(); b.drawText(0, 0, "dim", { dim: true }); const res = b.build(); assert.equal(res.ok, true); @@ -41,7 +41,7 @@ describe("drawlist style attrs encode dim", () => { }); test("v1 text-run attrs include dim without shifting existing bits", () => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ { text: "dim", style: { dim: true } }, { text: "base", style: { bold: true, italic: true, underline: true, inverse: true } }, @@ -59,7 +59,7 @@ describe("drawlist style attrs encode dim", () => { }); test("v2 text-run attrs include dim without shifting existing bits", () => { - const b = createDrawlistBuilderV2(); + const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ { text: "dim", style: { dim: true } }, { text: "base", style: { bold: true, italic: true, underline: true, inverse: true } }, diff --git a/packages/core/src/drawlist/__tests__/writers.gen.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.test.ts index 3d1e3a07..e5d9c169 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V5, createDrawlistBuilderV3 } from "../../index.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; import type { EncodedStyle } from "../types.js"; import { CLEAR_SIZE, @@ -326,7 +326,7 @@ function buildReferenceDrawlist(): Uint8Array { const dv = view(out); dv.setUint32(0, ZRDL_MAGIC, true); - dv.setUint32(4, ZR_DRAWLIST_VERSION_V5, true); + dv.setUint32(4, ZR_DRAWLIST_VERSION_V1, true); dv.setUint32(8, HEADER_SIZE, true); dv.setUint32(12, totalSize, true); dv.setUint32(16, cmdOffset, true); @@ -624,7 +624,7 @@ describe("writers.gen - style encoding", () => { describe("writers.gen - round trip integration", () => { test("builder output matches reference encoding and header offsets stay valid", () => { - const b = createDrawlistBuilderV3({ drawlistVersion: 5 }); + const b = createDrawlistBuilder(); const blobIndex = b.addBlob(new Uint8Array([1, 2, 3, 4])); assert.equal(blobIndex, 0); if (blobIndex === null) throw new Error("blob index was null"); @@ -645,7 +645,7 @@ describe("writers.gen - round trip integration", () => { const bytes = built.bytes; assert.equal(u32(bytes, 0), ZRDL_MAGIC); - assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V5); + assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V1); assert.equal(u32(bytes, 8), HEADER_SIZE); assert.equal(u32(bytes, 12), bytes.byteLength); assert.equal(u32(bytes, 24), 9); diff --git a/packages/core/src/drawlist/builder_v3.ts b/packages/core/src/drawlist/builder.ts similarity index 89% rename from packages/core/src/drawlist/builder_v3.ts rename to packages/core/src/drawlist/builder.ts index 3b25c5ab..3f873cda 100644 --- a/packages/core/src/drawlist/builder_v3.ts +++ b/packages/core/src/drawlist/builder.ts @@ -1,10 +1,10 @@ -import { ZR_DRAWLIST_VERSION_V3, ZR_DRAWLIST_VERSION_V4, ZR_DRAWLIST_VERSION_V5 } from "../abi.js"; +import { ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; import type { TextStyle } from "../widgets/style.js"; -import { DrawlistBuilderBase, type DrawlistBuilderBaseOpts, packRgb } from "./builderBase.js"; +import { DrawlistBuilderBase, type DrawlistBuilderBaseOpts } from "./builderBase.js"; import type { CursorState, + DrawlistBuilder, DrawlistBuildResult, - DrawlistBuilderV3, DrawlistCanvasBlitter, DrawlistImageFit, DrawlistImageFormat, @@ -32,11 +32,7 @@ import { writeSetCursor, } from "./writers.gen.js"; -export type DrawlistBuilderV3Opts = Readonly< - DrawlistBuilderBaseOpts & { - drawlistVersion?: 3 | 4 | 5; - } ->; +export type DrawlistBuilderOpts = DrawlistBuilderBaseOpts; const BLITTER_CODE: Readonly> = Object.freeze({ auto: 0, @@ -98,6 +94,11 @@ function encodeUnderlineStyle(style: TextStyle["underlineStyle"] | undefined): n } } +function asPackedRgb24(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return (value >>> 0) & 0x00ff_ffff; +} + function encodeStyle(style: TextStyle | undefined, linkRefs: LinkRefs | null): EncodedStyle { if (!style) { return { @@ -111,20 +112,26 @@ function encodeStyle(style: TextStyle | undefined, linkRefs: LinkRefs | null): E }; } - const fg = packRgb(style.fg) ?? 0; - const bg = packRgb(style.bg) ?? 0; - const underlineColor = packRgb(style.underlineColor) ?? 0; + const fg = asPackedRgb24(style.fg); + const bg = asPackedRgb24(style.bg); + const underlineColor = asPackedRgb24(style.underlineColor); const underlineStyle = encodeUnderlineStyle(style.underlineStyle); - let attrs = 0; - if (style.bold) attrs |= 1 << 0; - if (style.italic) attrs |= 1 << 1; - if (style.underline || underlineStyle !== 0) attrs |= 1 << 2; - if (style.inverse) attrs |= 1 << 3; - if (style.dim) attrs |= 1 << 4; - if (style.strikethrough) attrs |= 1 << 5; - if (style.overline) attrs |= 1 << 6; - if (style.blink) attrs |= 1 << 7; + const prepackedAttrs = (style as { attrs?: unknown }).attrs; + let attrs = + typeof prepackedAttrs === "number" && Number.isFinite(prepackedAttrs) + ? (prepackedAttrs >>> 0) & 0xff + : 0; + if (attrs === 0) { + if (style.bold) attrs |= 1 << 0; + if (style.italic) attrs |= 1 << 1; + if (style.underline || underlineStyle !== 0) attrs |= 1 << 2; + if (style.inverse) attrs |= 1 << 3; + if (style.dim) attrs |= 1 << 4; + if (style.strikethrough) attrs |= 1 << 5; + if (style.overline) attrs |= 1 << 6; + if (style.blink) attrs |= 1 << 7; + } return { fg, @@ -158,27 +165,16 @@ function inferAutoCanvasPx(blobLen: number, cols: number): CanvasPixelSize | nul return Object.freeze({ pxWidth, pxHeight }); } -export function createDrawlistBuilderV3(opts: DrawlistBuilderV3Opts = {}): DrawlistBuilderV3 { - return new DrawlistBuilderV3Impl(opts); +export function createDrawlistBuilder(opts: DrawlistBuilderOpts = {}): DrawlistBuilder { + return new DrawlistBuilderImpl(opts); } -class DrawlistBuilderV3Impl extends DrawlistBuilderBase implements DrawlistBuilderV3 { - readonly drawlistVersion: 3 | 4 | 5; - +class DrawlistBuilderImpl extends DrawlistBuilderBase implements DrawlistBuilder { private activeLinkUriRef = 0; private activeLinkIdRef = 0; - constructor(opts: DrawlistBuilderV3Opts) { - super(opts, "DrawlistBuilderV3"); - - const drawlistVersion = opts.drawlistVersion ?? ZR_DRAWLIST_VERSION_V5; - this.drawlistVersion = ( - drawlistVersion === ZR_DRAWLIST_VERSION_V3 - ? ZR_DRAWLIST_VERSION_V3 - : drawlistVersion === ZR_DRAWLIST_VERSION_V4 - ? ZR_DRAWLIST_VERSION_V4 - : ZR_DRAWLIST_VERSION_V5 - ) as 3 | 4 | 5; + constructor(opts: DrawlistBuilderOpts) { + super(opts, "DrawlistBuilder"); } setCursor(state: CursorState): void { @@ -260,13 +256,6 @@ class DrawlistBuilderV3Impl extends DrawlistBuilderBase implements pxHeight?: number, ): void { if (this.error) return; - if (this.drawlistVersion < ZR_DRAWLIST_VERSION_V4) { - this.fail( - "ZRDL_BAD_PARAMS", - `drawCanvas: requires drawlist version >= 4 (current=${this.drawlistVersion})`, - ); - return; - } const xi = this.validateParams ? this.requireI32NonNeg("drawCanvas", "x", x) : x | 0; const yi = this.validateParams ? this.requireI32NonNeg("drawCanvas", "y", y) : y | 0; @@ -385,13 +374,6 @@ class DrawlistBuilderV3Impl extends DrawlistBuilderBase implements pxHeight?: number, ): void { if (this.error) return; - if (this.drawlistVersion < ZR_DRAWLIST_VERSION_V5) { - this.fail( - "ZRDL_BAD_PARAMS", - `drawImage: requires drawlist version >= 5 (current=${this.drawlistVersion})`, - ); - return; - } const xi = this.validateParams ? this.requireI32NonNeg("drawImage", "x", x) : x | 0; const yi = this.validateParams ? this.requireI32NonNeg("drawImage", "y", y) : y | 0; @@ -546,11 +528,11 @@ class DrawlistBuilderV3Impl extends DrawlistBuilderBase implements } buildInto(dst: Uint8Array): DrawlistBuildResult { - return this.buildIntoWithVersion(this.drawlistVersion, dst); + return this.buildIntoWithVersion(ZR_DRAWLIST_VERSION_V1, dst); } build(): DrawlistBuildResult { - return this.buildWithVersion(this.drawlistVersion); + return this.buildWithVersion(ZR_DRAWLIST_VERSION_V1); } override reset(): void { diff --git a/packages/core/src/drawlist/builderBase.ts b/packages/core/src/drawlist/builderBase.ts index 716f2ee3..cdce8283 100644 --- a/packages/core/src/drawlist/builderBase.ts +++ b/packages/core/src/drawlist/builderBase.ts @@ -4,7 +4,6 @@ import type { DrawlistBuildError, DrawlistBuildErrorCode, DrawlistBuildResult, - DrawlistBuilderV1, DrawlistTextRunSegment, } from "./types.js"; @@ -42,8 +41,6 @@ export const OP_POP_CLIP = 5; export const OP_DRAW_TEXT_RUN = 6; export const OP_SET_CURSOR = 7; -export type EncodedStyleV1 = Readonly<{ fg: number; bg: number; attrs: number }>; - type Utf8Encoder = Readonly<{ encode(input: string): Uint8Array }>; type Layout = Readonly<{ @@ -70,70 +67,12 @@ export function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null; } -export function isRgbLike(v: unknown): v is Readonly<{ r: unknown; g: unknown; b: unknown }> { - return isObject(v) && "r" in v && "g" in v && "b" in v; -} - export function isTextRunSegment(v: unknown): v is DrawlistTextRunSegment { if (typeof v !== "object" || v === null) return false; if (typeof (v as { text?: unknown }).text !== "string") return false; return true; } - -export function packRgb(v: unknown): number | null { - if (typeof v === "string") { - const raw = v.startsWith("#") ? v.slice(1) : v; - if (/^[0-9a-fA-F]{6}$/.test(raw)) { - return Number.parseInt(raw, 16) & 0x00ff_ff_ff; - } - return null; - } - - if (!isRgbLike(v)) return null; - - const r0 = v.r; - const g0 = v.g; - const b0 = v.b; - const r = typeof r0 === "number" && Number.isFinite(r0) ? r0 | 0 : 0; - const g = typeof g0 === "number" && Number.isFinite(g0) ? g0 | 0 : 0; - const b = typeof b0 === "number" && Number.isFinite(b0) ? b0 | 0 : 0; - return ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); -} - -function hasUnderlineVariant(style: TextStyle): boolean { - const underlineStyle = (style as { underlineStyle?: unknown }).underlineStyle; - switch (underlineStyle) { - case "straight": - case "double": - case "curly": - case "dotted": - case "dashed": - return true; - default: - return false; - } -} - -export function encodeBasicStyle(style: TextStyle | undefined): EncodedStyleV1 { - if (!style) return { fg: 0, bg: 0, attrs: 0 }; - - const fg = packRgb(style.fg) ?? 0; - const bg = packRgb(style.bg) ?? 0; - - let attrs = 0; - if (style.bold) attrs |= 1 << 0; - if (style.italic) attrs |= 1 << 1; - if (style.underline || hasUnderlineVariant(style)) attrs |= 1 << 2; - if (style.inverse) attrs |= 1 << 3; - if (style.dim) attrs |= 1 << 4; - if (style.strikethrough) attrs |= 1 << 5; - if (style.overline) attrs |= 1 << 6; - if (style.blink) attrs |= 1 << 7; - - return { fg, bg, attrs }; -} - -export abstract class DrawlistBuilderBase implements DrawlistBuilderV1 { +export abstract class DrawlistBuilderBase { protected readonly builderName: string; protected readonly maxDrawlistBytes: number; @@ -1213,128 +1152,3 @@ export abstract class DrawlistBuilderBase implements DrawlistBuil return null; } } - -export abstract class DrawlistBuilderLegacyBase extends DrawlistBuilderBase { - protected constructor(opts: DrawlistBuilderBaseOpts, builderName: string) { - super(opts, builderName); - } - - protected override encodeFillRectStyle(style: TextStyle | undefined): EncodedStyleV1 { - return encodeBasicStyle(style); - } - - protected override encodeDrawTextStyle(style: TextStyle | undefined): EncodedStyleV1 { - return encodeBasicStyle(style); - } - - protected override appendClearCommand(): void { - this.writeCommandHeader(OP_CLEAR, 8); - } - - protected override appendFillRectCommand( - x: number, - y: number, - w: number, - h: number, - style: EncodedStyleV1, - ): void { - this.writeCommandHeader(OP_FILL_RECT, 8 + 32); - this.writeI32(x); - this.writeI32(y); - this.writeI32(w); - this.writeI32(h); - this.writeLegacyStyle(style); - this.padCmdTo4(); - } - - protected override appendDrawTextCommand( - x: number, - y: number, - stringIndex: number, - byteLen: number, - style: EncodedStyleV1, - ): void { - this.writeCommandHeader(OP_DRAW_TEXT, 8 + 40); - this.writeI32(x); - this.writeI32(y); - this.writeU32(stringIndex); - this.writeU32(0); - this.writeU32(byteLen); - this.writeLegacyStyle(style); - this.writeU32(0); - this.padCmdTo4(); - } - - protected override appendPushClipCommand(x: number, y: number, w: number, h: number): void { - this.writeCommandHeader(OP_PUSH_CLIP, 8 + 16); - this.writeI32(x); - this.writeI32(y); - this.writeI32(w); - this.writeI32(h); - this.padCmdTo4(); - } - - protected override appendPopClipCommand(): void { - this.writeCommandHeader(OP_POP_CLIP, 8); - } - - protected override appendDrawTextRunCommand(x: number, y: number, blobIndex: number): void { - this.writeCommandHeader(OP_DRAW_TEXT_RUN, 8 + 16); - this.writeI32(x); - this.writeI32(y); - this.writeU32(blobIndex); - this.writeU32(0); - this.padCmdTo4(); - } - - protected override textRunBlobSegmentSize(): number { - return 28; - } - - protected override writeTextRunBlobSegment( - dv: DataView, - off: number, - style: EncodedStyleV1, - stringIndex: number, - byteLen: number, - ): number { - dv.setUint32(off + 0, style.fg >>> 0, true); - dv.setUint32(off + 4, style.bg >>> 0, true); - dv.setUint32(off + 8, style.attrs >>> 0, true); - dv.setUint32(off + 12, 0, true); - dv.setUint32(off + 16, stringIndex >>> 0, true); - dv.setUint32(off + 20, 0, true); - dv.setUint32(off + 24, byteLen >>> 0, true); - return off + 28; - } - - protected override expectedCmdSize(opcode: number): number { - switch (opcode) { - case OP_CLEAR: - return 8; - case OP_FILL_RECT: - return 8 + 32; - case OP_DRAW_TEXT: - return 8 + 40; - case OP_PUSH_CLIP: - return 8 + 16; - case OP_POP_CLIP: - return 8; - case OP_DRAW_TEXT_RUN: - return 8 + 16; - default: - return this.expectedExtraCmdSize(opcode); - } - } - - protected expectedExtraCmdSize(_opcode: number): number { - return -1; - } - - private writeLegacyStyle(style: EncodedStyleV1): void { - this.writeU32(style.fg); - this.writeU32(style.bg); - this.writeU32(style.attrs); - this.writeU32(0); - } -} diff --git a/packages/core/src/drawlist/builder_v1.ts b/packages/core/src/drawlist/builder_v1.ts deleted file mode 100644 index 1d2687b4..00000000 --- a/packages/core/src/drawlist/builder_v1.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; -import { type DrawlistBuilderBaseOpts, DrawlistBuilderLegacyBase } from "./builderBase.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "./types.js"; - -export type DrawlistBuilderV1Opts = DrawlistBuilderBaseOpts; - -export function createDrawlistBuilderV1(opts: DrawlistBuilderV1Opts = {}): DrawlistBuilderV1 { - return new DrawlistBuilderV1Impl(opts); -} - -class DrawlistBuilderV1Impl extends DrawlistBuilderLegacyBase implements DrawlistBuilderV1 { - constructor(opts: DrawlistBuilderV1Opts) { - super(opts, "DrawlistBuilderV1"); - } - - build(): DrawlistBuildResult { - return this.buildWithVersion(ZR_DRAWLIST_VERSION_V1); - } -} diff --git a/packages/core/src/drawlist/builder_v2.ts b/packages/core/src/drawlist/builder_v2.ts deleted file mode 100644 index 3ba0743e..00000000 --- a/packages/core/src/drawlist/builder_v2.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ZR_DRAWLIST_VERSION_V2 } from "../abi.js"; -import { - type DrawlistBuilderBaseOpts, - DrawlistBuilderLegacyBase, - OP_SET_CURSOR, -} from "./builderBase.js"; -import type { CursorState, DrawlistBuildResult, DrawlistBuilderV2 } from "./types.js"; - -export type DrawlistBuilderV2Opts = DrawlistBuilderBaseOpts; - -export function createDrawlistBuilderV2(opts: DrawlistBuilderV2Opts = {}): DrawlistBuilderV2 { - return new DrawlistBuilderV2Impl(opts); -} - -class DrawlistBuilderV2Impl extends DrawlistBuilderLegacyBase implements DrawlistBuilderV2 { - constructor(opts: DrawlistBuilderV2Opts) { - super(opts, "DrawlistBuilderV2"); - } - - setCursor(state: CursorState): void { - if (this.error) return; - - const xi = this.validateParams ? this.requireI32("setCursor", "x", state.x) : state.x | 0; - const yi = this.validateParams ? this.requireI32("setCursor", "y", state.y) : state.y | 0; - if (this.error) return; - if (xi === null || yi === null) return; - - const shape = state.shape & 0xff; - if (this.validateParams && (shape < 0 || shape > 2)) { - this.fail("ZRDL_BAD_PARAMS", `setCursor: shape must be 0, 1, or 2 (got ${shape})`); - return; - } - - this.writeCommandHeader(OP_SET_CURSOR, 20); - this.writeI32(xi); - this.writeI32(yi); - this.writeU8(shape); - this.writeU8(state.visible ? 1 : 0); - this.writeU8(state.blink ? 1 : 0); - this.writeU8(0); - this.padCmdTo4(); - - this.maybeFailTooLargeAfterWrite(); - } - - hideCursor(): void { - this.setCursor({ x: -1, y: -1, shape: 0, visible: false, blink: false }); - } - - buildInto(dst: Uint8Array): DrawlistBuildResult { - return this.buildIntoWithVersion(ZR_DRAWLIST_VERSION_V2, dst); - } - - build(): DrawlistBuildResult { - return this.buildWithVersion(ZR_DRAWLIST_VERSION_V2); - } - - protected override expectedExtraCmdSize(opcode: number): number { - if (opcode === OP_SET_CURSOR) { - return 8 + 12; - } - return -1; - } -} diff --git a/packages/core/src/drawlist/index.ts b/packages/core/src/drawlist/index.ts index 86ab42a0..6205079a 100644 --- a/packages/core/src/drawlist/index.ts +++ b/packages/core/src/drawlist/index.ts @@ -8,9 +8,7 @@ * @see docs/protocol/zrdl.md */ -export { createDrawlistBuilderV1, type DrawlistBuilderV1Opts } from "./builder_v1.js"; -export { createDrawlistBuilderV2, type DrawlistBuilderV2Opts } from "./builder_v2.js"; -export { createDrawlistBuilderV3, type DrawlistBuilderV3Opts } from "./builder_v3.js"; +export { createDrawlistBuilder, type DrawlistBuilderOpts } from "./builder.js"; export type { DrawlistCanvasBlitter, @@ -22,7 +20,5 @@ export type { DrawlistBuildErrorCode, DrawlistBuildResult, DrawlistBuildInto, - DrawlistBuilderV1, - DrawlistBuilderV2, - DrawlistBuilderV3, + DrawlistBuilder, } from "./types.js"; diff --git a/packages/core/src/drawlist/types.ts b/packages/core/src/drawlist/types.ts index 2181a37b..fc4fce89 100644 --- a/packages/core/src/drawlist/types.ts +++ b/packages/core/src/drawlist/types.ts @@ -1,14 +1,5 @@ /** * packages/core/src/drawlist/types.ts — ZRDL drawlist builder type definitions. - * - * Why: Defines the TypeScript interface for building ZRDL binary drawlists. - * These types form the contract between the widget renderer and the binary - * builder, ensuring type-safe drawing command emission. - * - * ZRDL format: Little-endian, 4-byte aligned drawlist for the C engine. - * - * @see docs/protocol/abi.md - * @see docs/protocol/zrdl.md */ import type { CursorShape } from "../abi.js"; @@ -19,7 +10,7 @@ export type DrawlistTextRunSegment = Readonly<{ style?: TextStyle; }>; -/** Encoded v3+ style payload used by generated drawlist writers. */ +/** Encoded style payload used by generated drawlist writers. */ export type EncodedStyle = Readonly<{ fg: number; bg: number; @@ -30,111 +21,22 @@ export type EncodedStyle = Readonly<{ linkIdRef: number; }>; -/** - * Error codes for ZRDL build failures. - * - * Error categories: - * - ZRDL_TOO_LARGE: Output exceeds configured size/count caps - * - ZRDL_BAD_PARAMS: Invalid parameters passed to drawing commands - * - ZRDL_FORMAT: Internal format constraint violated (e.g., alignment) - * - ZRDL_INTERNAL: Implementation bug (should never occur) - */ export type DrawlistBuildErrorCode = | "ZRDL_TOO_LARGE" | "ZRDL_BAD_PARAMS" | "ZRDL_FORMAT" | "ZRDL_INTERNAL"; -/** Structured build error with diagnostic context. */ export type DrawlistBuildError = Readonly<{ code: DrawlistBuildErrorCode; detail: string }>; -/** - * Discriminated union result type for build operations. - * On success, bytes is a self-contained ZRDL binary ready for engine submission. - */ export type DrawlistBuildResult = | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; error: DrawlistBuildError }>; -/** - * Optional capability for serializing directly into a caller-provided buffer. - * - * Semantics: - * - Returns ok=true with a view over the written prefix of `dst`. - * - Returns ok=false if `dst` is too small or build state is invalid. - */ export interface DrawlistBuildInto { buildInto(dst: Uint8Array): DrawlistBuildResult; } -/** - * ZRDL v1 drawlist builder interface. - * - * Usage pattern: - * 1. Call drawing commands (clear, fillRect, drawText, pushClip, popClip) - * 2. Call build() to produce the final ZRDL binary - * 3. Call reset() to reuse the builder for the next frame - * - * Error handling: Commands record errors internally; build() returns failure - * if any command failed. This allows batching commands without per-call checks. - * - * Ownership: The Uint8Array returned by build() is owned by the caller. - */ -export interface DrawlistBuilderV1 { - /** Clear framebuffer. Emits OP_CLEAR (opcode 1). */ - clear(): void; - /** - * Clear framebuffer and fill the viewport with a style. - * - * Why: ZRDL v1 CLEAR carries no style payload. This helper provides a - * deterministic "clear with background" that works in both raw and widget - * render modes by emitting CLEAR + FILL_RECT. - */ - clearTo(cols: number, rows: number, style?: TextStyle): void; - /** Fill rectangle at (x,y) with size (w,h). Emits OP_FILL_RECT (opcode 2). */ - fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void; - /** Draw text at (x,y). Strings are interned and deduplicated. Emits OP_DRAW_TEXT (opcode 3). */ - drawText(x: number, y: number, text: string, style?: TextStyle): void; - /** Push clipping rectangle onto clip stack. Emits OP_PUSH_CLIP (opcode 4). */ - pushClip(x: number, y: number, w: number, h: number): void; - /** Pop clipping rectangle from clip stack. Emits OP_POP_CLIP (opcode 5). */ - popClip(): void; - /** - * Append a blob payload to the blob table and return its index. - * - * Notes: - * - The blob span length MUST be 4-byte aligned. - * - Blob content is not deduplicated; callers may cache returned indices. - */ - addBlob(bytes: Uint8Array): number | null; - /** - * Convenience helper that encodes a ZRDL v1 DRAW_TEXT_RUN blob payload from - * segments and appends it via addBlob(). - */ - addTextRunBlob(segments: readonly DrawlistTextRunSegment[]): number | null; - /** Draw a pre-measured text run blob at (x,y). Emits OP_DRAW_TEXT_RUN (opcode 6). */ - drawTextRun(x: number, y: number, blobIndex: number): void; - /** Finalize and return the ZRDL binary, or error if any command failed. */ - build(): DrawlistBuildResult; - /** Reset builder state for reuse. Clears commands, strings, and error state. */ - reset(): void; -} - -// ============================================================================= -// Cursor Types (ZRDL v2) -// ============================================================================= - -/** - * Desired cursor state for SET_CURSOR command (v2+). - * - * Semantics: - * - x, y: 0-based cell position; -1 means "leave unchanged" (engine-side) - * - shape: 0=block, 1=underline, 2=bar - * - visible: whether cursor is shown - * - blink: whether cursor blinks - * - * @see zr_dl_cmd_set_cursor_t in zr_drawlist.h - */ export type CursorState = Readonly<{ x: number; y: number; @@ -143,35 +45,6 @@ export type CursorState = Readonly<{ blink: boolean; }>; -/** - * ZRDL v2 drawlist builder interface. - * - * Extends v1 with SET_CURSOR command for native cursor control. - * When v2 is negotiated, the engine handles cursor display internally, - * eliminating the need for "fake cursor" glyphs. - */ -export interface DrawlistBuilderV2 extends DrawlistBuilderV1, DrawlistBuildInto { - /** - * Set cursor position and appearance. Emits OP_SET_CURSOR (opcode 7). - * - * Notes: - * - x/y = -1 means "leave unchanged" (engine decides) - * - visible = false hides the cursor regardless of position - * - shape is ignored if the terminal doesn't support cursor shaping - */ - setCursor(state: CursorState): void; - - /** - * Hide the cursor by emitting SET_CURSOR with visible=false. - * Convenience method equivalent to setCursor({ ..., visible: false }). - */ - hideCursor(): void; -} - -// ============================================================================= -// Graphics Types (ZRDL v3+) -// ============================================================================= - export type DrawlistCanvasBlitter = | "auto" | "braille" @@ -187,34 +60,21 @@ export type DrawlistImageProtocol = "auto" | "kitty" | "sixel" | "iterm2" | "bli export type DrawlistImageFit = "fill" | "contain" | "cover"; /** - * ZRDL v3+ drawlist builder interface. - * - * Extends v2 with v3 style extensions and v4/v5 graphics commands. + * Current drawlist builder interface. Produces protocol-current ZRDL buffers. */ -export interface DrawlistBuilderV3 extends DrawlistBuilderV2 { - /** - * Active drawlist version for this builder (3, 4, or 5). - * - * - v3: style extensions (underline color + hyperlinks) - * - v4: v3 + DRAW_CANVAS - * - v5: v4 + DRAW_IMAGE - */ - readonly drawlistVersion: 3 | 4 | 5; - - /** - * Set/clear the active hyperlink refs used for subsequent text style encoding. - * - * This is a builder-side state helper; it does not emit an explicit command. - * Pass `uri=null` to clear the active hyperlink. - */ +export interface DrawlistBuilder extends DrawlistBuildInto { + clear(): void; + clearTo(cols: number, rows: number, style?: TextStyle): void; + fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void; + drawText(x: number, y: number, text: string, style?: TextStyle): void; + pushClip(x: number, y: number, w: number, h: number): void; + popClip(): void; + addBlob(bytes: Uint8Array): number | null; + addTextRunBlob(segments: readonly DrawlistTextRunSegment[]): number | null; + drawTextRun(x: number, y: number, blobIndex: number): void; + setCursor(state: CursorState): void; + hideCursor(): void; setLink(uri: string | null, id?: string): void; - - /** - * Draw a canvas RGBA blob (v4+). - * - * `pxWidth`/`pxHeight` are optional for backwards compatibility. When omitted, - * the builder derives them from destination cell size + blitter. - */ drawCanvas( x: number, y: number, @@ -225,13 +85,6 @@ export interface DrawlistBuilderV3 extends DrawlistBuilderV2 { pxWidth?: number, pxHeight?: number, ): void; - - /** - * Draw an image blob (v5+). - * - * `pxWidth`/`pxHeight` are optional for backwards compatibility. When omitted, - * callers should ensure the builder can infer dimensions deterministically. - */ drawImage( x: number, y: number, @@ -246,4 +99,6 @@ export interface DrawlistBuilderV3 extends DrawlistBuilderV2 { pxWidth?: number, pxHeight?: number, ): void; + build(): DrawlistBuildResult; + reset(): void; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c61c906..5e33fc1b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,10 +18,7 @@ export { ZR_ENGINE_ABI_MINOR, ZR_ENGINE_ABI_PATCH, ZR_DRAWLIST_VERSION_V1, - ZR_DRAWLIST_VERSION_V2, - ZR_DRAWLIST_VERSION_V3, - ZR_DRAWLIST_VERSION_V4, - ZR_DRAWLIST_VERSION_V5, + ZR_DRAWLIST_VERSION, ZR_EVENT_BATCH_VERSION_V1, ZR_UNICODE_VERSION_MAJOR, ZR_UNICODE_VERSION_MINOR, @@ -45,11 +42,8 @@ export { // ============================================================================= import { + ZR_DRAWLIST_VERSION, ZR_DRAWLIST_VERSION_V1, - ZR_DRAWLIST_VERSION_V2, - ZR_DRAWLIST_VERSION_V3, - ZR_DRAWLIST_VERSION_V4, - ZR_DRAWLIST_VERSION_V5, ZR_ENGINE_ABI_MAJOR, ZR_ENGINE_ABI_MINOR, ZR_ENGINE_ABI_PATCH, @@ -84,7 +78,7 @@ export const ZR_UNICODE_VERSION = { patch: ZR_UNICODE_VERSION_PATCH, } as const; -export const ZR_DRAWLIST_VERSION: 1 = ZR_DRAWLIST_VERSION_V1; +export const ZR_DRAWLIST_CURRENT_VERSION: 1 = ZR_DRAWLIST_VERSION; export const ZR_EVENT_BATCH_VERSION: 1 = ZR_EVENT_BATCH_VERSION_V1; export { @@ -502,7 +496,7 @@ export { type VisibleRangeResult, type MeasuredItemHeights, } from "./widgets/virtualList.js"; -export { rgb, type Rgb, type TextStyle } from "./widgets/style.js"; +export { rgb, type Rgb24, type TextStyle } from "./widgets/style.js"; // ============================================================================= // Layout Types @@ -737,21 +731,17 @@ export { export { debug, inspect } from "./debug/debug.js"; // ============================================================================= -// Drawlist Builder (ZRDL v1 + v3) +// Drawlist Builder (current protocol) // ============================================================================= -export { createDrawlistBuilderV1, type DrawlistBuilderV1Opts } from "./drawlist/index.js"; -export { createDrawlistBuilderV2, type DrawlistBuilderV2Opts } from "./drawlist/index.js"; -export { createDrawlistBuilderV3, type DrawlistBuilderV3Opts } from "./drawlist/index.js"; +export { createDrawlistBuilder, type DrawlistBuilderOpts } from "./drawlist/index.js"; export type { CursorState, DrawlistCanvasBlitter, DrawlistBuildError, DrawlistBuildErrorCode, DrawlistBuildResult, - DrawlistBuilderV1, - DrawlistBuilderV2, - DrawlistBuilderV3, + DrawlistBuilder, DrawlistImageFit, DrawlistImageFormat, DrawlistImageProtocol, diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index 99d5f384..9ceb5c19 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -434,7 +434,7 @@ describe("layout edge cases", () => { kind: "layer", props: { id: "layer-border-inset", - frameStyle: { border: { r: 120, g: 121, b: 122 } }, + frameStyle: { border: ((120 << 16) | (121 << 8) | 122) }, content: { kind: "text", text: "edge", props: {} }, }, }; diff --git a/packages/core/src/layout/__tests__/layout.overflow-scroll.test.ts b/packages/core/src/layout/__tests__/layout.overflow-scroll.test.ts index fae9b8ca..c0bab5f4 100644 --- a/packages/core/src/layout/__tests__/layout.overflow-scroll.test.ts +++ b/packages/core/src/layout/__tests__/layout.overflow-scroll.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; @@ -65,7 +65,7 @@ function renderAndGetLayoutTree( assert.fail("layout failed"); } - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: committed.value.root, layout: laidOut.value, diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index 2808f0a2..9c6f3888 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -56,6 +56,25 @@ type SyntheticThemedColumnCacheEntry = Readonly<{ columnNode: VNode; }>; +// Temporary profiling counters (remove after investigation) +export const __layoutProfile = { + enabled: false, + layoutNodeCalls: 0, + measureNodeCalls: 0, + measureCacheHits: 0, + layoutCacheHits: 0, + layoutByKind: {} as Record, + measureByKind: {} as Record, + reset(): void { + this.layoutNodeCalls = 0; + this.measureNodeCalls = 0; + this.measureCacheHits = 0; + this.layoutCacheHits = 0; + this.layoutByKind = {}; + this.measureByKind = {}; + }, +}; + let activeMeasureCache: MeasureCache | null = null; const measureCacheStack: MeasureCache[] = []; let activeLayoutCache: LayoutCache | null = null; @@ -123,6 +142,11 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo }; } + if (__layoutProfile.enabled) { + __layoutProfile.measureNodeCalls++; + __layoutProfile.measureByKind[vnode.kind] = (__layoutProfile.measureByKind[vnode.kind] ?? 0) + 1; + } + const cache = activeMeasureCache; if (cache) { const entry = cache.get(vnode); @@ -130,7 +154,10 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo const axisMap = axis === "row" ? entry.row : entry.column; const byH = axisMap.get(maxW); const hit = byH?.get(maxH); - if (hit) return hit; + if (hit) { + if (__layoutProfile.enabled) __layoutProfile.measureCacheHits++; + return hit; + } } } @@ -344,6 +371,11 @@ function layoutNode( }; } + if (__layoutProfile.enabled) { + __layoutProfile.layoutNodeCalls++; + __layoutProfile.layoutByKind[vnode.kind] = (__layoutProfile.layoutByKind[vnode.kind] ?? 0) + 1; + } + const cache = activeLayoutCache; const cacheKey = layoutCacheKey(maxW, maxH, forcedW, forcedH, x, y); const dirtySet = getActiveDirtySet(); @@ -354,6 +386,7 @@ function layoutNode( const axisMap = axis === "row" ? entry.row : entry.column; cacheHit = axisMap.get(cacheKey) ?? null; if (cacheHit && (dirtySet === null || !dirtySet.has(vnode))) { + if (__layoutProfile.enabled) __layoutProfile.layoutCacheHits++; return cacheHit; } } diff --git a/packages/core/src/perf/perf.ts b/packages/core/src/perf/perf.ts index 2f5b19f9..cfc7d4d9 100644 --- a/packages/core/src/perf/perf.ts +++ b/packages/core/src/perf/perf.ts @@ -70,6 +70,7 @@ export type PhaseStats = Readonly<{ /** Aggregated perf snapshot. */ export type PerfSnapshot = Readonly<{ phases: Readonly<{ [K in InstrumentationPhase]?: PhaseStats }>; + counters: Readonly>; }>; /** Token returned by markStart for timing correlation. */ @@ -195,6 +196,7 @@ function computeStats(ring: PhaseRing): PhaseStats | null { /** Global perf aggregator. */ class PerfAggregator { private readonly rings = new Map(); + private readonly counters = new Map(); private pendingStarts = new Map(); markStart(phase: InstrumentationPhase): PerfToken { @@ -227,6 +229,10 @@ class PerfAggregator { recordSample(ring, durationMs); } + count(name: string, delta = 1): void { + this.counters.set(name, (this.counters.get(name) ?? 0) + delta); + } + snapshot(): PerfSnapshot { const phases: { [K in InstrumentationPhase]?: PhaseStats } = {}; for (const p of PERF_PHASES) { @@ -238,11 +244,14 @@ class PerfAggregator { } } } - return Object.freeze({ phases: Object.freeze(phases) }); + const counters: Record = {}; + for (const [name, value] of this.counters) counters[name] = value; + return Object.freeze({ phases: Object.freeze(phases), counters: Object.freeze(counters) }); } reset(): void { this.rings.clear(); + this.counters.clear(); this.pendingStarts.clear(); } } @@ -299,7 +308,7 @@ export function perfRecord(phase: InstrumentationPhase, durationMs: number): voi */ export function perfSnapshot(): PerfSnapshot { if (!PERF_ENABLED) { - return Object.freeze({ phases: Object.freeze({}) }); + return Object.freeze({ phases: Object.freeze({}), counters: Object.freeze({}) }); } return getAggregator().snapshot(); } @@ -312,3 +321,9 @@ export function perfReset(): void { if (!PERF_ENABLED) return; getAggregator().reset(); } + +/** Increment a named counter (no-op when perf is disabled). */ +export function perfCount(name: string, delta = 1): void { + if (!PERF_ENABLED) return; + getAggregator().count(name, delta); +} diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts new file mode 100644 index 00000000..1f8977ea --- /dev/null +++ b/packages/core/src/pipeline.ts @@ -0,0 +1,35 @@ +/** + * packages/core/src/pipeline.ts — Pipeline primitives for external renderers. + * + * Re-exports the commit, layout, drawlist, and stability APIs that + * ink-compat (and similar consumers) need to build an optimized render loop + * without depending on the test-only createTestRenderer helper. + */ + +// Commit +export { commitVNodeTree, __commitDiag } from "./runtime/commit.js"; +export type { RuntimeInstance, CommitDiagEntry } from "./runtime/commit.js"; +export { createInstanceIdAllocator } from "./runtime/instance.js"; +export type { InstanceId, InstanceIdAllocator } from "./runtime/instance.js"; + +// Layout +export { layout } from "./layout/layout.js"; +export type { LayoutTree } from "./layout/layout.js"; + +// Drawlist rendering +export { renderToDrawlist } from "./renderer/renderToDrawlist.js"; + +// Layout stability +export { updateLayoutStabilitySignatures } from "./app/widgetRenderer/submitFramePipeline.js"; + +// Layout dirty set +export { + computeDirtyLayoutSet, + instanceDirtySetToVNodeDirtySet, +} from "./layout/engine/dirtySet.js"; + +// Damage tracking (for collecting dirty instance IDs from commit flags) +export { collectSelfDirtyInstanceIds } from "./app/widgetRenderer/damageTracking.js"; + +// Temporary profiling (remove after investigation) +export { __layoutProfile } from "./layout/engine/layoutEngine.js"; diff --git a/packages/core/src/renderer/__tests__/focusIndicators.test.ts b/packages/core/src/renderer/__tests__/focusIndicators.test.ts index b6415abd..85772468 100644 --- a/packages/core/src/renderer/__tests__/focusIndicators.test.ts +++ b/packages/core/src/renderer/__tests__/focusIndicators.test.ts @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1, TextStyle, VNode } from "../../index.js"; +import type { DrawlistBuildResult, DrawlistBuilder, TextStyle, VNode } from "../../index.js"; import { ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -21,7 +21,7 @@ type DrawOp = type DrawTextOp = Readonly<{ x: number; y: number; text: string; style?: TextStyle }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly ops: DrawOp[] = []; clear(): void { @@ -58,6 +58,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } @@ -325,7 +339,7 @@ describe("focus indicator rendering contracts", () => { }); test("FocusConfig.style overrides design-system focus defaults", () => { - const customRing = Object.freeze({ r: 255, g: 0, b: 128 }); + const customRing = Object.freeze(((255 << 16) | (0 << 8) | 128)); const theme = coerceToLegacyTheme(darkTheme); const textOps = drawTextOps( renderOps( diff --git a/packages/core/src/renderer/__tests__/overlay.edge.test.ts b/packages/core/src/renderer/__tests__/overlay.edge.test.ts index b11e6e9f..bd190769 100644 --- a/packages/core/src/renderer/__tests__/overlay.edge.test.ts +++ b/packages/core/src/renderer/__tests__/overlay.edge.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; @@ -58,7 +58,7 @@ function renderStrings( assert.equal(layoutRes.ok, true, "layout should succeed"); if (!layoutRes.ok) return Object.freeze([]); - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: commitRes.value.root, layout: layoutRes.value, diff --git a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts index cbe04f80..581be92c 100644 --- a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts +++ b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts @@ -2,7 +2,7 @@ import { assert } from "@rezi-ui/testkit"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { DrawlistBuildResult, - DrawlistBuilderV1, + DrawlistBuilder, TextStyle, Theme, VNode, @@ -21,7 +21,7 @@ export type DrawOp = | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> | Readonly<{ kind: "popClip" }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly ops: DrawOp[] = []; clear(): void { @@ -63,6 +63,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } diff --git a/packages/core/src/renderer/__tests__/render.golden.test.ts b/packages/core/src/renderer/__tests__/render.golden.test.ts index 8d792437..588da217 100644 --- a/packages/core/src/renderer/__tests__/render.golden.test.ts +++ b/packages/core/src/renderer/__tests__/render.golden.test.ts @@ -1,5 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { truncateMiddle, truncateWithEllipsis } from "../../layout/textMeasure.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -115,7 +115,7 @@ function renderBytes( const committed = commitTree(vnode); const lt = layoutTree(committed.vnode); - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); renderToDrawlist({ tree: committed, layout: lt, @@ -157,7 +157,7 @@ describe("renderer - widget tree to deterministic ZRDL bytes", () => { assert.equal(ops.includes(5), false, "no POP_CLIP"); const noClip = (() => { - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); b.drawText(1, 1, "hello"); const built = b.build(); assert.equal(built.ok, true); @@ -438,7 +438,7 @@ describe("renderer - widget tree to deterministic ZRDL bytes", () => { assert.equal(l.ok, true); if (!l.ok) return; - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); renderToDrawlist({ tree: committed, layout: l.value, diff --git a/packages/core/src/renderer/__tests__/renderer.border.test.ts b/packages/core/src/renderer/__tests__/renderer.border.test.ts index 5cd24582..62070128 100644 --- a/packages/core/src/renderer/__tests__/renderer.border.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.border.test.ts @@ -3,7 +3,7 @@ import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { BorderStyle, DrawlistBuildResult, - DrawlistBuilderV1, + DrawlistBuilder, TextStyle, VNode, } from "../../index.js"; @@ -24,7 +24,7 @@ type DrawOp = type DrawTextOp = Readonly<{ index: number; x: number; y: number; text: string }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly ops: DrawOp[] = []; clear(): void { @@ -66,6 +66,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } @@ -478,9 +492,9 @@ describe("renderer border rendering (deterministic)", () => { }); test("box opacity blends merged own style against parent backdrop", () => { - const backdrop = { r: 32, g: 48, b: 64 }; - const childFg = { r: 250, g: 180, b: 110 }; - const childBg = { r: 10, g: 20, b: 30 }; + const backdrop = ((32 << 16) | (48 << 8) | 64); + const childFg = ((250 << 16) | (180 << 8) | 110); + const childBg = ((10 << 16) | (20 << 8) | 30); const ops = renderOps( ui.column({ id: "root", style: { bg: backdrop } }, [ ui.box( @@ -505,14 +519,8 @@ describe("renderer border rendering (deterministic)", () => { op.w === 8 && op.h === 4 && op.style !== undefined && - op.style.fg !== undefined && - op.style.bg !== undefined && - op.style.fg.r === backdrop.r && - op.style.fg.g === backdrop.g && - op.style.fg.b === backdrop.b && - op.style.bg.r === backdrop.r && - op.style.bg.g === backdrop.g && - op.style.bg.b === backdrop.b, + op.style.fg === backdrop && + op.style.bg === backdrop, ); assert.equal(childFillMatchesBackdrop, true, "faded fill should match parent backdrop"); @@ -526,7 +534,7 @@ describe("renderer border rendering (deterministic)", () => { }); test("button pressedStyle is applied when pressedId matches", () => { - const pressedFg = Object.freeze({ r: 255, g: 64, b: 64 }); + const pressedFg = Object.freeze(((255 << 16) | (64 << 8) | 64)); const ops = renderOps( ui.button({ id: "btn", diff --git a/packages/core/src/renderer/__tests__/renderer.clip.test.ts b/packages/core/src/renderer/__tests__/renderer.clip.test.ts index 27bbfd80..cab46f1d 100644 --- a/packages/core/src/renderer/__tests__/renderer.clip.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.clip.test.ts @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1, TextStyle, VNode } from "../../index.js"; +import type { DrawlistBuildResult, DrawlistBuilder, TextStyle, VNode } from "../../index.js"; import { ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import type { Axis } from "../../layout/types.js"; @@ -32,7 +32,7 @@ type DrawDepth = Readonly<{ depth: number; }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly ops: DrawOp[] = []; clear(): void { @@ -74,6 +74,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } diff --git a/packages/core/src/renderer/__tests__/renderer.damage.test.ts b/packages/core/src/renderer/__tests__/renderer.damage.test.ts index 99d1260a..9b167b8c 100644 --- a/packages/core/src/renderer/__tests__/renderer.damage.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.damage.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { TextStyle, VNode } from "../../index.js"; import { ui } from "../../index.js"; @@ -47,7 +47,7 @@ type Framebuffer = Readonly<{ styles: string[]; }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { private readonly ops: RecordedOp[] = []; getOps(): readonly RecordedOp[] { @@ -86,6 +86,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array([0]) }; } @@ -469,7 +483,7 @@ describe("renderer damage rect behavior", () => { height: 3, border: "none", shadow: true, - style: { bg: { r: 30, g: 30, b: 30 } }, + style: { bg: ((30 << 16) | (30 << 8) | 30) }, }), viewport, ); @@ -478,7 +492,7 @@ describe("renderer damage rect behavior", () => { width: 6, height: 3, border: "none", - style: { bg: { r: 30, g: 30, b: 30 } }, + style: { bg: ((30 << 16) | (30 << 8) | 30) }, }), viewport, ); @@ -497,7 +511,7 @@ describe("renderer damage rect behavior", () => { height: 2, border: "none", shadow: true, - style: { bg: { r: 20, g: 20, b: 20 } }, + style: { bg: ((20 << 16) | (20 << 8) | 20) }, }), ]), viewport, @@ -509,7 +523,7 @@ describe("renderer damage rect behavior", () => { height: 2, border: "none", shadow: true, - style: { bg: { r: 20, g: 20, b: 20 } }, + style: { bg: ((20 << 16) | (20 << 8) | 20) }, }), ]), viewport, diff --git a/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts b/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts index 25e38531..237fde24 100644 --- a/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts @@ -2,7 +2,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { Viewport, WidgetRenderPlan } from "../../app/widgetRenderer.js"; import { WidgetRenderer } from "../../app/widgetRenderer.js"; import type { RuntimeBackend } from "../../backend.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { ZrevEvent } from "../../events.js"; import type { VNode } from "../../index.js"; @@ -11,7 +11,7 @@ import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; -class CountingBuilder implements DrawlistBuilderV1 { +class CountingBuilder implements DrawlistBuilder { private opCount = 0; private lastBuiltCount = 0; @@ -53,6 +53,20 @@ class CountingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { this.lastBuiltCount = this.opCount; return { ok: true, bytes: new Uint8Array([this.opCount & 0xff]) }; diff --git a/packages/core/src/renderer/__tests__/renderer.partial.test.ts b/packages/core/src/renderer/__tests__/renderer.partial.test.ts index d6dd0564..13d6fba1 100644 --- a/packages/core/src/renderer/__tests__/renderer.partial.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.partial.test.ts @@ -2,7 +2,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { Viewport, WidgetRenderPlan } from "../../app/widgetRenderer.js"; import { WidgetRenderer } from "../../app/widgetRenderer.js"; import type { RuntimeBackend } from "../../backend.js"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { ZrevEvent } from "../../events.js"; import type { TextStyle, VNode } from "../../index.js"; @@ -19,7 +19,7 @@ type RecordedOp = | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> | Readonly<{ kind: "popClip" }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { private ops: RecordedOp[] = []; private lastBuiltOps: readonly RecordedOp[] = Object.freeze([]); @@ -61,11 +61,25 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + build(): DrawlistBuildResult { this.lastBuiltOps = this.ops.slice(); return { ok: true, bytes: new Uint8Array([this.ops.length & 0xff]) }; } + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + reset(): void { this.ops = []; } diff --git a/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts b/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts index 4b427a28..45d57201 100644 --- a/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { TextStyle, VNode } from "../../index.js"; import { ui } from "../../index.js"; @@ -49,7 +49,7 @@ type OverflowMeta = Readonly<{ viewportHeight: number; }>; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { private readonly ops: RecordedOp[] = []; getOps(): readonly RecordedOp[] { @@ -86,6 +86,20 @@ class RecordingBuilder implements DrawlistBuilderV1 { drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array([0]) }; } @@ -491,7 +505,7 @@ describe("renderer scroll container integration", () => { test("scrollbarStyle overrides rendered scrollbar draw style", () => { const lines: VNode[] = []; for (let i = 0; i < 6; i++) lines.push(ui.text("abcd")); - const customFg = Object.freeze({ r: 1, g: 2, b: 3 }); + const customFg = Object.freeze(((1 << 16) | (2 << 8) | 3)); const vnode = ui.box( { width: 6, diff --git a/packages/core/src/renderer/__tests__/renderer.text.test.ts b/packages/core/src/renderer/__tests__/renderer.text.test.ts index a5ae51d9..b0bab201 100644 --- a/packages/core/src/renderer/__tests__/renderer.text.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.text.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; @@ -275,7 +275,7 @@ function renderBytes( assert.fail("layout should succeed"); } - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: committed, layout: layoutRes.value, diff --git a/packages/core/src/renderer/__tests__/spinner.golden.test.ts b/packages/core/src/renderer/__tests__/spinner.golden.test.ts index a356e7ea..83803f2b 100644 --- a/packages/core/src/renderer/__tests__/spinner.golden.test.ts +++ b/packages/core/src/renderer/__tests__/spinner.golden.test.ts @@ -1,5 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import type { FocusState } from "../../runtime/focus.js"; @@ -35,7 +35,7 @@ function renderBytes(vnode: VNode, focusState: FocusState, tick: number): Uint8A const committed = commitTree(vnode); const lt = layoutTree(committed.vnode); - const b = createDrawlistBuilderV1(); + const b = createDrawlistBuilder(); renderToDrawlist({ tree: committed, layout: lt, diff --git a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts index df8f5a0c..5e448470 100644 --- a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts @@ -17,11 +17,9 @@ const data: readonly Row[] = [ function rgbEquals( actual: unknown, - expected: Readonly<{ r: number; g: number; b: number }> | undefined, + expected: number | undefined, ): boolean { - if (!expected || typeof actual !== "object" || actual === null) return false; - const a = actual as { r?: unknown; g?: unknown; b?: unknown }; - return a.r === expected.r && a.g === expected.g && a.b === expected.b; + return typeof actual === "number" && expected !== undefined && actual === expected; } function firstDrawText( diff --git a/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts b/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts index 59a76657..2be1a699 100644 --- a/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts +++ b/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { rgbBlend } from "../../widgets/style.js"; import { DEFAULT_BASE_STYLE, applyOpacityToStyle, @@ -8,8 +9,8 @@ import { describe("renderer/textStyle opacity blending", () => { test("opacity >= 1 returns the original style reference", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 255, g: 0, b: 0 }, - bg: { r: 0, g: 0, b: 80 }, + fg: ((255 << 16) | (0 << 8) | 0), + bg: ((0 << 16) | (0 << 8) | 80), bold: true, }); assert.equal(applyOpacityToStyle(style, 1), style); @@ -18,8 +19,8 @@ describe("renderer/textStyle opacity blending", () => { test("opacity <= 0 collapses fg/bg to base background", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 240, g: 120, b: 60 }, - bg: { r: 30, g: 40, b: 50 }, + fg: ((240 << 16) | (120 << 8) | 60), + bg: ((30 << 16) | (40 << 8) | 50), italic: true, }); @@ -31,39 +32,31 @@ describe("renderer/textStyle opacity blending", () => { test("blends channels with rounding and preserves non-color attrs", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 107, g: 203, b: 31 }, - bg: { r: 90, g: 40, b: 200 }, + fg: ((107 << 16) | (203 << 8) | 31), + bg: ((90 << 16) | (40 << 8) | 200), underline: true, }); const applied = applyOpacityToStyle(style, 0.5); assert.equal(applied.underline, true); - assert.deepEqual(applied.fg, { - r: Math.round(DEFAULT_BASE_STYLE.bg.r + (style.fg.r - DEFAULT_BASE_STYLE.bg.r) * 0.5), - g: Math.round(DEFAULT_BASE_STYLE.bg.g + (style.fg.g - DEFAULT_BASE_STYLE.bg.g) * 0.5), - b: Math.round(DEFAULT_BASE_STYLE.bg.b + (style.fg.b - DEFAULT_BASE_STYLE.bg.b) * 0.5), - }); - assert.deepEqual(applied.bg, { - r: Math.round(DEFAULT_BASE_STYLE.bg.r + (style.bg.r - DEFAULT_BASE_STYLE.bg.r) * 0.5), - g: Math.round(DEFAULT_BASE_STYLE.bg.g + (style.bg.g - DEFAULT_BASE_STYLE.bg.g) * 0.5), - b: Math.round(DEFAULT_BASE_STYLE.bg.b + (style.bg.b - DEFAULT_BASE_STYLE.bg.b) * 0.5), - }); + assert.equal(applied.fg, rgbBlend(DEFAULT_BASE_STYLE.bg, style.fg, 0.5)); + assert.equal(applied.bg, rgbBlend(DEFAULT_BASE_STYLE.bg, style.bg, 0.5)); }); test("non-finite opacity is treated as fully opaque", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 200, g: 100, b: 90 }, - bg: { r: 10, g: 40, b: 80 }, + fg: ((200 << 16) | (100 << 8) | 90), + bg: ((10 << 16) | (40 << 8) | 80), }); assert.equal(applyOpacityToStyle(style, Number.NaN), style); assert.equal(applyOpacityToStyle(style, Number.NEGATIVE_INFINITY), style); }); test("blends against custom backdrop when provided", () => { - const backdrop = { r: 90, g: 100, b: 110 }; + const backdrop = ((90 << 16) | (100 << 8) | 110); const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 240, g: 80, b: 20 }, - bg: { r: 10, g: 30, b: 50 }, + fg: ((240 << 16) | (80 << 8) | 20), + bg: ((10 << 16) | (30 << 8) | 50), }); const hidden = applyOpacityToStyle(style, 0, backdrop); @@ -71,15 +64,7 @@ describe("renderer/textStyle opacity blending", () => { assert.deepEqual(hidden.bg, backdrop); const half = applyOpacityToStyle(style, 0.5, backdrop); - assert.deepEqual(half.fg, { - r: Math.round(backdrop.r + (style.fg.r - backdrop.r) * 0.5), - g: Math.round(backdrop.g + (style.fg.g - backdrop.g) * 0.5), - b: Math.round(backdrop.b + (style.fg.b - backdrop.b) * 0.5), - }); - assert.deepEqual(half.bg, { - r: Math.round(backdrop.r + (style.bg.r - backdrop.r) * 0.5), - g: Math.round(backdrop.g + (style.bg.g - backdrop.g) * 0.5), - b: Math.round(backdrop.b + (style.bg.b - backdrop.b) * 0.5), - }); + assert.equal(half.fg, rgbBlend(backdrop, style.fg, 0.5)); + assert.equal(half.bg, rgbBlend(backdrop, style.bg, 0.5)); }); }); diff --git a/packages/core/src/renderer/renderToDrawlist.ts b/packages/core/src/renderer/renderToDrawlist.ts index 50b4446c..fccb2315 100644 --- a/packages/core/src/renderer/renderToDrawlist.ts +++ b/packages/core/src/renderer/renderToDrawlist.ts @@ -7,7 +7,7 @@ * @see docs/guide/runtime-and-layout.md */ -import type { DrawlistBuilderV1, DrawlistBuilderV2 } from "../drawlist/types.js"; +import type { DrawlistBuilder } from "../drawlist/types.js"; import { defaultTheme } from "../theme/defaultTheme.js"; import { indexIdRects, indexLayoutRects } from "./renderToDrawlist/indices.js"; import { renderTree } from "./renderToDrawlist/renderTree.js"; @@ -23,13 +23,6 @@ export type { CodeEditorRenderCache, } from "./renderToDrawlist/types.js"; -/** - * Check if a builder supports cursor commands. - */ -function isCursorBuilder(builder: DrawlistBuilderV1): builder is DrawlistBuilderV2 { - return typeof (builder as DrawlistBuilderV2).setCursor === "function"; -} - /** * Render a committed runtime tree to a drawlist. * @@ -88,7 +81,7 @@ export function renderToDrawlist(params: RenderToDrawlistParams): void { ); /* Cursor protocol: emit SET_CURSOR for focused input */ - if (resolvedCursor && isCursorBuilder(params.builder)) { + if (resolvedCursor) { params.builder.setCursor({ x: resolvedCursor.x, y: resolvedCursor.y, @@ -96,7 +89,7 @@ export function renderToDrawlist(params: RenderToDrawlistParams): void { visible: true, blink: resolvedCursor.blink, }); - } else if (params.cursorInfo && isCursorBuilder(params.builder)) { + } else if (params.cursorInfo) { /* No focused input: hide cursor */ params.builder.hideCursor(); } diff --git a/packages/core/src/renderer/renderToDrawlist/boxBorder.ts b/packages/core/src/renderer/renderToDrawlist/boxBorder.ts index fd91b565..71153084 100644 --- a/packages/core/src/renderer/renderToDrawlist/boxBorder.ts +++ b/packages/core/src/renderer/renderToDrawlist/boxBorder.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../drawlist/types.js"; import { measureTextCells, truncateWithEllipsis } from "../../layout/textMeasure.js"; import type { Rect } from "../../layout/types.js"; import { @@ -56,7 +56,7 @@ export function readTitleAlign(v: unknown): "left" | "center" | "right" { * @param style - Text style for border and title */ export function renderBoxBorder( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, border: BorderStyle, title: string | undefined, @@ -97,7 +97,7 @@ export function renderBoxBorder( * Render the border frame (corners and edges). */ function renderBorderFrame( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, glyphs: BorderGlyphSet, style: ResolvedTextStyle, @@ -191,7 +191,7 @@ function renderBorderFrame( * Render the title in the top border. */ function renderBorderTitle( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, title: string, titleAlign: "left" | "center" | "right", @@ -235,7 +235,7 @@ function renderBorderTitle( * @param style - Text style */ export function renderBoxDivider( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, y: number, border: BorderStyle, diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 499baf0f..d5e4eae8 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../drawlist/types.js"; import type { LayoutTree } from "../../layout/layout.js"; import type { Rect } from "../../layout/types.js"; import type { RuntimeInstance } from "../../runtime/commit.js"; @@ -72,7 +72,7 @@ function usesVisibleOverflow(node: RuntimeInstance): boolean { } export function renderTree( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, layoutTree: LayoutTree, idRectIndex: IdRectIndex, diff --git a/packages/core/src/renderer/renderToDrawlist/simpleVNode.ts b/packages/core/src/renderer/renderToDrawlist/simpleVNode.ts index 23a9e222..141b7aef 100644 --- a/packages/core/src/renderer/renderToDrawlist/simpleVNode.ts +++ b/packages/core/src/renderer/renderToDrawlist/simpleVNode.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../drawlist/types.js"; import { type SpinnerVariant, getSpinnerFrame, @@ -203,7 +203,7 @@ function clipSegmentsToWidth( } function drawSegments( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, x: number, y: number, maxWidth: number, @@ -352,7 +352,7 @@ export function measureVNodeSimpleHeight(vnode: VNode, w: number): number { * This renders a VNode at the given position without going through the full layout system. */ export function renderVNodeSimple( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, vnode: VNode, x: number, y: number, diff --git a/packages/core/src/renderer/renderToDrawlist/textStyle.ts b/packages/core/src/renderer/renderToDrawlist/textStyle.ts index 10dcc72f..b3f55d2b 100644 --- a/packages/core/src/renderer/renderToDrawlist/textStyle.ts +++ b/packages/core/src/renderer/renderToDrawlist/textStyle.ts @@ -1,10 +1,21 @@ -import type { TextStyle } from "../../widgets/style.js"; +import { perfCount } from "../../perf/perf.js"; +import { rgb, rgbBlend, type TextStyle } from "../../widgets/style.js"; import { sanitizeTextStyle } from "../../widgets/styleUtils.js"; +const ATTR_BOLD = 1 << 0; +const ATTR_ITALIC = 1 << 1; +const ATTR_UNDERLINE = 1 << 2; +const ATTR_INVERSE = 1 << 3; +const ATTR_DIM = 1 << 4; +const ATTR_STRIKETHROUGH = 1 << 5; +const ATTR_OVERLINE = 1 << 6; +const ATTR_BLINK = 1 << 7; + export type ResolvedTextStyle = Readonly< { fg: NonNullable; bg: NonNullable; + attrs: number; } & Pick< TextStyle, | "bold" @@ -21,8 +32,9 @@ export type ResolvedTextStyle = Readonly< >; export const DEFAULT_BASE_STYLE: ResolvedTextStyle = Object.freeze({ - fg: Object.freeze({ r: 232, g: 238, b: 245 }), - bg: Object.freeze({ r: 7, g: 10, b: 12 }), + fg: rgb(232, 238, 245), + bg: rgb(7, 10, 12), + attrs: 0, }); // Fast path cache for `mergeTextStyle(DEFAULT_BASE_STYLE, override)` when override only toggles @@ -36,10 +48,60 @@ function encTriBool(v: boolean | undefined): number { return v ? 2 : 1; } +function computeAttrs( + bold: boolean | undefined, + dim: boolean | undefined, + italic: boolean | undefined, + underline: boolean | undefined, + inverse: boolean | undefined, + strikethrough: boolean | undefined, + overline: boolean | undefined, + blink: boolean | undefined, + underlineStyle: TextStyle["underlineStyle"] | undefined, +): number { + let attrs = 0; + if (bold) attrs |= ATTR_BOLD; + if (italic) attrs |= ATTR_ITALIC; + if (underline || (underlineStyle !== undefined && underlineStyle !== "none")) { + attrs |= ATTR_UNDERLINE; + } + if (inverse) attrs |= ATTR_INVERSE; + if (dim) attrs |= ATTR_DIM; + if (strikethrough) attrs |= ATTR_STRIKETHROUGH; + if (overline) attrs |= ATTR_OVERLINE; + if (blink) attrs |= ATTR_BLINK; + return attrs >>> 0; +} + +function freezeResolved( + merged: Omit & { attrs?: number }, +): ResolvedTextStyle { + const out: ResolvedTextStyle = Object.freeze({ + ...merged, + attrs: + merged.attrs ?? + computeAttrs( + merged.bold, + merged.dim, + merged.italic, + merged.underline, + merged.inverse, + merged.strikethrough, + merged.overline, + merged.blink, + merged.underlineStyle, + ), + }); + perfCount("style_objects_created", 1); + return out; +} + export function mergeTextStyle( base: ResolvedTextStyle, override: TextStyle | undefined, ): ResolvedTextStyle { + perfCount("style_merges_performed", 1); + perfCount("packRgb_calls", 0); if (!override) return base; const normalized = sanitizeTextStyle(override); if ( @@ -66,6 +128,7 @@ export function mergeTextStyle( const merged: { fg: NonNullable; bg: NonNullable; + attrs?: number; bold?: boolean; dim?: boolean; italic?: boolean; @@ -87,7 +150,19 @@ export function mergeTextStyle( if (normalized.overline !== undefined) merged.overline = normalized.overline; if (normalized.blink !== undefined) merged.blink = normalized.blink; - const frozenMerged = Object.freeze(merged); + merged.attrs = computeAttrs( + merged.bold, + merged.dim, + merged.italic, + merged.underline, + merged.inverse, + merged.strikethrough, + merged.overline, + merged.blink, + undefined, + ); + + const frozenMerged = freezeResolved(merged); BASE_BOOL_STYLE_CACHE[key] = frozenMerged; return frozenMerged; } @@ -119,14 +194,21 @@ export function mergeTextStyle( const blink = normalized.blink ?? base.blink; const underlineStyle = override.underlineStyle ?? base.underlineStyle; const underlineColor = override.underlineColor ?? base.underlineColor; + const attrs = computeAttrs( + bold, + dim, + italic, + underline, + inverse, + strikethrough, + overline, + blink, + underlineStyle, + ); if ( - fg.r === base.fg.r && - fg.g === base.fg.g && - fg.b === base.fg.b && - bg.r === base.bg.r && - bg.g === base.bg.g && - bg.b === base.bg.b && + fg === base.fg && + bg === base.bg && bold === base.bold && dim === base.dim && italic === base.italic && @@ -136,7 +218,8 @@ export function mergeTextStyle( overline === base.overline && blink === base.blink && underlineStyle === base.underlineStyle && - underlineColor === base.underlineColor + underlineColor === base.underlineColor && + attrs === base.attrs ) { return base; } @@ -144,6 +227,7 @@ export function mergeTextStyle( const merged: { fg: NonNullable; bg: NonNullable; + attrs?: number; bold?: boolean; dim?: boolean; italic?: boolean; @@ -157,6 +241,7 @@ export function mergeTextStyle( } = { fg, bg, + attrs, }; if (bold !== undefined) merged.bold = bold; @@ -169,7 +254,7 @@ export function mergeTextStyle( if (blink !== undefined) merged.blink = blink; if (underlineStyle !== undefined) merged.underlineStyle = underlineStyle; if (underlineColor !== undefined) merged.underlineColor = underlineColor; - return merged; + return freezeResolved(merged); } export function shouldFillForStyleOverride(override: TextStyle | undefined): boolean { @@ -184,10 +269,6 @@ function clampOpacity(opacity: number): number { return opacity; } -function blendChannel(from: number, to: number, opacity: number): number { - return Math.round(from + (to - from) * opacity); -} - /** * Apply opacity to a resolved style by blending fg/bg toward the provided backdrop color. */ @@ -199,29 +280,14 @@ export function applyOpacityToStyle( const clamped = clampOpacity(opacity); if (clamped >= 1) return style; - const fg = Object.freeze({ - r: blendChannel(backdrop.r, style.fg.r, clamped), - g: blendChannel(backdrop.g, style.fg.g, clamped), - b: blendChannel(backdrop.b, style.fg.b, clamped), - }); - const bg = Object.freeze({ - r: blendChannel(backdrop.r, style.bg.r, clamped), - g: blendChannel(backdrop.g, style.bg.g, clamped), - b: blendChannel(backdrop.b, style.bg.b, clamped), - }); + const fg = rgbBlend(backdrop, style.fg, clamped); + const bg = rgbBlend(backdrop, style.bg, clamped); - if ( - fg.r === style.fg.r && - fg.g === style.fg.g && - fg.b === style.fg.b && - bg.r === style.bg.r && - bg.g === style.bg.g && - bg.b === style.bg.b - ) { + if (fg === style.fg && bg === style.bg) { return style; } - return Object.freeze({ + return freezeResolved({ ...style, fg, bg, diff --git a/packages/core/src/renderer/renderToDrawlist/types.ts b/packages/core/src/renderer/renderToDrawlist/types.ts index caf85693..0b3cbd66 100644 --- a/packages/core/src/renderer/renderToDrawlist/types.ts +++ b/packages/core/src/renderer/renderToDrawlist/types.ts @@ -1,5 +1,5 @@ import type { CursorShape } from "../../abi.js"; -import type { DrawlistBuilderV1 } from "../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../drawlist/types.js"; import type { LayoutTree } from "../../layout/layout.js"; import type { Rect } from "../../layout/types.js"; import type { RuntimeInstance } from "../../runtime/commit.js"; @@ -68,7 +68,7 @@ export type RenderToDrawlistParams = Readonly<{ focusState: FocusState; /** Optional currently pressed interactive widget id. */ pressedId?: string | null | undefined; - builder: DrawlistBuilderV1; + builder: DrawlistBuilder; /** Optional animation tick/frame index (used by spinners, etc.). */ tick?: number | undefined; /** Optional app theme for themed widgets (e.g., divider). */ diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts index 189c9b67..f08f7b99 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { LayoutTree } from "../../../layout/layout.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; @@ -31,7 +31,7 @@ export type ResolvedCursor = Readonly<{ }>; function maybeFillOwnBackground( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, ownStyle: unknown, style: ResolvedTextStyle, @@ -47,7 +47,7 @@ function readZLayer(v: unknown): -1 | 0 | 1 { } export function renderBasicWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, pressedId: string | null, rect: Rect, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 1e8ba245..33ec6158 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { LayoutTree } from "../../../layout/layout.js"; import { measureTextCells, @@ -143,7 +143,7 @@ function alignCellContent( } function drawAlignedCellText( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, x: number, y: number, w: number, @@ -213,7 +213,7 @@ function setLayoutScrollMetadata( } export function renderCollectionWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, rect: Rect, theme: Theme, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 34ee08ab..682bd58f 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { LayoutTree } from "../../../layout/layout.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; @@ -278,26 +278,11 @@ function resolveBoxShadowConfig( }); } -function readRgbChannel(raw: unknown): number | null { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return null; - } - const clamped = Math.max(0, Math.min(255, Math.trunc(raw))); - return clamped; -} - function readRgbColor(raw: unknown): ResolvedTextStyle["fg"] | undefined { - if (typeof raw !== "object" || raw === null) { - return undefined; - } - const color = raw as { r?: unknown; g?: unknown; b?: unknown }; - const r = readRgbChannel(color.r); - const g = readRgbChannel(color.g); - const b = readRgbChannel(color.b); - if (r === null || g === null || b === null) { + if (typeof raw !== "number" || !Number.isFinite(raw)) { return undefined; } - return { r, g, b }; + return (Math.round(raw) >>> 0) & 0x00ff_ffff; } function readOverlayFrameColors(raw: unknown): OverlayFrameColors { @@ -458,7 +443,7 @@ function resolveScrollViewport(contentRect: Rect, meta: OverflowMetadata): Scrol } function drawScrollbars( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, viewport: ScrollViewport, style: ResolvedTextStyle, theme: Theme, @@ -520,7 +505,7 @@ function drawScrollbars( } export function renderContainerWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, currentClip: ClipRect | undefined, viewport: Readonly<{ cols: number; rows: number }>, @@ -789,6 +774,7 @@ export function renderContainerWidget( const style: ResolvedTextStyle = { fg: backdrop.foreground ?? theme.colors.border, bg: backdrop.background ?? theme.colors.bg, + attrs: 0, }; for (let dy = 0; dy < fill.h; dy++) { builder.drawText(fill.x, fill.y + dy, line, style); @@ -880,7 +866,11 @@ export function renderContainerWidget( } else if (backdrop === "dim") { if (fill.w > 0 && fill.h > 0) { const line = "░".repeat(fill.w); - const style: ResolvedTextStyle = { fg: theme.colors.border, bg: theme.colors.bg }; + const style: ResolvedTextStyle = { + fg: theme.colors.border, + bg: theme.colors.bg, + attrs: 0, + }; for (let dy = 0; dy < fill.h; dy++) { builder.drawText(fill.x, fill.y + dy, line, style); } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts index 4667ca33..b6e6ac47 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts @@ -1,10 +1,11 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { measureTextCells } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { Theme } from "../../../theme/theme.js"; import { tokenizeCodeEditorLineWithCustom } from "../../../widgets/codeEditorSyntax.js"; +import type { Rgb24 } from "../../../widgets/style.js"; import { formatCost, formatDuration, @@ -62,7 +63,7 @@ type CodeEditorSyntaxStyleMap = Readonly, + fallback: Rgb24, ) { return theme.colors[key] ?? fallback; } @@ -109,7 +110,7 @@ function createCodeEditorSyntaxStyleMap( } function drawCodeEditorSyntaxLine( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, x: number, y: number, width: number, @@ -153,7 +154,7 @@ function drawCodeEditorSyntaxLine( } export function renderEditorWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, rect: Rect, theme: Theme, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/files.ts b/packages/core/src/renderer/renderToDrawlist/widgets/files.ts index e981820c..20f4555c 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/files.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/files.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { measureTextCells } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; @@ -94,7 +94,7 @@ export function getTreePrefixes( } export function renderFileWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, rect: Rect, theme: Theme, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/navigation.ts b/packages/core/src/renderer/renderToDrawlist/widgets/navigation.ts index 6f368c5d..9d841e2a 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/navigation.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/navigation.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { LayoutTree } from "../../../layout/layout.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; @@ -157,7 +157,7 @@ function buildNavigationControlOverrides( } function resolveNavigationRenderStyle( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, parentStyle: ResolvedTextStyle, node: RuntimeInstance, @@ -222,7 +222,7 @@ function resolveNavigationRenderStyle( } export function renderNavigationWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, theme: Theme, parentStyle: ResolvedTextStyle, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts index 88896041..bc9f330a 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { computeDropdownGeometry } from "../../../layout/dropdownGeometry.js"; import { measureTextCells, truncateWithEllipsis } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; @@ -72,25 +72,11 @@ function readString(raw: unknown, fallback = ""): string { return typeof raw === "string" ? raw : fallback; } -function readRgbChannel(raw: unknown): number | null { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return null; - } - return Math.max(0, Math.min(255, Math.trunc(raw))); -} - function readRgbColor(raw: unknown): ResolvedTextStyle["fg"] | undefined { - if (typeof raw !== "object" || raw === null) { - return undefined; - } - const color = raw as { r?: unknown; g?: unknown; b?: unknown }; - const r = readRgbChannel(color.r); - const g = readRgbChannel(color.g); - const b = readRgbChannel(color.b); - if (r === null || g === null || b === null) { + if (typeof raw !== "number" || !Number.isFinite(raw)) { return undefined; } - return { r, g, b }; + return (Math.round(raw) >>> 0) & 0x00ff_ffff; } function readOverlayFrameColors(raw: unknown): OverlayFrameColors { @@ -181,7 +167,7 @@ function toastTypeToThemeColor(theme: Theme, type: "info" | "success" | "warning } export function renderOverlayWidget( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, rect: Rect, viewport: Readonly<{ cols: number; rows: number }>, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts index 02fe06c5..51ffc0a1 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts @@ -1,9 +1,10 @@ -import type { DrawlistBuilderV1, DrawlistBuilderV3 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { TerminalProfile } from "../../../terminalProfile.js"; import type { Theme } from "../../../theme/theme.js"; import { resolveColor } from "../../../theme/theme.js"; +import { rgbB, rgbG, rgbR } from "../../../widgets/style.js"; import { createCanvasDrawingSurface, resolveCanvasBlitter } from "../../../widgets/canvas.js"; import { type ImageBinaryFormat, @@ -39,35 +40,7 @@ function repeatCached(glyph: string, count: number): string { return value; } -function parseHexRgb(value: string): Readonly<{ r: number; g: number; b: number }> | null { - const raw = value.startsWith("#") ? value.slice(1) : value; - if (/^[0-9a-fA-F]{6}$/.test(raw)) { - const parsed = Number.parseInt(raw, 16); - return Object.freeze({ - r: (parsed >> 16) & 0xff, - g: (parsed >> 8) & 0xff, - b: parsed & 0xff, - }); - } - if (/^[0-9a-fA-F]{3}$/.test(raw)) { - const r = Number.parseInt(raw[0] ?? "0", 16); - const g = Number.parseInt(raw[1] ?? "0", 16); - const b = Number.parseInt(raw[2] ?? "0", 16); - return Object.freeze({ - r: (r << 4) | r, - g: (g << 4) | g, - b: (b << 4) | b, - }); - } - return null; -} - -function resolveCanvasOverlayColor( - theme: Theme, - color: string, -): Readonly<{ r: number; g: number; b: number }> { - const parsedHex = parseHexRgb(color); - if (parsedHex) return parsedHex; +function resolveCanvasOverlayColor(theme: Theme, color: string): number { return resolveColor(theme, color); } @@ -92,15 +65,6 @@ function readString(v: unknown): string | undefined { return typeof v === "string" ? v : undefined; } -function isV3Builder(builder: DrawlistBuilderV1): builder is DrawlistBuilderV3 { - const maybe = builder as Partial; - return ( - typeof maybe.drawCanvas === "function" && - typeof maybe.drawImage === "function" && - typeof maybe.setLink === "function" - ); -} - function readGraphicsBlitter(v: unknown): GraphicsBlitter | undefined { switch (v) { case "auto": @@ -155,7 +119,7 @@ function resolveProtocolForImageSource( ): ImageRenderRoute { if (requested === "blitter") { if (!canDrawCanvas) { - return Object.freeze({ ok: false, reason: "blitter protocol requires drawlist v4" }); + return Object.freeze({ ok: false, reason: "blitter protocol requires canvas draw support" }); } if (format !== "rgba") { return Object.freeze({ ok: false, reason: "blitter protocol requires RGBA source" }); @@ -213,7 +177,7 @@ function resolveProtocolForImageSource( } export function drawPlaceholderBox( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, style: ResolvedTextStyle, title: string, @@ -246,7 +210,7 @@ function align4(value: number): number { return (value + 3) & ~3; } -export function addBlobAligned(builder: DrawlistBuilderV1, bytes: Uint8Array): number | null { +export function addBlobAligned(builder: DrawlistBuilder, bytes: Uint8Array): number | null { if ((bytes.byteLength & 3) === 0) return builder.addBlob(bytes); const padded = new Uint8Array(align4(bytes.byteLength)); padded.set(bytes); @@ -254,14 +218,14 @@ export function addBlobAligned(builder: DrawlistBuilderV1, bytes: Uint8Array): n } export function rgbToHex(color: ReturnType): string { - const r = color.r.toString(16).padStart(2, "0"); - const g = color.g.toString(16).padStart(2, "0"); - const b = color.b.toString(16).padStart(2, "0"); + const r = rgbR(color).toString(16).padStart(2, "0"); + const g = rgbG(color).toString(16).padStart(2, "0"); + const b = rgbB(color).toString(16).padStart(2, "0"); return `#${r}${g}${b}`; } export function renderCanvasWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, theme: Theme, parentStyle: ResolvedTextStyle, @@ -286,7 +250,7 @@ export function renderCanvasWidgets( ); (props.draw as (ctx: typeof surface.ctx) => void)(surface.ctx); - if (isV3Builder(builder) && rect.w > 0 && rect.h > 0) { + if (rect.w > 0 && rect.h > 0) { const blobIndex = addBlobAligned(builder, surface.rgba); if (blobIndex !== null) { builder.drawCanvas(rect.x, rect.y, rect.w, rect.h, blobIndex, surface.blitter); @@ -294,7 +258,7 @@ export function renderCanvasWidgets( drawPlaceholderBox(builder, rect, parentStyle, "Canvas", "blob allocation failed"); } } else { - drawPlaceholderBox(builder, rect, parentStyle, "Canvas", "graphics not supported"); + drawPlaceholderBox(builder, rect, parentStyle, "Canvas", "invalid canvas size"); } if (surface.overlays.length > 0) { @@ -347,7 +311,7 @@ export function renderCanvasWidgets( break; } - if (!isV3Builder(builder) || rect.w <= 0 || rect.h <= 0) { + if (rect.w <= 0 || rect.h <= 0) { drawPlaceholderBox( builder, rect, @@ -364,7 +328,7 @@ export function renderCanvasWidgets( requestedProtocol, analyzed.format, terminalProfile, - builder.drawlistVersion >= 4, + true, ); if (!resolvedProtocol.ok) { drawPlaceholderBox( diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts index eb890844..c95360fa 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1, DrawlistBuilderV3 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { measureTextCells } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; @@ -30,7 +30,7 @@ import { } from "./renderTextWidgets.js"; type MaybeFillOwnBackground = ( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, ownStyle: unknown, style: ResolvedTextStyle, @@ -109,15 +109,6 @@ function readGraphicsBlitter(v: unknown): GraphicsBlitter | undefined { } } -function isV3Builder(builder: DrawlistBuilderV1): builder is DrawlistBuilderV3 { - const maybe = builder as Partial; - return ( - typeof maybe.drawCanvas === "function" && - typeof maybe.drawImage === "function" && - typeof maybe.setLink === "function" - ); -} - function sparklineForData( data: readonly number[], width: number, @@ -140,7 +131,7 @@ function sparklineForData( } export function renderChartWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, theme: Theme, parentStyle: ResolvedTextStyle, @@ -202,15 +193,11 @@ export function renderChartWidgets( } } - if (isV3Builder(builder)) { - const blobIndex = addBlobAligned(builder, surface.rgba); - if (blobIndex !== null) { - builder.drawCanvas(rect.x, rect.y, rect.w, chartRows, blobIndex, surface.blitter); - } else { - drawPlaceholderBox(builder, rect, parentStyle, "Line Chart", "blob allocation failed"); - } + const blobIndex = addBlobAligned(builder, surface.rgba); + if (blobIndex !== null) { + builder.drawCanvas(rect.x, rect.y, rect.w, chartRows, blobIndex, surface.blitter); } else { - drawPlaceholderBox(builder, rect, parentStyle, "Line Chart", "graphics not supported"); + drawPlaceholderBox(builder, rect, parentStyle, "Line Chart", "blob allocation failed"); } if (showLegend && rect.h > chartRows) { @@ -282,15 +269,11 @@ export function renderChartWidgets( surface.ctx.setPixel(point.x, point.y, point.color ?? fallbackColor); } - if (isV3Builder(builder)) { - const blobIndex = addBlobAligned(builder, surface.rgba); - if (blobIndex !== null) { - builder.drawCanvas(rect.x, rect.y, rect.w, rect.h, blobIndex, surface.blitter); - } else { - drawPlaceholderBox(builder, rect, parentStyle, "Scatter", "blob allocation failed"); - } + const blobIndex = addBlobAligned(builder, surface.rgba); + if (blobIndex !== null) { + builder.drawCanvas(rect.x, rect.y, rect.w, rect.h, blobIndex, surface.blitter); } else { - drawPlaceholderBox(builder, rect, parentStyle, "Scatter", "graphics not supported"); + drawPlaceholderBox(builder, rect, parentStyle, "Scatter", "blob allocation failed"); } break; } @@ -340,15 +323,11 @@ export function renderChartWidgets( } } - if (isV3Builder(builder)) { - const blobIndex = addBlobAligned(builder, surface.rgba); - if (blobIndex !== null) { - builder.drawCanvas(rect.x, rect.y, rect.w, rect.h, blobIndex, surface.blitter); - } else { - drawPlaceholderBox(builder, rect, parentStyle, "Heatmap", "blob allocation failed"); - } + const blobIndex = addBlobAligned(builder, surface.rgba); + if (blobIndex !== null) { + builder.drawCanvas(rect.x, rect.y, rect.w, rect.h, blobIndex, surface.blitter); } else { - drawPlaceholderBox(builder, rect, parentStyle, "Heatmap", "graphics not supported"); + drawPlaceholderBox(builder, rect, parentStyle, "Heatmap", "blob allocation failed"); } break; } @@ -389,7 +368,7 @@ export function renderChartWidgets( const line = sparklineForData(data, width, min, max); const highRes = props.highRes === true; - if (highRes && isV3Builder(builder) && rect.w > 0 && rect.h > 0) { + if (highRes && rect.w > 0 && rect.h > 0) { const blitter = resolveCanvasBlitter(readGraphicsBlitter(props.blitter) ?? "braille", true); const surface = createCanvasDrawingSurface(rect.w, rect.h, blitter, (color) => resolveColor(theme, color), @@ -470,7 +449,7 @@ export function renderChartWidgets( const values = data.map((item) => Math.max(0, readNumber(item.value) ?? 0)); const maxValue = Math.max(1, ...values); - if (props.highRes === true && isV3Builder(builder) && rect.w > 0 && rect.h > 0) { + if (props.highRes === true && rect.w > 0 && rect.h > 0) { const blitter = resolveCanvasBlitter(readGraphicsBlitter(props.blitter), true); const surface = createCanvasDrawingSurface(rect.w, rect.h, blitter, (color) => resolveColor(theme, color), diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts index 3cda0a5e..c06c3785 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import type { LayoutTree } from "../../../layout/layout.js"; import { measureTextCells, truncateWithEllipsis } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; @@ -47,7 +47,7 @@ type ResolvedCursor = Readonly<{ }>; type MaybeFillOwnBackground = ( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, ownStyle: unknown, style: ResolvedTextStyle, @@ -234,7 +234,7 @@ function resolveFocusFlags( } export function renderFormWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, pressedId: string | null, rect: Rect, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderIndicatorWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderIndicatorWidgets.ts index 8675d8ac..204d7376 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderIndicatorWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderIndicatorWidgets.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { resolveIconGlyph as resolveIconRenderGlyph } from "../../../icons/index.js"; import { measureTextCells } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; @@ -21,7 +21,7 @@ import { } from "./renderTextWidgets.js"; type MaybeFillOwnBackground = ( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, ownStyle: unknown, style: ResolvedTextStyle, @@ -183,7 +183,7 @@ function readActionLabel(action: unknown): string | undefined { } export function renderIndicatorWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, theme: Theme, parentStyle: ResolvedTextStyle, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts index 6093b7fb..8a01d9d8 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts @@ -1,4 +1,4 @@ -import type { DrawlistBuilderV1, DrawlistBuilderV3 } from "../../../drawlist/types.js"; +import type { DrawlistBuilder } from "../../../drawlist/types.js"; import { type SpinnerVariant, getSpinnerFrame, @@ -36,7 +36,7 @@ type ResolvedCursor = Readonly<{ }>; type MaybeFillOwnBackground = ( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, ownStyle: unknown, style: ResolvedTextStyle, @@ -146,7 +146,7 @@ export function clipSegmentsToWidth( } export function drawSegments( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, x: number, y: number, maxWidth: number, @@ -234,17 +234,8 @@ function resolveIconText(iconPath: string, useFallback: boolean): string { return resolveIconRenderGlyph(iconPath, useFallback).glyph; } -function isV3Builder(builder: DrawlistBuilderV1): builder is DrawlistBuilderV3 { - const maybe = builder as Partial; - return ( - typeof maybe.drawCanvas === "function" && - typeof maybe.drawImage === "function" && - typeof maybe.setLink === "function" - ); -} - export function renderTextWidgets( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, focusState: FocusState, rect: Rect, theme: Theme, @@ -679,7 +670,7 @@ export function renderTextWidgets( ); builder.pushClip(rect.x, rect.y, rect.w, rect.h); - if (isV3Builder(builder) && !disabled) { + if (!disabled) { builder.setLink(url, id); builder.drawText(rect.x, rect.y, truncateToWidth(text, rect.w), finalStyle); builder.setLink(null); diff --git a/packages/core/src/renderer/shadow.ts b/packages/core/src/renderer/shadow.ts index 6c8d246f..6ddc51da 100644 --- a/packages/core/src/renderer/shadow.ts +++ b/packages/core/src/renderer/shadow.ts @@ -9,9 +9,9 @@ * @see docs/guide/runtime-and-layout.md */ -import type { DrawlistBuilderV1 } from "../drawlist/types.js"; +import type { DrawlistBuilder } from "../drawlist/types.js"; import type { Rect } from "../layout/types.js"; -import type { Rgb } from "../widgets/style.js"; +import { rgb, type Rgb24 } from "../widgets/style.js"; import type { ResolvedTextStyle } from "./renderToDrawlist/textStyle.js"; /** Shadow characters for different densities. */ @@ -31,7 +31,7 @@ export type ShadowConfig = Readonly<{ /** Vertical offset in cells (typically 1). */ offsetY: number; /** Shadow color. */ - color: Rgb; + color: Rgb24; /** Shadow character density. */ density: ShadowDensity; }>; @@ -47,7 +47,7 @@ type ShadowBoundsConfig = Readonly<{ export const DEFAULT_SHADOW: ShadowConfig = Object.freeze({ offsetX: 1, offsetY: 1, - color: Object.freeze({ r: 0, g: 0, b: 0 }), + color: rgb(0, 0, 0), density: "light", }); @@ -101,7 +101,7 @@ function getShadowChar(density: ShadowDensity): string { * ``` */ export function renderShadow( - builder: DrawlistBuilderV1, + builder: DrawlistBuilder, rect: Rect, config: ShadowConfig, baseStyle: ResolvedTextStyle, @@ -115,6 +115,7 @@ export function renderShadow( const style: ResolvedTextStyle = { fg: color, bg: baseStyle.bg, + attrs: 0, }; // Right edge shadow (vertical strip) diff --git a/packages/core/src/renderer/styles.ts b/packages/core/src/renderer/styles.ts index 0fb37890..f2e4c05d 100644 --- a/packages/core/src/renderer/styles.ts +++ b/packages/core/src/renderer/styles.ts @@ -1,61 +1,28 @@ /** * packages/core/src/renderer/styles.ts — Renderer style utilities. - * - * Why: Provides style computation for widget rendering, including focus - * and disabled visual states. Deterministic style mapping ensures - * consistent visual output across renders. - * - * @see docs/styling/style-props.md - * @see docs/styling/focus-styles.md */ import { type Theme, resolveColor } from "../theme/theme.js"; -import type { Rgb, TextStyle } from "../widgets/style.js"; +import { rgb, type TextStyle } from "../widgets/style.js"; /** Disabled widget foreground color (gray). */ -const DISABLED_FG: Rgb = Object.freeze({ r: 128, g: 128, b: 128 }); +const DISABLED_FG = rgb(128, 128, 128); function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null; } -function parseHexColor(value: string): Rgb | null { - const raw = value.startsWith("#") ? value.slice(1) : value; - if (/^[0-9a-fA-F]{6}$/.test(raw)) { - const parsed = Number.parseInt(raw, 16); - return Object.freeze({ - r: (parsed >> 16) & 0xff, - g: (parsed >> 8) & 0xff, - b: parsed & 0xff, - }); - } - if (/^[0-9a-fA-F]{3}$/.test(raw)) { - const r = Number.parseInt(raw[0] ?? "0", 16); - const g = Number.parseInt(raw[1] ?? "0", 16); - const b = Number.parseInt(raw[2] ?? "0", 16); - return Object.freeze({ - r: (r << 4) | r, - g: (g << 4) | g, - b: (b << 4) | b, - }); - } - return null; -} - function resolveStyleColor(theme: Theme, value: unknown): unknown { - if (typeof value !== "string") return value; - const parsedHex = parseHexColor(value); - if (parsedHex) return parsedHex; - return resolveColor(theme, value); + if (typeof value === "string") { + return resolveColor(theme, value); + } + return value; } /** * Coerce unknown value to TextStyle if object-shaped. - * Relies on drawlist builder for validation. */ export function asTextStyle(v: unknown, theme?: Theme): TextStyle | undefined { - /* Renderer is not responsible for validating user-provided style objects. - * Accept object-shaped values and rely on the drawlist builder's deterministic encoding. */ if (!isObject(v)) return undefined; const style = v as TextStyle; if (!theme) return style; @@ -87,13 +54,8 @@ export type ButtonVisualState = Readonly<{ focused: boolean; disabled: boolean } /** * Compute text style for button/input label based on visual state. - * - Focused: underline + bold for clear indication while maintaining readability - * - Disabled: gray foreground */ export function getButtonLabelStyle(state: ButtonVisualState): TextStyle | undefined { - // Deterministic mapping: - // - Focused: underline + bold (more readable than inverse) - // - Disabled: deterministic fg color override (engine v1 has no "dim" attr) if (state.disabled) return { fg: DISABLED_FG }; if (state.focused) return { underline: true, bold: true }; return undefined; diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts index 96d23e70..c7a7a758 100644 --- a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -73,17 +73,17 @@ test("commit: container fast reuse does not ignore parent prop changes", () => { test("commit: container fast reuse does not ignore inheritStyle changes", () => { const allocator = createInstanceIdAllocator(1); - const v0 = ui.column({ inheritStyle: { fg: { r: 136, g: 136, b: 136 } } }, [ui.text("x")]); + const v0 = ui.column({ inheritStyle: { fg: ((136 << 16) | (136 << 8) | 136) } }, [ui.text("x")]); const c0 = commitVNodeTree(null, v0, { allocator }); if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); - const v1 = ui.column({ inheritStyle: { fg: { r: 0, g: 255, b: 0 } } }, [ui.text("x")]); + const v1 = ui.column({ inheritStyle: { fg: ((0 << 16) | (255 << 8) | 0) } }, [ui.text("x")]); const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); assert.notEqual(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { inheritStyle?: { fg?: unknown } }; - assert.deepEqual(nextProps.inheritStyle?.fg, { r: 0, g: 255, b: 0 }); + assert.deepEqual(nextProps.inheritStyle?.fg, ((0 << 16) | (255 << 8) | 0)); assert.equal(c1.value.root.children[0], c0.value.root.children[0]); }); diff --git a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts index 869d64fb..dfacff8b 100644 --- a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts +++ b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts @@ -97,7 +97,7 @@ describe("runtime hooks - useTheme", () => { extendTheme(darkTheme, { colors: { accent: { - primary: { r: 250, g: 20, b: 20 }, + primary: ((250 << 16) | (20 << 8) | 20), }, }, }), @@ -125,7 +125,7 @@ describe("runtime hooks - useTheme", () => { test("resolves scoped themed overrides for composites", () => { const baseTheme = coerceToLegacyTheme(darkTheme); const override = Object.freeze({ - colors: { accent: { primary: { r: 18, g: 164, b: 245 } } }, + colors: { accent: { primary: ((18 << 16) | (164 << 8) | 245) } }, }); const scopedTheme = coerceToLegacyTheme(extendTheme(darkTheme, override)); const expected = requireColorTokens(getColorTokens(scopedTheme)); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index a58eb855..d5d69478 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -56,6 +56,51 @@ export type RuntimeInstance = { /** Shared frozen empty array for leaf RuntimeInstance children. Avoids per-node allocation. */ const EMPTY_CHILDREN: readonly RuntimeInstance[] = Object.freeze([]); +// --------------------------------------------------------------------------- +// Commit Diagnostics — zero-overhead when disabled +// --------------------------------------------------------------------------- + +/** Structured commit diagnostic entry. */ +export type CommitDiagEntry = { + id: number; + kind: string; + reason: "leaf-reuse" | "fast-reuse" | "new-mount" | "new-instance"; + /** Explains why reuse failed, or "was-dirty" if reused but was previously dirty. */ + detail?: + | "props-changed" + | "children-changed" + | "props+children" + | "general-path" + | "no-prev" + | "leaf-kind-mismatch" + | "leaf-content-changed" + | "kind-changed" + | "was-dirty" + | undefined; + /** Specific failing prop (only for props-changed containers). */ + failingProp?: string | undefined; + childDiffs?: number | undefined; // how many children refs differ + prevChildren?: number | undefined; + nextChildren?: number | undefined; +}; + +/** Global commit diagnostics buffer. */ +export const __commitDiag = { + enabled: false, + entries: [] as CommitDiagEntry[], + reset(): void { + this.entries.length = 0; + }, + push(e: CommitDiagEntry): void { + this.entries.push(e); + }, +}; + +/** Fast equality for packed color values. */ +function colorEqual(a: unknown, b: unknown): boolean { + return a === b; +} + /** * Fast shallow equality for text style objects. * Returns true if both styles produce identical render output. @@ -101,8 +146,8 @@ function textStyleEqual( a.strikethrough === b.strikethrough && a.overline === b.overline && a.blink === b.blink && - a.fg === b.fg && - a.bg === b.bg + colorEqual(a.fg, b.fg) && + colorEqual(a.bg, b.bg) ); } @@ -168,6 +213,28 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { ap.color === bp.color ); } + case "richText": { + if (b.kind !== "richText") return false; + const ap = a.props as { spans?: readonly { text: string; style?: unknown }[] }; + const bp = b.props as { spans?: readonly { text: string; style?: unknown }[] }; + const as = ap.spans; + const bs = bp.spans; + if (as === bs) return true; + if (!as || !bs || as.length !== bs.length) return false; + for (let i = 0; i < as.length; i++) { + const sa = as[i]!; + const sb = bs[i]!; + if (sa.text !== sb.text) return false; + if ( + !textStyleEqual( + sa.style as Parameters[0], + sb.style as Parameters[0], + ) + ) + return false; + } + return true; + } default: return false; } @@ -486,6 +553,41 @@ function canFastReuseContainerSelf(prev: VNode, next: VNode): boolean { } } +/** + * Diagnostic: identify which specific prop fails for container reuse. + * Only called when __commitDiag.enabled is true. + */ +function diagWhichPropFails(prev: VNode, next: VNode): string | undefined { + if (prev.kind !== next.kind) return "kind"; + const ap = (prev.props ?? {}) as Record; + const bp = (next.props ?? {}) as Record; + if (prev.kind === "row" || prev.kind === "column") { + for (const k of ["pad", "gap", "align", "justify", "items"] as const) { + if (ap[k] !== bp[k]) return k; + } + if (!textStyleEqual(ap["style"] as Parameters[0], bp["style"] as Parameters[0])) return "style"; + if (!textStyleEqual(ap["inheritStyle"] as Parameters[0], bp["inheritStyle"] as Parameters[0])) return "inheritStyle"; + // layout constraints + for (const k of ["width", "height", "minWidth", "maxWidth", "minHeight", "maxHeight", "flex", "aspectRatio"] as const) { + if (ap[k] !== bp[k]) return k; + } + // spacing + for (const k of ["p", "px", "py", "pt", "pb", "pl", "pr", "m", "mx", "my", "mt", "mr", "mb", "ml"] as const) { + if (ap[k] !== bp[k]) return k; + } + } + if (prev.kind === "box") { + for (const k of ["title", "titleAlign", "pad", "border", "borderTop", "borderRight", "borderBottom", "borderLeft", "opacity"] as const) { + if (ap[k] !== bp[k]) return k; + } + if (!textStyleEqual(ap["style"] as Parameters[0], bp["style"] as Parameters[0])) return "style"; + for (const k of ["width", "height", "minWidth", "maxWidth", "minHeight", "maxHeight", "flex", "aspectRatio"] as const) { + if (ap[k] !== bp[k]) return k; + } + } + return "unknown"; +} + function runtimeChildrenChanged( prevChildren: readonly RuntimeInstance[], nextChildren: readonly RuntimeInstance[], @@ -1163,10 +1265,80 @@ function commitContainer( canFastReuseContainerSelf(prev.vnode, vnode) ) { // All children are identical references → reuse parent entirely. - prev.dirty = false; + // Propagate dirty from children: a child may have been mutated in-place + // with dirty=true even though it returned the same reference. + if (__commitDiag.enabled) { + const wasDirty = prev.selfDirty; + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "fast-reuse", + detail: wasDirty ? "was-dirty" : undefined }); + } prev.selfDirty = false; + prev.dirty = hasDirtyChild(prev.children); return { ok: true, value: { root: prev } }; } + + // Fast-path in-place mutation: children changed but props are identical. + // Mutate the existing RuntimeInstance to preserve reference identity and + // prevent parent containers from cascading new-instance creation. + if ( + !allChildrenSame && + prev !== null && + nextChildren !== null && + committedChildVNodes !== null && + canFastReuseContainerSelf(prev.vnode, vnode) + ) { + if (__commitDiag.enabled) { + let childDiffs = 0; + for (let ci = 0; ci < prevChildren.length; ci++) { + if (prevChildren[ci] !== (nextChildren as readonly RuntimeInstance[])[ci]) childDiffs++; + } + __commitDiag.push({ + id: instanceId as number, kind: vnode.kind, + reason: "fast-reuse", + detail: "children-changed" as "was-dirty" | undefined, + childDiffs, + prevChildren: prevChildren.length, + nextChildren: (nextChildren as readonly RuntimeInstance[]).length, + }); + } + (prev as { children: readonly RuntimeInstance[] }).children = nextChildren; + (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, committedChildVNodes); + prev.selfDirty = true; + prev.dirty = true; + return { ok: true, value: { root: prev } }; + } + + // Diagnostic: fast-reuse check failed at container level + if (__commitDiag.enabled && prev !== null && canTryFastReuse) { + if (!allChildrenSame) { + // children are different — but WHY? count how many differ + let childDiffs = 0; + for (let ci = 0; ci < prevChildren.length; ci++) { + if (nextChildren && prevChildren[ci] !== (nextChildren as readonly RuntimeInstance[])[ci]) childDiffs++; + } + // also check if props would have passed + const propsOk = canFastReuseContainerSelf(prev.vnode, vnode); + __commitDiag.push({ + id: instanceId as number, kind: vnode.kind, + reason: "new-instance", + detail: propsOk ? "children-changed" : "props+children", + failingProp: propsOk ? undefined : diagWhichPropFails(prev.vnode, vnode), + childDiffs, + prevChildren: prevChildren.length, + nextChildren: nextChildren ? (nextChildren as readonly RuntimeInstance[]).length : res.value.nextChildren.length, + }); + } else if (!childOrderStable) { + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "children-changed" }); + } else { + // allChildrenSame && childOrderStable but canFastReuseContainerSelf failed + __commitDiag.push({ + id: instanceId as number, kind: vnode.kind, + reason: "new-instance", + detail: "props-changed", + failingProp: diagWhichPropFails(prev.vnode, vnode), + }); + } + } } else { // General path: commit children and build next arrays. const nextChildrenArr: RuntimeInstance[] = []; @@ -1219,6 +1391,42 @@ function commitContainer( const propsChanged = prev === null || !canFastReuseContainerSelf(prev.vnode, vnode); const childrenChanged = prev === null || runtimeChildrenChanged(prevChildren, nextChildren); const selfDirty = propsChanged || childrenChanged; + + // Diagnostic: general-path new-instance (only if not already logged by fast-reuse diagnostic) + if (__commitDiag.enabled && !canTryFastReuse && prev !== null) { + let cDiffs = 0; + const minLen = Math.min(prevChildren.length, nextChildren.length); + for (let ci = 0; ci < minLen; ci++) { + if (prevChildren[ci] !== nextChildren[ci]) cDiffs++; + } + cDiffs += Math.abs(prevChildren.length - nextChildren.length); + __commitDiag.push({ + id: instanceId as number, kind: vnode.kind, + reason: "new-instance", + detail: propsChanged && childrenChanged ? "props+children" + : propsChanged ? "props-changed" + : childrenChanged ? "children-changed" + : "general-path", + failingProp: propsChanged ? diagWhichPropFails(prev.vnode, vnode) : undefined, + childDiffs: cDiffs, + prevChildren: prevChildren.length, + nextChildren: nextChildren.length, + }); + } else if (__commitDiag.enabled && prev === null) { + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "no-prev" }); + } + + // In-place mutation: when props are unchanged and only children references + // changed, mutate the existing RuntimeInstance to preserve reference identity. + // This prevents parent containers from cascading new-instance creation. + if (prev !== null && !propsChanged && childrenChanged) { + (prev as { children: readonly RuntimeInstance[] }).children = nextChildren; + (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, committedChildVNodes!); + prev.selfDirty = true; + prev.dirty = true; + return { ok: true, value: { root: prev } }; + } + return { ok: true, value: { @@ -1468,15 +1676,36 @@ function commitNode( }; } + // Temporary debug: trace commit matching (remove after investigation) + if ((globalThis as Record)["__commitDebug"]) { + const debugLog = (globalThis as Record)["__commitDebugLog"] as string[] | undefined; + if (debugLog) { + debugLog.push(`commitNode(${String(instanceId)}, ${vnode.kind}, prev=${prev ? `${prev.vnode.kind}:${String(prev.instanceId)}` : "null"})`); + } + } + // Leaf nodes — fast path: reuse previous RuntimeInstance when content is unchanged. // Do this before any bookkeeping so unchanged leaf-heavy subtrees (lists, tables) // don't pay per-node validation overhead. if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { + if (__commitDiag.enabled) { + const wasDirty = prev.selfDirty; + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "leaf-reuse", + detail: wasDirty ? "was-dirty" : undefined }); + } if (ctx.collectLifecycleInstanceIds) ctx.lists.reused.push(instanceId); prev.dirty = false; prev.selfDirty = false; return { ok: true, value: { root: prev } }; } + // Diagnostic: leaf not reused + if (__commitDiag.enabled && prev && !isContainerVNode(vnode)) { + if (prev.vnode.kind !== vnode.kind) { + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "leaf-kind-mismatch" }); + } else { + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "leaf-content-changed" }); + } + } if (vnode.kind === "errorBoundary") { ctx.errorBoundary?.activePaths.add(nodePath); @@ -1541,7 +1770,10 @@ function commitNode( if (ctx.collectLifecycleInstanceIds) { if (prev) ctx.lists.reused.push(instanceId); - else ctx.lists.mounted.push(instanceId); + else { + ctx.lists.mounted.push(instanceId); + if (__commitDiag.enabled) __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-mount" }); + } } if (ctx.composite) { @@ -1562,6 +1794,16 @@ function commitNode( return commitContainer(instanceId, vnode, prev, ctx, [nodePath], layoutDepth); } + // Leaf node: when prev exists and kind matches, mutate in-place to preserve + // reference identity. This prevents parent containers from cascading new-instance + // creation when only leaf content changed. + if (prev !== null && prev.vnode.kind === vnode.kind) { + prev.vnode = vnode; + prev.selfDirty = true; + prev.dirty = true; + return { ok: true, value: { root: prev } }; + } + return { ok: true, value: { diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index f642e7f5..c25f5a3d 100644 --- a/packages/core/src/testing/renderer.ts +++ b/packages/core/src/testing/renderer.ts @@ -7,7 +7,7 @@ import type { DrawlistBuildResult, - DrawlistBuilderV1, + DrawlistBuilder, DrawlistTextRunSegment, } from "../drawlist/types.js"; import type { LayoutTree } from "../layout/layout.js"; @@ -120,7 +120,7 @@ function asPropsRecord(value: unknown): TestNodeProps { return Object.freeze({ ...(value as Record) }); } -class RecordingDrawlistBuilder implements DrawlistBuilderV1 { +class RecordingDrawlistBuilder implements DrawlistBuilder { private readonly ops: TestRecordedOp[] = []; private readonly textRunBlobs: Array = []; @@ -173,6 +173,19 @@ class RecordingDrawlistBuilder implements DrawlistBuilderV1 { } } + setCursor(..._args: Parameters): void {} + + hideCursor(): void {} + + setLink(..._args: Parameters): void {} + + drawCanvas(..._args: Parameters): void {} + + drawImage(..._args: Parameters): void {} + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array(0) }; } diff --git a/packages/core/src/theme/__tests__/theme.contrast.test.ts b/packages/core/src/theme/__tests__/theme.contrast.test.ts index 30b9a9e2..96e676c1 100644 --- a/packages/core/src/theme/__tests__/theme.contrast.test.ts +++ b/packages/core/src/theme/__tests__/theme.contrast.test.ts @@ -11,8 +11,8 @@ import { type PresetCase = Readonly<{ name: string; - fg: { r: number; g: number; b: number }; - bg: { r: number; g: number; b: number }; + fg: number; + bg: number; }>; const AA_MIN = 4.5; @@ -31,41 +31,41 @@ function formatRatio(ratio: number): string { describe("theme contrast", () => { test("white on white ratio is 1", () => { - const white = { r: 255, g: 255, b: 255 }; + const white = ((255 << 16) | (255 << 8) | 255); assert.equal(contrastRatio(white, white), 1); }); test("black on black ratio is 1", () => { - const black = { r: 0, g: 0, b: 0 }; + const black = ((0 << 16) | (0 << 8) | 0); assert.equal(contrastRatio(black, black), 1); }); test("black on white ratio is 21", () => { - const black = { r: 0, g: 0, b: 0 }; - const white = { r: 255, g: 255, b: 255 }; + const black = ((0 << 16) | (0 << 8) | 0); + const white = ((255 << 16) | (255 << 8) | 255); assert.equal(contrastRatio(black, white), 21); }); test("contrast ratio is symmetric for fg and bg order", () => { - const fg = { r: 12, g: 90, b: 200 }; - const bg = { r: 230, g: 180, b: 40 }; + const fg = ((12 << 16) | (90 << 8) | 200); + const bg = ((230 << 16) | (180 << 8) | 40); assert.equal(contrastRatio(fg, bg), contrastRatio(bg, fg)); }); test("known failing pair (#777777 on #ffffff) is below AA", () => { - const ratio = contrastRatio({ r: 119, g: 119, b: 119 }, { r: 255, g: 255, b: 255 }); + const ratio = contrastRatio(((119 << 16) | (119 << 8) | 119), ((255 << 16) | (255 << 8) | 255)); assertApprox(ratio, 4.478089453577214, 1e-12, "known failing pair ratio"); assert.ok(ratio < AA_MIN, `expected < ${AA_MIN}, got ${formatRatio(ratio)}`); }); test("near-threshold passing pair (#767676 on #ffffff) meets AA", () => { - const ratio = contrastRatio({ r: 118, g: 118, b: 118 }, { r: 255, g: 255, b: 255 }); + const ratio = contrastRatio(((118 << 16) | (118 << 8) | 118), ((255 << 16) | (255 << 8) | 255)); assertApprox(ratio, 4.542224959605253, 1e-12, "near-threshold passing pair ratio"); assert.ok(ratio >= AA_MIN, `expected >= ${AA_MIN}, got ${formatRatio(ratio)}`); }); test("mid-gray pair (#595959 on #ffffff) has expected ratio", () => { - const ratio = contrastRatio({ r: 89, g: 89, b: 89 }, { r: 255, g: 255, b: 255 }); + const ratio = contrastRatio(((89 << 16) | (89 << 8) | 89), ((255 << 16) | (255 << 8) | 255)); assertApprox(ratio, 7.004729208035935, 1e-12, "mid-gray pair ratio"); }); diff --git a/packages/core/src/theme/__tests__/theme.extend.test.ts b/packages/core/src/theme/__tests__/theme.extend.test.ts index b60be215..3c2e4a10 100644 --- a/packages/core/src/theme/__tests__/theme.extend.test.ts +++ b/packages/core/src/theme/__tests__/theme.extend.test.ts @@ -19,7 +19,7 @@ describe("theme.extend", () => { }, }); - assert.deepEqual(next.colors.accent.primary, { r: 1, g: 2, b: 3 }); + assert.deepEqual(next.colors.accent.primary, ((1 << 16) | (2 << 8) | 3)); assert.deepEqual(next.colors.accent.secondary, darkTheme.colors.accent.secondary); assert.deepEqual(next.colors.bg.base, darkTheme.colors.bg.base); }); @@ -48,22 +48,18 @@ describe("theme.extend", () => { assert.deepEqual(next.colors, lightTheme.colors); }); - test("partial RGB override merges channel-level values", () => { - const next = extendTheme(darkTheme, { - colors: { - accent: { - primary: { - r: 200, + test("partial RGB channel overrides are rejected", () => { + assert.throws( + () => + extendTheme(darkTheme, { + colors: { + accent: { + primary: { r: 200 } as unknown as number, + }, }, - }, - }, - }); - - assert.deepEqual(next.colors.accent.primary, { - r: 200, - g: darkTheme.colors.accent.primary.g, - b: darkTheme.colors.accent.primary.b, - }); + }), + () => true, + ); }); test("invalid overrides are rejected by validation (null colors)", () => { @@ -81,9 +77,7 @@ describe("theme.extend", () => { () => extendTheme(darkTheme, { colors: { - success: { - r: Number.NaN, - }, + success: Number.NaN, }, }), () => true, @@ -117,7 +111,7 @@ describe("theme.extend", () => { assert.notEqual(extended.colors, mutableBase.colors); assert.notEqual(extended.colors.bg, mutableBase.colors.bg); assert.notEqual(extended.colors.bg.base, mutableBase.colors.bg.base); - assert.equal(mutableBase.colors.bg.base.r, darkTheme.colors.bg.base.r); + assert.equal(mutableBase.colors.bg.base, darkTheme.colors.bg.base); }); test("undefined override branches do not freeze caller-owned base objects", () => { @@ -132,7 +126,6 @@ describe("theme.extend", () => { assert.equal(Object.isFrozen(mutableBase.colors), false); assert.equal(Object.isFrozen(mutableBase.colors.accent), false); - assert.equal(Object.isFrozen(mutableBase.colors.accent.primary), false); }); test("extended theme is deeply frozen", () => { @@ -166,8 +159,8 @@ describe("theme.extend", () => { }, }); - assert.deepEqual(two.colors.fg.primary, { r: 11, g: 22, b: 33 }); - assert.deepEqual(two.colors.border.strong, { r: 44, g: 55, b: 66 }); + assert.deepEqual(two.colors.fg.primary, ((11 << 16) | (22 << 8) | 33)); + assert.deepEqual(two.colors.border.strong, ((44 << 16) | (55 << 8) | 66)); assert.deepEqual(two.colors.accent.secondary, darkTheme.colors.accent.secondary); }); @@ -182,14 +175,12 @@ describe("theme.extend", () => { const two = extendTheme(one, { colors: { accent: { - primary: { - r: 99, - }, + primary: color(99, 20, 30), }, }, }); - assert.deepEqual(two.colors.accent.primary, { r: 99, g: 20, b: 30 }); + assert.deepEqual(two.colors.accent.primary, ((99 << 16) | (20 << 8) | 30)); }); test("nested extensions keep inherited tokens unless overridden", () => { @@ -203,7 +194,7 @@ describe("theme.extend", () => { }); assert.equal(two.name, "custom-1"); - assert.deepEqual(two.colors.info, { r: 1, g: 1, b: 1 }); + assert.deepEqual(two.colors.info, ((1 << 16) | (1 << 8) | 1)); assert.deepEqual(two.colors.warning, darkTheme.colors.warning); }); diff --git a/packages/core/src/theme/__tests__/theme.interop.test.ts b/packages/core/src/theme/__tests__/theme.interop.test.ts index 4cfd861c..6d502f5d 100644 --- a/packages/core/src/theme/__tests__/theme.interop.test.ts +++ b/packages/core/src/theme/__tests__/theme.interop.test.ts @@ -68,7 +68,7 @@ describe("theme.interop spacing", () => { extendTheme(darkTheme, { colors: { accent: { - primary: { r: 1, g: 2, b: 3 }, + primary: ((1 << 16) | (2 << 8) | 3), }, }, }), @@ -83,30 +83,30 @@ describe("theme.interop spacing", () => { const semanticTheme = extendTheme(darkTheme, { colors: { diagnostic: { - warning: { r: 1, g: 2, b: 3 }, + warning: ((1 << 16) | (2 << 8) | 3), }, }, }); const legacyTheme = coerceToLegacyTheme(semanticTheme); - assert.deepEqual(legacyTheme.colors["diagnostic.warning"], { r: 1, g: 2, b: 3 }); + assert.deepEqual(legacyTheme.colors["diagnostic.warning"], ((1 << 16) | (2 << 8) | 3)); }); test("mergeThemeOverride accepts nested legacy diagnostic overrides", () => { const parentTheme = createTheme({ colors: { - "diagnostic.error": { r: 9, g: 9, b: 9 }, + "diagnostic.error": ((9 << 16) | (9 << 8) | 9), }, }); const merged = mergeThemeOverride(parentTheme, { colors: { diagnostic: { - error: { r: 7, g: 8, b: 9 }, + error: ((7 << 16) | (8 << 8) | 9), }, }, }); - assert.deepEqual(merged.colors["diagnostic.error"], { r: 7, g: 8, b: 9 }); + assert.deepEqual(merged.colors["diagnostic.error"], ((7 << 16) | (8 << 8) | 9)); }); }); diff --git a/packages/core/src/theme/__tests__/theme.resolution.test.ts b/packages/core/src/theme/__tests__/theme.resolution.test.ts index 634344de..827a1dcf 100644 --- a/packages/core/src/theme/__tests__/theme.resolution.test.ts +++ b/packages/core/src/theme/__tests__/theme.resolution.test.ts @@ -48,9 +48,7 @@ describe("theme resolution", () => { color !== null, `Expected preset "${presetName}" token "${path}" to resolve, got null`, ); - assert.equal(typeof color?.r, "number"); - assert.equal(typeof color?.g, "number"); - assert.equal(typeof color?.b, "number"); + assert.equal(typeof color, "number"); } } }); @@ -94,18 +92,18 @@ describe("theme resolution", () => { }); test("resolveColorOrRgb returns direct RGB unchanged", () => { - const rgb = { r: 1, g: 2, b: 3 } as const; - const fallback = { r: 9, g: 9, b: 9 } as const; + const rgb = ((1 << 16) | (2 << 8) | 3); + const fallback = ((9 << 16) | (9 << 8) | 9); assert.deepEqual(resolveColorOrRgb(themePresets.dark, rgb, fallback), rgb); }); test("resolveColorOrRgb uses fallback for invalid token paths", () => { - const fallback = { r: 9, g: 8, b: 7 } as const; + const fallback = ((9 << 16) | (8 << 8) | 7); assert.deepEqual(resolveColorOrRgb(themePresets.dark, "not.valid", fallback), fallback); }); test("resolveColorOrRgb uses fallback for undefined input", () => { - const fallback = { r: 5, g: 4, b: 3 } as const; + const fallback = ((5 << 16) | (4 << 8) | 3); assert.deepEqual(resolveColorOrRgb(themePresets.dark, undefined, fallback), fallback); }); diff --git a/packages/core/src/theme/__tests__/theme.switch.test.ts b/packages/core/src/theme/__tests__/theme.switch.test.ts index 22e20bd3..6b844c21 100644 --- a/packages/core/src/theme/__tests__/theme.switch.test.ts +++ b/packages/core/src/theme/__tests__/theme.switch.test.ts @@ -7,7 +7,7 @@ import { import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import type { App, DrawlistBuildResult, DrawlistBuilderV1, TextStyle, VNode } from "../../index.js"; +import type { App, DrawlistBuildResult, DrawlistBuilder, TextStyle, VNode } from "../../index.js"; import { createTheme, ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -49,12 +49,12 @@ async function resolveNextFrame(backend: StubBackend): Promise { function themeWithPrimary(r: number, g: number, b: number): Theme { return createTheme({ colors: { - primary: { r, g, b }, + primary: ((r << 16) | (g << 8) | b), }, }); } -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly textOps: Array> = []; clear(): void {} @@ -72,6 +72,14 @@ class RecordingBuilder implements DrawlistBuilderV1 { return null; } drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + hideCursor(): void {} + setLink(..._args: Parameters): void {} + drawCanvas(..._args: Parameters): void {} + drawImage(..._args: Parameters): void {} + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } @@ -345,10 +353,10 @@ describe("theme runtime switching", () => { }); describe("theme scoped container overrides", () => { - const RED = Object.freeze({ r: 210, g: 40, b: 40 }); - const GREEN = Object.freeze({ r: 40, g: 190, b: 80 }); - const BLUE = Object.freeze({ r: 40, g: 100, b: 210 }); - const CYAN = Object.freeze({ r: 20, g: 180, b: 200 }); + const RED = Object.freeze(((210 << 16) | (40 << 8) | 40)); + const GREEN = Object.freeze(((40 << 16) | (190 << 8) | 80)); + const BLUE = Object.freeze(((40 << 16) | (100 << 8) | 210)); + const CYAN = Object.freeze(((20 << 16) | (180 << 8) | 200)); const baseTheme = createTheme({ colors: { primary: RED, diff --git a/packages/core/src/theme/__tests__/theme.test.ts b/packages/core/src/theme/__tests__/theme.test.ts index ca53d800..9f2f1094 100644 --- a/packages/core/src/theme/__tests__/theme.test.ts +++ b/packages/core/src/theme/__tests__/theme.test.ts @@ -1,24 +1,25 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { rgbB, rgbG, rgbR } from "../../widgets/style.js"; import { createTheme, defaultTheme, resolveColor, resolveSpacing } from "../index.js"; describe("theme", () => { test("createTheme merges colors and spacing", () => { const t = createTheme({ - colors: { primary: { r: 1, g: 2, b: 3 } }, + colors: { primary: ((1 << 16) | (2 << 8) | 3) }, spacing: [0, 2, 4], }); - assert.equal(t.colors.primary.r, 1); - assert.equal(t.colors.primary.g, 2); - assert.equal(t.colors.primary.b, 3); - assert.equal(t.colors.fg.r, defaultTheme.colors.fg.r); + assert.equal(rgbR(t.colors.primary), 1); + assert.equal(rgbG(t.colors.primary), 2); + assert.equal(rgbB(t.colors.primary), 3); + assert.equal(rgbR(t.colors.fg), rgbR(defaultTheme.colors.fg)); assert.deepEqual(t.spacing, [0, 2, 4]); }); test("resolveColor returns theme color or fg fallback", () => { assert.deepEqual(resolveColor(defaultTheme, "primary"), defaultTheme.colors.primary); assert.deepEqual(resolveColor(defaultTheme, "missing"), defaultTheme.colors.fg); - assert.deepEqual(resolveColor(defaultTheme, { r: 9, g: 8, b: 7 }), { r: 9, g: 8, b: 7 }); + assert.deepEqual(resolveColor(defaultTheme, ((9 << 16) | (8 << 8) | 7)), ((9 << 16) | (8 << 8) | 7)); }); test("resolveSpacing maps indices and allows raw values", () => { diff --git a/packages/core/src/theme/__tests__/theme.transition.test.ts b/packages/core/src/theme/__tests__/theme.transition.test.ts index 837d1adb..6787b521 100644 --- a/packages/core/src/theme/__tests__/theme.transition.test.ts +++ b/packages/core/src/theme/__tests__/theme.transition.test.ts @@ -64,7 +64,7 @@ async function drainPendingFrames(backend: StubBackend, maxRounds = 24): Promise function themeWithPrimary(r: number, g: number, b: number): Theme { return createTheme({ colors: { - primary: { r, g, b }, + primary: ((r << 16) | (g << 8) | b), }, }); } @@ -73,7 +73,7 @@ function semanticThemeWithAccent(r: number, g: number, b: number) { return extendTheme(darkTheme, { colors: { accent: { - primary: { r, g, b }, + primary: ((r << 16) | (g << 8) | b), }, }, }); @@ -149,7 +149,7 @@ describe("theme transition frames", () => { await resolveNextFrame(backend); assert.equal(seenPrimary.length >= 2, true); - assert.deepEqual(seenPrimary[seenPrimary.length - 1], { r: 0, g: 255, b: 0 }); + assert.deepEqual(seenPrimary[seenPrimary.length - 1], ((0 << 16) | (255 << 8) | 0)); }); test("retargeting during active transition converges to new target theme", async () => { diff --git a/packages/core/src/theme/blend.ts b/packages/core/src/theme/blend.ts index a218c756..5b2045e0 100644 --- a/packages/core/src/theme/blend.ts +++ b/packages/core/src/theme/blend.ts @@ -1,4 +1,4 @@ -import type { Rgb } from "../widgets/style.js"; +import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; function blendChannel(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t); @@ -7,11 +7,11 @@ function blendChannel(a: number, b: number, t: number): number { /** * Blend two RGB colors using `t` in [0..1]. */ -export function blendRgb(a: Rgb, b: Rgb, t: number): Rgb { +export function blendRgb(a: Rgb24, b: Rgb24, t: number): Rgb24 { const clampedT = Math.max(0, Math.min(1, t)); - return Object.freeze({ - r: blendChannel(a.r, b.r, clampedT), - g: blendChannel(a.g, b.g, clampedT), - b: blendChannel(a.b, b.b, clampedT), - }); + return rgb( + blendChannel(rgbR(a), rgbR(b), clampedT), + blendChannel(rgbG(a), rgbG(b), clampedT), + blendChannel(rgbB(a), rgbB(b), clampedT), + ); } diff --git a/packages/core/src/theme/contrast.ts b/packages/core/src/theme/contrast.ts index 24b0c3ee..a7ec0813 100644 --- a/packages/core/src/theme/contrast.ts +++ b/packages/core/src/theme/contrast.ts @@ -5,7 +5,7 @@ * foreground/background color pairs. */ -import type { Rgb } from "../widgets/style.js"; +import { rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; function srgbToLinear(channel: number): number { const srgb = channel / 255; @@ -13,10 +13,10 @@ function srgbToLinear(channel: number): number { return ((srgb + 0.055) / 1.055) ** 2.4; } -function relativeLuminance(color: Rgb): number { - const r = srgbToLinear(color.r); - const g = srgbToLinear(color.g); - const b = srgbToLinear(color.b); +function relativeLuminance(color: Rgb24): number { + const r = srgbToLinear(rgbR(color)); + const g = srgbToLinear(rgbG(color)); + const b = srgbToLinear(rgbB(color)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } @@ -24,7 +24,7 @@ function relativeLuminance(color: Rgb): number { * Compute WCAG 2.x contrast ratio between two colors. * Returns a value in the range [1, 21], independent of argument order. */ -export function contrastRatio(fg: Rgb, bg: Rgb): number { +export function contrastRatio(fg: Rgb24, bg: Rgb24): number { const fgLum = relativeLuminance(fg); const bgLum = relativeLuminance(bg); const lighter = Math.max(fgLum, bgLum); diff --git a/packages/core/src/theme/defaultTheme.ts b/packages/core/src/theme/defaultTheme.ts index 7f23ba20..bf5fb0ab 100644 --- a/packages/core/src/theme/defaultTheme.ts +++ b/packages/core/src/theme/defaultTheme.ts @@ -6,34 +6,35 @@ */ import type { Theme } from "./types.js"; +import { rgb } from "../widgets/style.js"; export const defaultTheme: Theme = Object.freeze({ colors: Object.freeze({ - primary: Object.freeze({ r: 0, g: 120, b: 215 }), - secondary: Object.freeze({ r: 108, g: 117, b: 125 }), - success: Object.freeze({ r: 40, g: 167, b: 69 }), - danger: Object.freeze({ r: 220, g: 53, b: 69 }), - warning: Object.freeze({ r: 255, g: 193, b: 7 }), - info: Object.freeze({ r: 23, g: 162, b: 184 }), - muted: Object.freeze({ r: 128, g: 128, b: 128 }), - bg: Object.freeze({ r: 30, g: 30, b: 30 }), - fg: Object.freeze({ r: 255, g: 255, b: 255 }), - border: Object.freeze({ r: 60, g: 60, b: 60 }), - "diagnostic.error": Object.freeze({ r: 220, g: 53, b: 69 }), - "diagnostic.warning": Object.freeze({ r: 255, g: 193, b: 7 }), - "diagnostic.info": Object.freeze({ r: 23, g: 162, b: 184 }), - "diagnostic.hint": Object.freeze({ r: 40, g: 167, b: 69 }), - "syntax.keyword": Object.freeze({ r: 255, g: 121, b: 198 }), - "syntax.type": Object.freeze({ r: 189, g: 147, b: 249 }), - "syntax.string": Object.freeze({ r: 241, g: 250, b: 140 }), - "syntax.number": Object.freeze({ r: 189, g: 147, b: 249 }), - "syntax.comment": Object.freeze({ r: 98, g: 114, b: 164 }), - "syntax.operator": Object.freeze({ r: 255, g: 121, b: 198 }), - "syntax.punctuation": Object.freeze({ r: 248, g: 248, b: 242 }), - "syntax.function": Object.freeze({ r: 80, g: 250, b: 123 }), - "syntax.variable": Object.freeze({ r: 139, g: 233, b: 253 }), - "syntax.cursor.fg": Object.freeze({ r: 40, g: 42, b: 54 }), - "syntax.cursor.bg": Object.freeze({ r: 139, g: 233, b: 253 }), + primary: rgb(0, 120, 215), + secondary: rgb(108, 117, 125), + success: rgb(40, 167, 69), + danger: rgb(220, 53, 69), + warning: rgb(255, 193, 7), + info: rgb(23, 162, 184), + muted: rgb(128, 128, 128), + bg: rgb(30, 30, 30), + fg: rgb(255, 255, 255), + border: rgb(60, 60, 60), + "diagnostic.error": rgb(220, 53, 69), + "diagnostic.warning": rgb(255, 193, 7), + "diagnostic.info": rgb(23, 162, 184), + "diagnostic.hint": rgb(40, 167, 69), + "syntax.keyword": rgb(255, 121, 198), + "syntax.type": rgb(189, 147, 249), + "syntax.string": rgb(241, 250, 140), + "syntax.number": rgb(189, 147, 249), + "syntax.comment": rgb(98, 114, 164), + "syntax.operator": rgb(255, 121, 198), + "syntax.punctuation": rgb(248, 248, 242), + "syntax.function": rgb(80, 250, 123), + "syntax.variable": rgb(139, 233, 253), + "syntax.cursor.fg": rgb(40, 42, 54), + "syntax.cursor.bg": rgb(139, 233, 253), }), spacing: Object.freeze([0, 1, 2, 4, 8, 16]), }); diff --git a/packages/core/src/theme/extract.ts b/packages/core/src/theme/extract.ts index 9142e359..ced96809 100644 --- a/packages/core/src/theme/extract.ts +++ b/packages/core/src/theme/extract.ts @@ -1,62 +1,62 @@ -import type { Rgb } from "../widgets/style.js"; +import type { Rgb24 } from "../widgets/style.js"; import type { Theme } from "./theme.js"; import type { ColorTokens } from "./tokens.js"; function extractColorTokens(theme: Theme): ColorTokens | null { const c = theme.colors; - const bgBase = c["bg.base"] as Rgb | undefined; + const bgBase = c["bg.base"] as Rgb24 | undefined; if (!bgBase) return null; return { bg: { base: bgBase, - elevated: (c["bg.elevated"] as Rgb) ?? bgBase, - overlay: (c["bg.overlay"] as Rgb) ?? bgBase, - subtle: (c["bg.subtle"] as Rgb) ?? bgBase, + elevated: (c["bg.elevated"] as Rgb24) ?? bgBase, + overlay: (c["bg.overlay"] as Rgb24) ?? bgBase, + subtle: (c["bg.subtle"] as Rgb24) ?? bgBase, }, fg: { - primary: (c["fg.primary"] as Rgb) ?? c.fg, - secondary: (c["fg.secondary"] as Rgb) ?? c.muted, - muted: (c["fg.muted"] as Rgb) ?? c.muted, - inverse: (c["fg.inverse"] as Rgb) ?? c.bg, + primary: (c["fg.primary"] as Rgb24) ?? c.fg, + secondary: (c["fg.secondary"] as Rgb24) ?? c.muted, + muted: (c["fg.muted"] as Rgb24) ?? c.muted, + inverse: (c["fg.inverse"] as Rgb24) ?? c.bg, }, accent: { - primary: (c["accent.primary"] as Rgb) ?? c.primary, - secondary: (c["accent.secondary"] as Rgb) ?? c.secondary, - tertiary: (c["accent.tertiary"] as Rgb) ?? c.info, + primary: (c["accent.primary"] as Rgb24) ?? c.primary, + secondary: (c["accent.secondary"] as Rgb24) ?? c.secondary, + tertiary: (c["accent.tertiary"] as Rgb24) ?? c.info, }, success: c.success, warning: c.warning, - error: c.danger ?? (c as { error?: Rgb }).error ?? c.primary ?? c.fg ?? bgBase, + error: c.danger ?? (c as { error?: Rgb24 }).error ?? c.primary ?? c.fg ?? bgBase, info: c.info, focus: { - ring: (c["focus.ring"] as Rgb) ?? c.primary, - bg: (c["focus.bg"] as Rgb) ?? c.bg, + ring: (c["focus.ring"] as Rgb24) ?? c.primary, + bg: (c["focus.bg"] as Rgb24) ?? c.bg, }, selected: { - bg: (c["selected.bg"] as Rgb) ?? c.primary, - fg: (c["selected.fg"] as Rgb) ?? c.fg, + bg: (c["selected.bg"] as Rgb24) ?? c.primary, + fg: (c["selected.fg"] as Rgb24) ?? c.fg, }, disabled: { - fg: (c["disabled.fg"] as Rgb) ?? c.muted, - bg: (c["disabled.bg"] as Rgb) ?? c.bg, + fg: (c["disabled.fg"] as Rgb24) ?? c.muted, + bg: (c["disabled.bg"] as Rgb24) ?? c.bg, }, diagnostic: { error: - (c["diagnostic.error"] as Rgb) ?? + (c["diagnostic.error"] as Rgb24) ?? c.danger ?? - (c as { error?: Rgb }).error ?? + (c as { error?: Rgb24 }).error ?? c.primary ?? c.fg ?? bgBase, - warning: (c["diagnostic.warning"] as Rgb) ?? c.warning, - info: (c["diagnostic.info"] as Rgb) ?? c.info, - hint: (c["diagnostic.hint"] as Rgb) ?? c.success, + warning: (c["diagnostic.warning"] as Rgb24) ?? c.warning, + info: (c["diagnostic.info"] as Rgb24) ?? c.info, + hint: (c["diagnostic.hint"] as Rgb24) ?? c.success, }, border: { - subtle: (c["border.subtle"] as Rgb) ?? c.border, - default: (c["border.default"] as Rgb) ?? c.border, - strong: (c["border.strong"] as Rgb) ?? c.border, + subtle: (c["border.subtle"] as Rgb24) ?? c.border, + default: (c["border.default"] as Rgb24) ?? c.border, + strong: (c["border.strong"] as Rgb24) ?? c.border, }, }; } diff --git a/packages/core/src/theme/interop.ts b/packages/core/src/theme/interop.ts index de5c82cf..0fb485e0 100644 --- a/packages/core/src/theme/interop.ts +++ b/packages/core/src/theme/interop.ts @@ -7,7 +7,7 @@ * conversion to keep the public API ergonomic. */ -import type { Rgb } from "../widgets/style.js"; +import type { Rgb24 } from "../widgets/style.js"; import { defaultTheme } from "./defaultTheme.js"; import type { Theme } from "./theme.js"; import type { ThemeDefinition } from "./tokens.js"; @@ -89,17 +89,8 @@ function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null; } -function isRgb(v: unknown): v is Rgb { - if (!isObject(v)) return false; - const candidate = v as { r?: unknown; g?: unknown; b?: unknown }; - return ( - typeof candidate.r === "number" && - Number.isFinite(candidate.r) && - typeof candidate.g === "number" && - Number.isFinite(candidate.g) && - typeof candidate.b === "number" && - Number.isFinite(candidate.b) - ); +function isRgb(v: unknown): v is Rgb24 { + return typeof v === "number" && Number.isFinite(v); } function readSpacingOverride(raw: unknown): Theme["spacing"] | undefined { @@ -148,17 +139,18 @@ function spacingEquals(a: Theme["spacing"], b: Theme["spacing"]): boolean { return true; } -function setColor(out: Record, key: string, value: unknown): Rgb | undefined { +function setColor(out: Record, key: string, value: unknown): Rgb24 | undefined { if (!isRgb(value)) return undefined; - out[key] = value; - return value; + const packed = (Math.round(value) >>> 0) & 0x00ff_ffff; + out[key] = packed; + return packed; } function extractLegacyColorOverrides(raw: unknown): Partial { if (!isObject(raw)) return {}; const source = raw as Readonly; - const out: Record = {}; + const out: Record = {}; const bg = isObject(source.bg) ? (source.bg as BgOverride) : null; if (bg) { @@ -241,7 +233,7 @@ function mergeLegacyTheme( colorsOverride: Partial, spacingOverride: Theme["spacing"] | undefined, ): Theme { - const colorEntries = Object.entries(colorsOverride) as Array<[string, Rgb]>; + const colorEntries = Object.entries(colorsOverride) as Array<[string, Rgb24]>; let colors = parent.colors; if (colorEntries.length > 0) { let colorChanged = false; diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index a05e5d8a..16ac9f28 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -7,7 +7,7 @@ * @see docs/styling/theme.md */ -import type { Rgb } from "../widgets/style.js"; +import type { Rgb24 } from "../widgets/style.js"; import type { ColorTokens, ThemeDefinition } from "./tokens.js"; /** @@ -56,7 +56,7 @@ export type ColorPath = /** * Result of color resolution. */ -export type ResolveColorResult = { ok: true; value: Rgb } | { ok: false; error: string }; +export type ResolveColorResult = { ok: true; value: Rgb24 } | { ok: false; error: string }; /** * Resolve a color token path to an RGB value. @@ -74,9 +74,9 @@ export type ResolveColorResult = { ok: true; value: Rgb } | { ok: false; error: * // { r: 240, g: 113, b: 120 } * ``` */ -export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb; -export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb | null; -export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb | null { +export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb24; +export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null; +export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null { const colors = theme.colors; const parts = path.split("."); @@ -102,7 +102,7 @@ export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb | n * Resolve a nested color token (two-level path). * @internal */ -function resolveNestedToken(colors: ColorTokens, group: string, key: string): Rgb | null { +function resolveNestedToken(colors: ColorTokens, group: string, key: string): Rgb24 | null { switch (group) { case "bg": if (key === "base") return colors.bg.base; @@ -174,9 +174,9 @@ export function tryResolveColorToken(theme: ThemeDefinition, path: string): Reso */ export function resolveColorOrRgb( theme: ThemeDefinition, - color: string | Rgb | undefined, - fallback: Rgb, -): Rgb { + color: string | Rgb24 | undefined, + fallback: Rgb24, +): Rgb24 { if (color === undefined) return fallback; if (typeof color !== "string") return color; return resolveColorToken(theme, color) ?? fallback; diff --git a/packages/core/src/theme/theme.ts b/packages/core/src/theme/theme.ts index 4c44a5d2..0198a945 100644 --- a/packages/core/src/theme/theme.ts +++ b/packages/core/src/theme/theme.ts @@ -5,7 +5,7 @@ * can be resolved deterministically at render time. */ -import type { Rgb } from "../widgets/style.js"; +import type { Rgb24 } from "../widgets/style.js"; import { defaultTheme } from "./defaultTheme.js"; import type { Theme, ThemeColors, ThemeSpacing } from "./types.js"; export type { Theme, ThemeColors, ThemeSpacing } from "./types.js"; @@ -24,7 +24,7 @@ export function createTheme( return Object.freeze({ colors, spacing }); } -export function resolveColor(theme: Theme, color: string | Rgb): Rgb { +export function resolveColor(theme: Theme, color: string | Rgb24): Rgb24 { if (typeof color !== "string") return color; return theme.colors[color] ?? theme.colors.fg; } diff --git a/packages/core/src/theme/tokens.ts b/packages/core/src/theme/tokens.ts index 71fd32f3..c3b6c58c 100644 --- a/packages/core/src/theme/tokens.ts +++ b/packages/core/src/theme/tokens.ts @@ -2,7 +2,7 @@ * packages/core/src/theme/tokens.ts — Semantic color token system. * * Why: Provides a structured, semantic color system for consistent theming. - * All colors are RGB objects { r, g, b } with values 0-255. + * All colors are packed RGB values (0x00RRGGBB). * * Token categories: * - bg: Surface/background colors @@ -15,20 +15,20 @@ * @see docs/styling/theme.md */ -import type { Rgb } from "../widgets/style.js"; +import { rgb, type Rgb24 } from "../widgets/style.js"; /** * Surface (background) color tokens. */ export type BgTokens = Readonly<{ /** Main background color */ - base: Rgb; + base: Rgb24; /** Elevated surfaces (cards, modals) */ - elevated: Rgb; + elevated: Rgb24; /** Overlay surfaces (dropdowns, tooltips) */ - overlay: Rgb; + overlay: Rgb24; /** Subtle hover/focus backgrounds */ - subtle: Rgb; + subtle: Rgb24; }>; /** @@ -36,13 +36,13 @@ export type BgTokens = Readonly<{ */ export type FgTokens = Readonly<{ /** Primary text color */ - primary: Rgb; + primary: Rgb24; /** Secondary/less important text */ - secondary: Rgb; + secondary: Rgb24; /** Muted text (disabled, placeholders) */ - muted: Rgb; + muted: Rgb24; /** Inverse text (on accent backgrounds) */ - inverse: Rgb; + inverse: Rgb24; }>; /** @@ -50,11 +50,11 @@ export type FgTokens = Readonly<{ */ export type AccentTokens = Readonly<{ /** Primary accent (actions, focus) */ - primary: Rgb; + primary: Rgb24; /** Secondary accent (links, highlights) */ - secondary: Rgb; + secondary: Rgb24; /** Tertiary accent (subtle accents) */ - tertiary: Rgb; + tertiary: Rgb24; }>; /** @@ -62,9 +62,9 @@ export type AccentTokens = Readonly<{ */ export type FocusTokens = Readonly<{ /** Focus ring/outline color */ - ring: Rgb; + ring: Rgb24; /** Focus background color */ - bg: Rgb; + bg: Rgb24; }>; /** @@ -72,9 +72,9 @@ export type FocusTokens = Readonly<{ */ export type SelectedTokens = Readonly<{ /** Selected item background */ - bg: Rgb; + bg: Rgb24; /** Selected item foreground */ - fg: Rgb; + fg: Rgb24; }>; /** @@ -82,9 +82,9 @@ export type SelectedTokens = Readonly<{ */ export type DisabledTokens = Readonly<{ /** Disabled foreground */ - fg: Rgb; + fg: Rgb24; /** Disabled background */ - bg: Rgb; + bg: Rgb24; }>; /** @@ -92,11 +92,11 @@ export type DisabledTokens = Readonly<{ */ export type BorderTokens = Readonly<{ /** Subtle borders (dividers) */ - subtle: Rgb; + subtle: Rgb24; /** Default borders */ - default: Rgb; + default: Rgb24; /** Strong/emphasized borders */ - strong: Rgb; + strong: Rgb24; }>; /** @@ -104,13 +104,13 @@ export type BorderTokens = Readonly<{ */ export type DiagnosticTokens = Readonly<{ /** Error diagnostics (squiggles, banners) */ - error: Rgb; + error: Rgb24; /** Warning diagnostics */ - warning: Rgb; + warning: Rgb24; /** Informational diagnostics */ - info: Rgb; + info: Rgb24; /** Hint diagnostics */ - hint: Rgb; + hint: Rgb24; }>; /** @@ -127,10 +127,10 @@ export type ColorTokens = Readonly<{ accent: AccentTokens; // Semantic colors - success: Rgb; - warning: Rgb; - error: Rgb; - info: Rgb; + success: Rgb24; + warning: Rgb24; + error: Rgb24; + info: Rgb24; // Interactive states focus: FocusTokens; @@ -166,7 +166,7 @@ export type ThemeSpacingTokens = Readonly<{ export type FocusIndicatorTokens = Readonly<{ bold: boolean; underline: boolean; - focusRingColor?: Rgb; + focusRingColor?: Rgb24; }>; /** @@ -210,10 +210,10 @@ export const DEFAULT_FOCUS_INDICATOR: FocusIndicatorTokens = Object.freeze({ }); /** - * Helper to create a frozen RGB color. + * Helper to create a packed RGB color. */ -export function color(r: number, g: number, b: number): Rgb { - return Object.freeze({ r, g, b }); +export function color(r: number, g: number, b: number): Rgb24 { + return rgb(r, g, b); } /** diff --git a/packages/core/src/theme/types.ts b/packages/core/src/theme/types.ts index b2cbea86..96536416 100644 --- a/packages/core/src/theme/types.ts +++ b/packages/core/src/theme/types.ts @@ -1,17 +1,17 @@ -import type { Rgb } from "../widgets/style.js"; +import type { Rgb24 } from "../widgets/style.js"; export type ThemeColors = Readonly<{ - primary: Rgb; - secondary: Rgb; - success: Rgb; - danger: Rgb; - warning: Rgb; - info: Rgb; - muted: Rgb; - bg: Rgb; - fg: Rgb; - border: Rgb; - [key: string]: Rgb; + primary: Rgb24; + secondary: Rgb24; + success: Rgb24; + danger: Rgb24; + warning: Rgb24; + info: Rgb24; + muted: Rgb24; + bg: Rgb24; + fg: Rgb24; + border: Rgb24; + [key: string]: Rgb24; }>; export type ThemeSpacing = readonly number[]; diff --git a/packages/core/src/ui/__tests__/themed.test.ts b/packages/core/src/ui/__tests__/themed.test.ts index 72d0f915..ae9dfc7e 100644 --- a/packages/core/src/ui/__tests__/themed.test.ts +++ b/packages/core/src/ui/__tests__/themed.test.ts @@ -1,7 +1,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistBuildResult, - DrawlistBuilderV1, + DrawlistBuilder, DrawlistTextRunSegment, } from "../../drawlist/types.js"; import { layout } from "../../layout/layout.js"; @@ -14,7 +14,7 @@ import type { TextStyle } from "../../widgets/style.js"; import type { VNode } from "../../widgets/types.js"; import { ui } from "../../widgets/ui.js"; -class RecordingBuilder implements DrawlistBuilderV1 { +class RecordingBuilder implements DrawlistBuilder { readonly textOps: Array> = []; clear(): void {} @@ -32,6 +32,14 @@ class RecordingBuilder implements DrawlistBuilderV1 { return null; } drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + setCursor(..._args: Parameters): void {} + hideCursor(): void {} + setLink(..._args: Parameters): void {} + drawCanvas(..._args: Parameters): void {} + drawImage(..._args: Parameters): void {} + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } build(): DrawlistBuildResult { return { ok: true, bytes: new Uint8Array() }; } @@ -84,7 +92,7 @@ function fgByText( describe("ui.themed", () => { test("creates themed vnode and filters children", () => { - const vnode = ui.themed({ colors: { accent: { primary: { r: 1, g: 2, b: 3 } } } }, [ + const vnode = ui.themed({ colors: { accent: { primary: ((1 << 16) | (2 << 8) | 3) } } }, [ ui.text("a"), null, false, @@ -94,17 +102,17 @@ describe("ui.themed", () => { if (vnode.kind !== "themed") return; assert.equal(vnode.children.length, 2); assert.deepEqual((vnode.props.theme as { colors?: unknown }).colors, { - accent: { primary: { r: 1, g: 2, b: 3 } }, + accent: { primary: ((1 << 16) | (2 << 8) | 3) }, }); }); test("applies theme override to subtree without leaking to siblings", () => { const baseTheme = createTheme({ colors: { - primary: { r: 200, g: 40, b: 40 }, + primary: ((200 << 16) | (40 << 8) | 40), }, }); - const scopedPrimary = { r: 30, g: 210, b: 40 }; + const scopedPrimary = ((30 << 16) | (210 << 8) | 40); const vnode = ui.column({}, [ ui.divider({ label: "OUT", color: "primary" }), @@ -130,7 +138,7 @@ describe("ui.themed", () => { test("is layout-transparent for single-child subtrees", () => { const tree = commitAndLayout( ui.column({}, [ - ui.themed({ colors: { accent: { primary: { r: 10, g: 20, b: 30 } } } }, [ + ui.themed({ colors: { accent: { primary: ((10 << 16) | (20 << 8) | 30) } } }, [ ui.text("inside"), ]), ui.text("outside"), diff --git a/packages/core/src/ui/designTokens.ts b/packages/core/src/ui/designTokens.ts index 4de6ae6f..e851739a 100644 --- a/packages/core/src/ui/designTokens.ts +++ b/packages/core/src/ui/designTokens.ts @@ -9,7 +9,7 @@ */ import { type ColorTokens, DEFAULT_THEME_SPACING, type ThemeDefinition } from "../theme/tokens.js"; -import type { Rgb, TextStyle } from "../widgets/style.js"; +import type { Rgb24, TextStyle } from "../widgets/style.js"; // --------------------------------------------------------------------------- // Typography roles @@ -24,7 +24,7 @@ export type TypographyRole = "title" | "subtitle" | "body" | "caption" | "code" * Resolved typography style: TextStyle attributes for a given role. */ export type TypographyStyle = Readonly<{ - fg: Rgb; + fg: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -67,9 +67,9 @@ export type ElevationLevel = 0 | 1 | 2 | 3; */ export type SurfaceStyle = Readonly<{ /** Background color for this surface level */ - bg: Rgb; + bg: Rgb24; /** Border color (none for level 0) */ - border: Rgb | null; + border: Rgb24 | null; /** Whether to render a shadow */ shadow: boolean; }>; @@ -179,7 +179,7 @@ export type WidgetTone = "default" | "primary" | "danger" | "success" | "warning /** * Resolve the accent color for a given tone. */ -export function resolveToneColor(colors: ColorTokens, tone: WidgetTone): Rgb { +export function resolveToneColor(colors: ColorTokens, tone: WidgetTone): Rgb24 { switch (tone) { case "default": case "primary": @@ -196,7 +196,7 @@ export function resolveToneColor(colors: ColorTokens, tone: WidgetTone): Rgb { /** * Resolve foreground color for text on a tone-colored background. */ -export function resolveToneFg(colors: ColorTokens, tone: WidgetTone): Rgb { +export function resolveToneFg(colors: ColorTokens, tone: WidgetTone): Rgb24 { return colors.fg.inverse; } diff --git a/packages/core/src/ui/recipes.ts b/packages/core/src/ui/recipes.ts index 3b9782c4..cc8a1733 100644 --- a/packages/core/src/ui/recipes.ts +++ b/packages/core/src/ui/recipes.ts @@ -13,7 +13,7 @@ import { blendRgb } from "../theme/blend.js"; import type { ColorTokens, ThemeSpacingTokens } from "../theme/tokens.js"; -import type { Rgb, TextStyle } from "../widgets/style.js"; +import type { Rgb24, TextStyle } from "../widgets/style.js"; import { type BorderVariant, type Density, @@ -37,8 +37,8 @@ import { // --------------------------------------------------------------------------- /** Lighten or darken a color toward white/black. */ -function adjustBrightness(color: Rgb, amount: number): Rgb { - const target = amount > 0 ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 }; +function adjustBrightness(color: Rgb24, amount: number): Rgb24 { + const target = amount > 0 ? ((255 << 16) | (255 << 8) | 255) : ((0 << 16) | (0 << 8) | 0); return blendRgb(color, target, Math.abs(amount)); } @@ -1232,7 +1232,7 @@ export function badgeRecipe( const tone = params.tone ?? "default"; const resolvedTone: WidgetTone = tone === "info" ? "primary" : tone; - let color: Rgb; + let color: Rgb24; switch (tone) { case "danger": color = colors.error; @@ -1277,7 +1277,7 @@ export function tagRecipe(colors: ColorTokens, params: TagRecipeParams = {}): Ta const tone = params.tone ?? "default"; const resolvedTone: WidgetTone = tone === "info" ? "primary" : tone; - let bg: Rgb; + let bg: Rgb24; switch (tone) { case "danger": bg = colors.error; @@ -1481,7 +1481,7 @@ export function calloutRecipe( ): CalloutRecipeResult { const tone = params.tone ?? "info"; - let accentColor: Rgb; + let accentColor: Rgb24; switch (tone) { case "danger": accentColor = colors.error; diff --git a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts index 701179ae..a53f354c 100644 --- a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts +++ b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts @@ -1,10 +1,9 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { - type DrawlistBuilderV1, + type DrawlistBuilder, type Theme, type VNode, - createDrawlistBuilderV1, - createDrawlistBuilderV3, + createDrawlistBuilder, createTheme, } from "../../index.js"; import { layout } from "../../layout/layout.js"; @@ -108,8 +107,8 @@ function parseDrawTextCommands(bytes: Uint8Array): readonly DrawTextCommand[] { return Object.freeze(out); } -function packRgb(color: Readonly<{ r: number; g: number; b: number }>): number { - return ((color.r & 0xff) << 16) | ((color.g & 0xff) << 8) | (color.b & 0xff); +function packRgb(color: number): number { + return color & 0x00ff_ffff; } const ATTR_BOLD = 1 << 0; @@ -124,7 +123,7 @@ function renderBytes( focusAnnouncement?: string | null; }> = {}, ): Uint8Array { - return renderBytesWithBuilder(vnode, () => createDrawlistBuilderV1(), viewport, opts); + return renderBytesWithBuilder(vnode, () => createDrawlistBuilder(), viewport, opts); } function renderBytesV3( @@ -136,12 +135,12 @@ function renderBytesV3( focusAnnouncement?: string | null; }> = {}, ): Uint8Array { - return renderBytesWithBuilder(vnode, () => createDrawlistBuilderV3(), viewport, opts); + return renderBytesWithBuilder(vnode, () => createDrawlistBuilder(), viewport, opts); } function renderBytesWithBuilder( vnode: VNode, - createBuilder: () => DrawlistBuilderV1, + createBuilder: () => DrawlistBuilder, viewport: Readonly<{ cols: number; rows: number }>, opts: Readonly<{ focusedId?: string | null; @@ -185,7 +184,7 @@ describe("basic widgets render to drawlist", () => { test("richText uses DRAW_TEXT_RUN and interns span strings", () => { const bytes = renderBytes( ui.richText([ - { text: "const ", style: { fg: { r: 90, g: 160, b: 255 } } }, + { text: "const ", style: { fg: ((90 << 16) | (160 << 8) | 255) } }, { text: "x", style: { bold: true } }, ]), ); @@ -352,14 +351,14 @@ describe("basic widgets render to drawlist", () => { ui.progress(1, { variant: "minimal", width: 10, - style: { fg: { r: 1, g: 2, b: 3 } }, + style: { fg: ((1 << 16) | (2 << 8) | 3) }, }), { cols: 24, rows: 3 }, { theme: createTheme(defaultTheme) }, ); const drawText = parseDrawTextCommands(bytes).find((cmd) => cmd.text.includes("━")); assert.ok(drawText, "expected drawText for filled progress glyphs"); - assert.equal(drawText.fg, packRgb({ r: 1, g: 2, b: 3 })); + assert.equal(drawText.fg, packRgb(((1 << 16) | (2 << 8) | 3))); }); test("slider renders track and clamps displayed value to range", () => { @@ -529,7 +528,7 @@ describe("basic widgets render to drawlist", () => { test("link underlineColor theme token resolves on v3", () => { const theme = createTheme({ colors: { - "diagnostic.info": { r: 1, g: 2, b: 3 }, + "diagnostic.info": ((1 << 16) | (2 << 8) | 3), }, }); const bytes = renderBytesV3( @@ -568,7 +567,7 @@ describe("basic widgets render to drawlist", () => { test("codeEditor diagnostics use curly underline + token color on v3", () => { const theme = createTheme({ colors: { - "diagnostic.warning": { r: 1, g: 2, b: 3 }, + "diagnostic.warning": ((1 << 16) | (2 << 8) | 3), }, }); const vnode = ui.codeEditor({ @@ -619,9 +618,9 @@ describe("basic widgets render to drawlist", () => { test("codeEditor applies syntax token colors for mainstream language presets", () => { const theme = createTheme({ colors: { - "syntax.keyword": { r: 10, g: 20, b: 30 }, - "syntax.function": { r: 30, g: 40, b: 50 }, - "syntax.string": { r: 60, g: 70, b: 80 }, + "syntax.keyword": ((10 << 16) | (20 << 8) | 30), + "syntax.function": ((30 << 16) | (40 << 8) | 50), + "syntax.string": ((60 << 16) | (70 << 8) | 80), }, }); const vnode = ui.codeEditor({ @@ -654,8 +653,8 @@ describe("basic widgets render to drawlist", () => { test("codeEditor draws a highlighted cursor cell for focused editor", () => { const theme = createTheme({ colors: { - "syntax.cursor.bg": { r: 1, g: 2, b: 3 }, - "syntax.cursor.fg": { r: 4, g: 5, b: 6 }, + "syntax.cursor.bg": ((1 << 16) | (2 << 8) | 3), + "syntax.cursor.fg": ((4 << 16) | (5 << 8) | 6), }, }); const vnode = ui.codeEditor({ diff --git a/packages/core/src/widgets/__tests__/canvas.primitives.test.ts b/packages/core/src/widgets/__tests__/canvas.primitives.test.ts index 2e71204f..2ff25087 100644 --- a/packages/core/src/widgets/__tests__/canvas.primitives.test.ts +++ b/packages/core/src/widgets/__tests__/canvas.primitives.test.ts @@ -27,20 +27,20 @@ describe("canvas primitives", () => { }); test("drawing surface resolves auto blitter to concrete pixel resolution", () => { - const surface = createCanvasDrawingSurface(3, 2, "auto", () => ({ r: 1, g: 2, b: 3 })); + const surface = createCanvasDrawingSurface(3, 2, "auto", () => ((1 << 16) | (2 << 8) | 3)); assert.equal(surface.blitter, "braille"); assert.equal(surface.widthPx, 6); assert.equal(surface.heightPx, 8); }); test("setPixel writes RGBA bytes", () => { - const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ({ r: 10, g: 20, b: 30 })); + const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ((10 << 16) | (20 << 8) | 30)); surface.ctx.setPixel(1, 1, "#112233"); assert.deepEqual(rgbaAt(surface, 1, 1), { r: 17, g: 34, b: 51, a: 255 }); }); test("line draws deterministic diagonal pixels", () => { - const surface = createCanvasDrawingSurface(4, 4, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(4, 4, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.line(0, 0, 3, 3, "#ffffff"); assert.equal(rgbaAt(surface, 0, 0).a, 255); assert.equal(rgbaAt(surface, 1, 1).a, 255); @@ -49,7 +49,7 @@ describe("canvas primitives", () => { }); test("fillRect fills the requested region", () => { - const surface = createCanvasDrawingSurface(4, 3, "ascii", () => ({ r: 255, g: 0, b: 0 })); + const surface = createCanvasDrawingSurface(4, 3, "ascii", () => ((255 << 16) | (0 << 8) | 0)); surface.ctx.fillRect(1, 1, 2, 1, "#ff0000"); assert.equal(rgbaAt(surface, 1, 1).r, 255); assert.equal(rgbaAt(surface, 2, 1).r, 255); @@ -57,7 +57,7 @@ describe("canvas primitives", () => { }); test("strokeRect draws perimeter only", () => { - const surface = createCanvasDrawingSurface(5, 5, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(5, 5, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.strokeRect(1, 1, 3, 3, "#ffffff"); assert.equal(rgbaAt(surface, 1, 1).a, 255); assert.equal(rgbaAt(surface, 2, 1).a, 255); @@ -66,7 +66,7 @@ describe("canvas primitives", () => { }); test("polyline connects each point with line segments", () => { - const surface = createCanvasDrawingSurface(7, 7, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(7, 7, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.polyline( [ { x: 0, y: 0 }, @@ -81,7 +81,7 @@ describe("canvas primitives", () => { }); test("roundedRect draws rounded corners and straight edges", () => { - const surface = createCanvasDrawingSurface(9, 7, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(9, 7, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.roundedRect(1, 1, 7, 5, 2, "#ffffff"); assert.equal(rgbaAt(surface, 4, 3).a, 0); assert.equal(rgbaAt(surface, 3, 1).a, 255); @@ -89,7 +89,7 @@ describe("canvas primitives", () => { }); test("circle outlines are symmetric", () => { - const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.circle(5, 5, 3, "#ffffff"); assert.equal(rgbaAt(surface, 5, 2).a, 255); assert.equal(rgbaAt(surface, 5, 8).a, 255); @@ -98,7 +98,7 @@ describe("canvas primitives", () => { }); test("fillCircle paints interior pixels", () => { - const surface = createCanvasDrawingSurface(9, 9, "ascii", () => ({ r: 0, g: 255, b: 0 })); + const surface = createCanvasDrawingSurface(9, 9, "ascii", () => ((0 << 16) | (255 << 8) | 0)); surface.ctx.fillCircle(4, 4, 2, "#00ff00"); assert.equal(rgbaAt(surface, 4, 4).g, 255); assert.equal(rgbaAt(surface, 4, 2).g, 255); @@ -107,7 +107,7 @@ describe("canvas primitives", () => { }); test("arc draws a partial circle segment", () => { - const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.arc(5, 5, 3, 0, Math.PI * 0.5, "#ffffff"); assert.equal(rgbaAt(surface, 8, 5).a, 255); assert.equal(rgbaAt(surface, 5, 8).a, 255); @@ -115,21 +115,21 @@ describe("canvas primitives", () => { }); test("fillTriangle paints enclosed pixels", () => { - const surface = createCanvasDrawingSurface(8, 6, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(8, 6, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.fillTriangle(1, 1, 6, 1, 3, 4, "#ffffff"); assert.equal(rgbaAt(surface, 3, 2).a, 255); assert.equal(rgbaAt(surface, 0, 0).a, 0); }); test("text overlays map subcell coordinates to cell coordinates", () => { - const surface = createCanvasDrawingSurface(4, 2, "braille", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(4, 2, "braille", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.text(3, 5, "ok", "#ffaa00"); surface.ctx.text(-0.2, 0, "hidden"); assert.deepEqual(surface.overlays, [{ x: 1, y: 1, text: "ok", color: "#ffaa00" }]); }); test("clear removes pixels and overlay text", () => { - const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.setPixel(1, 1, "#ffffff"); surface.ctx.text(0, 0, "hi"); surface.ctx.clear(); @@ -141,7 +141,7 @@ describe("canvas primitives", () => { }); test("clear with color fills surface and clears overlays", () => { - const surface = createCanvasDrawingSurface(2, 1, "ascii", () => ({ r: 255, g: 255, b: 255 })); + const surface = createCanvasDrawingSurface(2, 1, "ascii", () => ((255 << 16) | (255 << 8) | 255)); surface.ctx.text(0, 0, "x"); surface.ctx.clear("#123456"); assert.deepEqual(rgbaAt(surface, 0, 0), { r: 18, g: 52, b: 86, a: 255 }); diff --git a/packages/core/src/widgets/__tests__/collections.test.ts b/packages/core/src/widgets/__tests__/collections.test.ts index 5e6587d0..9673ba37 100644 --- a/packages/core/src/widgets/__tests__/collections.test.ts +++ b/packages/core/src/widgets/__tests__/collections.test.ts @@ -90,10 +90,10 @@ describe("collections", () => { virtualized: true, overscan: 5, stripedRows: true, - stripeStyle: { odd: { r: 1, g: 2, b: 3 }, even: { r: 4, g: 5, b: 6 } }, + stripeStyle: { odd: ((1 << 16) | (2 << 8) | 3), even: ((4 << 16) | (5 << 8) | 6) }, showHeader: true, border: "single", - borderStyle: { variant: "double", color: { r: 7, g: 8, b: 9 } }, + borderStyle: { variant: "double", color: ((7 << 16) | (8 << 8) | 9) }, onSelectionChange: () => undefined, onSort: () => undefined, onRowPress: () => undefined, @@ -108,10 +108,10 @@ describe("collections", () => { assert.equal(vnode.props.border, "single"); assert.equal(vnode.props.columns[0]?.overflow, "middle"); assert.deepEqual(vnode.props.stripeStyle, { - odd: { r: 1, g: 2, b: 3 }, - even: { r: 4, g: 5, b: 6 }, + odd: ((1 << 16) | (2 << 8) | 3), + even: ((4 << 16) | (5 << 8) | 6), }); - assert.deepEqual(vnode.props.borderStyle, { variant: "double", color: { r: 7, g: 8, b: 9 } }); + assert.deepEqual(vnode.props.borderStyle, { variant: "double", color: ((7 << 16) | (8 << 8) | 9) }); }); test("ui.tree creates tree VNode with optional tree features", () => { diff --git a/packages/core/src/widgets/__tests__/containers.test.ts b/packages/core/src/widgets/__tests__/containers.test.ts index 71d1b8bb..e2180fb5 100644 --- a/packages/core/src/widgets/__tests__/containers.test.ts +++ b/packages/core/src/widgets/__tests__/containers.test.ts @@ -75,44 +75,44 @@ describe("container widgets - VNode construction", () => { title: "Styled", content: ui.text("Body"), frameStyle: { - background: { r: 20, g: 22, b: 24 }, - foreground: { r: 220, g: 222, b: 224 }, - border: { r: 120, g: 122, b: 124 }, + background: ((20 << 16) | (22 << 8) | 24), + foreground: ((220 << 16) | (222 << 8) | 224), + border: ((120 << 16) | (122 << 8) | 124), }, backdrop: { variant: "dim", pattern: "#", - foreground: { r: 60, g: 70, b: 80 }, - background: { r: 4, g: 5, b: 6 }, + foreground: ((60 << 16) | (70 << 8) | 80), + background: ((4 << 16) | (5 << 8) | 6), }, }); assert.equal(modal.kind, "modal"); assert.deepEqual(modal.props.backdrop, { variant: "dim", pattern: "#", - foreground: { r: 60, g: 70, b: 80 }, - background: { r: 4, g: 5, b: 6 }, + foreground: ((60 << 16) | (70 << 8) | 80), + background: ((4 << 16) | (5 << 8) | 6), }); assert.deepEqual(modal.props.frameStyle, { - background: { r: 20, g: 22, b: 24 }, - foreground: { r: 220, g: 222, b: 224 }, - border: { r: 120, g: 122, b: 124 }, + background: ((20 << 16) | (22 << 8) | 24), + foreground: ((220 << 16) | (222 << 8) | 224), + border: ((120 << 16) | (122 << 8) | 124), }); const layer = ui.layer({ id: "styled-layer", content: ui.text("overlay"), frameStyle: { - background: { r: 10, g: 11, b: 12 }, - foreground: { r: 230, g: 231, b: 232 }, - border: { r: 90, g: 91, b: 92 }, + background: ((10 << 16) | (11 << 8) | 12), + foreground: ((230 << 16) | (231 << 8) | 232), + border: ((90 << 16) | (91 << 8) | 92), }, }); assert.equal(layer.kind, "layer"); assert.deepEqual(layer.props.frameStyle, { - background: { r: 10, g: 11, b: 12 }, - foreground: { r: 230, g: 231, b: 232 }, - border: { r: 90, g: 91, b: 92 }, + background: ((10 << 16) | (11 << 8) | 12), + foreground: ((230 << 16) | (231 << 8) | 232), + border: ((90 << 16) | (91 << 8) | 92), }); }); diff --git a/packages/core/src/widgets/__tests__/graphics.golden.test.ts b/packages/core/src/widgets/__tests__/graphics.golden.test.ts index 30366b2d..36ee9c64 100644 --- a/packages/core/src/widgets/__tests__/graphics.golden.test.ts +++ b/packages/core/src/widgets/__tests__/graphics.golden.test.ts @@ -1,5 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV3, ui } from "../../index.js"; +import { type VNode, createDrawlistBuilder, ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -109,7 +109,7 @@ function renderBytes( assert.equal(layoutRes.ok, true); if (!layoutRes.ok) throw new Error("layout failed"); - const builder = createDrawlistBuilderV3(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: committed.value.root, layout: layoutRes.value, @@ -321,7 +321,7 @@ describe("graphics/widgets/style (locked) - zrdl-v3 golden fixtures", () => { text: "warn", style: { underlineStyle: "dashed", - underlineColor: { r: 0, g: 170, b: 255 }, + underlineColor: ((0 << 16) | (170 << 8) | 255), }, }, ]), diff --git a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts index 3f277572..7c07345f 100644 --- a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts +++ b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import type { DrawlistBuilderV1, VNode } from "../../index.js"; -import { createDrawlistBuilderV1, createDrawlistBuilderV3 } from "../../index.js"; +import type { DrawlistBuilder, VNode } from "../../index.js"; +import { createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -132,7 +132,7 @@ const ALL_IMAGE_PROTOCOLS_TERMINAL_PROFILE: TerminalProfile = Object.freeze({ function renderBytes( vnode: VNode, - createBuilder: () => DrawlistBuilderV1, + createBuilder: () => DrawlistBuilder, viewport: Readonly<{ cols: number; rows: number }> = { cols: 80, rows: 30 }, terminalProfile: TerminalProfile | undefined = undefined, ): Uint8Array { @@ -168,8 +168,8 @@ function renderBytes( describe("graphics widgets", () => { test("link encodes hyperlink refs in v3 and degrades to text in v1", () => { const vnode = ui.link({ url: "https://example.com", label: "Docs", id: "docs-link" }); - const v3 = renderBytes(vnode, () => createDrawlistBuilderV3()); - const v1 = renderBytes(vnode, () => createDrawlistBuilderV1()); + const v3 = renderBytes(vnode, () => createDrawlistBuilder()); + const v1 = renderBytes(vnode, () => createDrawlistBuilder()); assert.equal(parseOpcodes(v3).includes(8), false); assert.equal(parseOpcodes(v1).includes(8), false); assert.equal(parseStrings(v3).includes("Docs"), true); @@ -190,7 +190,7 @@ describe("graphics widgets", () => { ctx.fillRect(2, 2, 4, 3, "#ff0000"); }, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(8), true); }); @@ -204,7 +204,7 @@ describe("graphics widgets", () => { ctx.text(1, 1, "A", "#ffd166"); }, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 20, rows: 8 }, ); const payloadOff = findCommandPayload(bytes, 3); @@ -226,7 +226,7 @@ describe("graphics widgets", () => { ctx.clear("#112233"); }, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); @@ -246,7 +246,7 @@ describe("graphics widgets", () => { const pngLike = makePngHeader(2, 1); const bytes = renderBytes( ui.image({ src: pngLike, width: 10, height: 4, fit: "contain" }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, ITERM2_TERMINAL_PROFILE, ); @@ -262,7 +262,7 @@ describe("graphics widgets", () => { const src = new Uint8Array([1, 2, 3, 4]); const bytes = renderBytes( ui.image({ src, width: 10, height: 4 }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, ALL_IMAGE_PROTOCOLS_TERMINAL_PROFILE, ); @@ -276,7 +276,7 @@ describe("graphics widgets", () => { const src = new Uint8Array([1, 2, 3, 4]); const bytes = renderBytes( ui.image({ src, width: 10, height: 4 }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, ITERM2_SIXEL_TERMINAL_PROFILE, ); @@ -290,7 +290,7 @@ describe("graphics widgets", () => { const src = new Uint8Array([1, 2, 3, 4]); const bytes = renderBytes( ui.image({ src, width: 10, height: 4 }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, SIXEL_TERMINAL_PROFILE, ); @@ -304,7 +304,7 @@ describe("graphics widgets", () => { const src = new Uint8Array([1, 2, 3, 4]); const bytes = renderBytes( ui.image({ src, width: 10, height: 4 }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, DEFAULT_TERMINAL_PROFILE, ); @@ -316,7 +316,7 @@ describe("graphics widgets", () => { const pngLike = makePngHeader(2, 1); const bytes = renderBytes( ui.image({ src: pngLike, width: 10, height: 4, fit: "contain" }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 80, rows: 30 }, KITTY_ITERM2_TERMINAL_PROFILE, ); @@ -330,7 +330,7 @@ describe("graphics widgets", () => { const pngLike = makePngHeader(2, 1); const bytes = renderBytes( ui.image({ src: pngLike, width: 10, height: 4, fit: "contain", alt: "Logo" }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( @@ -351,7 +351,7 @@ describe("graphics widgets", () => { zLayer: -1, imageId: 0x0102_0304, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); const payloadOff = findCommandPayload(bytes, 9); assert.equal(payloadOff !== null, true); @@ -366,7 +366,7 @@ describe("graphics widgets", () => { test("image defaults protocol/fit/z-layer/imageId when omitted", () => { const src = new Uint8Array([1, 2, 3, 4]); const bytes = renderBytes(ui.image({ src, width: 10, height: 4 }), () => - createDrawlistBuilderV3(), + createDrawlistBuilder(), ); const payloadOff = findCommandPayload(bytes, 9); assert.equal(payloadOff !== null, true); @@ -387,7 +387,7 @@ describe("graphics widgets", () => { height: 2, protocol: "blitter", }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(8), true); assert.equal(parseOpcodes(bytes).includes(9), false); @@ -410,7 +410,7 @@ describe("graphics widgets", () => { sourceWidth: 96, sourceHeight: 48, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); @@ -429,7 +429,7 @@ describe("graphics widgets", () => { protocol: "blitter", sourceWidth: 96, }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); @@ -449,7 +449,7 @@ describe("graphics widgets", () => { protocol: "blitter", alt: "Logo", }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); @@ -462,7 +462,7 @@ describe("graphics widgets", () => { test("image degrades to placeholder on v1", () => { const bytes = renderBytes( ui.image({ src: new Uint8Array([0, 0, 0, 0]), width: 20, height: 4, alt: "Logo" }), - () => createDrawlistBuilderV1(), + () => createDrawlistBuilder(), ); const strings = parseStrings(bytes); assert.equal( @@ -486,7 +486,7 @@ describe("graphics widgets", () => { alt: "Broken image", }, }, - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( @@ -516,7 +516,7 @@ describe("graphics widgets", () => { ], }), ]), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 40, rows: 30 }, ); const canvasCount = parseOpcodes(bytes).filter((opcode) => opcode === 8).length; @@ -535,7 +535,7 @@ describe("graphics widgets", () => { { highRes: true }, ), ]), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 40, rows: 12 }, ); const canvasCount = parseOpcodes(bytes).filter((opcode) => opcode === 8).length; @@ -545,7 +545,7 @@ describe("graphics widgets", () => { test("sparkline highRes draws pixels for single-point series", () => { const bytes = renderBytes( ui.sparkline([5], { width: 6, min: 0, max: 10, highRes: true }), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 10, rows: 4 }, ); const payloadOff = findCommandPayload(bytes, 8); @@ -568,7 +568,7 @@ describe("graphics widgets", () => { ], { highRes: true, showLabels: false, showValues: false, blitter: "quadrant" }, ), - () => createDrawlistBuilderV3(), + () => createDrawlistBuilder(), { cols: 20, rows: 8 }, ); const payloadOff = findCommandPayload(bytes, 8); diff --git a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts index 8e560104..8d10f432 100644 --- a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts +++ b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { RuntimeBreadcrumbSnapshot } from "../../app/runtimeBreadcrumbs.js"; -import { type VNode, ZR_CURSOR_SHAPE_BAR, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, ZR_CURSOR_SHAPE_BAR, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -59,7 +59,7 @@ function renderStrings( assert.equal(layoutRes.ok, true, "layout should succeed"); if (!layoutRes.ok) return Object.freeze([]); - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: commitRes.value.root, layout: layoutRes.value, diff --git a/packages/core/src/widgets/__tests__/overlays.test.ts b/packages/core/src/widgets/__tests__/overlays.test.ts index 69594d8e..6cc34184 100644 --- a/packages/core/src/widgets/__tests__/overlays.test.ts +++ b/packages/core/src/widgets/__tests__/overlays.test.ts @@ -30,9 +30,9 @@ describe("overlay widgets - VNode construction", () => { test("dropdown preserves frameStyle colors", () => { const frameStyle = { - background: { r: 12, g: 18, b: 24 }, - foreground: { r: 200, g: 210, b: 220 }, - border: { r: 80, g: 90, b: 100 }, + background: ((12 << 16) | (18 << 8) | 24), + foreground: ((200 << 16) | (210 << 8) | 220), + border: ((80 << 16) | (90 << 8) | 100), } as const; const vnode = ui.dropdown({ id: "styled-menu", @@ -79,9 +79,9 @@ describe("overlay widgets - VNode construction", () => { test("commandPalette preserves frameStyle colors", () => { const frameStyle = { - background: { r: 11, g: 12, b: 13 }, - foreground: { r: 210, g: 211, b: 212 }, - border: { r: 100, g: 101, b: 102 }, + background: ((11 << 16) | (12 << 8) | 13), + foreground: ((210 << 16) | (211 << 8) | 212), + border: ((100 << 16) | (101 << 8) | 102), } as const; const vnode = ui.commandPalette({ id: "palette-styled", @@ -147,9 +147,9 @@ describe("overlay widgets - VNode construction", () => { test("toastContainer preserves frameStyle colors", () => { const frameStyle = { - background: { r: 5, g: 6, b: 7 }, - foreground: { r: 230, g: 231, b: 232 }, - border: { r: 140, g: 141, b: 142 }, + background: ((5 << 16) | (6 << 8) | 7), + foreground: ((230 << 16) | (231 << 8) | 232), + border: ((140 << 16) | (141 << 8) | 142), } as const; const vnode = ui.toastContainer({ toasts: [], diff --git a/packages/core/src/widgets/__tests__/overlays.typecheck.ts b/packages/core/src/widgets/__tests__/overlays.typecheck.ts index 998314d2..af4f908c 100644 --- a/packages/core/src/widgets/__tests__/overlays.typecheck.ts +++ b/packages/core/src/widgets/__tests__/overlays.typecheck.ts @@ -10,38 +10,38 @@ const modalBackdropPreset: ModalProps["backdrop"] = "dim"; const modalBackdropObject: ModalProps["backdrop"] = { variant: "opaque", pattern: "#", - foreground: { r: 10, g: 20, b: 30 }, - background: { r: 1, g: 2, b: 3 }, + foreground: ((10 << 16) | (20 << 8) | 30), + background: ((1 << 16) | (2 << 8) | 3), }; // @ts-expect-error invalid backdrop variant const modalBackdropInvalid: ModalProps["backdrop"] = { variant: "blur" }; const dropdownFrame: DropdownProps["frameStyle"] = { - background: { r: 1, g: 2, b: 3 }, - foreground: { r: 4, g: 5, b: 6 }, - border: { r: 7, g: 8, b: 9 }, + background: ((1 << 16) | (2 << 8) | 3), + foreground: ((4 << 16) | (5 << 8) | 6), + border: ((7 << 16) | (8 << 8) | 9), }; // @ts-expect-error missing b component const dropdownFrameInvalid: DropdownProps["frameStyle"] = { border: { r: 1, g: 2 } }; const layerFrame: LayerProps["frameStyle"] = { - background: { r: 9, g: 9, b: 9 }, - foreground: { r: 200, g: 200, b: 200 }, - border: { r: 120, g: 120, b: 120 }, + background: ((9 << 16) | (9 << 8) | 9), + foreground: ((200 << 16) | (200 << 8) | 200), + border: ((120 << 16) | (120 << 8) | 120), }; const commandPaletteFrame: CommandPaletteProps["frameStyle"] = { - background: { r: 12, g: 13, b: 14 }, - foreground: { r: 220, g: 221, b: 222 }, - border: { r: 100, g: 110, b: 120 }, + background: ((12 << 16) | (13 << 8) | 14), + foreground: ((220 << 16) | (221 << 8) | 222), + border: ((100 << 16) | (110 << 8) | 120), }; const toastFrame: ToastContainerProps["frameStyle"] = { - background: { r: 15, g: 16, b: 17 }, - foreground: { r: 230, g: 231, b: 232 }, - border: { r: 111, g: 112, b: 113 }, + background: ((15 << 16) | (16 << 8) | 17), + foreground: ((230 << 16) | (231 << 8) | 232), + border: ((111 << 16) | (112 << 8) | 113), }; void modalBackdropPreset; diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index 1f829d30..ba3b7075 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -169,7 +169,7 @@ function renderBytes( assert.equal(layoutRes.ok, true, "layout should succeed"); if (!layoutRes.ok) return new Uint8Array(); - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: committed.value.root, layout: layoutRes.value, @@ -348,8 +348,8 @@ describe("renderer regressions", () => { getRowKey: (row) => row.name, stripedRows: false, stripeStyle: { - odd: { r: 1, g: 2, b: 3 }, - even: { r: 4, g: 5, b: 6 }, + odd: ((1 << 16) | (2 << 8) | 3), + even: ((4 << 16) | (5 << 8) | 6), }, border: "none", }), @@ -377,7 +377,7 @@ describe("renderer regressions", () => { border: "single", borderStyle: { variant: "double", - color: { r: 201, g: 202, b: 203 }, + color: ((201 << 16) | (202 << 8) | 203), }, }), { cols: 40, rows: 8 }, @@ -406,7 +406,7 @@ describe("renderer regressions", () => { border: "none", borderStyle: { variant: "double", - color: { r: 111, g: 112, b: 113 }, + color: ((111 << 16) | (112 << 8) | 113), }, }), { cols: 40, rows: 8 }, @@ -448,8 +448,8 @@ describe("renderer regressions", () => { backdrop: { variant: "dim", pattern: "#", - foreground: { r: 1, g: 2, b: 3 }, - background: { r: 4, g: 5, b: 6 }, + foreground: ((1 << 16) | (2 << 8) | 3), + background: ((4 << 16) | (5 << 8) | 6), }, }), { cols: 60, rows: 20 }, @@ -491,9 +491,9 @@ describe("renderer regressions", () => { content: ui.text("Styled modal"), backdrop: "none", frameStyle: { - background: { r: 12, g: 13, b: 14 }, - foreground: { r: 210, g: 211, b: 212 }, - border: { r: 90, g: 91, b: 92 }, + background: ((12 << 16) | (13 << 8) | 14), + foreground: ((210 << 16) | (211 << 8) | 212), + border: ((90 << 16) | (91 << 8) | 92), }, }), { cols: 60, rows: 20 }, @@ -520,9 +520,9 @@ describe("renderer regressions", () => { id: "layer-frame-style", backdrop: "none", frameStyle: { - background: { r: 21, g: 22, b: 23 }, - foreground: { r: 181, g: 182, b: 183 }, - border: { r: 101, g: 102, b: 103 }, + background: ((21 << 16) | (22 << 8) | 23), + foreground: ((181 << 16) | (182 << 8) | 183), + border: ((101 << 16) | (102 << 8) | 103), }, content: ui.text("Layer styled"), }), @@ -551,7 +551,7 @@ describe("renderer regressions", () => { id: "layer-clip-inner", backdrop: "none", frameStyle: { - border: { r: 130, g: 131, b: 132 }, + border: ((130 << 16) | (131 << 8) | 132), }, content: ui.text("edge"), }), @@ -577,9 +577,9 @@ describe("renderer regressions", () => { { id: "open", label: "Open" }, ], frameStyle: { - background: { r: 31, g: 32, b: 33 }, - foreground: { r: 191, g: 192, b: 193 }, - border: { r: 111, g: 112, b: 113 }, + background: ((31 << 16) | (32 << 8) | 33), + foreground: ((191 << 16) | (192 << 8) | 193), + border: ((111 << 16) | (112 << 8) | 113), }, }), ]), @@ -610,9 +610,9 @@ describe("renderer regressions", () => { sources: [{ id: "cmd", name: "Commands", getItems: () => [] }], selectedIndex: 0, frameStyle: { - background: { r: 41, g: 42, b: 43 }, - foreground: { r: 201, g: 202, b: 203 }, - border: { r: 121, g: 122, b: 123 }, + background: ((41 << 16) | (42 << 8) | 43), + foreground: ((201 << 16) | (202 << 8) | 203), + border: ((121 << 16) | (122 << 8) | 123), }, onQueryChange: noop, onSelect: noop, @@ -642,9 +642,9 @@ describe("renderer regressions", () => { toasts: [{ id: "toast-1", message: "Saved", type: "success" }], onDismiss: noop, frameStyle: { - background: { r: 51, g: 52, b: 53 }, - foreground: { r: 211, g: 212, b: 213 }, - border: { r: 131, g: 132, b: 133 }, + background: ((51 << 16) | (52 << 8) | 53), + foreground: ((211 << 16) | (212 << 8) | 213), + border: ((131 << 16) | (132 << 8) | 133), }, }), { cols: 70, rows: 24 }, diff --git a/packages/core/src/widgets/__tests__/style.attributes.test.ts b/packages/core/src/widgets/__tests__/style.attributes.test.ts index 0517debc..c5c3aa5d 100644 --- a/packages/core/src/widgets/__tests__/style.attributes.test.ts +++ b/packages/core/src/widgets/__tests__/style.attributes.test.ts @@ -62,12 +62,12 @@ describe("TextStyle attributes", () => { test("mergeStyles preserves fg/bg while updating attrs", () => { const merged = mergeStyles( - { fg: { r: 1, g: 2, b: 3 }, bg: { r: 4, g: 5, b: 6 }, bold: true }, + { fg: ((1 << 16) | (2 << 8) | 3), bg: ((4 << 16) | (5 << 8) | 6), bold: true }, { bold: false, underline: true }, ); assert.deepEqual(merged, { - fg: { r: 1, g: 2, b: 3 }, - bg: { r: 4, g: 5, b: 6 }, + fg: ((1 << 16) | (2 << 8) | 3), + bg: ((4 << 16) | (5 << 8) | 6), bold: false, underline: true, }); diff --git a/packages/core/src/widgets/__tests__/style.inheritance.test.ts b/packages/core/src/widgets/__tests__/style.inheritance.test.ts index c6ec485b..c99d34bb 100644 --- a/packages/core/src/widgets/__tests__/style.inheritance.test.ts +++ b/packages/core/src/widgets/__tests__/style.inheritance.test.ts @@ -8,14 +8,14 @@ import { type ChainLevelStyle = TextStyle | undefined; -const ROOT_FG = { r: 11, g: 22, b: 33 }; -const ROOT_BG = { r: 44, g: 55, b: 66 }; -const BOX_FG = { r: 77, g: 88, b: 99 }; -const BOX_BG = { r: 101, g: 111, b: 121 }; -const ROW_BG = { r: 131, g: 141, b: 151 }; -const ROW_FG = { r: 152, g: 162, b: 172 }; -const TEXT_FG = { r: 181, g: 191, b: 201 }; -const DEEP_BG = { r: 211, g: 212, b: 213 }; +const ROOT_FG = ((11 << 16) | (22 << 8) | 33); +const ROOT_BG = ((44 << 16) | (55 << 8) | 66); +const BOX_FG = ((77 << 16) | (88 << 8) | 99); +const BOX_BG = ((101 << 16) | (111 << 8) | 121); +const ROW_BG = ((131 << 16) | (141 << 8) | 151); +const ROW_FG = ((152 << 16) | (162 << 8) | 172); +const TEXT_FG = ((181 << 16) | (191 << 8) | 201); +const DEEP_BG = ((211 << 16) | (212 << 8) | 213); function resolveChain(levels: readonly ChainLevelStyle[]): ResolvedTextStyle { let resolved = DEFAULT_BASE_STYLE; @@ -233,12 +233,12 @@ describe("mergeTextStyle deep inheritance chains", () => { let override: TextStyle | undefined; if (level % 64 === 0) { const base = (level / 2) % 256; - const nextFg = { r: base, g: (base + 1) % 256, b: (base + 2) % 256 }; + const nextFg = ((base << 16) | (((base + 1) % 256) << 8) | ((base + 2) % 256)); override = { fg: nextFg }; expectedFg = nextFg; } else if (level % 45 === 0) { const base = (level * 3) % 256; - const nextBg = { r: base, g: (base + 7) % 256, b: (base + 14) % 256 }; + const nextBg = ((base << 16) | (((base + 7) % 256) << 8) | ((base + 14) % 256)); override = { bg: nextBg }; expectedBg = nextBg; } else if (level % 11 === 0) { diff --git a/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts b/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts index a9e5499d..90d8b0d9 100644 --- a/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts @@ -25,6 +25,7 @@ const ITERATIONS = 1024; type MutableResolvedTextStyle = { fg: NonNullable; bg: NonNullable; + attrs?: number; } & Partial>; type OverrideInput = { @@ -50,31 +51,27 @@ function randomBool(rng: Rng): boolean { } function randomRgb(rng: Rng): NonNullable { - return { - r: rng.u32() & 255, - g: rng.u32() & 255, - b: rng.u32() & 255, - }; + return (((rng.u32() & 255) << 16) | ((rng.u32() & 255) << 8) | (rng.u32() & 255)); } function randomBase(rng: Rng): ResolvedTextStyle { - const base: MutableResolvedTextStyle = { fg: randomRgb(rng), bg: randomRgb(rng) }; + const base: Record = { fg: randomRgb(rng), bg: randomRgb(rng) }; for (const attr of BOOL_ATTRS) { const state = rng.u32() % 3; if (state === 1) base[attr] = false; if (state === 2) base[attr] = true; } - return base; + return mergeTextStyle(DEFAULT_BASE_STYLE, base as TextStyle); } function randomTrueHeavyBase(rng: Rng): ResolvedTextStyle { - const base: MutableResolvedTextStyle = { fg: randomRgb(rng), bg: randomRgb(rng) }; + const base: Record = { fg: randomRgb(rng), bg: randomRgb(rng) }; for (const attr of BOOL_ATTRS) { const state = rng.u32() % 5; if (state === 1) base[attr] = false; if (state >= 2) base[attr] = true; } - return base; + return mergeTextStyle(DEFAULT_BASE_STYLE, base as TextStyle); } function setBoolOverride(out: OverrideInput, attr: BoolAttr, state: number, rng: Rng): void { @@ -176,9 +173,9 @@ function randomFalseHeavyOverride(rng: Rng): OverrideInput { } function expectedMerge( - base: ResolvedTextStyle, + base: MutableResolvedTextStyle, override: OverrideInput | undefined, -): ResolvedTextStyle { +): MutableResolvedTextStyle { if (!override) return base; const merged: MutableResolvedTextStyle = { @@ -194,14 +191,13 @@ function expectedMerge( return merged; } -function assertResolvedStyle(actual: ResolvedTextStyle, expected: ResolvedTextStyle): void { +function assertResolvedStyle(actual: ResolvedTextStyle, expected: MutableResolvedTextStyle): void { + assert.equal(typeof actual.attrs, "number"); assert.deepEqual(actual.fg, expected.fg); assert.deepEqual(actual.bg, expected.bg); for (const attr of BOOL_ATTRS) { assert.equal(actual[attr], expected[attr]); - assert.equal(Object.prototype.hasOwnProperty.call(actual, attr), expected[attr] !== undefined); } - assert.deepEqual(actual, expected); } function assertPairMerge( diff --git a/packages/core/src/widgets/__tests__/style.merge.test.ts b/packages/core/src/widgets/__tests__/style.merge.test.ts index 68eaf276..ac9e4d7d 100644 --- a/packages/core/src/widgets/__tests__/style.merge.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge.test.ts @@ -250,32 +250,31 @@ describe("mergeTextStyle cache correctness for DEFAULT_BASE_STYLE", () => { }); test("non-default base path does not stale-reuse entries across differing colors", () => { - const redBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: { r: 200, g: 10, b: 20 } }); - const blueBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: { r: 20, g: 10, b: 200 } }); + const redBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: ((200 << 16) | (10 << 8) | 20) }); + const blueBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: ((20 << 16) | (10 << 8) | 200) }); const redBoldA = mergeTextStyle(redBase, { bold: true }); const redBoldB = mergeTextStyle(redBase, { bold: true }); const blueBold = mergeTextStyle(blueBase, { bold: true }); assert.equal(redBoldA === redBoldB, false); assert.equal(redBoldA === blueBold, false); - assert.deepEqual(redBoldA.fg, { r: 200, g: 10, b: 20 }); - assert.deepEqual(redBoldB.fg, { r: 200, g: 10, b: 20 }); - assert.deepEqual(blueBold.fg, { r: 20, g: 10, b: 200 }); + assert.deepEqual(redBoldA.fg, ((200 << 16) | (10 << 8) | 20)); + assert.deepEqual(redBoldB.fg, ((200 << 16) | (10 << 8) | 20)); + assert.deepEqual(blueBold.fg, ((20 << 16) | (10 << 8) | 200)); }); test("invalid style channels are sanitized before merge", () => { const merged = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: { r: 999, g: -5, b: "15.6" as unknown as number }, - bg: { r: "4" as unknown as number, g: 5, b: 6 }, + fg: { r: 999, g: -5, b: "15.6" as unknown as number } as unknown as number, + bg: { r: "4" as unknown as number, g: 5, b: 6 } as unknown as number, blink: "true" as unknown as boolean, underline: 1 as unknown as boolean, }); - assert.deepEqual(merged, { - fg: { r: 255, g: 0, b: 16 }, - bg: { r: 4, g: 5, b: 6 }, - blink: true, - }); + assert.equal(merged.fg, DEFAULT_BASE_STYLE.fg); + assert.equal(merged.bg, DEFAULT_BASE_STYLE.bg); + assert.equal(merged.blink, true); + assert.equal(merged.underline, undefined); }); }); @@ -354,16 +353,16 @@ describe("mergeTextStyle extended underline fields", () => { const base = mergeTextStyle(DEFAULT_BASE_STYLE, { underline: true, underlineStyle: "double", - underlineColor: { r: 1, g: 2, b: 3 }, + underlineColor: ((1 << 16) | (2 << 8) | 3), }); const merged = mergeTextStyle(base, { underlineStyle: "curly", - underlineColor: { r: 4, g: 5, b: 6 }, + underlineColor: ((4 << 16) | (5 << 8) | 6), }); assert.equal(merged.underline, true); assert.equal(merged.underlineStyle, "curly"); - assert.deepEqual(merged.underlineColor, { r: 4, g: 5, b: 6 }); + assert.deepEqual(merged.underlineColor, ((4 << 16) | (5 << 8) | 6)); }); test("merge retains token-string underlineColor values", () => { diff --git a/packages/core/src/widgets/__tests__/style.utils.test.ts b/packages/core/src/widgets/__tests__/style.utils.test.ts index ca39a8ae..5f40f7c7 100644 --- a/packages/core/src/widgets/__tests__/style.utils.test.ts +++ b/packages/core/src/widgets/__tests__/style.utils.test.ts @@ -4,9 +4,9 @@ import { mergeStyles, sanitizeRgb, sanitizeTextStyle, styleWhen, styles } from " describe("style utils contracts", () => { test("mergeStyles performs a deterministic 3-way left-to-right merge", () => { - const a = { bold: true, underline: false, fg: { r: 1, g: 2, b: 3 } } as const; + const a = { bold: true, underline: false, fg: ((1 << 16) | (2 << 8) | 3) } as const; const b = { bold: false, italic: true } as const; - const c = { fg: { r: 9, g: 8, b: 7 }, dim: true } as const; + const c = { fg: ((9 << 16) | (8 << 8) | 7), dim: true } as const; const merged = mergeStyles(a, b, c); @@ -15,7 +15,7 @@ describe("style utils contracts", () => { underline: false, italic: true, dim: true, - fg: { r: 9, g: 8, b: 7 }, + fg: ((9 << 16) | (8 << 8) | 7), }); }); @@ -41,7 +41,7 @@ describe("style utils contracts", () => { }); test("composition via styleWhen + mergeStyles does not mutate inputs", () => { - const base = { bold: true, fg: { r: 5, g: 6, b: 7 } } as const; + const base = { bold: true, fg: ((5 << 16) | (6 << 8) | 7) } as const; const conditional: TextStyle = { italic: true }; const fallback: TextStyle = { dim: true }; @@ -51,12 +51,12 @@ describe("style utils contracts", () => { styleWhen(false, styles.underline), ); - assert.deepEqual(base, { bold: true, fg: { r: 5, g: 6, b: 7 } }); + assert.deepEqual(base, { bold: true, fg: ((5 << 16) | (6 << 8) | 7) }); assert.deepEqual(conditional, { italic: true }); assert.deepEqual(fallback, { dim: true }); assert.deepEqual(merged, { bold: true, - fg: { r: 5, g: 6, b: 7 }, + fg: ((5 << 16) | (6 << 8) | 7), italic: true, }); }); @@ -75,7 +75,7 @@ describe("style utils contracts", () => { test("sanitizeRgb clamps channels and accepts numeric strings", () => { const out = sanitizeRgb({ r: "260", g: -2, b: "127.6" }); - assert.deepEqual(out, { r: 255, g: 0, b: 128 }); + assert.deepEqual(out, ((255 << 16) | (0 << 8) | 128)); }); test("sanitizeTextStyle drops invalid fields and coerces booleans", () => { @@ -89,20 +89,20 @@ describe("style utils contracts", () => { }); assert.deepEqual(out, { - fg: { r: 1, g: 2, b: 4 }, + fg: ((1 << 16) | (2 << 8) | 4), bold: true, italic: false, }); }); test("mergeStyles sanitizes incoming style values", () => { - const merged = mergeStyles({ fg: { r: 0, g: 0, b: 0 }, bold: true }, { + const merged = mergeStyles({ fg: ((0 << 16) | (0 << 8) | 0), bold: true }, { fg: { r: 512, g: "-10", b: "3.2" }, bold: "false", } as unknown as TextStyle); assert.deepEqual(merged, { - fg: { r: 255, g: 0, b: 3 }, + fg: ((255 << 16) | (0 << 8) | 3), bold: false, }); }); diff --git a/packages/core/src/widgets/__tests__/styleUtils.test.ts b/packages/core/src/widgets/__tests__/styleUtils.test.ts index 5d163b0b..617d42d5 100644 --- a/packages/core/src/widgets/__tests__/styleUtils.test.ts +++ b/packages/core/src/widgets/__tests__/styleUtils.test.ts @@ -3,11 +3,11 @@ import { extendStyle, mergeStyles, sanitizeTextStyle, styleWhen, styles } from " describe("styleUtils", () => { test("mergeStyles applies later overrides", () => { - const a = { bold: true, fg: { r: 1, g: 1, b: 1 } } as const; + const a = { bold: true, fg: ((1 << 16) | (1 << 8) | 1) } as const; const b = { bold: false } as const; const merged = mergeStyles(a, b); assert.equal(merged.bold, false); - assert.deepEqual(merged.fg, { r: 1, g: 1, b: 1 }); + assert.deepEqual(merged.fg, ((1 << 16) | (1 << 8) | 1)); }); test("extendStyle delegates to mergeStyles", () => { @@ -34,7 +34,7 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle preserves underlineColor rgb", () => { - assert.deepEqual(sanitizeTextStyle({ underlineColor: { r: 1, g: 2, b: 3 } }).underlineColor, { + assert.deepEqual(sanitizeTextStyle({ underlineColor: ((1 << 16) | (2 << 8) | 3) }).underlineColor, { r: 1, g: 2, b: 3, @@ -61,11 +61,11 @@ describe("styleUtils", () => { test("mergeStyles merges underlineStyle and underlineColor", () => { const merged = mergeStyles( - { underlineStyle: "curly", underlineColor: { r: 255, g: 0, b: 0 } }, + { underlineStyle: "curly", underlineColor: ((255 << 16) | (0 << 8) | 0) }, { bold: true }, ); assert.equal(merged.underlineStyle, "curly"); - assert.deepEqual(merged.underlineColor, { r: 255, g: 0, b: 0 }); + assert.deepEqual(merged.underlineColor, ((255 << 16) | (0 << 8) | 0)); assert.equal(merged.bold, true); }); diff --git a/packages/core/src/widgets/__tests__/styled.test.ts b/packages/core/src/widgets/__tests__/styled.test.ts index 377aa2f7..f97f7b2d 100644 --- a/packages/core/src/widgets/__tests__/styled.test.ts +++ b/packages/core/src/widgets/__tests__/styled.test.ts @@ -7,8 +7,8 @@ describe("styled", () => { base: { bold: true }, variants: { intent: { - primary: { fg: { r: 1, g: 2, b: 3 } }, - danger: { fg: { r: 9, g: 9, b: 9 } }, + primary: { fg: ((1 << 16) | (2 << 8) | 3) }, + danger: { fg: ((9 << 16) | (9 << 8) | 9) }, }, }, defaults: { intent: "primary" }, @@ -18,7 +18,7 @@ describe("styled", () => { assert.equal(v.kind, "button"); assert.deepEqual((v.props as { style?: unknown }).style, { bold: true, - fg: { r: 1, g: 2, b: 3 }, + fg: ((1 << 16) | (2 << 8) | 3), }); }); }); diff --git a/packages/core/src/widgets/__tests__/table.typecheck.ts b/packages/core/src/widgets/__tests__/table.typecheck.ts index bf62e64d..bd1bc020 100644 --- a/packages/core/src/widgets/__tests__/table.typecheck.ts +++ b/packages/core/src/widgets/__tests__/table.typecheck.ts @@ -16,8 +16,8 @@ const columnWithInvalidOverflow: TableColumn = { }; const stripeStyle: NonNullable["stripeStyle"]> = { - odd: { r: 1, g: 2, b: 3 }, - even: { r: 4, g: 5, b: 6 }, + odd: ((1 << 16) | (2 << 8) | 3), + even: ((4 << 16) | (5 << 8) | 6), }; // @ts-expect-error missing b component @@ -25,7 +25,7 @@ const stripeStyleInvalid: NonNullable["stripeStyle"]> = { odd: { const borderStyle: TableBorderStyle = { variant: "heavy-dashed", - color: { r: 10, g: 11, b: 12 }, + color: ((10 << 16) | (11 << 8) | 12), }; // @ts-expect-error invalid border style variant @@ -37,7 +37,7 @@ const tableWithStyleOnly: TableProps = { data: [{ id: "r0", path: "/tmp/file.txt" }], getRowKey: (row) => row.id, stripeStyle, - borderStyle: { variant: "double", color: { r: 20, g: 21, b: 22 } }, + borderStyle: { variant: "double", color: ((20 << 16) | (21 << 8) | 22) }, }; void columnWithMiddleOverflow; diff --git a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts index 72e4f3e2..2fa57c3d 100644 --- a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts +++ b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { type VNode, createDrawlistBuilderV1 } from "../../index.js"; +import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; @@ -58,7 +58,7 @@ function renderBytes( assert.equal(layoutRes.ok, true, "layout should succeed"); if (!layoutRes.ok) return new Uint8Array(); - const builder = createDrawlistBuilderV1(); + const builder = createDrawlistBuilder(); renderToDrawlist({ tree: committed.value.root, layout: layoutRes.value, diff --git a/packages/core/src/widgets/canvas.ts b/packages/core/src/widgets/canvas.ts index 52c9594e..a453aa68 100644 --- a/packages/core/src/widgets/canvas.ts +++ b/packages/core/src/widgets/canvas.ts @@ -1,4 +1,4 @@ -import type { Rgb } from "./style.js"; +import { rgbB, rgbG, rgbR, type Rgb24 } from "./style.js"; import type { CanvasContext, GraphicsBlitter } from "./types.js"; export type CanvasOverlayText = Readonly<{ @@ -22,7 +22,7 @@ export type CanvasDrawingSurface = Readonly<{ blitter: GraphicsBlitter; }>; -export type CanvasColorResolver = (color: string) => Rgb; +export type CanvasColorResolver = (color: string) => Rgb24; const TRANSPARENT_PIXEL = Object.freeze({ r: 0, g: 0, b: 0, a: 0 }); const DEFAULT_SOLID_PIXEL = Object.freeze({ r: 255, g: 255, b: 255, a: 255 }); @@ -53,25 +53,16 @@ function clampInt(v: number, min: number, max: number): number { return n; } -function parseHexColor(input: string): Rgb | null { +function parseHexColor(input: string): Rgb24 | null { const raw = input.startsWith("#") ? input.slice(1) : input; if (/^[0-9a-fA-F]{6}$/.test(raw)) { - const n = Number.parseInt(raw, 16); - return Object.freeze({ - r: (n >> 16) & 0xff, - g: (n >> 8) & 0xff, - b: n & 0xff, - }); + return Number.parseInt(raw, 16) & 0x00ff_ffff; } if (/^[0-9a-fA-F]{3}$/.test(raw)) { const r = Number.parseInt(raw[0] ?? "0", 16); const g = Number.parseInt(raw[1] ?? "0", 16); const b = Number.parseInt(raw[2] ?? "0", 16); - return Object.freeze({ - r: (r << 4) | r, - g: (g << 4) | g, - b: (b << 4) | b, - }); + return (((r << 4) | r) << 16) | (((g << 4) | g) << 8) | ((b << 4) | b); } return null; } @@ -83,14 +74,19 @@ function resolvePixel( if (color === undefined) return DEFAULT_SOLID_PIXEL; const parsedHex = parseHexColor(color); if (parsedHex) { - return Object.freeze({ ...parsedHex, a: 255 }); + return Object.freeze({ + r: clampU8(rgbR(parsedHex)), + g: clampU8(rgbG(parsedHex)), + b: clampU8(rgbB(parsedHex)), + a: 255, + }); } if (!resolveColor) return DEFAULT_SOLID_PIXEL; const resolved = resolveColor(color); return Object.freeze({ - r: clampU8(resolved.r), - g: clampU8(resolved.g), - b: clampU8(resolved.b), + r: clampU8(rgbR(resolved)), + g: clampU8(rgbG(resolved)), + b: clampU8(rgbB(resolved)), a: 255, }); } diff --git a/packages/core/src/widgets/commandPalette.ts b/packages/core/src/widgets/commandPalette.ts index a6d24d31..c0f392a0 100644 --- a/packages/core/src/widgets/commandPalette.ts +++ b/packages/core/src/widgets/commandPalette.ts @@ -8,6 +8,7 @@ * @see docs/widgets/command-palette.md */ +import { rgb } from "./style.js"; import type { CommandItem, CommandSource } from "./types.js"; /** Default maximum visible items in the palette. */ @@ -281,9 +282,9 @@ export function computeHighlights( /** Palette color constants. */ export const PALETTE_COLORS = { - background: { r: 30, g: 30, b: 30 }, - border: { r: 60, g: 60, b: 60 }, - selectedBg: { r: 0, g: 120, b: 215 }, - highlight: { r: 255, g: 210, b: 0 }, - placeholder: { r: 128, g: 128, b: 128 }, + background: rgb(30, 30, 30), + border: rgb(60, 60, 60), + selectedBg: rgb(0, 120, 215), + highlight: rgb(255, 210, 0), + placeholder: rgb(128, 128, 128), } as const; diff --git a/packages/core/src/widgets/diffViewer.ts b/packages/core/src/widgets/diffViewer.ts index 28b5b392..4503c8f6 100644 --- a/packages/core/src/widgets/diffViewer.ts +++ b/packages/core/src/widgets/diffViewer.ts @@ -7,6 +7,7 @@ * @see docs/widgets/diff-viewer.md */ +import { rgb } from "./style.js"; import type { DiffData, DiffHunk, DiffLine } from "./types.js"; /** Default number of context lines around changes. */ @@ -228,11 +229,11 @@ export function getHunkScrollPosition(hunkIndex: number, hunks: readonly DiffHun /** Diff color constants. */ export const DIFF_COLORS = { - addBg: { r: 35, g: 65, b: 35 }, - deleteBg: { r: 65, g: 35, b: 35 }, - addFg: { r: 150, g: 255, b: 150 }, - deleteFg: { r: 255, g: 150, b: 150 }, - hunkHeader: { r: 100, g: 149, b: 237 }, - lineNumber: { r: 100, g: 100, b: 100 }, - border: { r: 80, g: 80, b: 80 }, + addBg: rgb(35, 65, 35), + deleteBg: rgb(65, 35, 35), + addFg: rgb(150, 255, 150), + deleteFg: rgb(255, 150, 150), + hunkHeader: rgb(100, 149, 237), + lineNumber: rgb(100, 100, 100), + border: rgb(80, 80, 80), } as const; diff --git a/packages/core/src/widgets/field.ts b/packages/core/src/widgets/field.ts index 4d5882c4..2c39752e 100644 --- a/packages/core/src/widgets/field.ts +++ b/packages/core/src/widgets/field.ts @@ -8,6 +8,7 @@ * @see docs/widgets/field.md (GitHub issue #119) */ +import { rgb } from "./style.js"; import type { FieldProps, VNode } from "./types.js"; /** Character used to indicate required fields. */ @@ -20,7 +21,7 @@ export const FIELD_LABEL_STYLE = Object.freeze({ /** Default styles for field error. */ export const FIELD_ERROR_STYLE = Object.freeze({ - fg: { r: 255, g: 0, b: 0 }, + fg: rgb(255, 0, 0), }); /** Default styles for field hint. */ diff --git a/packages/core/src/widgets/heatmap.ts b/packages/core/src/widgets/heatmap.ts index d8b7dc98..a3e884e2 100644 --- a/packages/core/src/widgets/heatmap.ts +++ b/packages/core/src/widgets/heatmap.ts @@ -1,47 +1,47 @@ -import type { Rgb } from "./style.js"; +import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "./style.js"; import type { HeatmapColorScale } from "./types.js"; -type ScaleStop = Readonly<{ t: number; rgb: Rgb }>; +type ScaleStop = Readonly<{ t: number; rgb: Rgb24 }>; const SCALE_ANCHORS: Readonly> = Object.freeze({ viridis: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 68, g: 1, b: 84 }) }, - { t: 0.25, rgb: Object.freeze({ r: 59, g: 82, b: 139 }) }, - { t: 0.5, rgb: Object.freeze({ r: 33, g: 145, b: 140 }) }, - { t: 0.75, rgb: Object.freeze({ r: 94, g: 201, b: 98 }) }, - { t: 1, rgb: Object.freeze({ r: 253, g: 231, b: 37 }) }, + { t: 0, rgb: rgb(68, 1, 84) }, + { t: 0.25, rgb: rgb(59, 82, 139) }, + { t: 0.5, rgb: rgb(33, 145, 140) }, + { t: 0.75, rgb: rgb(94, 201, 98) }, + { t: 1, rgb: rgb(253, 231, 37) }, ]), plasma: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 13, g: 8, b: 135 }) }, - { t: 0.25, rgb: Object.freeze({ r: 126, g: 3, b: 168 }) }, - { t: 0.5, rgb: Object.freeze({ r: 203, g: 71, b: 119 }) }, - { t: 0.75, rgb: Object.freeze({ r: 248, g: 149, b: 64 }) }, - { t: 1, rgb: Object.freeze({ r: 240, g: 249, b: 33 }) }, + { t: 0, rgb: rgb(13, 8, 135) }, + { t: 0.25, rgb: rgb(126, 3, 168) }, + { t: 0.5, rgb: rgb(203, 71, 119) }, + { t: 0.75, rgb: rgb(248, 149, 64) }, + { t: 1, rgb: rgb(240, 249, 33) }, ]), inferno: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 0, g: 0, b: 4 }) }, - { t: 0.25, rgb: Object.freeze({ r: 87, g: 15, b: 109 }) }, - { t: 0.5, rgb: Object.freeze({ r: 187, g: 55, b: 84 }) }, - { t: 0.75, rgb: Object.freeze({ r: 249, g: 142, b: 8 }) }, - { t: 1, rgb: Object.freeze({ r: 252, g: 255, b: 164 }) }, + { t: 0, rgb: rgb(0, 0, 4) }, + { t: 0.25, rgb: rgb(87, 15, 109) }, + { t: 0.5, rgb: rgb(187, 55, 84) }, + { t: 0.75, rgb: rgb(249, 142, 8) }, + { t: 1, rgb: rgb(252, 255, 164) }, ]), magma: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 0, g: 0, b: 4 }) }, - { t: 0.25, rgb: Object.freeze({ r: 79, g: 18, b: 123 }) }, - { t: 0.5, rgb: Object.freeze({ r: 182, g: 54, b: 121 }) }, - { t: 0.75, rgb: Object.freeze({ r: 251, g: 140, b: 60 }) }, - { t: 1, rgb: Object.freeze({ r: 252, g: 253, b: 191 }) }, + { t: 0, rgb: rgb(0, 0, 4) }, + { t: 0.25, rgb: rgb(79, 18, 123) }, + { t: 0.5, rgb: rgb(182, 54, 121) }, + { t: 0.75, rgb: rgb(251, 140, 60) }, + { t: 1, rgb: rgb(252, 253, 191) }, ]), turbo: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 48, g: 18, b: 59 }) }, - { t: 0.25, rgb: Object.freeze({ r: 63, g: 128, b: 234 }) }, - { t: 0.5, rgb: Object.freeze({ r: 34, g: 201, b: 169 }) }, - { t: 0.75, rgb: Object.freeze({ r: 246, g: 189, b: 39 }) }, - { t: 1, rgb: Object.freeze({ r: 122, g: 4, b: 3 }) }, + { t: 0, rgb: rgb(48, 18, 59) }, + { t: 0.25, rgb: rgb(63, 128, 234) }, + { t: 0.5, rgb: rgb(34, 201, 169) }, + { t: 0.75, rgb: rgb(246, 189, 39) }, + { t: 1, rgb: rgb(122, 4, 3) }, ]), grayscale: Object.freeze([ - { t: 0, rgb: Object.freeze({ r: 0, g: 0, b: 0 }) }, - { t: 1, rgb: Object.freeze({ r: 255, g: 255, b: 255 }) }, + { t: 0, rgb: rgb(0, 0, 0) }, + { t: 1, rgb: rgb(255, 255, 255) }, ]), }); @@ -61,11 +61,11 @@ function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } -function buildScaleTable(stops: readonly ScaleStop[]): readonly Rgb[] { - const table: Rgb[] = []; +function buildScaleTable(stops: readonly ScaleStop[]): readonly Rgb24[] { + const table: Rgb24[] = []; for (let index = 0; index < 256; index++) { const t = index / 255; - let left = stops[0] ?? { t: 0, rgb: { r: 0, g: 0, b: 0 } }; + let left = stops[0] ?? { t: 0, rgb: rgb(0, 0, 0) }; let right = stops[stops.length - 1] ?? left; for (let stopIndex = 0; stopIndex < stops.length - 1; stopIndex++) { const a = stops[stopIndex]; @@ -79,11 +79,11 @@ function buildScaleTable(stops: readonly ScaleStop[]): readonly Rgb[] { const span = Math.max(1e-9, right.t - left.t); const localT = clamp01((t - left.t) / span); table.push( - Object.freeze({ - r: Math.round(lerp(left.rgb.r, right.rgb.r, localT)), - g: Math.round(lerp(left.rgb.g, right.rgb.g, localT)), - b: Math.round(lerp(left.rgb.b, right.rgb.b, localT)), - }), + rgb( + Math.round(lerp(rgbR(left.rgb), rgbR(right.rgb), localT)), + Math.round(lerp(rgbG(left.rgb), rgbG(right.rgb), localT)), + Math.round(lerp(rgbB(left.rgb), rgbB(right.rgb), localT)), + ), ); } for (const stop of stops) { @@ -93,7 +93,7 @@ function buildScaleTable(stops: readonly ScaleStop[]): readonly Rgb[] { return Object.freeze(table); } -const SCALE_TABLES: Readonly> = Object.freeze({ +const SCALE_TABLES: Readonly> = Object.freeze({ viridis: buildScaleTable(SCALE_ANCHORS.viridis), plasma: buildScaleTable(SCALE_ANCHORS.plasma), inferno: buildScaleTable(SCALE_ANCHORS.inferno), @@ -102,7 +102,7 @@ const SCALE_TABLES: Readonly> = Object grayscale: buildScaleTable(SCALE_ANCHORS.grayscale), }); -export function getHeatmapColorTable(scale: HeatmapColorScale): readonly Rgb[] { +export function getHeatmapColorTable(scale: HeatmapColorScale): readonly Rgb24[] { return SCALE_TABLES[scale]; } @@ -152,9 +152,9 @@ export function colorForHeatmapValue( value: number, range: Readonly<{ min: number; max: number }>, scale: HeatmapColorScale, -): Rgb { +): Rgb24 { const table = getHeatmapColorTable(scale); const ratio = clamp01((value - range.min) / Math.max(1e-9, range.max - range.min)); const index = Math.round(ratio * 255); - return table[index] ?? table[0] ?? Object.freeze({ r: 0, g: 0, b: 0 }); + return table[index] ?? table[0] ?? rgb(0, 0, 0); } diff --git a/packages/core/src/widgets/logsConsole.ts b/packages/core/src/widgets/logsConsole.ts index c7f47797..1ecfe7e3 100644 --- a/packages/core/src/widgets/logsConsole.ts +++ b/packages/core/src/widgets/logsConsole.ts @@ -7,6 +7,7 @@ * @see docs/widgets/logs-console.md */ +import { rgb, type Rgb24 } from "./style.js"; import type { LogEntry, LogLevel } from "./types.js"; /** Default max log entries to keep. */ @@ -205,13 +206,13 @@ export function formatCost(costCents: number): string { return `$${(costCents / 100).toFixed(2)}`; } -/** Level color map (RGB). */ -export const LEVEL_COLORS: Record = { - trace: { r: 100, g: 100, b: 100 }, - debug: { r: 150, g: 150, b: 150 }, - info: { r: 255, g: 255, b: 255 }, - warn: { r: 255, g: 200, b: 50 }, - error: { r: 255, g: 80, b: 80 }, +/** Level color map (packed RGB24). */ +export const LEVEL_COLORS: Record = { + trace: rgb(100, 100, 100), + debug: rgb(150, 150, 150), + info: rgb(255, 255, 255), + warn: rgb(255, 200, 50), + error: rgb(255, 80, 80), }; /** Level priority for filtering. */ diff --git a/packages/core/src/widgets/splitPane.ts b/packages/core/src/widgets/splitPane.ts index 8c27255a..4cc83b6c 100644 --- a/packages/core/src/widgets/splitPane.ts +++ b/packages/core/src/widgets/splitPane.ts @@ -8,6 +8,7 @@ */ import { distributeInteger } from "../layout/engine/distributeInteger.js"; +import { rgb } from "./style.js"; import type { SplitDirection } from "./types.js"; /** Default divider size in cells. */ @@ -369,4 +370,4 @@ export function hitTestDivider( } /** Divider color constant. */ -export const DIVIDER_COLOR = { r: 80, g: 80, b: 80 } as const; +export const DIVIDER_COLOR = rgb(80, 80, 80); diff --git a/packages/core/src/widgets/style.ts b/packages/core/src/widgets/style.ts index 5d45e382..a3a6dc79 100644 --- a/packages/core/src/widgets/style.ts +++ b/packages/core/src/widgets/style.ts @@ -1,15 +1,9 @@ /** * packages/core/src/widgets/style.ts — Text styling types and helpers. - * - * Why: Defines the visual styling options for text and widget content. - * Styles are passed through to the drawlist builder and rendered by the - * C engine using terminal escape sequences. - * - * @see docs/styling/style-props.md */ -/** RGB color with components in range 0-255. */ -export type Rgb = Readonly<{ r: number; g: number; b: number }>; +/** Packed RGB color (0x00RRGGBB). Value 0 is reserved as default/unset sentinel. */ +export type Rgb24 = number; /** Theme color token path (e.g. "accent.primary", "diagnostic.error"). */ export type ThemeColor = string; @@ -17,14 +11,10 @@ export type ThemeColor = string; /** Underline style variants. */ export type UnderlineStyle = "none" | "straight" | "double" | "curly" | "dotted" | "dashed"; -/** - * Text styling options. - * - fg/bg: foreground/background colors - * - bold, dim, italic, underline, inverse, strikethrough, overline, blink: text attributes - */ +/** Text styling options. */ export type TextStyle = Readonly<{ - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -33,21 +23,43 @@ export type TextStyle = Readonly<{ strikethrough?: boolean; overline?: boolean; blink?: boolean; - /** - * Underline style variant. - * Terminals that do not support underline variants should fall back to straight underline. - */ underlineStyle?: UnderlineStyle | undefined; - /** - * Underline color. - * Accepts direct RGB or a theme token path. - */ - underlineColor?: Rgb | ThemeColor | undefined; + underlineColor?: Rgb24 | ThemeColor | undefined; }>; -/** - * Create an RGB color value. - */ -export function rgb(r: number, g: number, b: number): Rgb { - return { r, g, b }; +function clampChannel(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 255) return 255; + return Math.round(value); +} + +/** Create a packed RGB color value. */ +export function rgb(r: number, g: number, b: number): Rgb24 { + const rr = clampChannel(r); + const gg = clampChannel(g); + const bb = clampChannel(b); + return ((rr & 0xff) << 16) | ((gg & 0xff) << 8) | (bb & 0xff); +} + +export function rgbR(value: Rgb24): number { + return (value >>> 16) & 0xff; +} + +export function rgbG(value: Rgb24): number { + return (value >>> 8) & 0xff; +} + +export function rgbB(value: Rgb24): number { + return value & 0xff; +} + +export function rgbBlend(backdrop: Rgb24, value: Rgb24, opacity: number): Rgb24 { + const a = Number.isFinite(opacity) ? Math.min(1, Math.max(0, opacity)) : 1; + if (a >= 1) return value >>> 0; + if (a <= 0) return backdrop >>> 0; + const r = Math.round(rgbR(backdrop) + (rgbR(value) - rgbR(backdrop)) * a); + const g = Math.round(rgbG(backdrop) + (rgbG(value) - rgbG(backdrop)) * a); + const b = Math.round(rgbB(backdrop) + (rgbB(value) - rgbB(backdrop)) * a); + return rgb(r, g, b); } diff --git a/packages/core/src/widgets/styleUtils.ts b/packages/core/src/widgets/styleUtils.ts index a18c9602..22a3a909 100644 --- a/packages/core/src/widgets/styleUtils.ts +++ b/packages/core/src/widgets/styleUtils.ts @@ -1,16 +1,8 @@ /** * packages/core/src/widgets/styleUtils.ts — Style helpers. - * - * Why: Provides small, deterministic helpers for composing TextStyle objects. */ -import type { Rgb, TextStyle, ThemeColor, UnderlineStyle } from "./style.js"; - -type RgbInput = { - r?: unknown; - g?: unknown; - b?: unknown; -}; +import type { Rgb24, TextStyle, ThemeColor, UnderlineStyle } from "./style.js"; type TextStyleInput = { fg?: unknown; @@ -36,21 +28,6 @@ const VALID_UNDERLINE_STYLES = new Set([ "dashed", ]); -function parseChannel(value: unknown): number | undefined { - let n: number | undefined; - if (typeof value === "number" && Number.isFinite(value)) { - n = value; - } else if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed.length === 0) return undefined; - const parsed = Number(trimmed); - if (Number.isFinite(parsed)) n = parsed; - } - if (n === undefined) return undefined; - const rounded = Math.round(n); - return Math.min(255, Math.max(0, rounded)); -} - function parseBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value !== "string") return undefined; @@ -67,9 +44,9 @@ function parseUnderlineStyle(value: unknown): UnderlineStyle | undefined { return undefined; } -function parseUnderlineColor(value: unknown): Rgb | ThemeColor | undefined { - const rgb = sanitizeRgb(value); - if (rgb !== undefined) return rgb; +function parseUnderlineColor(value: unknown): Rgb24 | ThemeColor | undefined { + const packed = sanitizeRgb(value); + if (packed !== undefined) return packed; if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; @@ -77,22 +54,17 @@ function parseUnderlineColor(value: unknown): Rgb | ThemeColor | undefined { return undefined; } -/** - * Clamp/normalize RGB channels into valid byte range. - */ -export function sanitizeRgb(value: unknown): Rgb | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - const source = value as RgbInput; - const r = parseChannel(source.r); - const g = parseChannel(source.g); - const b = parseChannel(source.b); - if (r === undefined || g === undefined || b === undefined) return undefined; - return { r, g, b }; +/** Clamp/normalize packed RGB values to 24-bit. */ +export function sanitizeRgb(value: unknown): Rgb24 | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + if (value <= 0) return 0; + if (value >= 0x00ff_ffff) return 0x00ff_ffff; + return (Math.round(value) >>> 0) & 0x00ff_ffff; } /** - * Normalize style objects from dynamic inputs (e.g., agent-generated props). - * Invalid keys/values are dropped; RGB channels are clamped to 0..255. + * Normalize style objects from dynamic inputs. + * Invalid keys/values are dropped. */ export function sanitizeTextStyle(style: unknown): TextStyle { if (typeof style !== "object" || style === null || Array.isArray(style)) { @@ -101,8 +73,8 @@ export function sanitizeTextStyle(style: unknown): TextStyle { const src = style as TextStyleInput; const sanitized: { - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -112,7 +84,7 @@ export function sanitizeTextStyle(style: unknown): TextStyle { overline?: boolean; blink?: boolean; underlineStyle?: UnderlineStyle; - underlineColor?: Rgb | ThemeColor; + underlineColor?: Rgb24 | ThemeColor; } = {}; const fg = sanitizeRgb(src.fg); @@ -144,9 +116,7 @@ export function sanitizeTextStyle(style: unknown): TextStyle { return sanitized; } -/** - * Merge multiple styles; later styles override earlier. - */ +/** Merge multiple styles; later styles override earlier. */ export function mergeStyles(...styles: (TextStyle | undefined)[]): TextStyle { let out: TextStyle = {}; for (const s of styles) { @@ -156,16 +126,12 @@ export function mergeStyles(...styles: (TextStyle | undefined)[]): TextStyle { return out; } -/** - * Extend a base style with overrides. - */ +/** Extend a base style with overrides. */ export function extendStyle(base: TextStyle, overrides: TextStyle): TextStyle { return mergeStyles(base, overrides); } -/** - * Conditional style selection. - */ +/** Conditional style selection. */ export function styleWhen( condition: boolean, trueStyle: T, @@ -175,9 +141,7 @@ export function styleWhen( return falseStyle; } -/** - * Style presets. - */ +/** Style presets. */ export const styles = { bold: { bold: true } as const, dim: { dim: true } as const, diff --git a/packages/core/src/widgets/toast.ts b/packages/core/src/widgets/toast.ts index 32b0d033..055330a9 100644 --- a/packages/core/src/widgets/toast.ts +++ b/packages/core/src/widgets/toast.ts @@ -8,6 +8,7 @@ * @see docs/widgets/toast.md */ +import { rgb, type Rgb24 } from "./style.js"; import type { Toast, ToastPosition } from "./types.js"; /** Height of a single toast in cells. */ @@ -168,10 +169,10 @@ export const TOAST_ICONS: Record = { error: "✗", }; -/** Border color for each toast type (RGB). */ -export const TOAST_COLORS: Record = { - info: { r: 50, g: 150, b: 255 }, - success: { r: 50, g: 200, b: 100 }, - warning: { r: 255, g: 200, b: 50 }, - error: { r: 255, g: 80, b: 80 }, +/** Border color for each toast type (packed RGB24). */ +export const TOAST_COLORS: Record = { + info: rgb(50, 150, 255), + success: rgb(50, 200, 100), + warning: rgb(255, 200, 50), + error: rgb(255, 80, 80), }; diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index 4bfaec4a..711ead54 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -288,7 +288,7 @@ test("flexShrink defaults to 1 when not set", () => { assert.equal(vnode.props.flexShrink, 1); }); -test("percent dimensions map to percent marker props", () => { +test("percent dimensions map to native percent strings or markers", () => { const node = boxNode( { width: "100%", @@ -301,11 +301,13 @@ test("percent dimensions map to percent marker props", () => { ); const vnode = translateTree(containerWith(node)) as any; - assert.equal(vnode.props.__inkPercentWidth, 100); - assert.equal(vnode.props.__inkPercentHeight, 50); + // width, height, flexBasis: passed as native percent strings (layout engine resolves them) + assert.equal(vnode.props.width, "100%"); + assert.equal(vnode.props.height, "50%"); + assert.equal(vnode.props.flexBasis, "40%"); + // minWidth, minHeight: still use markers (layout engine only accepts numbers) assert.equal(vnode.props.__inkPercentMinWidth, 25); assert.equal(vnode.props.__inkPercentMinHeight, 75); - assert.equal(vnode.props.__inkPercentFlexBasis, 40); }); test("wrap-reverse is approximated as wrap + reverse", () => { diff --git a/packages/ink-compat/src/_bench_layout_profile.ts b/packages/ink-compat/src/_bench_layout_profile.ts new file mode 100644 index 00000000..93cf0982 --- /dev/null +++ b/packages/ink-compat/src/_bench_layout_profile.ts @@ -0,0 +1,56 @@ +/** + * Temporary: Profile layout engine behavior for ink-compat VNode trees. + * Adds counters and timing to layout internals. + * + * Usage: GEMINI_BENCH_NODE_ARGS="--prof" in pty-run.py, then + * node --prof-process isolate-*.log > profile.txt + * + * Or: run directly with a captured VNode tree. + */ +import { performance } from "node:perf_hooks"; + +// Counters for profiling the layout pass +export const layoutCounters = { + layoutNodeCalls: 0, + measureNodeCalls: 0, + measureCacheHits: 0, + layoutCacheHits: 0, + constraintPassCount: 0, + textMeasureCalls: 0, + textMeasureTotalChars: 0, + textMeasureTotalMs: 0, + stackLayoutCalls: 0, + leafLayoutCalls: 0, + frameCount: 0, +}; + +export function resetLayoutCounters(): void { + layoutCounters.layoutNodeCalls = 0; + layoutCounters.measureNodeCalls = 0; + layoutCounters.measureCacheHits = 0; + layoutCounters.layoutCacheHits = 0; + layoutCounters.constraintPassCount = 0; + layoutCounters.textMeasureCalls = 0; + layoutCounters.textMeasureTotalChars = 0; + layoutCounters.textMeasureTotalMs = 0; + layoutCounters.stackLayoutCalls = 0; + layoutCounters.leafLayoutCalls = 0; + layoutCounters.frameCount += 1; +} + +export function dumpLayoutCounters(label: string): string { + const c = layoutCounters; + return [ + `=== Layout Profile: ${label} ===`, + ` layoutNode calls: ${c.layoutNodeCalls}`, + ` measureNode calls: ${c.measureNodeCalls}`, + ` measure cache hits: ${c.measureCacheHits}`, + ` layout cache hits: ${c.layoutCacheHits}`, + ` constraint pass count: ${c.constraintPassCount}`, + ` stack layout calls: ${c.stackLayoutCalls}`, + ` leaf layout calls: ${c.leafLayoutCalls}`, + ` text measure calls: ${c.textMeasureCalls}`, + ` text measure total chars: ${c.textMeasureTotalChars}`, + ` text measure total ms: ${c.textMeasureTotalMs.toFixed(3)}`, + ].join("\n"); +} diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts new file mode 100644 index 00000000..1b6ffd5e --- /dev/null +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -0,0 +1,628 @@ +/** + * packages/ink-compat/src/runtime/createInkRenderer.ts — Optimized production renderer. + * + * Replaces createTestRenderer with a renderer that mirrors widgetRenderer's + * optimizations: layout stability signatures, measure/tree caches, pooled + * collections, and no unnecessary work. + */ + +import { + type DrawlistBuildResult, + type DrawlistBuilder, + type TextStyle, + type Theme, + type VNode, + defaultTheme as coreDefaultTheme, + measureTextCells, +} from "@rezi-ui/core"; + +/** Local mirror of DrawlistTextRunSegment (not publicly exported from core). */ +type TextRunSegment = Readonly<{ text: string; style?: TextStyle }>; +import { + type InstanceId, + type InstanceIdAllocator, + type LayoutTree, + type RuntimeInstance, + collectSelfDirtyInstanceIds, + commitVNodeTree, + computeDirtyLayoutSet, + createInstanceIdAllocator, + instanceDirtySetToVNodeDirtySet, + layout, + renderToDrawlist, +} from "@rezi-ui/core/pipeline"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type InkRenderTimings = Readonly<{ + commitMs: number; + layoutMs: number; + drawMs: number; + totalMs: number; + layoutSkipped: boolean; + _layoutProfile?: unknown; +}>; + +export type InkRenderNode = Readonly<{ + kind: string; + rect: Readonly<{ x: number; y: number; w: number; h: number }>; + props: Readonly>; +}>; + +export type InkRenderOp = + | Readonly<{ kind: "clear" }> + | Readonly<{ kind: "clearTo"; cols: number; rows: number; style?: TextStyle }> + | Readonly<{ kind: "fillRect"; x: number; y: number; w: number; h: number; style?: TextStyle }> + | Readonly<{ kind: "drawText"; x: number; y: number; text: string; style?: TextStyle }> + | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> + | Readonly<{ kind: "popClip" }>; + +export type InkRenderResult = Readonly<{ + ops: readonly InkRenderOp[]; + nodes: readonly InkRenderNode[]; + timings: InkRenderTimings; +}>; + +export type InkRendererViewport = Readonly<{ cols: number; rows: number }>; + +export type InkRendererTraceEvent = Readonly<{ + renderId: number; + viewport: InkRendererViewport; + focusedId: string | null; + tick: number; + timings: Readonly<{ + commitMs: number; + layoutMs: number; + drawMs: number; + textMs: number; + totalMs: number; + }>; + nodeCount: number; + opCount: number; + opCounts: Readonly>; + clipDepthMax: number; + textChars: number; + textLines: number; + nonBlankLines: number; + widestLine: number; + minRectY: number; + maxRectBottom: number; + zeroHeightRects: number; + detailIncluded: boolean; + layoutSkipped: boolean; + nodes?: readonly InkRenderNode[]; + ops?: readonly InkRenderOp[]; + text?: string; +}>; + +export type InkRendererOptions = Readonly<{ + viewport?: InkRendererViewport; + theme?: Theme; + trace?: (event: InkRendererTraceEvent) => void; + traceDetail?: boolean; +}>; + +export type InkRenderOptions = Readonly<{ + viewport?: InkRendererViewport; + forceLayout?: boolean; +}>; + +export type InkRenderer = Readonly<{ + render: (vnode: VNode, opts?: InkRenderOptions) => InkRenderResult; + reset: () => void; +}>; + +// --------------------------------------------------------------------------- +// RecordingDrawlistBuilder — records ops as JS objects (reusable per frame) +// --------------------------------------------------------------------------- + +class RecordingDrawlistBuilder implements DrawlistBuilder { + private _ops: InkRenderOp[] = []; + private readonly textRunBlobs: Array = []; + + clear(): void { + this._ops.push({ kind: "clear" }); + } + + clearTo(cols: number, rows: number, style?: TextStyle): void { + this._ops.push({ kind: "clearTo", cols, rows, ...(style ? { style } : {}) }); + } + + fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void { + this._ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); + } + + drawText(x: number, y: number, text: string, style?: TextStyle): void { + this._ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); + } + + pushClip(x: number, y: number, w: number, h: number): void { + this._ops.push({ kind: "pushClip", x, y, w, h }); + } + + popClip(): void { + this._ops.push({ kind: "popClip" }); + } + + addBlob(_bytes: Uint8Array): number | null { + return null; + } + + addTextRunBlob(segments: readonly TextRunSegment[]): number | null { + const index = this.textRunBlobs.length; + this.textRunBlobs.push(segments.slice()); + return index; + } + + drawTextRun(x: number, y: number, blobIndex: number): void { + const blob = this.textRunBlobs[blobIndex]; + if (!blob) return; + + let cursorX = x; + for (const segment of blob) { + const text = segment.text; + if (text.length > 0) { + const style = segment.style; + this._ops.push({ kind: "drawText", x: cursorX, y, text, ...(style ? { style } : {}) }); + cursorX += measureTextCells(text); + } + } + } + + setCursor(_state: { + x: number; + y: number; + shape: number; + visible: boolean; + blink: boolean; + }): void {} + + hideCursor(): void {} + + setLink(_uri: string | null, _id?: string): void {} + + drawCanvas( + _x: number, + _y: number, + _w: number, + _h: number, + _blobIndex: number, + _blitter: "auto" | "braille" | "sextant" | "quadrant" | "halfblock" | "ascii", + _pxWidth?: number, + _pxHeight?: number, + ): void {} + + drawImage( + _x: number, + _y: number, + _w: number, + _h: number, + _blobIndex: number, + _format: "rgba" | "png", + _protocol: "auto" | "kitty" | "sixel" | "iterm2" | "blitter", + _zLayer: -1 | 0 | 1, + _fit: "fill" | "contain" | "cover", + _imageId: number, + _pxWidth?: number, + _pxHeight?: number, + ): void {} + + build(): DrawlistBuildResult { + return { ok: true, bytes: new Uint8Array(0) }; + } + + buildInto(_dst: Uint8Array): DrawlistBuildResult { + return this.build(); + } + + reset(): void { + this._ops.length = 0; + this.textRunBlobs.length = 0; + } + + /** Clear ops for next frame without re-allocating the builder. */ + clearOps(): void { + this._ops.length = 0; + this.textRunBlobs.length = 0; + } + + snapshotOps(): readonly InkRenderOp[] { + return this._ops.slice(); + } +} + +// --------------------------------------------------------------------------- +// collectNodes — lean variant (no findText/findById, no frozen paths) +// --------------------------------------------------------------------------- + +function asPropsRecord(value: unknown): Readonly> { + if (typeof value !== "object" || value === null) return {}; + return { ...(value as Record) }; +} + +function collectNodes(layoutTree: LayoutTree): readonly InkRenderNode[] { + const out: InkRenderNode[] = []; + + const walk = (node: LayoutTree): void => { + const props = asPropsRecord((node.vnode as { props?: unknown }).props); + out.push({ + kind: node.vnode.kind, + rect: node.rect, + props, + }); + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (!child) continue; + walk(child); + } + }; + + walk(layoutTree); + return out; +} + +// --------------------------------------------------------------------------- +// opsToText — kept for trace callback compatibility +// --------------------------------------------------------------------------- + +type ClipRect = Readonly<{ x: number; y: number; w: number; h: number }>; + +function inClipStack(x: number, y: number, clipStack: readonly ClipRect[]): boolean { + for (const clip of clipStack) { + if (x < clip.x || x >= clip.x + clip.w || y < clip.y || y >= clip.y + clip.h) return false; + } + return true; +} + +function drawTextToGrid( + grid: string[][], + viewport: InkRendererViewport, + clipStack: readonly ClipRect[], + x0: number, + y: number, + text: string, +): void { + if (y < 0 || y >= viewport.rows) return; + let x = x0; + for (const glyph of text) { + const width = measureTextCells(glyph); + if (width <= 0) continue; + + if (x >= 0 && x < viewport.cols && inClipStack(x, y, clipStack)) { + const row = grid[y]; + if (row) row[x] = glyph; + } + + for (let i = 1; i < width; i++) { + const xx = x + i; + if (xx < 0 || xx >= viewport.cols || !inClipStack(xx, y, clipStack)) continue; + const row = grid[y]; + if (row) row[xx] = " "; + } + + x += width; + } +} + +function fillGridRect( + grid: string[][], + viewport: InkRendererViewport, + clipStack: readonly ClipRect[], + rect: Readonly<{ x: number; y: number; w: number; h: number }>, +): void { + for (let y = rect.y; y < rect.y + rect.h; y++) { + if (y < 0 || y >= viewport.rows) continue; + const row = grid[y]; + if (!row) continue; + for (let x = rect.x; x < rect.x + rect.w; x++) { + if (x < 0 || x >= viewport.cols || !inClipStack(x, y, clipStack)) continue; + row[x] = " "; + } + } +} + +function opsToText(ops: readonly InkRenderOp[], viewport: InkRendererViewport): string { + const grid: string[][] = []; + for (let y = 0; y < viewport.rows; y++) { + grid.push(new Array(viewport.cols).fill(" ")); + } + const clipStack: ClipRect[] = []; + + for (const op of ops) { + if (op.kind === "clear") { + fillGridRect(grid, viewport, clipStack, { x: 0, y: 0, w: viewport.cols, h: viewport.rows }); + continue; + } + if (op.kind === "clearTo") { + fillGridRect(grid, viewport, clipStack, { x: 0, y: 0, w: op.cols, h: op.rows }); + continue; + } + if (op.kind === "fillRect") { + fillGridRect(grid, viewport, clipStack, op); + continue; + } + if (op.kind === "drawText") { + drawTextToGrid(grid, viewport, clipStack, op.x, op.y, op.text); + continue; + } + if (op.kind === "pushClip") { + clipStack.push({ x: op.x, y: op.y, w: op.w, h: op.h }); + continue; + } + if (op.kind === "popClip") { + clipStack.pop(); + } + } + + const lines = grid.map((row) => row.join("").replace(/\s+$/u, "")); + while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Trace helpers (match createTestRenderer's trace event shape) +// --------------------------------------------------------------------------- + +function createZeroOpCounts(): Record { + return { + clear: 0, + clearTo: 0, + fillRect: 0, + drawText: 0, + pushClip: 0, + popClip: 0, + }; +} + +function summarizeOps( + ops: readonly InkRenderOp[], +): Readonly<{ opCounts: Readonly>; clipDepthMax: number }> { + const opCounts = createZeroOpCounts(); + let clipDepth = 0; + let clipDepthMax = 0; + + for (const op of ops) { + opCounts[op.kind] += 1; + if (op.kind === "pushClip") { + clipDepth += 1; + clipDepthMax = Math.max(clipDepthMax, clipDepth); + } else if (op.kind === "popClip") { + clipDepth = Math.max(0, clipDepth - 1); + } + } + + return { opCounts, clipDepthMax }; +} + +function summarizeNodes( + nodes: readonly InkRenderNode[], +): Readonly<{ minRectY: number; maxRectBottom: number; zeroHeightRects: number }> { + let minRectY = Number.POSITIVE_INFINITY; + let maxRectBottom = 0; + let zeroHeightRects = 0; + + for (const node of nodes) { + const y = node.rect.y; + const h = node.rect.h; + minRectY = Math.min(minRectY, y); + maxRectBottom = Math.max(maxRectBottom, y + h); + if (h === 0) zeroHeightRects += 1; + } + + return { + minRectY: Number.isFinite(minRectY) ? minRectY : -1, + maxRectBottom, + zeroHeightRects, + }; +} + +function summarizeText( + text: string, +): Readonly<{ textChars: number; textLines: number; nonBlankLines: number; widestLine: number }> { + const lines = text.split("\n"); + let nonBlankLines = 0; + let widestLine = 0; + + for (const line of lines) { + widestLine = Math.max(widestLine, line.length); + if (line.trimEnd().length > 0) nonBlankLines += 1; + } + + return { textChars: text.length, textLines: lines.length, nonBlankLines, widestLine }; +} + +// --------------------------------------------------------------------------- +// Viewport normalization +// --------------------------------------------------------------------------- + +function normalizeViewport(viewport: InkRendererViewport | undefined): InkRendererViewport { + const cols = viewport?.cols ?? 80; + const rows = viewport?.rows ?? 24; + const safeCols = Number.isFinite(cols) ? Math.max(0, Math.trunc(cols)) : 0; + const safeRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0; + return { cols: safeCols, rows: safeRows }; +} + +// --------------------------------------------------------------------------- +// Debug helpers (temporary — remove after investigation) +// --------------------------------------------------------------------------- + +function countSelfDirty(root: RuntimeInstance | null): number { + if (!root) return 0; + let count = 0; + const stack: RuntimeInstance[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.selfDirty) count++; + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) stack.push(child); + } + } + return count; +} + +// --------------------------------------------------------------------------- +// createInkRenderer — optimized production renderer +// --------------------------------------------------------------------------- + +export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { + // --- Persistent state (survives across frames) --- + let prevRoot: RuntimeInstance | null = null; + let allocator: InstanceIdAllocator = createInstanceIdAllocator(1); + let cachedLayoutTree: LayoutTree | null = null; + + const defaultViewport = normalizeViewport(opts.viewport); + const rendererTheme: Theme = opts.theme ?? coreDefaultTheme; + const trace = opts.trace; + const defaultTraceDetail = opts.traceDetail === true; + + // Layout caches (WeakMap — GC-friendly, survives across frames) + const layoutMeasureCache = new WeakMap(); + const layoutTreeCache = new WeakMap(); + + const runtimeStack: RuntimeInstance[] = []; + + // Dirty set pools (reused across frames) + const pooledDirtyLayoutInstanceIds: InstanceId[] = []; + + // Recording builder (reused, cleared each frame) + const builder = new RecordingDrawlistBuilder(); + + let renderId = 0; + + // Cached result for early-skip optimization + let cachedOps: readonly InkRenderOp[] = []; + let cachedNodes: readonly InkRenderNode[] = []; + + const render = (vnode: VNode, renderOpts: InkRenderOptions = {}): InkRenderResult => { + const t0 = performance.now(); + renderId += 1; + + const viewport = normalizeViewport(renderOpts.viewport ?? defaultViewport); + const forceLayout = renderOpts.forceLayout === true; + + // ─── COMMIT ─── + const commitStartedAt = performance.now(); + const isFirstFrame = prevRoot === null; + const committed = commitVNodeTree(prevRoot, vnode, { allocator }); + const commitMs = performance.now() - commitStartedAt; + if (!committed.ok) { + throw new Error( + `createInkRenderer: commit failed: ${committed.fatal.code}: ${committed.fatal.detail}`, + ); + } + prevRoot = committed.value.root; + + // ─── EARLY SKIP: nothing changed ─── + // After commit with in-place mutation, if root.dirty is false, the entire + // tree is unchanged. Skip layout, draw, and collect entirely. + if (!isFirstFrame && !forceLayout && !prevRoot.dirty && cachedLayoutTree !== null) { + const totalMs = performance.now() - t0; + return { ops: cachedOps, nodes: cachedNodes, timings: { commitMs, layoutMs: 0, drawMs: 0, totalMs, layoutSkipped: true } }; + } + + // ─── DIRTY SET ─── + let layoutDirtyVNodeSet: Set | null = null; + if (!isFirstFrame && !forceLayout) { + collectSelfDirtyInstanceIds(prevRoot, pooledDirtyLayoutInstanceIds, runtimeStack); + const dirtyInstanceIds = computeDirtyLayoutSet( + prevRoot, + committed.value.mountedInstanceIds, + pooledDirtyLayoutInstanceIds, + ); + layoutDirtyVNodeSet = instanceDirtySetToVNodeDirtySet(prevRoot, dirtyInstanceIds); + } + + // ─── LAYOUT ─── + const layoutStartedAt = performance.now(); + const layoutRes = layout( + prevRoot.vnode, + 0, + 0, + viewport.cols, + viewport.rows, + "column", + layoutMeasureCache, + layoutTreeCache, + layoutDirtyVNodeSet, + ); + if (!layoutRes.ok) { + throw new Error( + `createInkRenderer: layout failed: ${layoutRes.fatal.code}: ${layoutRes.fatal.detail}`, + ); + } + cachedLayoutTree = layoutRes.value; + const layoutMs = performance.now() - layoutStartedAt; + + // ─── RENDER TO DRAWLIST ─── + const drawStartedAt = performance.now(); + builder.clearOps(); + renderToDrawlist({ + tree: prevRoot, + layout: cachedLayoutTree, + viewport, + focusState: { focusedId: null }, + builder, + theme: rendererTheme, + tick: 0, + }); + const drawMs = performance.now() - drawStartedAt; + + // ─── COLLECT ─── + const ops = builder.snapshotOps(); + const nodes = collectNodes(cachedLayoutTree); + cachedOps = ops; + cachedNodes = nodes; + const totalMs = performance.now() - t0; + + // ─── TRACE (when configured) ─── + if (trace) { + const textStartedAt = performance.now(); + const screenText = opsToText(ops, viewport); + const textMs = performance.now() - textStartedAt; + + const opSummary = summarizeOps(ops); + const nodeSummary = summarizeNodes(nodes); + const textSummary = summarizeText(screenText); + trace({ + renderId, + viewport, + focusedId: null, + tick: 0, + timings: { commitMs, layoutMs, drawMs, textMs, totalMs }, + nodeCount: nodes.length, + opCount: ops.length, + opCounts: opSummary.opCounts, + clipDepthMax: opSummary.clipDepthMax, + textChars: textSummary.textChars, + textLines: textSummary.textLines, + nonBlankLines: textSummary.nonBlankLines, + widestLine: textSummary.widestLine, + minRectY: nodeSummary.minRectY, + maxRectBottom: nodeSummary.maxRectBottom, + zeroHeightRects: nodeSummary.zeroHeightRects, + detailIncluded: defaultTraceDetail, + layoutSkipped: false, + ...(defaultTraceDetail ? { nodes, ops, text: screenText } : {}), + }); + } + + return { + ops, + nodes, + timings: { commitMs, layoutMs, drawMs, totalMs, layoutSkipped: false }, + }; + }; + + const reset = (): void => { + prevRoot = null; + cachedLayoutTree = null; + allocator = createInstanceIdAllocator(1); + }; + + return { render, reset }; +} diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 110e364b..8e8a8893 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -2,10 +2,9 @@ import { appendFileSync } from "node:fs"; import type { Readable, Writable } from "node:stream"; import { format as formatConsoleMessage } from "node:util"; import { - type Rgb, + type Rgb24, type TextStyle, type VNode, - createTestRenderer, measureTextCells, } from "@rezi-ui/core"; import React from "react"; @@ -14,6 +13,7 @@ import { type KittyFlagName, resolveKittyFlags } from "../kitty-keyboard.js"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; import { enableTranslationTrace, flushTranslationTrace } from "../translation/traceCollector.js"; import { checkAllResizeObservers } from "./ResizeObserver.js"; +import { type InkRendererTraceEvent, createInkRenderer } from "./createInkRenderer.js"; import { createBridge } from "./bridge.js"; import { InkContext } from "./context.js"; import { commitSync, createReactRoot } from "./reactHelpers.js"; @@ -64,8 +64,8 @@ interface ClipRect { } interface CellStyle { - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -121,42 +121,14 @@ interface ResizeSignalRecord { viewport: ViewportSize; } -interface ReziRendererTraceEvent { - renderId: number; - viewport: ViewportSize; - focusedId: string | null; - tick: number; - timings: { - commitMs: number; - layoutMs: number; - drawMs: number; - textMs: number; - totalMs: number; - }; - nodeCount: number; - opCount: number; - clipDepthMax: number; - textChars: number; - textLines: number; - nonBlankLines: number; - widestLine: number; - minRectY: number; - maxRectBottom: number; - zeroHeightRects: number; - detailIncluded: boolean; - nodes?: readonly unknown[]; - ops?: readonly unknown[]; - text?: string; -} - interface RenderWritePayload { output: string; staticOutput: string; } const MAX_QUEUED_OUTPUTS = 4; -const CORE_DEFAULT_FG: Readonly = Object.freeze({ r: 232, g: 238, b: 245 }); -const CORE_DEFAULT_BG: Readonly = Object.freeze({ r: 7, g: 10, b: 12 }); +const CORE_DEFAULT_FG: Rgb24 = 0xe8eef5; +const CORE_DEFAULT_BG: Rgb24 = 0x070a0c; const FORCED_TRUECOLOR_SUPPORT: ColorSupport = Object.freeze({ level: 3, noColor: false }); const ANSI_SGR_PATTERN = /\u001b\[[0-9:;]*m|\u009b[0-9:;]*m/; @@ -930,8 +902,8 @@ function snapshotCellGridRows( for (let col = 0; col < captureTo; col++) { const cell = row[col]!; const entry: Record = { c: cell.char }; - if (cell.style?.bg) entry["bg"] = `${cell.style.bg.r},${cell.style.bg.g},${cell.style.bg.b}`; - if (cell.style?.fg) entry["fg"] = `${cell.style.fg.r},${cell.style.fg.g},${cell.style.fg.b}`; + if (cell.style?.bg) entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; + if (cell.style?.fg) entry["fg"] = `${rgbR(cell.style.fg)},${rgbG(cell.style.fg)},${rgbB(cell.style.fg)}`; if (cell.style?.bold) entry["bold"] = true; if (cell.style?.dim) entry["dim"] = true; if (cell.style?.inverse) entry["inv"] = true; @@ -1039,27 +1011,36 @@ function hostTreeContainsAnsiSgr(rootNode: InkHostContainer): boolean { return false; } -function isRgb(value: unknown): value is Rgb { - if (typeof value !== "object" || value === null) return false; - const r = (value as { r?: unknown }).r; - const g = (value as { g?: unknown }).g; - const b = (value as { b?: unknown }).b; - return ( - typeof r === "number" && - Number.isFinite(r) && - typeof g === "number" && - Number.isFinite(g) && - typeof b === "number" && - Number.isFinite(b) - ); -} - function clampByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -function isSameRgb(a: Rgb, b: Readonly): boolean { - return a.r === b.r && a.g === b.g && a.b === b.b; +function packRgb24(r: number, g: number, b: number): Rgb24 { + return ((clampByte(r) & 0xff) << 16) | ((clampByte(g) & 0xff) << 8) | (clampByte(b) & 0xff); +} + +function rgbR(value: Rgb24): number { + return (value >>> 16) & 0xff; +} + +function rgbG(value: Rgb24): number { + return (value >>> 8) & 0xff; +} + +function rgbB(value: Rgb24): number { + return value & 0xff; +} + +function clampRgb24(value: Rgb24): Rgb24 { + return packRgb24(rgbR(value), rgbG(value), rgbB(value)); +} + +function isRgb24(value: unknown): value is Rgb24 { + return typeof value === "number" && Number.isFinite(value); +} + +function isSameRgb(a: Rgb24, b: Rgb24): boolean { + return a === b; } const ANSI16_PALETTE: readonly [number, number, number][] = [ @@ -1118,14 +1099,14 @@ function detectColorSupport(stdout: Writable): ColorSupport { return { level: 3, noColor: false }; } -function colorDistanceSq(a: Rgb, b: readonly [number, number, number]): number { - const dr = a.r - b[0]; - const dg = a.g - b[1]; - const db = a.b - b[2]; +function colorDistanceSq(a: Rgb24, b: readonly [number, number, number]): number { + const dr = rgbR(a) - b[0]; + const dg = rgbG(a) - b[1]; + const db = rgbB(a) - b[2]; return dr * dr + dg * dg + db * db; } -function toAnsi16Code(color: Rgb, background: boolean): number { +function toAnsi16Code(color: Rgb24, background: boolean): number { let bestIndex = 0; let bestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < ANSI16_PALETTE.length; index += 1) { @@ -1149,26 +1130,26 @@ function rgbChannelToCubeLevel(channel: number): number { return Math.min(5, Math.floor((channel - 35) / 40)); } -function toAnsi256Code(color: Rgb): number { - const rLevel = rgbChannelToCubeLevel(color.r); - const gLevel = rgbChannelToCubeLevel(color.g); - const bLevel = rgbChannelToCubeLevel(color.b); +function toAnsi256Code(color: Rgb24): number { + const rLevel = rgbChannelToCubeLevel(rgbR(color)); + const gLevel = rgbChannelToCubeLevel(rgbG(color)); + const bLevel = rgbChannelToCubeLevel(rgbB(color)); const cubeCode = 16 + 36 * rLevel + 6 * gLevel + bLevel; - const cubeColor: Rgb = { - r: rLevel === 0 ? 0 : 55 + 40 * rLevel, - g: gLevel === 0 ? 0 : 55 + 40 * gLevel, - b: bLevel === 0 ? 0 : 55 + 40 * bLevel, - }; + const cubeColor: readonly [number, number, number] = [ + rLevel === 0 ? 0 : 55 + 40 * rLevel, + gLevel === 0 ? 0 : 55 + 40 * gLevel, + bLevel === 0 ? 0 : 55 + 40 * bLevel, + ]; - const avg = Math.round((color.r + color.g + color.b) / 3); + const avg = Math.round((rgbR(color) + rgbG(color) + rgbB(color)) / 3); const grayLevel = Math.max(0, Math.min(23, Math.round((avg - 8) / 10))); const grayCode = 232 + grayLevel; const grayValue = 8 + 10 * grayLevel; - const grayColor: Rgb = { r: grayValue, g: grayValue, b: grayValue }; + const grayColor: readonly [number, number, number] = [grayValue, grayValue, grayValue]; - const cubeDistance = colorDistanceSq(color, [cubeColor.r, cubeColor.g, cubeColor.b]); - const grayDistance = colorDistanceSq(color, [grayColor.r, grayColor.g, grayColor.b]); + const cubeDistance = colorDistanceSq(color, cubeColor); + const grayDistance = colorDistanceSq(color, grayColor); return grayDistance < cubeDistance ? grayCode : cubeCode; } @@ -1176,16 +1157,16 @@ function normalizeStyle(style: TextStyle | undefined): CellStyle | undefined { if (!style) return undefined; const normalized: CellStyle = {}; - if (isRgb(style.fg)) { - const fg = { r: clampByte(style.fg.r), g: clampByte(style.fg.g), b: clampByte(style.fg.b) }; + if (isRgb24(style.fg) && style.fg !== 0) { + const fg = clampRgb24(style.fg); // Rezi carries DEFAULT_BASE_STYLE through every text draw op. Ink treats // terminal defaults as implicit, so suppress those default color channels. if (!isSameRgb(fg, CORE_DEFAULT_FG)) { normalized.fg = fg; } } - if (isRgb(style.bg)) { - const bg = { r: clampByte(style.bg.r), g: clampByte(style.bg.g), b: clampByte(style.bg.b) }; + if (isRgb24(style.bg) && style.bg !== 0) { + const bg = clampRgb24(style.bg); if (!isSameRgb(bg, CORE_DEFAULT_BG)) { normalized.bg = bg; } @@ -1241,7 +1222,7 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s if (style.fg) { if (colorSupport.level >= 3) { codes.push( - `38;2;${clampByte(style.fg.r)};${clampByte(style.fg.g)};${clampByte(style.fg.b)}`, + `38;2;${rgbR(style.fg)};${rgbG(style.fg)};${rgbB(style.fg)}`, ); } else if (colorSupport.level === 2) { codes.push(`38;5;${toAnsi256Code(style.fg)}`); @@ -1252,7 +1233,7 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s if (style.bg) { if (colorSupport.level >= 3) { codes.push( - `48;2;${clampByte(style.bg.r)};${clampByte(style.bg.g)};${clampByte(style.bg.b)}`, + `48;2;${rgbR(style.bg)};${rgbG(style.bg)};${rgbB(style.bg)}`, ); } else if (colorSupport.level === 2) { codes.push(`48;5;${toAnsi256Code(style.bg)}`); @@ -1576,6 +1557,10 @@ function readNodeMainAxis(kind: unknown): FlexMainAxis { } function hasPercentMarkers(vnode: VNode): boolean { + // width, height, and flexBasis percent values are now passed as native + // percent strings (e.g. "50%") directly to the VNode props and resolved by + // the layout engine in a single pass. Only minWidth/minHeight still use + // __inkPercent* markers (rare in practice). if (typeof vnode !== "object" || vnode === null) return false; const candidate = vnode as { props?: unknown; children?: unknown }; const props = @@ -1585,11 +1570,8 @@ function hasPercentMarkers(vnode: VNode): boolean { if ( props && - (typeof props["__inkPercentWidth"] === "number" || - typeof props["__inkPercentHeight"] === "number" || - typeof props["__inkPercentMinWidth"] === "number" || - typeof props["__inkPercentMinHeight"] === "number" || - typeof props["__inkPercentFlexBasis"] === "number") + (typeof props["__inkPercentMinWidth"] === "number" || + typeof props["__inkPercentMinHeight"] === "number") ) { return true; } @@ -1602,6 +1584,9 @@ function hasPercentMarkers(vnode: VNode): boolean { } function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VNode { + // NOTE: width, height, and flexBasis percent values are now passed as native + // percent strings directly to VNode props. This function only handles the + // remaining __inkPercentMinWidth / __inkPercentMinHeight markers (rare). if (typeof vnode !== "object" || vnode === null) { return vnode; } @@ -1619,22 +1604,10 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN const nextProps: Record = { ...originalProps }; const hostNode = readHostNode(originalProps["__inkHostNode"]); const parentSize = readHostParentSize(hostNode, context.parentSize); - const parentMainAxis = - readHostMainAxis(hostNode?.parent ? (hostNode.parent as HostNodeWithLayout) : null) ?? - context.parentMainAxis; - const percentWidth = asFiniteNumber(originalProps["__inkPercentWidth"]); - const percentHeight = asFiniteNumber(originalProps["__inkPercentHeight"]); const percentMinWidth = asFiniteNumber(originalProps["__inkPercentMinWidth"]); const percentMinHeight = asFiniteNumber(originalProps["__inkPercentMinHeight"]); - const percentFlexBasis = asFiniteNumber(originalProps["__inkPercentFlexBasis"]); - if (percentWidth != null) { - nextProps["width"] = resolvePercent(percentWidth, parentSize.cols); - } - if (percentHeight != null) { - nextProps["height"] = resolvePercent(percentHeight, parentSize.rows); - } if (percentMinWidth != null) { nextProps["minWidth"] = resolvePercent(percentMinWidth, parentSize.cols); } @@ -1642,16 +1615,8 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN nextProps["minHeight"] = resolvePercent(percentMinHeight, parentSize.rows); } - if (percentFlexBasis != null) { - const basisBase = parentMainAxis === "column" ? parentSize.rows : parentSize.cols; - nextProps["flexBasis"] = resolvePercent(percentFlexBasis, basisBase); - } - - delete nextProps["__inkPercentWidth"]; - delete nextProps["__inkPercentHeight"]; delete nextProps["__inkPercentMinWidth"]; delete nextProps["__inkPercentMinHeight"]; - delete nextProps["__inkPercentFlexBasis"]; const localWidth = asFiniteNumber(nextProps["width"]); const localHeight = asFiniteNumber(nextProps["height"]); @@ -1768,6 +1733,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const detailNodeLimit = traceDetailFull ? 2000 : 300; const detailOpLimit = traceDetailFull ? 4000 : 500; const detailResizeLimit = traceDetailFull ? 300 : 80; + const frameProfileFile = process.env["INK_COMPAT_FRAME_PROFILE_FILE"]; const writeErr = (stderr as { write: (s: string) => void }).write.bind(stderr); const traceStartAt = Date.now(); @@ -1800,14 +1766,14 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }); let viewport = readViewportSize(stdout, fallbackStdout); - const renderer = createTestRenderer({ + const renderer = createInkRenderer({ viewport, ...(traceEnabled ? { traceDetail: traceDetailFull, - trace: (event: ReziRendererTraceEvent) => { + trace: (event: InkRendererTraceEvent) => { trace( - `rezi#${event.renderId} viewport=${event.viewport.cols}x${event.viewport.rows} focused=${event.focusedId ?? "none"} tick=${event.tick} totalMs=${event.timings.totalMs} commitMs=${event.timings.commitMs} layoutMs=${event.timings.layoutMs} drawMs=${event.timings.drawMs} textMs=${event.timings.textMs} nodes=${event.nodeCount} ops=${event.opCount} clipMax=${event.clipDepthMax} textChars=${event.textChars} textLines=${event.textLines} nonBlank=${event.nonBlankLines} widest=${event.widestLine} minY=${event.minRectY} maxBottom=${event.maxRectBottom} zeroH=${event.zeroHeightRects} detailIncluded=${event.detailIncluded}`, + `rezi#${event.renderId} viewport=${event.viewport.cols}x${event.viewport.rows} focused=${event.focusedId ?? "none"} tick=${event.tick} totalMs=${event.timings.totalMs} commitMs=${event.timings.commitMs} layoutMs=${event.timings.layoutMs} drawMs=${event.timings.drawMs} textMs=${event.timings.textMs} nodes=${event.nodeCount} ops=${event.opCount} clipMax=${event.clipDepthMax} textChars=${event.textChars} textLines=${event.textLines} nonBlank=${event.nonBlankLines} widest=${event.widestLine} minY=${event.minRectY} maxBottom=${event.maxRectBottom} zeroH=${event.zeroHeightRects} detailIncluded=${event.detailIncluded} layoutSkipped=${event.layoutSkipped}`, ); if (!traceDetail) return; @@ -1832,6 +1798,10 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } : {}), }); + // Separate renderer for content so static renders don't pollute + // the dynamic renderer's prevRoot / layout caches (the root cause of 0% + // instance reuse — every frame was mounting all nodes as new). + const staticRenderer = createInkRenderer({ viewport }); let lastOutput = ""; let lastStableOutput = ""; @@ -2205,7 +2175,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions parentSize: viewport, parentMainAxis: "column", }); - const staticResult = renderer.render(translatedStaticWithPercent, { viewport }); + const staticResult = staticRenderer.render(translatedStaticWithPercent, { viewport, forceLayout: true }); const staticHasAnsiSgr = hostTreeContainsAnsiSgr(bridge.rootNode); const staticColorSupport = staticHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; const { ansi: staticAnsi } = renderOpsToAnsi( @@ -2235,6 +2205,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions viewport = nextViewport; } + const translateStartedAt = performance.now(); const translatedDynamic = bridge.translateDynamicToVNode(); const hasDynamicPercentMarkers = hasPercentMarkers(translatedDynamic); @@ -2252,18 +2223,30 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ? { cols: viewport.cols, rows: Math.max(1, viewport.rows - staticRowsUsed) } : viewport; + const percentStartedAt = performance.now(); let translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { parentSize: layoutViewport, parentMainAxis: "column", }); const translationTraceEntries = traceEnabled ? flushTranslationTrace() : []; + const translationMs = performance.now() - translateStartedAt; + const percentResolveMs = performance.now() - percentStartedAt; let vnode: VNode; let rootHeightCoerced: boolean; + let coreRenderPassesThisFrame = 1; + let coreRenderMs = 0; + let assignLayoutsMs = 0; + const coerced = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); vnode = coerced.vnode; rootHeightCoerced = coerced.coerced; - let result = renderer.render(vnode, { viewport: layoutViewport }); + + let t0 = performance.now(); + let result = renderer.render(vnode, { viewport: layoutViewport, forceLayout: viewportChanged }); + coreRenderMs += performance.now() - t0; + + t0 = performance.now(); clearHostLayouts(bridge.rootNode); assignHostLayouts( result.nodes as readonly { @@ -2271,7 +2254,10 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions props?: Record; }[], ); + assignLayoutsMs += performance.now() - t0; + if (hasDynamicPercentMarkers) { + coreRenderPassesThisFrame = 2; translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { parentSize: layoutViewport, parentMainAxis: "column", @@ -2279,7 +2265,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const secondPass = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); vnode = secondPass.vnode; rootHeightCoerced = rootHeightCoerced || secondPass.coerced; - result = renderer.render(vnode, { viewport: layoutViewport }); + + t0 = performance.now(); + result = renderer.render(vnode, { viewport: layoutViewport, forceLayout: true }); + coreRenderMs += performance.now() - t0; + + t0 = performance.now(); clearHostLayouts(bridge.rootNode); assignHostLayouts( result.nodes as readonly { @@ -2287,11 +2278,13 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions props?: Record; }[], ); + assignLayoutsMs += performance.now() - t0; } checkAllResizeObservers(); // Compute maxRectBottom from layout result — needed to size the ANSI // grid correctly in non-alternate-buffer mode. + const rectScanStartedAt = performance.now(); let minRectY = Number.POSITIVE_INFINITY; let maxRectBottom = 0; let zeroHeightRects = 0; @@ -2305,6 +2298,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions maxRectBottom = Math.max(maxRectBottom, y + h); if (h === 0) zeroHeightRects += 1; } + const rectScanMs = performance.now() - rectScanStartedAt; // Keep non-alt output content-sized by using computed layout height. // When root coercion applies (overflow hidden/scroll), maxRectBottom @@ -2313,6 +2307,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ? layoutViewport : { cols: layoutViewport.cols, rows: Math.max(1, maxRectBottom) }; + const ansiStartedAt = performance.now(); const frameHasAnsiSgr = hostTreeContainsAnsiSgr(bridge.rootNode); const frameColorSupport = frameHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; const { ansi: rawAnsiOutput, grid: cellGrid } = renderOpsToAnsi( @@ -2320,6 +2315,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions gridViewport, frameColorSupport, ); + const ansiMs = performance.now() - ansiStartedAt; // In alternate-buffer mode the output fills the full layoutViewport. // In non-alternate-buffer mode the grid is content-sized so the @@ -2518,6 +2514,34 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ...(staticOutput.length > 0 ? { staticOutput } : {}), }); + if (frameProfileFile) { + const _r = (v: number): number => Math.round(v * 1000) / 1000; + try { + appendFileSync( + frameProfileFile, + JSON.stringify({ + frame: frameCount, + ts: Date.now(), + totalMs: _r(renderTime), + translationMs: _r(translationMs), + percentResolveMs: _r(percentResolveMs), + coreRenderMs: _r(coreRenderMs), + coreCommitMs: _r(result.timings?.commitMs ?? 0), + coreLayoutMs: _r(result.timings?.layoutMs ?? 0), + coreDrawMs: _r(result.timings?.drawMs ?? 0), + layoutSkipped: result.timings?.layoutSkipped ?? false, + assignLayoutsMs: _r(assignLayoutsMs), + rectScanMs: _r(rectScanMs), + ansiMs: _r(ansiMs), + passes: coreRenderPassesThisFrame, + ops: result.ops.length, + nodes: result.nodes.length, + ...(((result.timings as any)?._layoutProfile) ? { _lp: (result.timings as any)._layoutProfile } : {}), + }) + "\n", + ); + } catch {} + } + const cursorPosition = bridge.context.getCursorPosition(); const cursorSignature = cursorPosition ? `${Math.trunc(cursorPosition.x)},${Math.trunc(cursorPosition.y)}` diff --git a/packages/ink-compat/src/translation/colorMap.ts b/packages/ink-compat/src/translation/colorMap.ts index 0677e823..0c39d2e3 100644 --- a/packages/ink-compat/src/translation/colorMap.ts +++ b/packages/ink-compat/src/translation/colorMap.ts @@ -1,7 +1,7 @@ -import { type Rgb, rgb } from "@rezi-ui/core"; +import { type Rgb24, rgb } from "@rezi-ui/core"; import { isTranslationTraceEnabled, pushTranslationTrace } from "./traceCollector.js"; -const NAMED_COLORS: Record = { +const NAMED_COLORS: Record = { black: rgb(0, 0, 0), red: rgb(205, 0, 0), green: rgb(0, 205, 0), @@ -21,21 +21,33 @@ const NAMED_COLORS: Record = { whiteBright: rgb(255, 255, 255), }; -const NAMED_COLORS_LOWER: Record = {}; +const NAMED_COLORS_LOWER: Record = {}; for (const [name, value] of Object.entries(NAMED_COLORS)) { NAMED_COLORS_LOWER[name.toLowerCase()] = value; } +function rgbR(value: Rgb24): number { + return (value >>> 16) & 0xff; +} + +function rgbG(value: Rgb24): number { + return (value >>> 8) & 0xff; +} + +function rgbB(value: Rgb24): number { + return value & 0xff; +} + function isByte(value: number): boolean { return Number.isFinite(value) && value >= 0 && value <= 255; } /** - * Parse an Ink color string into a Rezi Rgb object. + * Parse an Ink color string into a packed Rezi color. * Supports: named colors, "#rrggbb", "#rgb", "rgb(r, g, b)". * Returns undefined for unrecognized input. */ -export function parseColor(color: string | undefined): Rgb | undefined { +export function parseColor(color: string | undefined): Rgb24 | undefined { if (!color) return undefined; const result = parseColorInner(color); @@ -43,13 +55,13 @@ export function parseColor(color: string | undefined): Rgb | undefined { pushTranslationTrace({ kind: "color-parse", input: color, - result: result ? { r: result.r, g: result.g, b: result.b } : null, + result: result ? { r: rgbR(result), g: rgbG(result), b: rgbB(result) } : null, }); } return result; } -function parseColorInner(color: string): Rgb | undefined { +function parseColorInner(color: string): Rgb24 | undefined { if (color in NAMED_COLORS) return NAMED_COLORS[color]; const lower = color.toLowerCase(); @@ -96,9 +108,9 @@ function parseColorInner(color: string): Rgb | undefined { return rgb(r, g, b); } -function decodeAnsi256Color(index: number): Rgb { +function decodeAnsi256Color(index: number): Rgb24 { if (index < 16) { - const palette16: readonly Rgb[] = [ + const palette16: readonly Rgb24[] = [ rgb(0, 0, 0), rgb(205, 0, 0), rgb(0, 205, 0), diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index 8a8d0692..1c49c480 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -1,4 +1,4 @@ -import { type Rgb, type VNode, rgb, ui } from "@rezi-ui/core"; +import { type Rgb24, type VNode, rgb, ui } from "@rezi-ui/core"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; import { mapBorderStyle } from "./borderMap.js"; @@ -12,8 +12,8 @@ interface TextSpan { } interface TextStyleMap { - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; italic?: boolean; underline?: boolean; @@ -131,13 +131,13 @@ interface LayoutProps extends Record { mb?: number; ml?: number; gap?: number; - width?: number; - height?: number; + width?: number | `${number}%`; + height?: number | `${number}%`; minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number; - flexBasis?: number; + flexBasis?: number | `${number}%`; flex?: number; flexShrink?: number; items?: string; @@ -221,7 +221,7 @@ function readAccessibilityLabel(props: Record): string | undefi return undefined; } -const ANSI_16_PALETTE: readonly Rgb[] = [ +const ANSI_16_PALETTE: readonly Rgb24[] = [ rgb(0, 0, 0), rgb(205, 0, 0), rgb(0, 205, 0), @@ -492,6 +492,14 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul layoutProps.gap = p.rowGap; } + // Rezi core's layout engine natively resolves percent strings (e.g. "50%") + // for width, height, and flexBasis via resolveConstraint(). Pass them through + // directly instead of creating __inkPercent* markers that trigger a costly + // two-pass layout in renderFrame(). + // minWidth/minHeight only accept numbers in Rezi core, so those still use + // the marker approach (but gemini-cli doesn't use percent values for those). + const NATIVE_PERCENT_PROPS = new Set(["width", "height", "flexBasis"]); + const applyNumericOrPercentDimension = ( prop: "width" | "height" | "minWidth" | "minHeight" | "flexBasis", value: unknown, @@ -503,8 +511,14 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul const percent = parsePercentValue(value); if (percent != null) { - const markerKey = `__inkPercent${prop.charAt(0).toUpperCase()}${prop.slice(1)}`; - layoutProps[markerKey] = percent; + if (NATIVE_PERCENT_PROPS.has(prop)) { + // Pass percent string directly — layout engine resolves it natively + (layoutProps as Record)[prop] = `${percent}%`; + } else { + // minWidth/minHeight: layout engine only accepts numbers, use marker + const markerKey = `__inkPercent${prop.charAt(0).toUpperCase()}${prop.slice(1)}`; + layoutProps[markerKey] = percent; + } return; } @@ -683,7 +697,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (Object.keys(style).length > 0) layoutProps.style = style; const explicitBorderColor = parseColor(p.borderColor as string | undefined); - const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb | undefined> = { + const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb24 | undefined> = { top: parseColor(p.borderTopColor as string | undefined), right: parseColor(p.borderRightColor as string | undefined), bottom: parseColor(p.borderBottomColor as string | undefined), @@ -1298,7 +1312,7 @@ function resetSgrColor( delete activeStyle[channel]; } -function decodeAnsi256Color(index: number): Rgb { +function decodeAnsi256Color(index: number): Rgb24 { if (index < 16) return ANSI_16_PALETTE[index]!; if (index <= 231) { diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 44404c86..19009c31 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -260,7 +260,7 @@ export type { AnimatedValue, ParallelAnimationEntry, PlaybackControl, - Rgb, + Rgb24, TextStyle, UseAnimatedValueConfig, UseChainConfig, diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index 43556531..4a3b7b22 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -f60ffe78afb4cd4391e75cc12e14643e2b96fc1e +52f3fd81a437e5a351dd026a75b2f9b01ca44a76 diff --git a/packages/native/vendor/zireael/include/zr/zr_drawlist.h b/packages/native/vendor/zireael/include/zr/zr_drawlist.h index c1481e40..5aea6fb0 100644 --- a/packages/native/vendor/zireael/include/zr/zr_drawlist.h +++ b/packages/native/vendor/zireael/include/zr/zr_drawlist.h @@ -1,11 +1,8 @@ /* - include/zr/zr_drawlist.h — Drawlist ABI structs (v1 + v2 + v3 + v4 + v5). + include/zr/zr_drawlist.h — Drawlist ABI structs (v1). - Why: Defines the versioned, little-endian drawlist command stream used by - wrappers to drive rendering through engine_submit_drawlist(). v1/v2 layouts - remain behavior-stable; v3 extends style payloads for underline color + links; - v4 adds DRAW_CANVAS for sub-cell RGBA blitting; v5 adds DRAW_IMAGE for - terminal image protocols with deterministic fallback. + Why: Defines the little-endian drawlist command stream used by wrappers to + drive rendering through engine_submit_drawlist(). */ #ifndef ZR_ZR_DRAWLIST_H_INCLUDED @@ -24,6 +21,10 @@ typedef struct zr_dl_header_t { uint32_t cmd_bytes; uint32_t cmd_count; + /* + v1 uses engine-owned persistent resources. + These drawlist-local table fields are reserved and must be 0. + */ uint32_t strings_span_offset; uint32_t strings_count; uint32_t strings_bytes_offset; @@ -56,15 +57,13 @@ typedef enum zr_dl_opcode_t { ZR_DL_OP_PUSH_CLIP = 4, ZR_DL_OP_POP_CLIP = 5, ZR_DL_OP_DRAW_TEXT_RUN = 6, - - /* v2: cursor control (does not draw glyphs into the framebuffer). */ ZR_DL_OP_SET_CURSOR = 7, - - /* v4: RGBA canvas blit into framebuffer cells. */ ZR_DL_OP_DRAW_CANVAS = 8, - - /* v5: protocol image command with optional sub-cell fallback. */ - ZR_DL_OP_DRAW_IMAGE = 9 + ZR_DL_OP_DRAW_IMAGE = 9, + ZR_DL_OP_DEF_STRING = 10, + ZR_DL_OP_FREE_STRING = 11, + ZR_DL_OP_DEF_BLOB = 12, + ZR_DL_OP_FREE_BLOB = 13 } zr_dl_opcode_t; /* @@ -116,14 +115,14 @@ typedef struct zr_dl_style_t { uint32_t fg; uint32_t bg; uint32_t attrs; - uint32_t reserved0; /* must be 0 in v1 */ + uint32_t reserved0; } zr_dl_style_t; /* - v3 style extension: + v1 style extension: - underline_rgb: 0x00RRGGBB underline color (0 means default underline color) - - link_uri_ref: 1-based string-table reference to a URI; 0 means no hyperlink - - link_id_ref: optional 1-based string-table reference to OSC 8 id param + - link_uri_ref: string resource id for URI; 0 means no hyperlink + - link_id_ref: optional string resource id for OSC 8 id param */ typedef struct zr_dl_style_v3_ext_t { uint32_t underline_rgb; @@ -147,11 +146,11 @@ typedef struct zr_dl_cmd_fill_rect_t { typedef struct zr_dl_cmd_draw_text_t { int32_t x; int32_t y; - uint32_t string_index; + uint32_t string_id; uint32_t byte_off; uint32_t byte_len; zr_dl_style_t style; - uint32_t reserved0; /* must be 0 in v1 */ + uint32_t reserved0; /* must be 0 */ } zr_dl_cmd_draw_text_t; typedef struct zr_dl_cmd_fill_rect_v3_t { @@ -165,7 +164,7 @@ typedef struct zr_dl_cmd_fill_rect_v3_t { typedef struct zr_dl_cmd_draw_text_v3_t { int32_t x; int32_t y; - uint32_t string_index; + uint32_t string_id; uint32_t byte_off; uint32_t byte_len; zr_dl_style_v3_t style; @@ -182,13 +181,13 @@ typedef struct zr_dl_cmd_push_clip_t { typedef struct zr_dl_cmd_draw_text_run_t { int32_t x; int32_t y; - uint32_t blob_index; - uint32_t reserved0; /* must be 0 in v1 */ + uint32_t blob_id; + uint32_t reserved0; /* must be 0 */ } zr_dl_cmd_draw_text_run_t; typedef struct zr_dl_text_run_segment_v3_t { zr_dl_style_v3_t style; - uint32_t string_index; + uint32_t string_id; uint32_t byte_off; uint32_t byte_len; } zr_dl_text_run_segment_v3_t; @@ -203,36 +202,52 @@ typedef struct zr_dl_cmd_set_cursor_t { } zr_dl_cmd_set_cursor_t; typedef struct zr_dl_cmd_draw_canvas_t { - uint16_t dst_col; /* destination cell x */ - uint16_t dst_row; /* destination cell y */ - uint16_t dst_cols; /* destination width in cells */ - uint16_t dst_rows; /* destination height in cells */ - uint16_t px_width; /* source width in RGBA pixels */ - uint16_t px_height; /* source height in RGBA pixels */ - uint32_t blob_offset; /* byte offset inside drawlist blob-bytes section */ - uint32_t blob_len; /* RGBA payload bytes (must be px_width*px_height*4) */ - uint8_t blitter; /* zr_blitter_t */ - uint8_t flags; /* reserved; must be 0 */ - uint16_t reserved; /* reserved; must be 0 */ + uint16_t dst_col; /* destination cell x */ + uint16_t dst_row; /* destination cell y */ + uint16_t dst_cols; /* destination width in cells */ + uint16_t dst_rows; /* destination height in cells */ + uint16_t px_width; /* source width in RGBA pixels */ + uint16_t px_height; /* source height in RGBA pixels */ + uint32_t blob_id; /* persistent blob resource id */ + uint32_t reserved0; /* must be 0 */ + uint8_t blitter; /* zr_blitter_t */ + uint8_t flags; /* reserved; must be 0 */ + uint16_t reserved; /* reserved; must be 0 */ } zr_dl_cmd_draw_canvas_t; typedef struct zr_dl_cmd_draw_image_t { - uint16_t dst_col; /* destination cell x */ - uint16_t dst_row; /* destination cell y */ - uint16_t dst_cols; /* destination width in cells */ - uint16_t dst_rows; /* destination height in cells */ - uint16_t px_width; /* source width in pixels */ - uint16_t px_height; /* source height in pixels */ - uint32_t blob_offset; /* byte offset inside drawlist blob-bytes section */ - uint32_t blob_len; /* payload bytes */ - uint32_t image_id; /* stable image key for protocol cache reuse */ - uint8_t format; /* zr_dl_draw_image_format_t */ - uint8_t protocol; /* zr_dl_draw_image_protocol_t */ - int8_t z_layer; /* zr_dl_draw_image_z_layer_t */ - uint8_t fit_mode; /* zr_dl_draw_image_fit_mode_t */ - uint8_t flags; /* reserved; must be 0 */ - uint8_t reserved0; /* reserved; must be 0 */ - uint16_t reserved1; /* reserved; must be 0 */ + uint16_t dst_col; /* destination cell x */ + uint16_t dst_row; /* destination cell y */ + uint16_t dst_cols; /* destination width in cells */ + uint16_t dst_rows; /* destination height in cells */ + uint16_t px_width; /* source width in pixels */ + uint16_t px_height; /* source height in pixels */ + uint32_t blob_id; /* persistent blob resource id */ + uint32_t reserved_blob; /* must be 0 */ + uint32_t image_id; /* stable image key for protocol cache reuse */ + uint8_t format; /* zr_dl_draw_image_format_t */ + uint8_t protocol; /* zr_dl_draw_image_protocol_t */ + int8_t z_layer; /* zr_dl_draw_image_z_layer_t */ + uint8_t fit_mode; /* zr_dl_draw_image_fit_mode_t */ + uint8_t flags; /* reserved; must be 0 */ + uint8_t reserved0; /* reserved; must be 0 */ + uint16_t reserved1; /* reserved; must be 0 */ } zr_dl_cmd_draw_image_t; +/* + DEF_* command payload format: + - u32 id + - u32 byte_len + - u8 bytes[byte_len] + - u8 pad[0..3] (must be zero) to keep cmd size 4-byte aligned +*/ +typedef struct zr_dl_cmd_def_resource_t { + uint32_t id; + uint32_t byte_len; +} zr_dl_cmd_def_resource_t; + +typedef struct zr_dl_cmd_free_resource_t { + uint32_t id; +} zr_dl_cmd_free_resource_t; + #endif /* ZR_ZR_DRAWLIST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_version.h b/packages/native/vendor/zireael/include/zr/zr_version.h index d0be2c5f..dfe2471a 100644 --- a/packages/native/vendor/zireael/include/zr/zr_version.h +++ b/packages/native/vendor/zireael/include/zr/zr_version.h @@ -14,8 +14,8 @@ */ #if defined(ZR_LIBRARY_VERSION_MAJOR) || defined(ZR_LIBRARY_VERSION_MINOR) || defined(ZR_LIBRARY_VERSION_PATCH) || \ defined(ZR_ENGINE_ABI_MAJOR) || defined(ZR_ENGINE_ABI_MINOR) || defined(ZR_ENGINE_ABI_PATCH) || \ - defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_DRAWLIST_VERSION_V2) || defined(ZR_DRAWLIST_VERSION_V3) || \ - defined(ZR_DRAWLIST_VERSION_V4) || defined(ZR_DRAWLIST_VERSION_V5) || defined(ZR_EVENT_BATCH_VERSION_V1) + defined(ZR_DRAWLIST_VERSION_V1) || \ + defined(ZR_EVENT_BATCH_VERSION_V1) #error "Zireael version pins are locked; do not override ZR_*_VERSION_* macros." #endif @@ -29,12 +29,8 @@ #define ZR_ENGINE_ABI_MINOR (2u) #define ZR_ENGINE_ABI_PATCH (0u) -/* Drawlist binary format versions. */ +/* Drawlist binary format version (current protocol baseline). */ #define ZR_DRAWLIST_VERSION_V1 (1u) -#define ZR_DRAWLIST_VERSION_V2 (2u) -#define ZR_DRAWLIST_VERSION_V3 (3u) -#define ZR_DRAWLIST_VERSION_V4 (4u) -#define ZR_DRAWLIST_VERSION_V5 (5u) /* Packed event batch binary format versions. */ #define ZR_EVENT_BATCH_VERSION_V1 (1u) diff --git a/packages/native/vendor/zireael/src/core/zr_config.c b/packages/native/vendor/zireael/src/core/zr_config.c index 35becf93..62d8e15a 100644 --- a/packages/native/vendor/zireael/src/core/zr_config.c +++ b/packages/native/vendor/zireael/src/core/zr_config.c @@ -139,11 +139,7 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg) { return ZR_ERR_UNSUPPORTED; } - if ((cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 && - cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V2 && - cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V3 && - cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V4 && - cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V5) || + if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 || cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index 193df692..b34a15ed 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1,5 +1,5 @@ /* - src/core/zr_drawlist.c — Drawlist validator + executor (v1 + v2 + v3 + v4 + v5). + src/core/zr_drawlist.c — Drawlist validator + executor (v1). Why: Validates wrapper-provided drawlist bytes (bounds/caps/version) and executes deterministic drawing into the framebuffer without UB. @@ -23,9 +23,10 @@ #include "util/zr_checked.h" #include "util/zr_macros.h" +#include #include -/* Drawlist v1 format identifiers. */ +/* Drawlist format identifiers. */ #define ZR_DL_MAGIC 0x4C44525Au /* 'ZRDL' as little-endian u32 */ /* Alignment requirement for drawlist sections. */ @@ -40,8 +41,8 @@ #define ZR_DL_DRAW_TEXT_FIELDS_BYTES ((2u * sizeof(int32_t)) + (3u * sizeof(uint32_t))) #define ZR_DL_DRAW_TEXT_TRAILER_BYTES sizeof(uint32_t) -/* v3 style extension payload (underline color + hyperlink URI/ID refs). */ -#define ZR_DL_STYLE_V3_EXT_BYTES (3u * sizeof(uint32_t)) +/* v1 style payload (base + underline/link refs). */ +#define ZR_DL_STYLE_V1_BYTES ((uint32_t)sizeof(zr_dl_style_v3_t)) typedef struct zr_dl_style_wire_t { uint32_t fg; @@ -64,7 +65,7 @@ typedef struct zr_dl_cmd_fill_rect_wire_t { typedef struct zr_dl_cmd_draw_text_wire_t { int32_t x; int32_t y; - uint32_t string_index; + uint32_t string_id; uint32_t byte_off; uint32_t byte_len; zr_dl_style_wire_t style; @@ -73,32 +74,28 @@ typedef struct zr_dl_cmd_draw_text_wire_t { typedef struct zr_dl_text_run_segment_wire_t { zr_dl_style_wire_t style; - uint32_t string_index; + uint32_t string_id; uint32_t byte_off; uint32_t byte_len; } zr_dl_text_run_segment_wire_t; -static uint32_t zr_dl_style_wire_bytes(uint32_t version) { - uint32_t n = (uint32_t)sizeof(zr_dl_style_t); - if (version >= ZR_DRAWLIST_VERSION_V3) { - n += (uint32_t)ZR_DL_STYLE_V3_EXT_BYTES; - } - return n; +static uint32_t zr_dl_style_wire_bytes(void) { + return ZR_DL_STYLE_V1_BYTES; } -static uint32_t zr_dl_cmd_fill_rect_size(uint32_t version) { - const uint32_t payload = (uint32_t)ZR_DL_FILL_RECT_FIELDS_BYTES + zr_dl_style_wire_bytes(version); +static uint32_t zr_dl_cmd_fill_rect_size(void) { + const uint32_t payload = (uint32_t)ZR_DL_FILL_RECT_FIELDS_BYTES + zr_dl_style_wire_bytes(); return (uint32_t)sizeof(zr_dl_cmd_header_t) + payload; } -static uint32_t zr_dl_cmd_draw_text_size(uint32_t version) { - const uint32_t payload = (uint32_t)ZR_DL_DRAW_TEXT_FIELDS_BYTES + zr_dl_style_wire_bytes(version) + +static uint32_t zr_dl_cmd_draw_text_size(void) { + const uint32_t payload = (uint32_t)ZR_DL_DRAW_TEXT_FIELDS_BYTES + zr_dl_style_wire_bytes() + (uint32_t)ZR_DL_DRAW_TEXT_TRAILER_BYTES; return (uint32_t)sizeof(zr_dl_cmd_header_t) + payload; } -static size_t zr_dl_text_run_segment_bytes(uint32_t version) { - return (size_t)zr_dl_style_wire_bytes(version) + ZR_DL_TEXT_RUN_SEGMENT_TAIL_BYTES; +static size_t zr_dl_text_run_segment_bytes(void) { + return (size_t)zr_dl_style_wire_bytes() + ZR_DL_TEXT_RUN_SEGMENT_TAIL_BYTES; } static bool zr_is_aligned4_u32(uint32_t v) { @@ -123,6 +120,268 @@ static bool zr_checked_mul_u32_to_u32(uint32_t a, uint32_t b, uint32_t* out) { return true; } +static int32_t zr_dl_store_find_index(const zr_dl_resource_store_t* store, uint32_t id) { + if (!store) { + return -1; + } + for (uint32_t i = 0u; i < store->len; i++) { + if (store->entries[i].id == id) { + return (int32_t)i; + } + } + return -1; +} + +static zr_result_t zr_dl_store_ensure_cap(zr_dl_resource_store_t* store, uint32_t need) { + uint32_t cap = 0u; + zr_dl_resource_entry_t* next = NULL; + size_t bytes = 0u; + if (!store) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (need <= store->cap) { + return ZR_OK; + } + + cap = (store->cap == 0u) ? 8u : store->cap; + while (cap < need) { + if (cap > (UINT32_MAX / 2u)) { + cap = need; + break; + } + cap *= 2u; + } + if (!zr_checked_mul_size((size_t)cap, sizeof(zr_dl_resource_entry_t), &bytes)) { + return ZR_ERR_LIMIT; + } + next = (zr_dl_resource_entry_t*)realloc(store->entries, bytes); + if (!next) { + return ZR_ERR_OOM; + } + store->entries = next; + store->cap = cap; + return ZR_OK; +} + +static void zr_dl_store_release(zr_dl_resource_store_t* store) { + if (!store) { + return; + } + for (uint32_t i = 0u; i < store->len; i++) { + if (store->entries[i].owned != 0u) { + free(store->entries[i].bytes); + } + } + free(store->entries); + memset(store, 0, sizeof(*store)); +} + +static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id, const uint8_t* bytes, + uint32_t byte_len) { + int32_t idx = -1; + uint8_t* copy = NULL; + uint32_t old_len = 0u; + uint32_t base_total = 0u; + + if (!store || id == 0u || (!bytes && byte_len != 0u)) { + return ZR_ERR_FORMAT; + } + + if (byte_len != 0u) { + copy = (uint8_t*)malloc((size_t)byte_len); + if (!copy) { + return ZR_ERR_OOM; + } + memcpy(copy, bytes, (size_t)byte_len); + } + + idx = zr_dl_store_find_index(store, id); + if (idx >= 0) { + old_len = store->entries[(uint32_t)idx].len; + if (old_len > store->total_bytes) { + free(copy); + return ZR_ERR_LIMIT; + } + base_total = store->total_bytes - old_len; + if (byte_len > (UINT32_MAX - base_total)) { + free(copy); + return ZR_ERR_LIMIT; + } + if (store->entries[(uint32_t)idx].owned != 0u) { + free(store->entries[(uint32_t)idx].bytes); + } + store->entries[(uint32_t)idx].bytes = copy; + store->entries[(uint32_t)idx].len = byte_len; + store->entries[(uint32_t)idx].owned = 1u; + memset(store->entries[(uint32_t)idx].reserved0, 0, sizeof(store->entries[(uint32_t)idx].reserved0)); + store->total_bytes = base_total + byte_len; + return ZR_OK; + } + + if (store->total_bytes > (UINT32_MAX - byte_len)) { + free(copy); + return ZR_ERR_LIMIT; + } + zr_result_t rc = zr_dl_store_ensure_cap(store, store->len + 1u); + if (rc != ZR_OK) { + free(copy); + return rc; + } + + store->entries[store->len].id = id; + store->entries[store->len].bytes = copy; + store->entries[store->len].len = byte_len; + store->entries[store->len].owned = 1u; + memset(store->entries[store->len].reserved0, 0, sizeof(store->entries[store->len].reserved0)); + store->len += 1u; + store->total_bytes += byte_len; + return ZR_OK; +} + +static zr_result_t zr_dl_store_free_id(zr_dl_resource_store_t* store, uint32_t id) { + int32_t idx = -1; + uint32_t i = 0u; + if (!store || id == 0u) { + return ZR_ERR_FORMAT; + } + idx = zr_dl_store_find_index(store, id); + if (idx < 0) { + return ZR_OK; + } + i = (uint32_t)idx; + if (store->entries[i].len > store->total_bytes) { + return ZR_ERR_LIMIT; + } + store->total_bytes -= store->entries[i].len; + if (store->entries[i].owned != 0u) { + free(store->entries[i].bytes); + } + for (; i + 1u < store->len; i++) { + store->entries[i] = store->entries[i + 1u]; + } + store->len -= 1u; + return ZR_OK; +} + +static zr_result_t zr_dl_store_lookup(const zr_dl_resource_store_t* store, uint32_t id, const uint8_t** out_bytes, + uint32_t* out_len) { + int32_t idx = -1; + if (!store || !out_bytes || !out_len || id == 0u) { + return ZR_ERR_FORMAT; + } + idx = zr_dl_store_find_index(store, id); + if (idx < 0) { + return ZR_ERR_FORMAT; + } + *out_bytes = store->entries[(uint32_t)idx].bytes; + *out_len = store->entries[(uint32_t)idx].len; + return ZR_OK; +} + +void zr_dl_resources_init(zr_dl_resources_t* resources) { + if (!resources) { + return; + } + memset(resources, 0, sizeof(*resources)); +} + +void zr_dl_resources_release(zr_dl_resources_t* resources) { + if (!resources) { + return; + } + zr_dl_store_release(&resources->strings); + zr_dl_store_release(&resources->blobs); +} + +void zr_dl_resources_swap(zr_dl_resources_t* a, zr_dl_resources_t* b) { + zr_dl_resources_t tmp; + if (!a || !b) { + return; + } + tmp = *a; + *a = *b; + *b = tmp; +} + +zr_result_t zr_dl_resources_clone(zr_dl_resources_t* dst, const zr_dl_resources_t* src) { + zr_dl_resources_t tmp; + zr_result_t rc = ZR_OK; + if (!dst || !src) { + return ZR_ERR_INVALID_ARGUMENT; + } + + zr_dl_resources_init(&tmp); + for (uint32_t i = 0u; i < src->strings.len; i++) { + const zr_dl_resource_entry_t* e = &src->strings.entries[i]; + rc = zr_dl_store_define(&tmp.strings, e->id, e->bytes, e->len); + if (rc != ZR_OK) { + zr_dl_resources_release(&tmp); + return rc; + } + } + for (uint32_t i = 0u; i < src->blobs.len; i++) { + const zr_dl_resource_entry_t* e = &src->blobs.entries[i]; + rc = zr_dl_store_define(&tmp.blobs, e->id, e->bytes, e->len); + if (rc != ZR_OK) { + zr_dl_resources_release(&tmp); + return rc; + } + } + + zr_dl_resources_release(dst); + *dst = tmp; + return ZR_OK; +} + +static zr_result_t zr_dl_store_clone_shallow(zr_dl_resource_store_t* dst, const zr_dl_resource_store_t* src) { + zr_dl_resource_store_t tmp; + zr_result_t rc = ZR_OK; + if (!dst || !src) { + return ZR_ERR_INVALID_ARGUMENT; + } + + memset(&tmp, 0, sizeof(tmp)); + rc = zr_dl_store_ensure_cap(&tmp, src->len); + if (rc != ZR_OK) { + return rc; + } + for (uint32_t i = 0u; i < src->len; i++) { + tmp.entries[i] = src->entries[i]; + tmp.entries[i].owned = 0u; + memset(tmp.entries[i].reserved0, 0, sizeof(tmp.entries[i].reserved0)); + } + tmp.len = src->len; + tmp.total_bytes = src->total_bytes; + + zr_dl_store_release(dst); + *dst = tmp; + return ZR_OK; +} + +zr_result_t zr_dl_resources_clone_shallow(zr_dl_resources_t* dst, const zr_dl_resources_t* src) { + zr_dl_resources_t tmp; + zr_result_t rc = ZR_OK; + if (!dst || !src) { + return ZR_ERR_INVALID_ARGUMENT; + } + + zr_dl_resources_init(&tmp); + rc = zr_dl_store_clone_shallow(&tmp.strings, &src->strings); + if (rc != ZR_OK) { + zr_dl_resources_release(&tmp); + return rc; + } + rc = zr_dl_store_clone_shallow(&tmp.blobs, &src->blobs); + if (rc != ZR_OK) { + zr_dl_resources_release(&tmp); + return rc; + } + + zr_dl_resources_release(dst); + *dst = tmp; + return ZR_OK; +} + static zr_result_t zr_dl_read_i32le(zr_byte_reader_t* r, int32_t* out) { uint32_t tmp = 0u; if (!out) { @@ -149,6 +408,7 @@ static zr_result_t zr_dl_read_style(zr_byte_reader_t* r, zr_dl_style_t* out) { static zr_result_t zr_dl_read_style_wire(zr_byte_reader_t* r, uint32_t version, zr_dl_style_wire_t* out) { zr_dl_style_t base; zr_result_t rc = ZR_OK; + (void)version; if (!out) { return ZR_ERR_INVALID_ARGUMENT; } @@ -163,11 +423,9 @@ static zr_result_t zr_dl_read_style_wire(zr_byte_reader_t* r, uint32_t version, out->attrs = base.attrs; out->reserved0 = base.reserved0; - if (version >= ZR_DRAWLIST_VERSION_V3) { - if (!zr_byte_reader_read_u32le(r, &out->underline_rgb) || !zr_byte_reader_read_u32le(r, &out->link_uri_ref) || - !zr_byte_reader_read_u32le(r, &out->link_id_ref)) { - return ZR_ERR_FORMAT; - } + if (!zr_byte_reader_read_u32le(r, &out->underline_rgb) || !zr_byte_reader_read_u32le(r, &out->link_uri_ref) || + !zr_byte_reader_read_u32le(r, &out->link_id_ref)) { + return ZR_ERR_FORMAT; } return ZR_OK; @@ -231,7 +489,7 @@ static zr_result_t zr_dl_read_cmd_draw_text(zr_byte_reader_t* r, uint32_t versio if (rc != ZR_OK) { return rc; } - if (!zr_byte_reader_read_u32le(r, &out->string_index) || !zr_byte_reader_read_u32le(r, &out->byte_off) || + if (!zr_byte_reader_read_u32le(r, &out->string_id) || !zr_byte_reader_read_u32le(r, &out->byte_off) || !zr_byte_reader_read_u32le(r, &out->byte_len)) { return ZR_ERR_FORMAT; } @@ -282,7 +540,7 @@ static zr_result_t zr_dl_read_cmd_draw_text_run(zr_byte_reader_t* r, zr_dl_cmd_d if (rc != ZR_OK) { return rc; } - if (!zr_byte_reader_read_u32le(r, &out->blob_index) || !zr_byte_reader_read_u32le(r, &out->reserved0)) { + if (!zr_byte_reader_read_u32le(r, &out->blob_id) || !zr_byte_reader_read_u32le(r, &out->reserved0)) { return ZR_ERR_FORMAT; } return ZR_OK; @@ -317,7 +575,7 @@ static zr_result_t zr_dl_read_cmd_draw_canvas(zr_byte_reader_t* r, zr_dl_cmd_dra if (!zr_byte_reader_read_u16le(r, &out->dst_col) || !zr_byte_reader_read_u16le(r, &out->dst_row) || !zr_byte_reader_read_u16le(r, &out->dst_cols) || !zr_byte_reader_read_u16le(r, &out->dst_rows) || !zr_byte_reader_read_u16le(r, &out->px_width) || !zr_byte_reader_read_u16le(r, &out->px_height) || - !zr_byte_reader_read_u32le(r, &out->blob_offset) || !zr_byte_reader_read_u32le(r, &out->blob_len) || + !zr_byte_reader_read_u32le(r, &out->blob_id) || !zr_byte_reader_read_u32le(r, &out->reserved0) || !zr_byte_reader_read_u8(r, &out->blitter) || !zr_byte_reader_read_u8(r, &out->flags) || !zr_byte_reader_read_u16le(r, &out->reserved)) { return ZR_ERR_FORMAT; @@ -333,7 +591,7 @@ static zr_result_t zr_dl_read_cmd_draw_image(zr_byte_reader_t* r, zr_dl_cmd_draw if (!zr_byte_reader_read_u16le(r, &out->dst_col) || !zr_byte_reader_read_u16le(r, &out->dst_row) || !zr_byte_reader_read_u16le(r, &out->dst_cols) || !zr_byte_reader_read_u16le(r, &out->dst_rows) || !zr_byte_reader_read_u16le(r, &out->px_width) || !zr_byte_reader_read_u16le(r, &out->px_height) || - !zr_byte_reader_read_u32le(r, &out->blob_offset) || !zr_byte_reader_read_u32le(r, &out->blob_len) || + !zr_byte_reader_read_u32le(r, &out->blob_id) || !zr_byte_reader_read_u32le(r, &out->reserved_blob) || !zr_byte_reader_read_u32le(r, &out->image_id) || !zr_byte_reader_read_u8(r, &out->format) || !zr_byte_reader_read_u8(r, &out->protocol) || !zr_byte_reader_read_u8(r, &z_layer_u8) || !zr_byte_reader_read_u8(r, &out->fit_mode) || !zr_byte_reader_read_u8(r, &out->flags) || @@ -344,6 +602,62 @@ static zr_result_t zr_dl_read_cmd_draw_image(zr_byte_reader_t* r, zr_dl_cmd_draw return ZR_OK; } +static bool zr_dl_align4_u32(uint32_t n, uint32_t* out) { + uint32_t padded = 0u; + if (!out) { + return false; + } + if (!zr_checked_add_u32(n, 3u, &padded)) { + return false; + } + padded &= ~3u; + *out = padded; + return true; +} + +static zr_result_t zr_dl_read_cmd_def_resource(zr_byte_reader_t* r, const zr_dl_cmd_header_t* ch, + zr_dl_cmd_def_resource_t* out, const uint8_t** out_bytes, + uint32_t* out_padded_len) { + uint32_t payload_len = 0u; + uint32_t padded_len = 0u; + if (!r || !ch || !out || !out_bytes || !out_padded_len) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (ch->size < ((uint32_t)sizeof(zr_dl_cmd_header_t) + (uint32_t)sizeof(zr_dl_cmd_def_resource_t))) { + return ZR_ERR_FORMAT; + } + payload_len = ch->size - (uint32_t)sizeof(zr_dl_cmd_header_t); + + if (!zr_byte_reader_read_u32le(r, &out->id) || !zr_byte_reader_read_u32le(r, &out->byte_len)) { + return ZR_ERR_FORMAT; + } + if (!zr_dl_align4_u32(out->byte_len, &padded_len)) { + return ZR_ERR_FORMAT; + } + if (payload_len != ((uint32_t)sizeof(zr_dl_cmd_def_resource_t) + padded_len)) { + return ZR_ERR_FORMAT; + } + if (zr_byte_reader_remaining(r) < (size_t)padded_len) { + return ZR_ERR_FORMAT; + } + *out_bytes = r->bytes + r->off; + if (!zr_byte_reader_skip(r, (size_t)padded_len)) { + return ZR_ERR_FORMAT; + } + *out_padded_len = padded_len; + return ZR_OK; +} + +static zr_result_t zr_dl_read_cmd_free_resource(zr_byte_reader_t* r, zr_dl_cmd_free_resource_t* out) { + if (!r || !out) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (!zr_byte_reader_read_u32le(r, &out->id)) { + return ZR_ERR_FORMAT; + } + return ZR_OK; +} + static zr_result_t zr_dl_read_header(const uint8_t* bytes, size_t bytes_len, zr_dl_header_t* out) { if (!out) { return ZR_ERR_INVALID_ARGUMENT; @@ -428,8 +742,6 @@ static zr_result_t zr_dl_span_read_host(const uint8_t* p, zr_dl_span_t* out) { return ZR_OK; } -static zr_result_t zr_dl_validate_text_run_blob(const zr_dl_view_t* v, uint32_t blob_index, const zr_limits_t* lim); - typedef struct zr_dl_v1_ranges_t { zr_dl_range_t header; zr_dl_range_t cmd; @@ -448,9 +760,7 @@ static zr_result_t zr_dl_validate_header(const zr_dl_header_t* hdr, size_t bytes if (hdr->magic != ZR_DL_MAGIC) { return ZR_ERR_FORMAT; } - if (hdr->version != ZR_DRAWLIST_VERSION_V1 && hdr->version != ZR_DRAWLIST_VERSION_V2 && - hdr->version != ZR_DRAWLIST_VERSION_V3 && hdr->version != ZR_DRAWLIST_VERSION_V4 && - hdr->version != ZR_DRAWLIST_VERSION_V5) { + if (hdr->version != ZR_DRAWLIST_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } if (hdr->header_size != (uint32_t)sizeof(zr_dl_header_t)) { @@ -468,20 +778,17 @@ static zr_result_t zr_dl_validate_header(const zr_dl_header_t* hdr, size_t bytes return ZR_ERR_FORMAT; } - if (hdr->cmd_count > lim->dl_max_cmds || hdr->strings_count > lim->dl_max_strings || - hdr->blobs_count > lim->dl_max_blobs) { + if (hdr->cmd_count > lim->dl_max_cmds) { return ZR_ERR_LIMIT; } - if (hdr->strings_count == 0u) { - if (hdr->strings_span_offset != 0u || hdr->strings_bytes_offset != 0u || hdr->strings_bytes_len != 0u) { - return ZR_ERR_FORMAT; - } + if (hdr->strings_count != 0u || hdr->strings_span_offset != 0u || hdr->strings_bytes_offset != 0u || + hdr->strings_bytes_len != 0u) { + return ZR_ERR_FORMAT; } - if (hdr->blobs_count == 0u) { - if (hdr->blobs_span_offset != 0u || hdr->blobs_bytes_offset != 0u || hdr->blobs_bytes_len != 0u) { - return ZR_ERR_FORMAT; - } + if (hdr->blobs_count != 0u || hdr->blobs_span_offset != 0u || hdr->blobs_bytes_offset != 0u || + hdr->blobs_bytes_len != 0u) { + return ZR_ERR_FORMAT; } if (!zr_is_aligned4_u32(hdr->cmd_offset) || !zr_is_aligned4_u32(hdr->strings_span_offset) || @@ -612,46 +919,12 @@ static zr_result_t zr_dl_validate_cmd_clear(const zr_dl_cmd_header_t* ch) { return ZR_OK; } -static zr_result_t zr_dl_validate_style_link_ref(const zr_dl_view_t* view, uint32_t link_ref, uint32_t max_len, - bool require_non_empty) { - if (!view) { - return ZR_ERR_INVALID_ARGUMENT; - } - if (link_ref == 0u) { - return ZR_OK; - } - if (link_ref > view->hdr.strings_count) { - return ZR_ERR_FORMAT; - } - - const uint32_t span_index = link_ref - 1u; - size_t span_off = 0u; - if (!zr_checked_mul_size((size_t)span_index, sizeof(zr_dl_span_t), &span_off)) { - return ZR_ERR_FORMAT; - } - - zr_dl_span_t span; - if (zr_dl_span_read_host(view->strings_span_bytes + span_off, &span) != ZR_OK) { - return ZR_ERR_FORMAT; - } - if ((require_non_empty && span.len == 0u) || span.len > max_len) { - return ZR_ERR_FORMAT; - } - return ZR_OK; -} - static zr_result_t zr_dl_validate_style(const zr_dl_view_t* view, const zr_dl_style_wire_t* style, uint32_t version) { + (void)version; if (!view || !style) { return ZR_ERR_INVALID_ARGUMENT; } - if (version < ZR_DRAWLIST_VERSION_V3 && style->reserved0 != 0u) { - return ZR_ERR_FORMAT; - } - zr_result_t rc = zr_dl_validate_style_link_ref(view, style->link_uri_ref, (uint32_t)ZR_FB_LINK_URI_MAX_BYTES, true); - if (rc != ZR_OK) { - return rc; - } - return zr_dl_validate_style_link_ref(view, style->link_id_ref, (uint32_t)ZR_FB_LINK_ID_MAX_BYTES, false); + return ZR_OK; } static zr_result_t zr_dl_validate_cmd_fill_rect(const zr_dl_view_t* view, const zr_dl_cmd_header_t* ch, @@ -659,7 +932,7 @@ static zr_result_t zr_dl_validate_cmd_fill_rect(const zr_dl_view_t* view, const if (!view || !ch || !r) { return ZR_ERR_INVALID_ARGUMENT; } - if (ch->size != zr_dl_cmd_fill_rect_size(view->hdr.version)) { + if (ch->size != zr_dl_cmd_fill_rect_size()) { return ZR_ERR_FORMAT; } @@ -676,7 +949,7 @@ static zr_result_t zr_dl_validate_cmd_draw_text(const zr_dl_view_t* view, const if (!view || !ch || !r) { return ZR_ERR_INVALID_ARGUMENT; } - if (ch->size != zr_dl_cmd_draw_text_size(view->hdr.version)) { + if (ch->size != zr_dl_cmd_draw_text_size()) { return ZR_ERR_FORMAT; } @@ -685,28 +958,10 @@ static zr_result_t zr_dl_validate_cmd_draw_text(const zr_dl_view_t* view, const if (rc != ZR_OK) { return rc; } - if (cmd.reserved0 != 0u || cmd.string_index >= view->hdr.strings_count) { - return ZR_ERR_FORMAT; - } - rc = zr_dl_validate_style(view, &cmd.style, view->hdr.version); - if (rc != ZR_OK) { - return rc; - } - - zr_dl_span_t span; - size_t span_off = 0u; - if (!zr_checked_mul_size((size_t)cmd.string_index, sizeof(zr_dl_span_t), &span_off)) { - return ZR_ERR_FORMAT; - } - if (zr_dl_span_read_host(view->strings_span_bytes + span_off, &span) != ZR_OK) { - return ZR_ERR_FORMAT; - } - - uint32_t slice_end = 0u; - if (!zr_checked_add_u32(cmd.byte_off, cmd.byte_len, &slice_end) || slice_end > span.len) { + if (cmd.reserved0 != 0u || cmd.string_id == 0u) { return ZR_ERR_FORMAT; } - return ZR_OK; + return zr_dl_validate_style(view, &cmd.style, view->hdr.version); } static zr_result_t zr_dl_validate_cmd_push_clip(const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r, @@ -757,7 +1012,10 @@ static zr_result_t zr_dl_validate_cmd_draw_text_run(const zr_dl_view_t* view, co if (cmd.reserved0 != 0u) { return ZR_ERR_FORMAT; } - return zr_dl_validate_text_run_blob(view, cmd.blob_index, lim); + if (cmd.blob_id == 0u) { + return ZR_ERR_FORMAT; + } + return ZR_OK; } static zr_result_t zr_dl_validate_cmd_set_cursor(const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { @@ -800,10 +1058,7 @@ static uint8_t zr_dl_image_fit_mode_valid(uint8_t fit_mode) { static zr_result_t zr_dl_validate_cmd_draw_canvas(const zr_dl_view_t* view, const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { zr_dl_cmd_draw_canvas_t cmd; - uint32_t px_count = 0u; - uint32_t expected_len = 0u; uint32_t row_bytes = 0u; - size_t blob_end = 0u; if (!view || !ch || !r) { return ZR_ERR_INVALID_ARGUMENT; @@ -817,37 +1072,23 @@ static zr_result_t zr_dl_validate_cmd_draw_canvas(const zr_dl_view_t* view, cons return rc; } - if (cmd.flags != 0u || cmd.reserved != 0u || cmd.dst_cols == 0u || cmd.dst_rows == 0u || cmd.px_width == 0u || - cmd.px_height == 0u || zr_dl_canvas_blitter_valid(cmd.blitter) == 0u) { + if (cmd.flags != 0u || cmd.reserved != 0u || cmd.reserved0 != 0u || cmd.blob_id == 0u || cmd.dst_cols == 0u || + cmd.dst_rows == 0u || cmd.px_width == 0u || cmd.px_height == 0u || zr_dl_canvas_blitter_valid(cmd.blitter) == 0u) { return ZR_ERR_FORMAT; } - if (!zr_checked_mul_u32((uint32_t)cmd.px_width, (uint32_t)cmd.px_height, &px_count) || - !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len) || - !zr_checked_mul_u32((uint32_t)cmd.px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &row_bytes)) { + if (!zr_checked_mul_u32((uint32_t)cmd.px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &row_bytes)) { return ZR_ERR_FORMAT; } if (row_bytes > UINT16_MAX) { return ZR_ERR_FORMAT; } - if (expected_len != cmd.blob_len) { - return ZR_ERR_FORMAT; - } - if (!zr_checked_add_u32_to_size(cmd.blob_offset, cmd.blob_len, &blob_end)) { - return ZR_ERR_FORMAT; - } - if (blob_end > view->blobs_bytes_len) { - return ZR_ERR_FORMAT; - } return ZR_OK; } static zr_result_t zr_dl_validate_cmd_draw_image(const zr_dl_view_t* view, const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { zr_dl_cmd_draw_image_t cmd; - uint32_t px_count = 0u; - uint32_t expected_len = 0u; - size_t blob_end = 0u; if (!view || !ch || !r) { return ZR_ERR_INVALID_ARGUMENT; @@ -861,37 +1102,59 @@ static zr_result_t zr_dl_validate_cmd_draw_image(const zr_dl_view_t* view, const return rc; } - if (cmd.flags != 0u || cmd.reserved0 != 0u || cmd.reserved1 != 0u || cmd.dst_cols == 0u || cmd.dst_rows == 0u || - cmd.px_width == 0u || cmd.px_height == 0u || zr_dl_image_protocol_valid(cmd.protocol) == 0u || - zr_dl_image_format_valid(cmd.format) == 0u || zr_dl_image_fit_mode_valid(cmd.fit_mode) == 0u || - cmd.z_layer < -1 || cmd.z_layer > 1) { + if (cmd.flags != 0u || cmd.reserved0 != 0u || cmd.reserved1 != 0u || cmd.reserved_blob != 0u || cmd.blob_id == 0u || + cmd.dst_cols == 0u || cmd.dst_rows == 0u || cmd.px_width == 0u || cmd.px_height == 0u || + zr_dl_image_protocol_valid(cmd.protocol) == 0u || zr_dl_image_format_valid(cmd.format) == 0u || + zr_dl_image_fit_mode_valid(cmd.fit_mode) == 0u || cmd.z_layer < -1 || cmd.z_layer > 1) { return ZR_ERR_FORMAT; } + return ZR_OK; +} - if (cmd.format == (uint8_t)ZR_IMAGE_FORMAT_RGBA) { - if (!zr_checked_mul_u32((uint32_t)cmd.px_width, (uint32_t)cmd.px_height, &px_count) || - !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len)) { - return ZR_ERR_FORMAT; - } - if (expected_len != cmd.blob_len) { +static zr_result_t zr_dl_validate_cmd_def_resource(const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { + zr_dl_cmd_def_resource_t cmd; + const uint8_t* bytes = NULL; + uint32_t padded_len = 0u; + zr_result_t rc = ZR_OK; + if (!ch || !r) { + return ZR_ERR_INVALID_ARGUMENT; + } + rc = zr_dl_read_cmd_def_resource(r, ch, &cmd, &bytes, &padded_len); + if (rc != ZR_OK) { + return rc; + } + if (cmd.id == 0u) { + return ZR_ERR_FORMAT; + } + for (uint32_t i = cmd.byte_len; i < padded_len; i++) { + if (bytes[i] != 0u) { return ZR_ERR_FORMAT; } - } else if (cmd.blob_len == 0u) { - return ZR_ERR_FORMAT; } + return ZR_OK; +} - if (!zr_checked_add_u32_to_size(cmd.blob_offset, cmd.blob_len, &blob_end)) { +static zr_result_t zr_dl_validate_cmd_free_resource(const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { + zr_dl_cmd_free_resource_t cmd; + zr_result_t rc = ZR_OK; + if (!ch || !r) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (ch->size != ((uint32_t)sizeof(zr_dl_cmd_header_t) + (uint32_t)sizeof(zr_dl_cmd_free_resource_t))) { return ZR_ERR_FORMAT; } - if (blob_end > view->blobs_bytes_len) { + rc = zr_dl_read_cmd_free_resource(r, &cmd); + if (rc != ZR_OK) { + return rc; + } + if (cmd.id == 0u) { return ZR_ERR_FORMAT; } return ZR_OK; } static zr_result_t zr_dl_validate_cmd_payload(const zr_dl_view_t* view, const zr_limits_t* lim, zr_byte_reader_t* r, - const zr_dl_cmd_header_t* ch, uint32_t* clip_depth, bool allow_cursor, - bool allow_canvas, bool allow_image) { + const zr_dl_cmd_header_t* ch, uint32_t* clip_depth) { if (!view || !lim || !r || !ch || !clip_depth) { return ZR_ERR_INVALID_ARGUMENT; } @@ -910,18 +1173,23 @@ static zr_result_t zr_dl_validate_cmd_payload(const zr_dl_view_t* view, const zr case ZR_DL_OP_DRAW_TEXT_RUN: return zr_dl_validate_cmd_draw_text_run(view, ch, r, lim); case ZR_DL_OP_SET_CURSOR: - return allow_cursor ? zr_dl_validate_cmd_set_cursor(ch, r) : ZR_ERR_UNSUPPORTED; + return zr_dl_validate_cmd_set_cursor(ch, r); case ZR_DL_OP_DRAW_CANVAS: - return allow_canvas ? zr_dl_validate_cmd_draw_canvas(view, ch, r) : ZR_ERR_UNSUPPORTED; + return zr_dl_validate_cmd_draw_canvas(view, ch, r); case ZR_DL_OP_DRAW_IMAGE: - return allow_image ? zr_dl_validate_cmd_draw_image(view, ch, r) : ZR_ERR_UNSUPPORTED; + return zr_dl_validate_cmd_draw_image(view, ch, r); + case ZR_DL_OP_DEF_STRING: + case ZR_DL_OP_DEF_BLOB: + return zr_dl_validate_cmd_def_resource(ch, r); + case ZR_DL_OP_FREE_STRING: + case ZR_DL_OP_FREE_BLOB: + return zr_dl_validate_cmd_free_resource(ch, r); default: return ZR_ERR_UNSUPPORTED; } } -static zr_result_t zr_dl_validate_cmd_stream_common(const zr_dl_view_t* view, const zr_limits_t* lim, bool allow_cursor, - bool allow_canvas, bool allow_image) { +static zr_result_t zr_dl_validate_cmd_stream_common(const zr_dl_view_t* view, const zr_limits_t* lim) { if (!view || !lim) { return ZR_ERR_INVALID_ARGUMENT; } @@ -944,7 +1212,7 @@ static zr_result_t zr_dl_validate_cmd_stream_common(const zr_dl_view_t* view, co return ZR_ERR_FORMAT; } - rc = zr_dl_validate_cmd_payload(view, lim, &r, &ch, &clip_depth, allow_cursor, allow_canvas, allow_image); + rc = zr_dl_validate_cmd_payload(view, lim, &r, &ch, &clip_depth); if (rc != ZR_OK) { return rc; } @@ -956,104 +1224,24 @@ static zr_result_t zr_dl_validate_cmd_stream_common(const zr_dl_view_t* view, co return ZR_OK; } -/* Walk and validate every command in the command stream (framing, opcodes, indices). */ -static zr_result_t zr_dl_validate_cmd_stream_v1(const zr_dl_view_t* view, const zr_limits_t* lim) { - return zr_dl_validate_cmd_stream_common(view, lim, false, false, false); -} - -/* Walk and validate every command in the command stream (v2: includes cursor op). */ -static zr_result_t zr_dl_validate_cmd_stream_v2(const zr_dl_view_t* view, const zr_limits_t* lim) { - return zr_dl_validate_cmd_stream_common(view, lim, true, false, false); -} - -/* Walk and validate every command in the command stream (v3: v2 opcodes + style extension). */ -static zr_result_t zr_dl_validate_cmd_stream_v3(const zr_dl_view_t* view, const zr_limits_t* lim) { - return zr_dl_validate_cmd_stream_common(view, lim, true, false, false); -} - -/* Walk and validate every command in the command stream (v4: adds DRAW_CANVAS). */ -static zr_result_t zr_dl_validate_cmd_stream_v4(const zr_dl_view_t* view, const zr_limits_t* lim) { - return zr_dl_validate_cmd_stream_common(view, lim, true, true, false); -} - -/* Walk and validate every command in the command stream (v5: adds DRAW_IMAGE). */ -static zr_result_t zr_dl_validate_cmd_stream_v5(const zr_dl_view_t* view, const zr_limits_t* lim) { - return zr_dl_validate_cmd_stream_common(view, lim, true, true, true); +/* Walk and validate every command in the command stream (framing/opcodes/fields). */ +static zr_result_t zr_dl_validate_cmd_stream_v6(const zr_dl_view_t* view, const zr_limits_t* lim) { + return zr_dl_validate_cmd_stream_common(view, lim); } -static zr_result_t zr_dl_span_table_read_checked(const uint8_t* span_table, size_t span_count, uint32_t index, - zr_dl_span_t* out_span) { - if (!span_table || !out_span) { +static zr_result_t zr_dl_read_text_run_segment(zr_byte_reader_t* r, uint32_t version, + zr_dl_text_run_segment_wire_t* out) { + zr_result_t rc = ZR_OK; + if (!r || !out) { return ZR_ERR_INVALID_ARGUMENT; } - if ((size_t)index >= span_count) { - return ZR_ERR_FORMAT; - } - size_t span_off = 0u; - if (!zr_checked_mul_size((size_t)index, sizeof(zr_dl_span_t), &span_off)) { - return ZR_ERR_FORMAT; + rc = zr_dl_read_style_wire(r, version, &out->style); + if (rc != ZR_OK) { + return rc; } - if (zr_dl_span_read_host(span_table + span_off, out_span) != ZR_OK) { - return ZR_ERR_FORMAT; - } - return ZR_OK; -} - -static zr_result_t zr_dl_validate_span_slice_u32(uint32_t byte_off, uint32_t byte_len, uint32_t span_len) { - uint32_t slice_end = 0u; - if (!zr_checked_add_u32(byte_off, byte_len, &slice_end)) { - return ZR_ERR_FORMAT; - } - if (slice_end > span_len) { - return ZR_ERR_FORMAT; - } - return ZR_OK; -} - -static zr_result_t zr_dl_validate_text_run_blob_span(const zr_dl_view_t* v, uint32_t blob_index, zr_dl_span_t* out_span, - const uint8_t** out_blob) { - if (!v || !out_span || !out_blob) { - return ZR_ERR_INVALID_ARGUMENT; - } - - zr_result_t rc = zr_dl_span_table_read_checked(v->blobs_span_bytes, v->blobs_count, blob_index, out_span); - if (rc != ZR_OK) { - return rc; - } - - if (!zr_is_aligned4_u32(out_span->off) || (out_span->len & 3u) != 0u) { - return ZR_ERR_FORMAT; - } - if (out_span->off > (uint32_t)v->blobs_bytes_len) { - return ZR_ERR_FORMAT; - } - - size_t end = 0u; - if (!zr_checked_add_u32_to_size(out_span->off, out_span->len, &end)) { - return ZR_ERR_FORMAT; - } - if (end > v->blobs_bytes_len) { - return ZR_ERR_FORMAT; - } - - *out_blob = v->blobs_bytes + out_span->off; - return ZR_OK; -} - -static zr_result_t zr_dl_read_text_run_segment(zr_byte_reader_t* r, uint32_t version, - zr_dl_text_run_segment_wire_t* out) { - zr_result_t rc = ZR_OK; - if (!r || !out) { - return ZR_ERR_INVALID_ARGUMENT; - } - - rc = zr_dl_read_style_wire(r, version, &out->style); - if (rc != ZR_OK) { - return rc; - } - if (!zr_byte_reader_read_u32le(r, &out->string_index) || !zr_byte_reader_read_u32le(r, &out->byte_off) || - !zr_byte_reader_read_u32le(r, &out->byte_len)) { + if (!zr_byte_reader_read_u32le(r, &out->string_id) || !zr_byte_reader_read_u32le(r, &out->byte_off) || + !zr_byte_reader_read_u32le(r, &out->byte_len)) { return ZR_ERR_FORMAT; } @@ -1066,7 +1254,8 @@ static zr_result_t zr_dl_text_run_expected_bytes(uint32_t seg_count, uint32_t ve } size_t expected = 0u; - const size_t seg_bytes = zr_dl_text_run_segment_bytes(version); + (void)version; + const size_t seg_bytes = zr_dl_text_run_segment_bytes(); if (!zr_checked_mul_size((size_t)seg_count, seg_bytes, &expected)) { return ZR_ERR_FORMAT; } @@ -1078,74 +1267,6 @@ static zr_result_t zr_dl_text_run_expected_bytes(uint32_t seg_count, uint32_t ve return ZR_OK; } -static zr_result_t zr_dl_validate_text_run_segment(const zr_dl_view_t* v, zr_byte_reader_t* r) { - if (!v || !r) { - return ZR_ERR_INVALID_ARGUMENT; - } - - zr_dl_text_run_segment_wire_t seg; - zr_result_t rc = zr_dl_read_text_run_segment(r, v->hdr.version, &seg); - if (rc != ZR_OK) { - return rc; - } - rc = zr_dl_validate_style(v, &seg.style, v->hdr.version); - if (rc != ZR_OK) { - return rc; - } - - zr_dl_span_t str_span; - rc = zr_dl_span_table_read_checked(v->strings_span_bytes, v->strings_count, seg.string_index, &str_span); - if (rc != ZR_OK) { - return rc; - } - return zr_dl_validate_span_slice_u32(seg.byte_off, seg.byte_len, str_span.len); -} - -/* Validate a text run blob: segment count, alignment, and all string references. */ -static zr_result_t zr_dl_validate_text_run_blob(const zr_dl_view_t* v, uint32_t blob_index, const zr_limits_t* lim) { - if (!v || !lim) { - return ZR_ERR_INVALID_ARGUMENT; - } - - zr_dl_span_t span; - const uint8_t* blob = NULL; - zr_result_t rc = zr_dl_validate_text_run_blob_span(v, blob_index, &span, &blob); - if (rc != ZR_OK) { - return rc; - } - - zr_byte_reader_t r; - zr_byte_reader_init(&r, blob, (size_t)span.len); - - uint32_t seg_count = 0u; - if (!zr_byte_reader_read_u32le(&r, &seg_count)) { - return ZR_ERR_FORMAT; - } - if (seg_count > lim->dl_max_text_run_segments) { - return ZR_ERR_LIMIT; - } - - size_t expected = 0u; - rc = zr_dl_text_run_expected_bytes(seg_count, v->hdr.version, &expected); - if (rc != ZR_OK) { - return rc; - } - if (expected != (size_t)span.len) { - return ZR_ERR_FORMAT; - } - - for (uint32_t i = 0u; i < seg_count; i++) { - rc = zr_dl_validate_text_run_segment(v, &r); - if (rc != ZR_OK) { - return rc; - } - } - if (zr_byte_reader_remaining(&r) != 0u) { - return ZR_ERR_FORMAT; - } - return ZR_OK; -} - /* Fully validate a drawlist buffer and produce a view for execution. * Checks header, section ranges, span tables, and all command stream contents. */ zr_result_t zr_dl_validate(const uint8_t* bytes, size_t bytes_len, const zr_limits_t* lim, zr_dl_view_t* out_view) { @@ -1201,19 +1322,7 @@ zr_result_t zr_dl_validate(const uint8_t* bytes, size_t bytes_len, const zr_limi /* Command stream framing + opcode validation. */ zr_dl_view_t view; zr_dl_view_init(&view, &hdr, bytes, bytes_len); - if (hdr.version == ZR_DRAWLIST_VERSION_V1) { - rc = zr_dl_validate_cmd_stream_v1(&view, lim); - } else if (hdr.version == ZR_DRAWLIST_VERSION_V2) { - rc = zr_dl_validate_cmd_stream_v2(&view, lim); - } else if (hdr.version == ZR_DRAWLIST_VERSION_V3) { - rc = zr_dl_validate_cmd_stream_v3(&view, lim); - } else if (hdr.version == ZR_DRAWLIST_VERSION_V4) { - rc = zr_dl_validate_cmd_stream_v4(&view, lim); - } else if (hdr.version == ZR_DRAWLIST_VERSION_V5) { - rc = zr_dl_validate_cmd_stream_v5(&view, lim); - } else { - rc = ZR_ERR_UNSUPPORTED; - } + rc = zr_dl_validate_cmd_stream_v6(&view, lim); if (rc != ZR_OK) { return rc; } @@ -1222,9 +1331,54 @@ zr_result_t zr_dl_validate(const uint8_t* bytes, size_t bytes_len, const zr_limi return ZR_OK; } -static zr_result_t zr_dl_style_resolve_link(const zr_dl_view_t* v, zr_fb_t* fb, uint32_t link_uri_ref, +static zr_result_t zr_dl_validate_span_slice_u32(uint32_t byte_off, uint32_t byte_len, uint32_t span_len) { + uint32_t slice_end = 0u; + if (!zr_checked_add_u32(byte_off, byte_len, &slice_end)) { + return ZR_ERR_FORMAT; + } + if (slice_end > span_len) { + return ZR_ERR_FORMAT; + } + return ZR_OK; +} + +static zr_result_t zr_dl_resolve_string_slice(const zr_dl_resource_store_t* strings, uint32_t string_id, uint32_t byte_off, + uint32_t byte_len, const uint8_t** out_bytes) { + const uint8_t* bytes = NULL; + uint32_t total_len = 0u; + static const uint8_t kEmptySlice[1] = {0u}; + zr_result_t rc = ZR_OK; + if (!strings || !out_bytes) { + return ZR_ERR_INVALID_ARGUMENT; + } + rc = zr_dl_store_lookup(strings, string_id, &bytes, &total_len); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_validate_span_slice_u32(byte_off, byte_len, total_len); + if (rc != ZR_OK) { + return rc; + } + if (byte_len == 0u) { + *out_bytes = kEmptySlice; + return ZR_OK; + } + if (!bytes) { + return ZR_ERR_FORMAT; + } + *out_bytes = bytes + byte_off; + return ZR_OK; +} + +static zr_result_t zr_dl_style_resolve_link(const zr_dl_resource_store_t* strings, zr_fb_t* fb, uint32_t link_uri_ref, uint32_t link_id_ref, uint32_t* out_fb_link_ref) { - if (!v || !fb || !out_fb_link_ref) { + const uint8_t* uri = NULL; + const uint8_t* id = NULL; + uint32_t uri_len = 0u; + uint32_t id_len = 0u; + zr_result_t rc = ZR_OK; + + if (!strings || !fb || !out_fb_link_ref) { return ZR_ERR_INVALID_ARGUMENT; } *out_fb_link_ref = 0u; @@ -1232,44 +1386,40 @@ static zr_result_t zr_dl_style_resolve_link(const zr_dl_view_t* v, zr_fb_t* fb, if (link_uri_ref == 0u) { return ZR_OK; } - if (link_uri_ref > v->hdr.strings_count) { - return ZR_ERR_FORMAT; - } - if (link_id_ref > v->hdr.strings_count) { - return ZR_ERR_FORMAT; - } - zr_dl_span_t uri_span; - zr_result_t rc = zr_dl_span_table_read_checked(v->strings_span_bytes, v->strings_count, link_uri_ref - 1u, &uri_span); + rc = zr_dl_store_lookup(strings, link_uri_ref, &uri, &uri_len); if (rc != ZR_OK) { return rc; } - if (uri_span.len == 0u || uri_span.len > (uint32_t)ZR_FB_LINK_URI_MAX_BYTES) { + if (uri_len == 0u || uri_len > (uint32_t)ZR_FB_LINK_URI_MAX_BYTES) { return ZR_ERR_FORMAT; } - - const uint8_t* id = NULL; - size_t id_len = 0u; if (link_id_ref != 0u) { - zr_dl_span_t id_span; - rc = zr_dl_span_table_read_checked(v->strings_span_bytes, v->strings_count, link_id_ref - 1u, &id_span); + rc = zr_dl_store_lookup(strings, link_id_ref, &id, &id_len); if (rc != ZR_OK) { return rc; } - if (id_span.len > (uint32_t)ZR_FB_LINK_ID_MAX_BYTES) { + if (id_len > (uint32_t)ZR_FB_LINK_ID_MAX_BYTES) { return ZR_ERR_FORMAT; } - id = v->strings_bytes + id_span.off; - id_len = (size_t)id_span.len; } - const uint8_t* uri = v->strings_bytes + uri_span.off; - return zr_fb_link_intern(fb, uri, (size_t)uri_span.len, id, id_len, out_fb_link_ref); + return zr_fb_link_intern(fb, uri, (size_t)uri_len, id, (size_t)id_len, out_fb_link_ref); } -static zr_result_t zr_style_from_dl(const zr_dl_view_t* v, zr_fb_t* fb, const zr_dl_style_wire_t* in, zr_style_t* out) { +static zr_result_t zr_dl_preflight_style_links(const zr_dl_resource_store_t* strings, zr_fb_t* fb, + const zr_dl_style_wire_t* style) { + uint32_t link_ref = 0u; + if (!strings || !fb || !style) { + return ZR_ERR_INVALID_ARGUMENT; + } + return zr_dl_style_resolve_link(strings, fb, style->link_uri_ref, style->link_id_ref, &link_ref); +} + +static zr_result_t zr_style_from_dl(const zr_dl_resource_store_t* strings, zr_fb_t* fb, const zr_dl_style_wire_t* in, + zr_style_t* out) { zr_result_t rc = ZR_OK; - if (!v || !fb || !in || !out) { + if (!strings || !fb || !in || !out) { return ZR_ERR_INVALID_ARGUMENT; } @@ -1280,13 +1430,321 @@ static zr_result_t zr_style_from_dl(const zr_dl_view_t* v, zr_fb_t* fb, const zr out->underline_rgb = in->underline_rgb; out->link_ref = 0u; - rc = zr_dl_style_resolve_link(v, fb, in->link_uri_ref, in->link_id_ref, &out->link_ref); + rc = zr_dl_style_resolve_link(strings, fb, in->link_uri_ref, in->link_id_ref, &out->link_ref); if (rc != ZR_OK) { return rc; } return ZR_OK; } +static zr_result_t zr_dl_preflight_draw_text_run_links(const zr_dl_view_t* v, zr_fb_t* fb, + const zr_dl_resource_store_t* strings, + const zr_dl_resource_store_t* blobs, uint32_t blob_id, + const zr_limits_t* lim) { + const uint8_t* blob = NULL; + uint32_t blob_len = 0u; + zr_byte_reader_t br; + uint32_t seg_count = 0u; + size_t expected = 0u; + zr_result_t rc = ZR_OK; + + if (!v || !fb || !strings || !blobs || !lim) { + return ZR_ERR_INVALID_ARGUMENT; + } + + rc = zr_dl_store_lookup(blobs, blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; + } + + zr_byte_reader_init(&br, blob, (size_t)blob_len); + if (!zr_byte_reader_read_u32le(&br, &seg_count)) { + return ZR_ERR_FORMAT; + } + if (seg_count > lim->dl_max_text_run_segments) { + return ZR_ERR_LIMIT; + } + + rc = zr_dl_text_run_expected_bytes(seg_count, v->hdr.version, &expected); + if (rc != ZR_OK) { + return rc; + } + if (expected != (size_t)blob_len) { + return ZR_ERR_FORMAT; + } + + for (uint32_t si = 0u; si < seg_count; si++) { + zr_dl_text_run_segment_wire_t seg; + rc = zr_dl_read_text_run_segment(&br, v->hdr.version, &seg); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_preflight_style_links(strings, fb, &seg.style); + if (rc != ZR_OK) { + return rc; + } + const uint8_t* sbytes = NULL; + rc = zr_dl_resolve_string_slice(strings, seg.string_id, seg.byte_off, seg.byte_len, &sbytes); + if (rc != ZR_OK) { + return rc; + } + (void)sbytes; + } + + if (zr_byte_reader_remaining(&br) != 0u) { + return ZR_ERR_FORMAT; + } + return ZR_OK; +} + +static zr_result_t zr_dl_apply_def_resource(zr_dl_resource_store_t* store, uint32_t max_items, const zr_limits_t* lim, + zr_byte_reader_t* r, const zr_dl_cmd_header_t* ch) { + zr_dl_cmd_def_resource_t cmd; + const uint8_t* bytes = NULL; + uint32_t padded_len = 0u; + uint32_t old_len = 0u; + uint32_t base_total = 0u; + int32_t idx = -1; + zr_result_t rc = ZR_OK; + + if (!store || !lim || !r || !ch) { + return ZR_ERR_INVALID_ARGUMENT; + } + rc = zr_dl_read_cmd_def_resource(r, ch, &cmd, &bytes, &padded_len); + if (rc != ZR_OK) { + return rc; + } + if (cmd.id == 0u) { + return ZR_ERR_FORMAT; + } + for (uint32_t i = cmd.byte_len; i < padded_len; i++) { + if (bytes[i] != 0u) { + return ZR_ERR_FORMAT; + } + } + + idx = zr_dl_store_find_index(store, cmd.id); + if (idx < 0 && store->len >= max_items) { + return ZR_ERR_LIMIT; + } + if (idx >= 0) { + old_len = store->entries[(uint32_t)idx].len; + if (old_len > store->total_bytes) { + return ZR_ERR_LIMIT; + } + } + base_total = store->total_bytes - old_len; + if (base_total > lim->dl_max_total_bytes) { + return ZR_ERR_LIMIT; + } + if (cmd.byte_len > (lim->dl_max_total_bytes - base_total)) { + return ZR_ERR_LIMIT; + } + + return zr_dl_store_define(store, cmd.id, bytes, cmd.byte_len); +} + +static zr_result_t zr_dl_apply_free_resource(zr_dl_resource_store_t* store, zr_byte_reader_t* r, + const zr_dl_cmd_header_t* ch) { + zr_dl_cmd_free_resource_t cmd; + zr_result_t rc = ZR_OK; + if (!store || !r || !ch) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (ch->size != ((uint32_t)sizeof(zr_dl_cmd_header_t) + (uint32_t)sizeof(zr_dl_cmd_free_resource_t))) { + return ZR_ERR_FORMAT; + } + rc = zr_dl_read_cmd_free_resource(r, &cmd); + if (rc != ZR_OK) { + return rc; + } + return zr_dl_store_free_id(store, cmd.id); +} + +zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_image_frame_t* image_stage, + const zr_limits_t* lim, const zr_terminal_profile_t* term_profile, + zr_dl_resources_t* resources) { + zr_result_t rc = ZR_OK; + uint32_t image_cmd_count = 0u; + uint32_t image_blob_total_bytes = 0u; + + if (!v || !fb || !image_stage || !lim || !resources) { + return ZR_ERR_INVALID_ARGUMENT; + } + + zr_byte_reader_t r; + zr_byte_reader_init(&r, v->cmd_bytes, v->cmd_bytes_len); + + for (uint32_t ci = 0u; ci < v->hdr.cmd_count; ci++) { + zr_dl_cmd_header_t ch; + rc = zr_dl_read_cmd_header(&r, &ch); + if (rc != ZR_OK) { + return rc; + } + + switch ((zr_dl_opcode_t)ch.opcode) { + case ZR_DL_OP_CLEAR: + break; + case ZR_DL_OP_DEF_STRING: + rc = zr_dl_apply_def_resource(&resources->strings, lim->dl_max_strings, lim, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + case ZR_DL_OP_FREE_STRING: + rc = zr_dl_apply_free_resource(&resources->strings, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + case ZR_DL_OP_DEF_BLOB: + rc = zr_dl_apply_def_resource(&resources->blobs, lim->dl_max_blobs, lim, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + case ZR_DL_OP_FREE_BLOB: + rc = zr_dl_apply_free_resource(&resources->blobs, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + case ZR_DL_OP_FILL_RECT: { + zr_dl_cmd_fill_rect_wire_t cmd; + rc = zr_dl_read_cmd_fill_rect(&r, v->hdr.version, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_preflight_style_links(&resources->strings, fb, &cmd.style); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_DRAW_TEXT: { + zr_dl_cmd_draw_text_wire_t cmd; + rc = zr_dl_read_cmd_draw_text(&r, v->hdr.version, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_preflight_style_links(&resources->strings, fb, &cmd.style); + if (rc != ZR_OK) { + return rc; + } + const uint8_t* sbytes = NULL; + rc = zr_dl_resolve_string_slice(&resources->strings, cmd.string_id, cmd.byte_off, cmd.byte_len, &sbytes); + if (rc != ZR_OK) { + return rc; + } + (void)sbytes; + break; + } + case ZR_DL_OP_PUSH_CLIP: { + zr_dl_cmd_push_clip_t cmd; + rc = zr_dl_read_cmd_push_clip(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_POP_CLIP: + break; + case ZR_DL_OP_DRAW_TEXT_RUN: { + zr_dl_cmd_draw_text_run_t cmd; + rc = zr_dl_read_cmd_draw_text_run(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_preflight_draw_text_run_links(v, fb, &resources->strings, &resources->blobs, cmd.blob_id, lim); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_SET_CURSOR: { + zr_dl_cmd_set_cursor_t cmd; + rc = zr_dl_read_cmd_set_cursor(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_DRAW_CANVAS: { + zr_dl_cmd_draw_canvas_t cmd; + const uint8_t* blob = NULL; + uint32_t blob_len = 0u; + uint32_t px_count = 0u; + uint32_t expected_len = 0u; + uint32_t row_bytes = 0u; + rc = zr_dl_read_cmd_draw_canvas(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_store_lookup(&resources->blobs, cmd.blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; + } + if (!zr_checked_mul_u32((uint32_t)cmd.px_width, (uint32_t)cmd.px_height, &px_count) || + !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len) || + !zr_checked_mul_u32((uint32_t)cmd.px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &row_bytes)) { + return ZR_ERR_FORMAT; + } + if (blob_len != expected_len || row_bytes > UINT16_MAX) { + return ZR_ERR_FORMAT; + } + (void)blob; + break; + } + case ZR_DL_OP_DRAW_IMAGE: { + zr_dl_cmd_draw_image_t cmd; + const uint8_t* blob = NULL; + uint32_t blob_len = 0u; + uint32_t px_count = 0u; + uint32_t expected_len = 0u; + rc = zr_dl_read_cmd_draw_image(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_store_lookup(&resources->blobs, cmd.blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; + } + if (cmd.format == (uint8_t)ZR_IMAGE_FORMAT_RGBA) { + if (!zr_checked_mul_u32((uint32_t)cmd.px_width, (uint32_t)cmd.px_height, &px_count) || + !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len)) { + return ZR_ERR_FORMAT; + } + if (blob_len != expected_len) { + return ZR_ERR_FORMAT; + } + } else if (blob_len == 0u) { + return ZR_ERR_FORMAT; + } + + const zr_image_protocol_t proto = zr_image_select_protocol(cmd.protocol, term_profile); + if (proto != ZR_IMG_PROTO_NONE) { + if (!zr_checked_add_u32(image_cmd_count, 1u, &image_cmd_count) || + !zr_checked_add_u32(image_blob_total_bytes, blob_len, &image_blob_total_bytes)) { + return ZR_ERR_LIMIT; + } + if (image_cmd_count > lim->dl_max_cmds || image_blob_total_bytes > lim->dl_max_total_bytes) { + return ZR_ERR_LIMIT; + } + } + (void)blob; + break; + } + default: + return ZR_ERR_UNSUPPORTED; + } + } + + if (zr_byte_reader_remaining(&r) != 0u) { + return ZR_ERR_FORMAT; + } + return zr_image_frame_reserve(image_stage, image_cmd_count, image_blob_total_bytes); +} + static zr_result_t zr_dl_exec_clear(zr_fb_t* dst) { return zr_fb_clear(dst, NULL); } @@ -1376,7 +1834,8 @@ static zr_result_t zr_dl_draw_text_utf8(zr_fb_painter_t* p, int32_t y, int32_t* return ZR_OK; } -static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* v, zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resource_store_t* strings, + zr_fb_painter_t* p) { zr_dl_cmd_fill_rect_wire_t cmd; zr_result_t rc = zr_dl_read_cmd_fill_rect(r, v->hdr.version, &cmd); if (rc != ZR_OK) { @@ -1384,29 +1843,28 @@ static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* } zr_rect_t rr = {cmd.x, cmd.y, cmd.w, cmd.h}; zr_style_t s; - rc = zr_style_from_dl(v, p->fb, &cmd.style, &s); + rc = zr_style_from_dl(strings, p->fb, &cmd.style, &s); if (rc != ZR_OK) { return rc; } return zr_fb_fill_rect(p, rr, &s); } -static zr_result_t zr_dl_exec_draw_text(zr_byte_reader_t* r, const zr_dl_view_t* v, zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_draw_text(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resource_store_t* strings, + zr_fb_painter_t* p) { zr_dl_cmd_draw_text_wire_t cmd; zr_result_t rc = zr_dl_read_cmd_draw_text(r, v->hdr.version, &cmd); if (rc != ZR_OK) { return rc; } - const size_t span_off = (size_t)cmd.string_index * sizeof(zr_dl_span_t); - zr_dl_span_t sspan; - if (zr_dl_span_read_host(v->strings_span_bytes + span_off, &sspan) != ZR_OK) { - return ZR_ERR_FORMAT; + const uint8_t* sbytes = NULL; + rc = zr_dl_resolve_string_slice(strings, cmd.string_id, cmd.byte_off, cmd.byte_len, &sbytes); + if (rc != ZR_OK) { + return rc; } - - const uint8_t* sbytes = v->strings_bytes + sspan.off + cmd.byte_off; zr_style_t s; - rc = zr_style_from_dl(v, p->fb, &cmd.style, &s); + rc = zr_style_from_dl(strings, p->fb, &cmd.style, &s); if (rc != ZR_OK) { return rc; } @@ -1432,28 +1890,22 @@ static zr_result_t zr_dl_exec_pop_clip(zr_fb_painter_t* p) { return rc; } -static zr_result_t zr_dl_exec_draw_text_run_segment(const zr_dl_view_t* v, zr_byte_reader_t* br, zr_fb_painter_t* p, - int32_t y, int32_t* inout_x) { - /* - Note: This path assumes `v` came from zr_dl_validate() (so all indices and - spans are in-bounds). Execution is structured as a straight-line interpreter - for readability. - */ +static zr_result_t zr_dl_exec_draw_text_run_segment(const zr_dl_view_t* v, const zr_dl_resource_store_t* strings, + zr_byte_reader_t* br, zr_fb_painter_t* p, int32_t y, + int32_t* inout_x) { zr_dl_text_run_segment_wire_t seg; zr_result_t rc = zr_dl_read_text_run_segment(br, v->hdr.version, &seg); if (rc != ZR_OK) { return rc; } - const size_t sspan_off = (size_t)seg.string_index * sizeof(zr_dl_span_t); - zr_dl_span_t sspan; - if (zr_dl_span_read_host(v->strings_span_bytes + sspan_off, &sspan) != ZR_OK) { - return ZR_ERR_FORMAT; + const uint8_t* sbytes = NULL; + rc = zr_dl_resolve_string_slice(strings, seg.string_id, seg.byte_off, seg.byte_len, &sbytes); + if (rc != ZR_OK) { + return rc; } - - const uint8_t* sbytes = v->strings_bytes + sspan.off + seg.byte_off; zr_style_t s; - rc = zr_style_from_dl(v, p->fb, &seg.style, &s); + rc = zr_style_from_dl(strings, p->fb, &seg.style, &s); if (rc != ZR_OK) { return rc; } @@ -1461,36 +1913,52 @@ static zr_result_t zr_dl_exec_draw_text_run_segment(const zr_dl_view_t* v, zr_by return zr_dl_draw_text_utf8(p, y, inout_x, sbytes, (size_t)seg.byte_len, v->text.tab_width, v->text.width_policy, &s); } -static zr_result_t zr_dl_exec_draw_text_run(zr_byte_reader_t* r, const zr_dl_view_t* v, zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_draw_text_run(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resources_t* resources, + const zr_limits_t* lim, zr_fb_painter_t* p) { zr_dl_cmd_draw_text_run_t cmd; + const uint8_t* blob = NULL; + uint32_t blob_len = 0u; + size_t expected = 0u; + uint32_t seg_count = 0u; + zr_byte_reader_t br; zr_result_t rc = zr_dl_read_cmd_draw_text_run(r, &cmd); if (rc != ZR_OK) { return rc; } - - const size_t bspan_off = (size_t)cmd.blob_index * sizeof(zr_dl_span_t); - zr_dl_span_t bspan; - if (zr_dl_span_read_host(v->blobs_span_bytes + bspan_off, &bspan) != ZR_OK) { - return ZR_ERR_FORMAT; + if (!resources || !lim) { + return ZR_ERR_INVALID_ARGUMENT; + } + rc = zr_dl_store_lookup(&resources->blobs, cmd.blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; } - const uint8_t* blob = v->blobs_bytes + bspan.off; - zr_byte_reader_t br; - zr_byte_reader_init(&br, blob, (size_t)bspan.len); - - uint32_t seg_count = 0u; + zr_byte_reader_init(&br, blob, (size_t)blob_len); if (!zr_byte_reader_read_u32le(&br, &seg_count)) { return ZR_ERR_FORMAT; } + if (seg_count > lim->dl_max_text_run_segments) { + return ZR_ERR_LIMIT; + } + rc = zr_dl_text_run_expected_bytes(seg_count, v->hdr.version, &expected); + if (rc != ZR_OK) { + return rc; + } + if (expected != (size_t)blob_len) { + return ZR_ERR_FORMAT; + } int32_t cx = cmd.x; for (uint32_t si = 0u; si < seg_count; si++) { - rc = zr_dl_exec_draw_text_run_segment(v, &br, p, cmd.y, &cx); + rc = zr_dl_exec_draw_text_run_segment(v, &resources->strings, &br, p, cmd.y, &cx); if (rc != ZR_OK) { return rc; } } + if (zr_byte_reader_remaining(&br) != 0u) { + return ZR_ERR_FORMAT; + } return ZR_OK; } @@ -1535,12 +2003,15 @@ static zr_result_t zr_dl_exec_canvas_bounds(const zr_fb_t* fb, const zr_dl_cmd_d } /* Execute DRAW_CANVAS by routing RGBA bytes through the selected sub-cell blitter. */ -static zr_result_t zr_dl_exec_draw_canvas(zr_byte_reader_t* r, const zr_dl_view_t* v, zr_fb_painter_t* p, +static zr_result_t zr_dl_exec_draw_canvas(zr_byte_reader_t* r, const zr_dl_resources_t* resources, zr_fb_painter_t* p, const zr_blit_caps_t* blit_caps) { zr_dl_cmd_draw_canvas_t cmd; zr_blit_caps_t default_caps; zr_blitter_t effective = ZR_BLIT_ASCII; - size_t blob_end = 0u; + const uint8_t* blob = NULL; + uint32_t blob_len = 0u; + uint32_t px_count = 0u; + uint32_t expected_len = 0u; uint32_t stride_bytes = 0u; zr_blit_input_t input; zr_rect_t dst_rect; @@ -1548,20 +2019,26 @@ static zr_result_t zr_dl_exec_draw_canvas(zr_byte_reader_t* r, const zr_dl_view_ if (rc != ZR_OK) { return rc; } + if (!resources) { + return ZR_ERR_INVALID_ARGUMENT; + } rc = zr_dl_exec_canvas_bounds(p->fb, &cmd); if (rc != ZR_OK) { return rc; } - if (!zr_checked_add_u32_to_size(cmd.blob_offset, cmd.blob_len, &blob_end) || blob_end > v->blobs_bytes_len) { - return ZR_ERR_INVALID_ARGUMENT; + rc = zr_dl_store_lookup(&resources->blobs, cmd.blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; } - if (!zr_checked_mul_u32((uint32_t)cmd.px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &stride_bytes) || - stride_bytes > UINT16_MAX) { + if (!zr_checked_mul_u32((uint32_t)cmd.px_width, (uint32_t)cmd.px_height, &px_count) || + !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len) || + !zr_checked_mul_u32((uint32_t)cmd.px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &stride_bytes) || + stride_bytes > UINT16_MAX || expected_len != blob_len) { return ZR_ERR_INVALID_ARGUMENT; } - input.pixels = v->blobs_bytes + cmd.blob_offset; + input.pixels = blob; input.px_width = cmd.px_width; input.px_height = cmd.px_height; input.stride = (uint16_t)stride_bytes; @@ -1600,7 +2077,8 @@ static zr_result_t zr_dl_exec_image_bounds(const zr_fb_t* fb, const zr_dl_cmd_dr } static zr_result_t zr_dl_exec_draw_image_fallback_rgba(const zr_dl_cmd_draw_image_t* cmd, const uint8_t* blob, - zr_fb_painter_t* p, const zr_blit_caps_t* blit_caps) { + uint32_t blob_len, zr_fb_painter_t* p, + const zr_blit_caps_t* blit_caps) { zr_blit_caps_t default_caps; zr_blitter_t effective = ZR_BLIT_ASCII; zr_blit_input_t input; @@ -1619,7 +2097,7 @@ static zr_result_t zr_dl_exec_draw_image_fallback_rgba(const zr_dl_cmd_draw_imag return rc; } if (!zr_checked_mul_u32((uint32_t)cmd->px_width, (uint32_t)cmd->px_height, &px_count) || - !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len) || expected_len != cmd->blob_len) { + !zr_checked_mul_u32(px_count, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &expected_len) || expected_len != blob_len) { return ZR_ERR_INVALID_ARGUMENT; } if (!zr_checked_mul_u32((uint32_t)cmd->px_width, ZR_BLIT_RGBA_BYTES_PER_PIXEL, &stride_bytes) || @@ -1650,16 +2128,16 @@ static zr_result_t zr_dl_exec_draw_image_fallback_rgba(const zr_dl_cmd_draw_imag } /* Execute DRAW_IMAGE by staging protocol payloads or falling back to sub-cell blit. */ -static zr_result_t zr_dl_exec_draw_image(zr_byte_reader_t* r, const zr_dl_view_t* v, zr_fb_painter_t* p, +static zr_result_t zr_dl_exec_draw_image(zr_byte_reader_t* r, const zr_dl_resources_t* resources, zr_fb_painter_t* p, const zr_blit_caps_t* blit_caps, const zr_terminal_profile_t* term_profile, zr_image_frame_t* image_frame_stage) { zr_dl_cmd_draw_image_t cmd; zr_image_protocol_t proto = ZR_IMG_PROTO_NONE; const uint8_t* blob = NULL; - size_t blob_end = 0u; + uint32_t blob_len = 0u; zr_result_t rc = ZR_OK; - if (!r || !v || !p) { + if (!r || !resources || !p) { return ZR_ERR_INVALID_ARGUMENT; } @@ -1668,17 +2146,17 @@ static zr_result_t zr_dl_exec_draw_image(zr_byte_reader_t* r, const zr_dl_view_t return rc; } - if (!zr_checked_add_u32_to_size(cmd.blob_offset, cmd.blob_len, &blob_end) || blob_end > v->blobs_bytes_len) { - return ZR_ERR_INVALID_ARGUMENT; + rc = zr_dl_store_lookup(&resources->blobs, cmd.blob_id, &blob, &blob_len); + if (rc != ZR_OK) { + return rc; } - blob = v->blobs_bytes + cmd.blob_offset; proto = zr_image_select_protocol(cmd.protocol, term_profile); if (proto == ZR_IMG_PROTO_NONE) { if (cmd.format != (uint8_t)ZR_IMAGE_FORMAT_RGBA) { return ZR_ERR_UNSUPPORTED; } - return zr_dl_exec_draw_image_fallback_rgba(&cmd, blob, p, blit_caps); + return zr_dl_exec_draw_image_fallback_rgba(&cmd, blob, blob_len, p, blit_caps); } if ((proto == ZR_IMG_PROTO_KITTY || proto == ZR_IMG_PROTO_SIXEL) && cmd.format != (uint8_t)ZR_IMAGE_FORMAT_RGBA) { @@ -1696,8 +2174,8 @@ static zr_result_t zr_dl_exec_draw_image(zr_byte_reader_t* r, const zr_dl_view_t staged.dst_rows = cmd.dst_rows; staged.px_width = cmd.px_width; staged.px_height = cmd.px_height; - staged.blob_off = cmd.blob_offset; - staged.blob_len = cmd.blob_len; + staged.blob_off = 0u; + staged.blob_len = blob_len; staged.image_id = cmd.image_id; staged.format = cmd.format; /* @@ -1717,10 +2195,13 @@ static zr_result_t zr_dl_exec_draw_image(zr_byte_reader_t* r, const zr_dl_view_t zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t* lim, uint32_t tab_width, uint32_t width_policy, const zr_blit_caps_t* blit_caps, const zr_terminal_profile_t* term_profile, zr_image_frame_t* image_frame_stage, - zr_cursor_state_t* inout_cursor_state) { + zr_dl_resources_t* resources, zr_cursor_state_t* inout_cursor_state) { if (!v || !dst || !lim) { return ZR_ERR_INVALID_ARGUMENT; } + if (!resources) { + return ZR_ERR_INVALID_ARGUMENT; + } if (!inout_cursor_state) { return ZR_ERR_INVALID_ARGUMENT; } @@ -1766,15 +2247,43 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t } break; } + case ZR_DL_OP_DEF_STRING: { + rc = zr_dl_apply_def_resource(&resources->strings, lim->dl_max_strings, lim, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_FREE_STRING: { + rc = zr_dl_apply_free_resource(&resources->strings, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_DEF_BLOB: { + rc = zr_dl_apply_def_resource(&resources->blobs, lim->dl_max_blobs, lim, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + } + case ZR_DL_OP_FREE_BLOB: { + rc = zr_dl_apply_free_resource(&resources->blobs, &r, &ch); + if (rc != ZR_OK) { + return rc; + } + break; + } case ZR_DL_OP_FILL_RECT: { - rc = zr_dl_exec_fill_rect(&r, &view, &painter); + rc = zr_dl_exec_fill_rect(&r, &view, &resources->strings, &painter); if (rc != ZR_OK) { return rc; } break; } case ZR_DL_OP_DRAW_TEXT: { - rc = zr_dl_exec_draw_text(&r, &view, &painter); + rc = zr_dl_exec_draw_text(&r, &view, &resources->strings, &painter); if (rc != ZR_OK) { return rc; } @@ -1795,16 +2304,13 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t break; } case ZR_DL_OP_DRAW_TEXT_RUN: { - rc = zr_dl_exec_draw_text_run(&r, &view, &painter); + rc = zr_dl_exec_draw_text_run(&r, &view, resources, lim, &painter); if (rc != ZR_OK) { return rc; } break; } case ZR_DL_OP_SET_CURSOR: { - if (view.hdr.version < ZR_DRAWLIST_VERSION_V2) { - return ZR_ERR_UNSUPPORTED; - } rc = zr_dl_exec_set_cursor(&r, inout_cursor_state); if (rc != ZR_OK) { return rc; @@ -1812,20 +2318,14 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t break; } case ZR_DL_OP_DRAW_CANVAS: { - if (view.hdr.version < ZR_DRAWLIST_VERSION_V4) { - return ZR_ERR_UNSUPPORTED; - } - rc = zr_dl_exec_draw_canvas(&r, &view, &painter, blit_caps); + rc = zr_dl_exec_draw_canvas(&r, resources, &painter, blit_caps); if (rc != ZR_OK) { return rc; } break; } case ZR_DL_OP_DRAW_IMAGE: { - if (view.hdr.version < ZR_DRAWLIST_VERSION_V5) { - return ZR_ERR_UNSUPPORTED; - } - rc = zr_dl_exec_draw_image(&r, &view, &painter, blit_caps, term_profile, image_frame_stage); + rc = zr_dl_exec_draw_image(&r, resources, &painter, blit_caps, term_profile, image_frame_stage); if (rc != ZR_OK) { return rc; } diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.h b/packages/native/vendor/zireael/src/core/zr_drawlist.h index 06b49fa9..7735b584 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.h +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.h @@ -24,6 +24,32 @@ typedef struct zr_fb_t zr_fb_t; typedef struct zr_image_frame_t zr_image_frame_t; +typedef struct zr_dl_resource_entry_t { + uint32_t id; + uint8_t* bytes; + uint32_t len; + /* + Ownership tag for `bytes`. + + Why: preflight uses shallow snapshots that borrow `bytes` pointers from the + stage store to avoid deep-copying persistent payloads every submit. + */ + uint8_t owned; + uint8_t reserved0[3]; +} zr_dl_resource_entry_t; + +typedef struct zr_dl_resource_store_t { + zr_dl_resource_entry_t* entries; + uint32_t len; + uint32_t cap; + uint32_t total_bytes; +} zr_dl_resource_store_t; + +typedef struct zr_dl_resources_t { + zr_dl_resource_store_t strings; + zr_dl_resource_store_t blobs; +} zr_dl_resources_t; + /* zr_dl_view_t (engine-internal validated view): - All pointers are borrowed views into the caller-provided drawlist byte @@ -60,9 +86,18 @@ typedef struct zr_dl_view_t { } zr_dl_view_t; zr_result_t zr_dl_validate(const uint8_t* bytes, size_t bytes_len, const zr_limits_t* lim, zr_dl_view_t* out_view); +zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_image_frame_t* image_stage, + const zr_limits_t* lim, const zr_terminal_profile_t* term_profile, + zr_dl_resources_t* resources); zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t* lim, uint32_t tab_width, uint32_t width_policy, const zr_blit_caps_t* blit_caps, const zr_terminal_profile_t* term_profile, zr_image_frame_t* image_frame_stage, - zr_cursor_state_t* inout_cursor_state); + zr_dl_resources_t* resources, zr_cursor_state_t* inout_cursor_state); + +void zr_dl_resources_init(zr_dl_resources_t* resources); +void zr_dl_resources_release(zr_dl_resources_t* resources); +void zr_dl_resources_swap(zr_dl_resources_t* a, zr_dl_resources_t* b); +zr_result_t zr_dl_resources_clone(zr_dl_resources_t* dst, const zr_dl_resources_t* src); +zr_result_t zr_dl_resources_clone_shallow(zr_dl_resources_t* dst, const zr_dl_resources_t* src); #endif /* ZR_CORE_ZR_DRAWLIST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/core/zr_engine.c b/packages/native/vendor/zireael/src/core/zr_engine.c index dd3cc60f..44e66405 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine.c +++ b/packages/native/vendor/zireael/src/core/zr_engine.c @@ -71,7 +71,7 @@ struct zr_engine_t { /* --- Tick scheduling (ZR_EV_TICK emission) --- */ uint32_t last_tick_ms; - /* --- Framebuffers (double buffered + staging for no-partial-effects) --- */ + /* --- Framebuffers (double buffered + overlay presentation stage) --- */ zr_fb_t fb_prev; zr_fb_t fb_next; zr_fb_t fb_stage; @@ -84,6 +84,10 @@ struct zr_engine_t { zr_image_frame_t image_frame_stage; zr_image_state_t image_state; + /* --- Persistent drawlist resources (DEF_* and FREE_* command state) --- */ + zr_dl_resources_t dl_resources_next; + zr_dl_resources_t dl_resources_stage; + /* --- Output buffer (single flush per present) --- */ uint8_t* out_buf; size_t out_cap; @@ -501,13 +505,43 @@ static zr_result_t zr_engine_fb_copy(const zr_fb_t* src, zr_fb_t* dst) { return zr_fb_links_clone_from(dst, src); } -static void zr_engine_fb_swap(zr_fb_t* a, zr_fb_t* b) { - if (!a || !b) { - return; +/* + Allocation-free framebuffer copy used for submit rollback. + + Why: engine_submit_drawlist() must restore fb_next to fb_prev after any + in-place failure without depending on fresh allocations. +*/ +static zr_result_t zr_engine_fb_copy_noalloc(const zr_fb_t* src, zr_fb_t* dst) { + if (!src || !dst) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (src->cols != dst->cols || src->rows != dst->rows) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (!src->cells || !dst->cells) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (src->links_len > dst->links_cap || src->link_bytes_len > dst->link_bytes_cap) { + return ZR_ERR_LIMIT; + } + if ((src->links_len != 0u && (!src->links || !dst->links)) || + (src->link_bytes_len != 0u && (!src->link_bytes || !dst->link_bytes))) { + return ZR_ERR_INVALID_ARGUMENT; + } + + const size_t n = zr_engine_cells_bytes(src); + if (n != 0u) { + memcpy(dst->cells, src->cells, n); } - zr_fb_t tmp = *a; - *a = *b; - *b = tmp; + if (src->links_len != 0u) { + memcpy(dst->links, src->links, (size_t)src->links_len * sizeof(zr_fb_link_t)); + } + if (src->link_bytes_len != 0u) { + memcpy(dst->link_bytes, src->link_bytes, (size_t)src->link_bytes_len); + } + dst->links_len = src->links_len; + dst->link_bytes_len = src->link_bytes_len; + return ZR_OK; } /* @@ -1237,6 +1271,8 @@ zr_result_t engine_create(zr_engine_t** out_engine, const zr_engine_config_t* cf zr_image_frame_init(&e->image_frame_next); zr_image_frame_init(&e->image_frame_stage); zr_image_state_init(&e->image_state); + zr_dl_resources_init(&e->dl_resources_next); + zr_dl_resources_init(&e->dl_resources_stage); e->cursor_desired = zr_engine_cursor_default(); e->last_tick_ms = zr_engine_now_ms_u32(); @@ -1288,6 +1324,8 @@ static void zr_engine_release_heap_state(zr_engine_t* e) { zr_image_frame_release(&e->image_frame_next); zr_image_frame_release(&e->image_frame_stage); zr_image_state_init(&e->image_state); + zr_dl_resources_release(&e->dl_resources_next); + zr_dl_resources_release(&e->dl_resources_stage); zr_arena_release(&e->arena_frame); zr_arena_release(&e->arena_persistent); @@ -1413,10 +1451,10 @@ static void zr_engine_build_blit_caps(const zr_engine_t* e, zr_blit_caps_t* out_ } /* - Validate and execute a drawlist against the staging framebuffer. + Validate and execute a drawlist directly into fb_next. - Why: Enforces the "no partial effects" contract by only committing to fb_next - after a successful execute. + Why: Keeps steady-state work proportional to command effects (not full-frame + clones) while preserving the no-partial-effects contract through rollback. */ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int bytes_len) { if (!e || !bytes) { @@ -1443,29 +1481,65 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt return ZR_ERR_UNSUPPORTED; } - rc = zr_engine_fb_copy(&e->fb_next, &e->fb_stage); + zr_cursor_state_t cursor_stage = e->cursor_desired; + zr_dl_resources_t preflight_resources; + zr_dl_resources_init(&preflight_resources); + + zr_dl_resources_release(&e->dl_resources_stage); + rc = zr_dl_resources_clone(&e->dl_resources_stage, &e->dl_resources_next); + if (rc != ZR_OK) { + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, rc); + return rc; + } + rc = zr_dl_resources_clone_shallow(&preflight_resources, &e->dl_resources_stage); if (rc != ZR_OK) { + zr_dl_resources_release(&e->dl_resources_stage); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); return rc; } - zr_cursor_state_t cursor_stage = e->cursor_desired; zr_image_frame_reset(&e->image_frame_stage); + rc = zr_dl_preflight_resources(&v, &e->fb_next, &e->image_frame_stage, &e->cfg_runtime.limits, &e->term_profile, + &preflight_resources); + zr_dl_resources_release(&preflight_resources); + if (rc != ZR_OK) { + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); + zr_image_frame_reset(&e->image_frame_stage); + zr_dl_resources_release(&e->dl_resources_stage); + if (rollback_rc != ZR_OK) { + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, rollback_rc); + return rollback_rc; + } + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, rc); + return rc; + } + zr_blit_caps_t blit_caps; zr_engine_build_blit_caps(e, &blit_caps); - rc = zr_dl_execute(&v, &e->fb_stage, &e->cfg_runtime.limits, e->cfg_runtime.tab_width, e->cfg_runtime.width_policy, - &blit_caps, &e->term_profile, &e->image_frame_stage, &cursor_stage); + rc = zr_dl_execute(&v, &e->fb_next, &e->cfg_runtime.limits, e->cfg_runtime.tab_width, e->cfg_runtime.width_policy, + &blit_caps, &e->term_profile, &e->image_frame_stage, &e->dl_resources_stage, &cursor_stage); if (rc != ZR_OK) { + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); + zr_dl_resources_release(&e->dl_resources_stage); + if (rollback_rc != ZR_OK) { + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, rollback_rc); + return rollback_rc; + } zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); return rc; } - zr_engine_fb_swap(&e->fb_next, &e->fb_stage); zr_image_frame_swap(&e->image_frame_next, &e->image_frame_stage); zr_image_frame_reset(&e->image_frame_stage); + zr_dl_resources_swap(&e->dl_resources_next, &e->dl_resources_stage); + zr_dl_resources_release(&e->dl_resources_stage); e->cursor_desired = cursor_stage; zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index b040aa58..b9aa12d5 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -35,18 +35,6 @@ static zr_result_t zr_engine_present_pick_fb(zr_engine_t* e, const zr_fb_t** out } if (e->cfg_runtime.enable_debug_overlay == 0u) { - /* - Keep fb_stage as a no-allocation clone of fb_next for commit-time - synchronization. - - Why: Commit happens after a successful write; cloning here preserves the - single-flush/no-partial-effects contract by surfacing OOM/limit failures - before any terminal bytes are emitted. - */ - const zr_result_t rc = zr_engine_fb_copy(&e->fb_next, &e->fb_stage); - if (rc != ZR_OK) { - return rc; - } *out_present_fb = &e->fb_next; *out_presented_stage = false; return ZR_OK; @@ -340,18 +328,43 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ } const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); + const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; + const bool use_damage_rect_copy = + (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && (stats->damage_rects <= e->damage_rect_cap); - /* --- Swap framebuffers to keep prev/next/stage invariants --- */ /* - presented_stage==true: fb_stage was rendered (overlay path), so commit it. - presented_stage==false: fb_next was rendered, then keep stage synchronized. + Resync fb_prev to the framebuffer that was actually presented. + + Why: Keep fb_next as the mutable next-frame target and avoid full-frame + clones in steady-state by copying only damage rects when possible. */ - if (presented_stage) { - zr_engine_fb_swap(&e->fb_prev, &e->fb_stage); + if (!e->fb_prev.cells || !presented_fb->cells || e->fb_prev.cols != presented_fb->cols || + e->fb_prev.rows != presented_fb->rows) { + e->diff_prev_hashes_valid = 0u; } else { - zr_engine_fb_swap(&e->fb_prev, &e->fb_next); - zr_engine_fb_swap(&e->fb_next, &e->fb_stage); + if (!use_damage_rect_copy) { + const size_t n = zr_engine_cells_bytes(presented_fb); + if (n != 0u) { + memcpy(e->fb_prev.cells, presented_fb->cells, n); + } + } else { + const zr_result_t rc = + zr_fb_copy_damage_rects(&e->fb_prev, presented_fb, e->damage_rects, stats->damage_rects); + if (rc != ZR_OK) { + const size_t n = zr_engine_cells_bytes(presented_fb); + if (n != 0u) { + memcpy(e->fb_prev.cells, presented_fb->cells, n); + } + e->diff_prev_hashes_valid = 0u; + } + } + + zr_fb_links_reset(&e->fb_prev); + if (zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { + e->diff_prev_hashes_valid = 0u; + } } + zr_engine_swap_diff_hashes_on_commit(e); e->term_state = *final_ts; e->image_state = *image_state_stage; diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.c b/packages/native/vendor/zireael/src/core/zr_framebuffer.c index 78144f27..7870bf8c 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.c +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.c @@ -205,6 +205,70 @@ zr_result_t zr_fb_links_clone_from(zr_fb_t* dst, const zr_fb_t* src) { return ZR_OK; } +zr_result_t zr_fb_copy_damage_rects(zr_fb_t* dst, const zr_fb_t* src, const zr_damage_rect_t* rects, + uint32_t rect_count) { + if (!dst || !src) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (dst->cols != src->cols || dst->rows != src->rows) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (rect_count != 0u && !rects) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (dst == src || rect_count == 0u) { + return ZR_OK; + } + if (dst->cols == 0u || dst->rows == 0u) { + return ZR_OK; + } + if (!dst->cells || !src->cells) { + return ZR_ERR_INVALID_ARGUMENT; + } + + const uint32_t max_x = dst->cols - 1u; + const uint32_t max_y = dst->rows - 1u; + for (uint32_t i = 0u; i < rect_count; i++) { + uint32_t x0 = rects[i].x0; + uint32_t y0 = rects[i].y0; + uint32_t x1 = rects[i].x1; + uint32_t y1 = rects[i].y1; + + if (x0 > x1 || y0 > y1) { + continue; + } + if (x0 > max_x || y0 > max_y) { + continue; + } + + x1 = ZR_MIN(x1, max_x); + y1 = ZR_MIN(y1, max_y); + if (x0 > x1 || y0 > y1) { + continue; + } + + size_t span_cells = 0u; + if (!zr_checked_add_size((size_t)(x1 - x0), 1u, &span_cells)) { + return ZR_ERR_LIMIT; + } + size_t span_bytes = 0u; + if (!zr_checked_mul_size(span_cells, sizeof(zr_cell_t), &span_bytes)) { + return ZR_ERR_LIMIT; + } + + for (uint32_t y = y0; y <= y1; y++) { + size_t row_start = 0u; + if (!zr_checked_mul_size((size_t)y, (size_t)dst->cols, &row_start) || + !zr_checked_add_size(row_start, (size_t)x0, &row_start)) { + return ZR_ERR_LIMIT; + } + memcpy(dst->cells + row_start, src->cells + row_start, span_bytes); + } + } + + return ZR_OK; +} + static zr_rect_t zr_rect_empty(void) { zr_rect_t r; r.x = 0; diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.h b/packages/native/vendor/zireael/src/core/zr_framebuffer.h index 7a75ffde..5ba7fbc2 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.h +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.h @@ -11,6 +11,8 @@ #ifndef ZR_CORE_ZR_FRAMEBUFFER_H_INCLUDED #define ZR_CORE_ZR_FRAMEBUFFER_H_INCLUDED +#include "core/zr_damage.h" + #include "util/zr_result.h" #include @@ -88,6 +90,8 @@ zr_result_t zr_fb_link_intern(zr_fb_t* fb, const uint8_t* uri, size_t uri_len, c uint32_t* out_link_ref); zr_result_t zr_fb_link_lookup(const zr_fb_t* fb, uint32_t link_ref, const uint8_t** out_uri, size_t* out_uri_len, const uint8_t** out_id, size_t* out_id_len); +zr_result_t zr_fb_copy_damage_rects(zr_fb_t* dst, const zr_fb_t* src, const zr_damage_rect_t* rects, + uint32_t rect_count); /* Painter + clip stack: diff --git a/packages/native/vendor/zireael/src/core/zr_image.c b/packages/native/vendor/zireael/src/core/zr_image.c index b4301785..046ae2e8 100644 --- a/packages/native/vendor/zireael/src/core/zr_image.c +++ b/packages/native/vendor/zireael/src/core/zr_image.c @@ -97,6 +97,19 @@ void zr_image_frame_reset(zr_image_frame_t* frame) { frame->blob_len = 0u; } +zr_result_t zr_image_frame_reserve(zr_image_frame_t* frame, uint32_t cmd_cap, uint32_t blob_cap) { + zr_result_t rc = ZR_OK; + if (!frame) { + return ZR_ERR_INVALID_ARGUMENT; + } + + rc = zr_image_frame_ensure_cmd_cap(frame, cmd_cap); + if (rc != ZR_OK) { + return rc; + } + return zr_image_frame_ensure_blob_cap(frame, blob_cap); +} + void zr_image_frame_release(zr_image_frame_t* frame) { if (!frame) { return; diff --git a/packages/native/vendor/zireael/src/core/zr_image.h b/packages/native/vendor/zireael/src/core/zr_image.h index 36a02553..05d2e906 100644 --- a/packages/native/vendor/zireael/src/core/zr_image.h +++ b/packages/native/vendor/zireael/src/core/zr_image.h @@ -110,6 +110,7 @@ typedef struct zr_image_emit_ctx_t { /* --- Shared frame storage helpers --- */ void zr_image_frame_init(zr_image_frame_t* frame); void zr_image_frame_reset(zr_image_frame_t* frame); +zr_result_t zr_image_frame_reserve(zr_image_frame_t* frame, uint32_t cmd_cap, uint32_t blob_cap); void zr_image_frame_release(zr_image_frame_t* frame); zr_result_t zr_image_frame_push_copy(zr_image_frame_t* frame, const zr_image_cmd_t* cmd, const uint8_t* blob_bytes); void zr_image_frame_swap(zr_image_frame_t* a, zr_image_frame_t* b); diff --git a/packages/node/src/__tests__/config_guards.test.ts b/packages/node/src/__tests__/config_guards.test.ts index 795218a5..0b4f48e1 100644 --- a/packages/node/src/__tests__/config_guards.test.ts +++ b/packages/node/src/__tests__/config_guards.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { createApp, ui } from "@rezi-ui/core"; +import { BACKEND_DRAWLIST_VERSION_MARKER, createApp, rgb, ui } from "@rezi-ui/core"; import { ZrUiError } from "@rezi-ui/core"; import { createNodeApp, createNodeBackend } from "../index.js"; @@ -75,19 +75,13 @@ function withNoColor(value: string | undefined, fn: () => void): void { } } -test("config guard: backend drawlist version 1 is rejected", () => { - assert.throws( - () => createNodeBackend({ drawlistVersion: 1 as unknown as 2 }), - (err) => - err instanceof ZrUiError && - err.code === "ZRUI_INVALID_PROPS" && - err.message.includes("drawlistVersion must be one of 2, 3, 4, 5"), - ); -}); - -test("config guard: backend drawlist >=2 is allowed", () => { - const backend = createNodeBackend({ drawlistVersion: 5 }); +test("config guard: backend drawlist marker is fixed to current protocol", () => { + const backend = createNodeBackend(); try { + assert.equal( + (backend as unknown as Record)[BACKEND_DRAWLIST_VERSION_MARKER], + 1, + ); const app = createApp({ backend, initialState: { value: 0 }, @@ -281,20 +275,20 @@ test("createNodeApp exposes isNoColor=true when NO_COLOR is present", () => { initialState: { value: 0 }, theme: { colors: { - primary: { r: 255, g: 0, b: 0 }, - secondary: { r: 0, g: 255, b: 0 }, - success: { r: 0, g: 0, b: 255 }, - danger: { r: 255, g: 0, b: 255 }, - warning: { r: 255, g: 255, b: 0 }, - info: { r: 0, g: 255, b: 255 }, - muted: { r: 120, g: 120, b: 120 }, - bg: { r: 10, g: 10, b: 10 }, - fg: { r: 240, g: 240, b: 240 }, - border: { r: 64, g: 64, b: 64 }, - "diagnostic.error": { r: 255, g: 90, b: 90 }, - "diagnostic.warning": { r: 255, g: 200, b: 90 }, - "diagnostic.info": { r: 90, g: 180, b: 255 }, - "diagnostic.hint": { r: 140, g: 255, b: 120 }, + primary: rgb(255, 0, 0), + secondary: rgb(0, 255, 0), + success: rgb(0, 0, 255), + danger: rgb(255, 0, 255), + warning: rgb(255, 255, 0), + info: rgb(0, 255, 255), + muted: rgb(120, 120, 120), + bg: rgb(10, 10, 10), + fg: rgb(240, 240, 240), + border: rgb(64, 64, 64), + "diagnostic.error": rgb(255, 90, 90), + "diagnostic.warning": rgb(255, 200, 90), + "diagnostic.info": rgb(90, 180, 255), + "diagnostic.hint": rgb(140, 255, 120), }, spacing: [0, 1, 2, 4, 8, 16], }, diff --git a/packages/node/src/backend/nodeBackend.ts b/packages/node/src/backend/nodeBackend.ts index 59f93b0c..afe8a9ea 100644 --- a/packages/node/src/backend/nodeBackend.ts +++ b/packages/node/src/backend/nodeBackend.ts @@ -29,7 +29,7 @@ import { FRAME_ACCEPTED_ACK_MARKER, } from "@rezi-ui/core"; import { - ZR_DRAWLIST_VERSION_V5, + ZR_DRAWLIST_VERSION_V1, ZR_ENGINE_ABI_MAJOR, ZR_ENGINE_ABI_MINOR, ZR_ENGINE_ABI_PATCH, @@ -80,13 +80,6 @@ export type NodeBackendConfig = Readonly<{ * remain aligned by construction. */ maxEventBytes?: number; - /** - * Explicit drawlist version request. - * - * Defaults to `5` (enables v3 style extensions + v4 canvas + v5 image commands). - * Supported versions are `2`-`5`. - */ - drawlistVersion?: 2 | 3 | 4 | 5; /** * Frame transport mode: * - "auto": prefer SAB mailbox transport when available, fallback to transfer. @@ -233,21 +226,6 @@ function parsePositiveInt(n: unknown): number | null { return n; } -function parseDrawlistVersion(v: unknown): 2 | 3 | 4 | 5 | null { - if (v === undefined) return null; - if (v === 2 || v === 3 || v === 4 || v === 5) return v; - throw new ZrUiError( - "ZRUI_INVALID_PROPS", - `createNodeBackend config mismatch: drawlistVersion must be one of 2, 3, 4, 5 (got ${String(v)}).`, - ); -} - -function resolveRequestedDrawlistVersion(config: NodeBackendConfig): 2 | 3 | 4 | 5 { - const explicitDrawlistVersion = parseDrawlistVersion(config.drawlistVersion); - if (explicitDrawlistVersion !== null) return explicitDrawlistVersion; - return ZR_DRAWLIST_VERSION_V5; -} - function parseBoundedPositiveIntOrThrow( name: string, value: unknown, @@ -434,7 +412,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N if (executionMode === "inline") { return createNodeBackendInlineInternal(opts); } - const requestedDrawlistVersion = resolveRequestedDrawlistVersion(cfg); + const requestedDrawlistVersion = ZR_DRAWLIST_VERSION_V1; const maxEventBytes = parseBoundedPositiveIntOrThrow( "maxEventBytes", cfg.maxEventBytes, diff --git a/packages/node/src/backend/nodeBackendInline.ts b/packages/node/src/backend/nodeBackendInline.ts index 624ec692..0cac702c 100644 --- a/packages/node/src/backend/nodeBackendInline.ts +++ b/packages/node/src/backend/nodeBackendInline.ts @@ -27,7 +27,7 @@ import { DEFAULT_TERMINAL_CAPS, } from "@rezi-ui/core"; import { - ZR_DRAWLIST_VERSION_V5, + ZR_DRAWLIST_VERSION_V1, ZR_ENGINE_ABI_MAJOR, ZR_ENGINE_ABI_MINOR, ZR_ENGINE_ABI_PATCH, @@ -247,23 +247,6 @@ export function readDebugBytesWithRetry( } } -function parseDrawlistVersion(v: unknown): 2 | 3 | 4 | 5 | null { - if (v === undefined) return null; - if (v === 2 || v === 3 || v === 4 || v === 5) return v; - throw new ZrUiError( - "ZRUI_INVALID_PROPS", - `createNodeBackend config mismatch: drawlistVersion must be one of 2, 3, 4, 5 (got ${String(v)}).`, - ); -} - -function resolveRequestedDrawlistVersion( - config: Readonly<{ drawlistVersion?: 2 | 3 | 4 | 5 }>, -): 2 | 3 | 4 | 5 { - const explicitDrawlistVersion = parseDrawlistVersion(config.drawlistVersion); - if (explicitDrawlistVersion !== null) return explicitDrawlistVersion; - return ZR_DRAWLIST_VERSION_V5; -} - function parseBoundedPositiveIntOrThrow( name: string, value: unknown, @@ -376,7 +359,7 @@ async function loadNative(shimModule: string | undefined): Promise { export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = {}): NodeBackend { const cfg = opts.config ?? {}; - const requestedDrawlistVersion = resolveRequestedDrawlistVersion(cfg); + const requestedDrawlistVersion = ZR_DRAWLIST_VERSION_V1; const fpsCap = parseBoundedPositiveIntOrThrow( "fpsCap", cfg.fpsCap, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 540fd33e..c2f7d858 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -98,10 +98,8 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isRgb(value: unknown): value is Readonly<{ r: number; g: number; b: number }> { - if (!value || typeof value !== "object") return false; - const candidate = value as { r?: unknown; g?: unknown; b?: unknown }; - return isFiniteNumber(candidate.r) && isFiniteNumber(candidate.g) && isFiniteNumber(candidate.b); +function isRgb24(value: unknown): value is number { + return isFiniteNumber(value) && value >= 0 && value <= 0x00ff_ffff; } function isSpacingToken(value: unknown): value is number { @@ -113,7 +111,7 @@ function readLegacyThemeColors(theme: Theme | ThemeDefinition | undefined): Them const colors = (theme as { colors?: unknown }).colors; if (!colors || typeof colors !== "object") return null; const candidate = colors as { fg?: unknown; bg?: unknown } & Record; - if (!isRgb(candidate.fg) || !isRgb(candidate.bg)) return null; + if (!isRgb24(candidate.fg) || !isRgb24(candidate.bg)) return null; return candidate as Theme["colors"]; } @@ -219,7 +217,6 @@ function toBackendConfig(config: NodeAppConfig | undefined): NodeBackendConfig { ...(config.executionMode !== undefined ? { executionMode: config.executionMode } : {}), ...(config.fpsCap !== undefined ? { fpsCap: config.fpsCap } : {}), ...(config.maxEventBytes !== undefined ? { maxEventBytes: config.maxEventBytes } : {}), - ...(config.drawlistVersion !== undefined ? { drawlistVersion: config.drawlistVersion } : {}), ...(config.frameTransport !== undefined ? { frameTransport: config.frameTransport } : {}), ...(config.frameSabSlotCount !== undefined ? { frameSabSlotCount: config.frameSabSlotCount } From 25f82d5e04b4f18d5d1de493775d06ee47acce70 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:51:38 +0400 Subject: [PATCH 07/20] fix(core): restore canvas overlay hex parsing --- .../widgets/renderCanvasWidgets.ts | 16 ++++++++++++++++ .../widgets/__tests__/graphicsWidgets.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts index 51ffc0a1..677b2a12 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts @@ -40,7 +40,23 @@ function repeatCached(glyph: string, count: number): string { return value; } +function parseHexRgb24(input: string): number | undefined { + const raw = input.startsWith("#") ? input.slice(1) : input; + if (/^[0-9a-fA-F]{6}$/.test(raw)) { + return Number.parseInt(raw, 16) & 0x00ff_ffff; + } + if (/^[0-9a-fA-F]{3}$/.test(raw)) { + const r = Number.parseInt(raw[0] ?? "0", 16); + const g = Number.parseInt(raw[1] ?? "0", 16); + const b = Number.parseInt(raw[2] ?? "0", 16); + return (((r << 4) | r) << 16) | (((g << 4) | g) << 8) | ((b << 4) | b); + } + return undefined; +} + function resolveCanvasOverlayColor(theme: Theme, color: string): number { + const parsedHex = parseHexRgb24(color); + if (parsedHex !== undefined) return parsedHex; return resolveColor(theme, color); } diff --git a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts index 7c07345f..e9c54880 100644 --- a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts +++ b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts @@ -214,6 +214,25 @@ describe("graphics widgets", () => { assert.equal(fg, packRgb(0xff, 0xd1, 0x66)); }); + test("canvas text overlay accepts short hex color", () => { + const bytes = renderBytes( + ui.canvas({ + width: 10, + height: 4, + draw: (ctx) => { + ctx.text(1, 1, "A", "#f0a"); + }, + }), + () => createDrawlistBuilder(), + { cols: 20, rows: 8 }, + ); + const payloadOff = findCommandPayload(bytes, 3); + assert.equal(payloadOff !== null, true); + if (payloadOff === null) return; + const fg = u32(bytes, payloadOff + 20); + assert.equal(fg, packRgb(0xff, 0x00, 0xaa)); + }); + test("canvas auto blitter resolves to braille and blob span matches payload", () => { const width = 4; const height = 2; From a559378fb9d0b8da5ac1407792097267f4a196d4 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:05:25 +0400 Subject: [PATCH 08/20] fix(ci): satisfy guardrails, lint, and typecheck --- packages/bench/src/profile-packed-style.ts | 20 +- .../__tests__/interpolate.easing.test.ts | 24 ++- packages/core/src/animation/interpolate.ts | 2 +- .../__tests__/partialDrawlistEmission.test.ts | 5 +- .../drawlist/__tests__/builder.golden.test.ts | 8 +- .../__tests__/builder.graphics.test.ts | 2 +- .../__tests__/builder.round-trip.test.ts | 20 +- .../__tests__/builder.string-cache.test.ts | 4 +- .../__tests__/builder.string-intern.test.ts | 4 +- .../__tests__/builder.style-encoding.test.ts | 27 +-- .../__tests__/builder.text-run.test.ts | 4 +- .../__tests__/builder_v6_resources.test.ts | 4 +- packages/core/src/drawlist/builder.ts | 2 +- .../layout/__tests__/layout.edgecases.test.ts | 2 +- .../core/src/layout/engine/layoutEngine.ts | 3 +- .../__tests__/focusIndicators.test.ts | 2 +- .../__tests__/recipeRendering.test-utils.ts | 8 +- .../__tests__/renderer.border.test.ts | 8 +- .../__tests__/renderer.damage.test.ts | 8 +- .../__tests__/renderer.scrollbar.test.ts | 2 +- .../__tests__/tableRecipeRendering.test.ts | 5 +- .../__tests__/textStyle.opacity.test.ts | 22 +- .../renderer/renderToDrawlist/textStyle.ts | 2 +- .../renderToDrawlist/widgets/editors.ts | 8 +- .../widgets/renderCanvasWidgets.ts | 2 +- packages/core/src/renderer/shadow.ts | 2 +- packages/core/src/renderer/styles.ts | 2 +- .../commit.fastReuse.regression.test.ts | 6 +- .../runtime/__tests__/hooks.useTheme.test.ts | 4 +- packages/core/src/runtime/commit.ts | 193 +++++++++++++++--- .../theme/__tests__/theme.contrast.test.ts | 18 +- .../src/theme/__tests__/theme.extend.test.ts | 10 +- .../src/theme/__tests__/theme.interop.test.ts | 12 +- .../theme/__tests__/theme.resolution.test.ts | 8 +- .../src/theme/__tests__/theme.switch.test.ts | 10 +- .../core/src/theme/__tests__/theme.test.ts | 7 +- .../theme/__tests__/theme.transition.test.ts | 6 +- packages/core/src/theme/blend.ts | 2 +- packages/core/src/theme/contrast.ts | 2 +- packages/core/src/theme/defaultTheme.ts | 2 +- packages/core/src/theme/tokens.ts | 2 +- packages/core/src/ui/__tests__/themed.test.ts | 10 +- packages/core/src/ui/recipes.ts | 2 +- .../__tests__/basicWidgets.render.test.ts | 20 +- .../__tests__/canvas.primitives.test.ts | 43 ++-- .../src/widgets/__tests__/collections.test.ts | 13 +- .../src/widgets/__tests__/containers.test.ts | 32 +-- .../widgets/__tests__/graphics.golden.test.ts | 2 +- .../src/widgets/__tests__/overlays.test.ts | 18 +- .../widgets/__tests__/overlays.typecheck.ts | 28 +-- .../__tests__/renderer.regressions.test.ts | 44 ++-- .../__tests__/style.attributes.test.ts | 6 +- .../__tests__/style.inheritance.test.ts | 20 +- .../__tests__/style.merge-fuzz.test.ts | 2 +- .../src/widgets/__tests__/style.merge.test.ts | 16 +- .../src/widgets/__tests__/style.utils.test.ts | 20 +- .../src/widgets/__tests__/styleUtils.test.ts | 21 +- .../core/src/widgets/__tests__/styled.test.ts | 6 +- .../src/widgets/__tests__/table.typecheck.ts | 8 +- packages/core/src/widgets/canvas.ts | 2 +- packages/core/src/widgets/heatmap.ts | 2 +- packages/core/src/widgets/logsConsole.ts | 2 +- packages/core/src/widgets/toast.ts | 2 +- .../src/runtime/createInkRenderer.ts | 9 +- packages/ink-compat/src/runtime/render.ts | 41 ++-- scripts/guardrails.sh | 4 +- 66 files changed, 499 insertions(+), 358 deletions(-) diff --git a/packages/bench/src/profile-packed-style.ts b/packages/bench/src/profile-packed-style.ts index 42bd3b8e..1e182b18 100644 --- a/packages/bench/src/profile-packed-style.ts +++ b/packages/bench/src/profile-packed-style.ts @@ -5,7 +5,15 @@ * REZI_PERF=1 REZI_PERF_DETAIL=1 npx tsx src/profile-packed-style.ts */ -import { type TextStyle, type VNode, createApp, perfReset, perfSnapshot, rgb, ui } from "@rezi-ui/core"; +import { + type TextStyle, + type VNode, + createApp, + perfReset, + perfSnapshot, + rgb, + ui, +} from "@rezi-ui/core"; import { BenchBackend } from "./backends.js"; const ROWS = 1200; @@ -41,7 +49,9 @@ function makeRowStyle(index: number, tick: number): TextStyle { function packedStyleTree(tick: number): VNode { const rows: VNode[] = []; for (let i = 0; i < ROWS; i++) { - rows.push(ui.text(`row-${String(i).padStart(4, "0")} tick=${tick}`, { style: makeRowStyle(i, tick) })); + rows.push( + ui.text(`row-${String(i).padStart(4, "0")} tick=${tick}`, { style: makeRowStyle(i, tick) }), + ); } return ui.column({ p: 0, gap: 0 }, rows); } @@ -72,9 +82,9 @@ async function main() { const snap = perfSnapshot(); const counters = snap.counters; - const merges = counters["style_merges_performed"] ?? 0; - const styleObjects = counters["style_objects_created"] ?? 0; - const packRgbCalls = counters["packRgb_calls"] ?? 0; + const merges = counters.style_merges_performed ?? 0; + const styleObjects = counters.style_objects_created ?? 0; + const packRgbCalls = counters.packRgb_calls ?? 0; const renderAvgUs = ((snap.phases.render?.avg ?? 0) * 1000).toFixed(0); const drawlistAvgUs = ((snap.phases.drawlist_build?.avg ?? 0) * 1000).toFixed(0); const reusePct = merges > 0 ? (((merges - styleObjects) / merges) * 100).toFixed(2) : "0.00"; diff --git a/packages/core/src/animation/__tests__/interpolate.easing.test.ts b/packages/core/src/animation/__tests__/interpolate.easing.test.ts index 64477f0d..723db823 100644 --- a/packages/core/src/animation/__tests__/interpolate.easing.test.ts +++ b/packages/core/src/animation/__tests__/interpolate.easing.test.ts @@ -34,28 +34,34 @@ describe("animation/interpolate", () => { test("interpolateRgb interpolates channel values", () => { assert.equal( - interpolateRgb(((0 << 16) | (0 << 8) | 0), ((255 << 16) | (255 << 8) | 255), 0.5), - ((128 << 16) | (128 << 8) | 128), + interpolateRgb((0 << 16) | (0 << 8) | 0, (255 << 16) | (255 << 8) | 255, 0.5), + (128 << 16) | (128 << 8) | 128, ); }); test("interpolateRgb returns endpoints at t=0 and t=1", () => { - const from = ((3 << 16) | (40 << 8) | 200); - const to = ((250 << 16) | (100 << 8) | 0); + const from = (3 << 16) | (40 << 8) | 200; + const to = (250 << 16) | (100 << 8) | 0; assert.deepEqual(interpolateRgb(from, to, 0), from); assert.deepEqual(interpolateRgb(from, to, 1), to); }); test("interpolateRgb rounds channel interpolation to byte integers", () => { - assert.equal(interpolateRgb(((0 << 16) | (0 << 8) | 0), ((1 << 16) | (1 << 8) | 1), 0.5), ((1 << 16) | (1 << 8) | 1)); - assert.equal(interpolateRgb(((0 << 16) | (0 << 8) | 0), ((2 << 16) | (2 << 8) | 2), 0.5), ((1 << 16) | (1 << 8) | 1)); + assert.equal( + interpolateRgb((0 << 16) | (0 << 8) | 0, (1 << 16) | (1 << 8) | 1, 0.5), + (1 << 16) | (1 << 8) | 1, + ); + assert.equal( + interpolateRgb((0 << 16) | (0 << 8) | 0, (2 << 16) | (2 << 8) | 2, 0.5), + (1 << 16) | (1 << 8) | 1, + ); }); test("interpolateRgbArray returns the requested number of steps", () => { - const steps = interpolateRgbArray(((0 << 16) | (0 << 8) | 0), ((255 << 16) | (0 << 8) | 0), 4); + const steps = interpolateRgbArray((0 << 16) | (0 << 8) | 0, (255 << 16) | (0 << 8) | 0, 4); assert.equal(steps.length, 4); - assert.deepEqual(steps[0], ((0 << 16) | (0 << 8) | 0)); - assert.deepEqual(steps[3], ((255 << 16) | (0 << 8) | 0)); + assert.deepEqual(steps[0], (0 << 16) | (0 << 8) | 0); + assert.deepEqual(steps[3], (255 << 16) | (0 << 8) | 0); }); }); diff --git a/packages/core/src/animation/interpolate.ts b/packages/core/src/animation/interpolate.ts index cec939b0..36009264 100644 --- a/packages/core/src/animation/interpolate.ts +++ b/packages/core/src/animation/interpolate.ts @@ -2,7 +2,7 @@ * packages/core/src/animation/interpolate.ts — Primitive interpolation helpers. */ -import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; +import { type Rgb24, rgb, rgbB, rgbG, rgbR } from "../widgets/style.js"; /** Clamp a number into [0, 1]. */ export function clamp01(value: number): number { diff --git a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts index 7219e94c..dec65993 100644 --- a/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts +++ b/packages/core/src/app/__tests__/partialDrawlistEmission.test.ts @@ -1,9 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { RuntimeBackend } from "../../backend.js"; -import type { - DrawlistBuildResult, - DrawlistBuilder, -} from "../../drawlist/index.js"; +import type { DrawlistBuildResult, DrawlistBuilder } from "../../drawlist/index.js"; import type { CursorState } from "../../drawlist/index.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { TextStyle, VNode } from "../../index.js"; diff --git a/packages/core/src/drawlist/__tests__/builder.golden.test.ts b/packages/core/src/drawlist/__tests__/builder.golden.test.ts index 52a7e855..a96f182e 100644 --- a/packages/core/src/drawlist/__tests__/builder.golden.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.golden.test.ts @@ -80,7 +80,7 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { const expected = await load("fill_rect.bin"); const b = createDrawlistBuilder(); - b.fillRect(1, 2, 3, 4, { fg: ((0 << 16) | (255 << 8) | 0), bold: true, underline: true }); + b.fillRect(1, 2, 3, 4, { fg: (0 << 16) | (255 << 8) | 0, bold: true, underline: true }); const res = b.build(); assert.equal(res.ok, true); if (!res.ok) return; @@ -106,9 +106,9 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { const expected = await load("draw_text_interned.bin"); const b = createDrawlistBuilder(); - b.drawText(0, 0, "hello", { fg: ((255 << 16) | (255 << 8) | 255) }); + b.drawText(0, 0, "hello", { fg: (255 << 16) | (255 << 8) | 255 }); b.drawText(0, 1, "hello"); - b.drawText(0, 2, "world", { bg: ((0 << 16) | (0 << 8) | 255), inverse: true }); + b.drawText(0, 2, "world", { bg: (0 << 16) | (0 << 8) | 255, inverse: true }); const res = b.build(); assert.equal(res.ok, true); if (!res.ok) return; @@ -136,7 +136,7 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { const b = createDrawlistBuilder(); b.pushClip(0, 0, 10, 10); b.pushClip(1, 1, 8, 8); - b.fillRect(2, 2, 3, 4, { bg: ((255 << 16) | (0 << 8) | 0), inverse: true }); + b.fillRect(2, 2, 3, 4, { bg: (255 << 16) | (0 << 8) | 0, inverse: true }); b.popClip(); b.popClip(); const res = b.build(); diff --git a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts index 861074ac..9d7a232a 100644 --- a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts @@ -393,7 +393,7 @@ describe("DrawlistBuilder graphics/link commands", () => { text: "x", style: { underlineStyle: "dashed", - underlineColor: ((1 << 16) | (2 << 8) | 3), + underlineColor: (1 << 16) | (2 << 8) | 3, }, }, ]); diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts index 109e1fae..18ce3f2e 100644 --- a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -1,9 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { - ZRDL_MAGIC, - ZR_DRAWLIST_VERSION_V1, - createDrawlistBuilder, -} from "../../index.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; const INT32_MAX = 2147483647; @@ -211,8 +207,8 @@ describe("DrawlistBuilder round-trip binary readback", () => { const b = createDrawlistBuilder(); b.clear(); b.fillRect(1, 2, 3, 4, { - fg: ((0x11 << 16) | (0x22 << 8) | 0x33), - bg: ((0x44 << 16) | (0x55 << 8) | 0x66), + fg: (0x11 << 16) | (0x22 << 8) | 0x33, + bg: (0x44 << 16) | (0x55 << 8) | 0x66, bold: true, italic: true, }); @@ -246,8 +242,8 @@ describe("DrawlistBuilder round-trip binary readback", () => { test("v1 fillRect command readback preserves geometry and packed style", () => { const b = createDrawlistBuilder(); b.fillRect(-3, 9, 11, 13, { - fg: ((1 << 16) | (2 << 8) | 3), - bg: ((4 << 16) | (5 << 8) | 6), + fg: (1 << 16) | (2 << 8) | 3, + bg: (4 << 16) | (5 << 8) | 6, bold: true, underline: true, dim: true, @@ -280,8 +276,8 @@ describe("DrawlistBuilder round-trip binary readback", () => { test("v1 drawText command readback resolves string span and style fields", () => { const b = createDrawlistBuilder(); b.drawText(7, 9, "hello", { - fg: ((255 << 16) | (128 << 8) | 1), - bg: ((2 << 16) | (3 << 8) | 4), + fg: (255 << 16) | (128 << 8) | 1, + bg: (2 << 16) | (3 << 8) | 4, italic: true, inverse: true, }); @@ -506,7 +502,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { const b = createDrawlistBuilder(); b.clear(); b.pushClip(0, 0, 80, 24); - b.fillRect(1, 1, 5, 2, { bg: ((7 << 16) | (8 << 8) | 9), inverse: true }); + b.fillRect(1, 1, 5, 2, { bg: (7 << 16) | (8 << 8) | 9, inverse: true }); b.drawText(2, 2, "rt"); b.setCursor({ x: 2, y: 2, shape: 1, visible: true, blink: false }); b.popClip(); diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts index 8a2c3c47..c784a4a8 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -20,9 +20,7 @@ type BuilderOpts = Readonly<{ const FACTORIES: readonly Readonly<{ name: string; create(opts?: BuilderOpts): BuilderLike; -}>[] = [ - { name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }, -]; +}>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts index f8f4a6c5..cfb49e37 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -22,9 +22,7 @@ type BuilderOpts = Readonly<{ const FACTORIES: readonly Readonly<{ name: string; create(opts?: BuilderOpts): BuilderLike; -}>[] = [ - { name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }, -]; +}>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); diff --git a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts index daeef701..37bfbc07 100644 --- a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts @@ -1,10 +1,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { - type TextStyle, - createDrawlistBuilder, -} from "../../index.js"; -import { DRAW_TEXT_SIZE } from "../writers.gen.js"; +import { type TextStyle, createDrawlistBuilder } from "../../index.js"; import { DEFAULT_BASE_STYLE, mergeTextStyle } from "../../renderer/renderToDrawlist/textStyle.js"; +import { DRAW_TEXT_SIZE } from "../writers.gen.js"; function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -64,9 +61,7 @@ const BUILDERS: ReadonlyArray< name: "current"; create: typeof createDrawlistBuilder; }> -> = [ - { name: "current", create: createDrawlistBuilder }, -]; +> = [{ name: "current", create: createDrawlistBuilder }]; function singleAttrStyle(attr: AttrName): TextStyle { return { [attr]: true } as TextStyle; @@ -170,8 +165,8 @@ describe("drawlist style fg/bg and undefined fg/bg encoding", () => { for (const builder of BUILDERS) { test(`${builder.name} drawText encodes fg/bg with attrs`, () => { const encoded = encodeViaDrawText(builder.create, { - fg: ((10 << 16) | (20 << 8) | 30), - bg: ((40 << 16) | (50 << 8) | 60), + fg: (10 << 16) | (20 << 8) | 30, + bg: (40 << 16) | (50 << 8) | 60, bold: true, inverse: true, }); @@ -192,8 +187,8 @@ describe("drawlist style fg/bg and undefined fg/bg encoding", () => { test(`${builder.name} text-run encodes fg/bg with attrs`, () => { const encoded = encodeViaTextRun(builder.create, { - fg: ((1 << 16) | (2 << 8) | 3), - bg: ((4 << 16) | (5 << 8) | 6), + fg: (1 << 16) | (2 << 8) | 3, + bg: (4 << 16) | (5 << 8) | 6, dim: true, blink: true, }); @@ -259,12 +254,8 @@ describe("style merge stress encodes fg/bg/attrs bytes deterministically", () => let resolved = DEFAULT_BASE_STYLE; for (let i = 0; i < 192; i++) { const override: TextStyle = { - ...(i % 3 === 0 - ? { fg: packRgb((i * 17) & 0xff, (i * 29) & 0xff, (i * 43) & 0xff) } - : {}), - ...(i % 5 === 0 - ? { bg: packRgb((i * 11) & 0xff, (i * 7) & 0xff, (i * 13) & 0xff) } - : {}), + ...(i % 3 === 0 ? { fg: packRgb((i * 17) & 0xff, (i * 29) & 0xff, (i * 43) & 0xff) } : {}), + ...(i % 5 === 0 ? { bg: packRgb((i * 11) & 0xff, (i * 7) & 0xff, (i * 13) & 0xff) } : {}), ...(i % 2 === 0 ? { bold: true } : {}), ...(i % 4 === 0 ? { italic: true } : {}), ...(i % 6 === 0 ? { underline: true } : {}), diff --git a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts index fc7805e6..37b9f08b 100644 --- a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts @@ -21,8 +21,8 @@ describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ - { text: "ABC", style: { fg: ((255 << 16) | (0 << 8) | 0), bold: true } }, - { text: "DEF", style: { fg: ((0 << 16) | (255 << 8) | 0), underline: true } }, + { text: "ABC", style: { fg: (255 << 16) | (0 << 8) | 0, bold: true } }, + { text: "DEF", style: { fg: (0 << 16) | (255 << 8) | 0, underline: true } }, ]); assert.equal(blobIndex, 0); if (blobIndex === null) return; diff --git a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts index 450904c0..712dd74b 100644 --- a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts @@ -1,7 +1,9 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { createDrawlistBuilder } from "../../index.js"; -function expectOk(result: ReturnType["build"]>): Uint8Array { +function expectOk( + result: ReturnType["build"]>, +): Uint8Array { assert.equal(result.ok, true); if (!result.ok) throw new Error("drawlist build failed"); return result.bytes; diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index 3f873cda..d7f87ad1 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -3,8 +3,8 @@ import type { TextStyle } from "../widgets/style.js"; import { DrawlistBuilderBase, type DrawlistBuilderBaseOpts } from "./builderBase.js"; import type { CursorState, - DrawlistBuilder, DrawlistBuildResult, + DrawlistBuilder, DrawlistCanvasBlitter, DrawlistImageFit, DrawlistImageFormat, diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index 9ceb5c19..39d41ef4 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -434,7 +434,7 @@ describe("layout edge cases", () => { kind: "layer", props: { id: "layer-border-inset", - frameStyle: { border: ((120 << 16) | (121 << 8) | 122) }, + frameStyle: { border: (120 << 16) | (121 << 8) | 122 }, content: { kind: "text", text: "edge", props: {} }, }, }; diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index 9c6f3888..a6cfbe6d 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -144,7 +144,8 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo if (__layoutProfile.enabled) { __layoutProfile.measureNodeCalls++; - __layoutProfile.measureByKind[vnode.kind] = (__layoutProfile.measureByKind[vnode.kind] ?? 0) + 1; + __layoutProfile.measureByKind[vnode.kind] = + (__layoutProfile.measureByKind[vnode.kind] ?? 0) + 1; } const cache = activeMeasureCache; diff --git a/packages/core/src/renderer/__tests__/focusIndicators.test.ts b/packages/core/src/renderer/__tests__/focusIndicators.test.ts index 85772468..0146bd6e 100644 --- a/packages/core/src/renderer/__tests__/focusIndicators.test.ts +++ b/packages/core/src/renderer/__tests__/focusIndicators.test.ts @@ -339,7 +339,7 @@ describe("focus indicator rendering contracts", () => { }); test("FocusConfig.style overrides design-system focus defaults", () => { - const customRing = Object.freeze(((255 << 16) | (0 << 8) | 128)); + const customRing = Object.freeze((255 << 16) | (0 << 8) | 128); const theme = coerceToLegacyTheme(darkTheme); const textOps = drawTextOps( renderOps( diff --git a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts index 581be92c..809063bf 100644 --- a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts +++ b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts @@ -1,12 +1,6 @@ import { assert } from "@rezi-ui/testkit"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import type { - DrawlistBuildResult, - DrawlistBuilder, - TextStyle, - Theme, - VNode, -} from "../../index.js"; +import type { DrawlistBuildResult, DrawlistBuilder, TextStyle, Theme, VNode } from "../../index.js"; import { layout } from "../../layout/layout.js"; import type { Axis } from "../../layout/types.js"; import { commitVNodeTree } from "../../runtime/commit.js"; diff --git a/packages/core/src/renderer/__tests__/renderer.border.test.ts b/packages/core/src/renderer/__tests__/renderer.border.test.ts index 62070128..57010cfa 100644 --- a/packages/core/src/renderer/__tests__/renderer.border.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.border.test.ts @@ -492,9 +492,9 @@ describe("renderer border rendering (deterministic)", () => { }); test("box opacity blends merged own style against parent backdrop", () => { - const backdrop = ((32 << 16) | (48 << 8) | 64); - const childFg = ((250 << 16) | (180 << 8) | 110); - const childBg = ((10 << 16) | (20 << 8) | 30); + const backdrop = (32 << 16) | (48 << 8) | 64; + const childFg = (250 << 16) | (180 << 8) | 110; + const childBg = (10 << 16) | (20 << 8) | 30; const ops = renderOps( ui.column({ id: "root", style: { bg: backdrop } }, [ ui.box( @@ -534,7 +534,7 @@ describe("renderer border rendering (deterministic)", () => { }); test("button pressedStyle is applied when pressedId matches", () => { - const pressedFg = Object.freeze(((255 << 16) | (64 << 8) | 64)); + const pressedFg = Object.freeze((255 << 16) | (64 << 8) | 64); const ops = renderOps( ui.button({ id: "btn", diff --git a/packages/core/src/renderer/__tests__/renderer.damage.test.ts b/packages/core/src/renderer/__tests__/renderer.damage.test.ts index 9b167b8c..5ae4460c 100644 --- a/packages/core/src/renderer/__tests__/renderer.damage.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.damage.test.ts @@ -483,7 +483,7 @@ describe("renderer damage rect behavior", () => { height: 3, border: "none", shadow: true, - style: { bg: ((30 << 16) | (30 << 8) | 30) }, + style: { bg: (30 << 16) | (30 << 8) | 30 }, }), viewport, ); @@ -492,7 +492,7 @@ describe("renderer damage rect behavior", () => { width: 6, height: 3, border: "none", - style: { bg: ((30 << 16) | (30 << 8) | 30) }, + style: { bg: (30 << 16) | (30 << 8) | 30 }, }), viewport, ); @@ -511,7 +511,7 @@ describe("renderer damage rect behavior", () => { height: 2, border: "none", shadow: true, - style: { bg: ((20 << 16) | (20 << 8) | 20) }, + style: { bg: (20 << 16) | (20 << 8) | 20 }, }), ]), viewport, @@ -523,7 +523,7 @@ describe("renderer damage rect behavior", () => { height: 2, border: "none", shadow: true, - style: { bg: ((20 << 16) | (20 << 8) | 20) }, + style: { bg: (20 << 16) | (20 << 8) | 20 }, }), ]), viewport, diff --git a/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts b/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts index 45d57201..97e7d14a 100644 --- a/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.scrollbar.test.ts @@ -505,7 +505,7 @@ describe("renderer scroll container integration", () => { test("scrollbarStyle overrides rendered scrollbar draw style", () => { const lines: VNode[] = []; for (let i = 0; i < 6; i++) lines.push(ui.text("abcd")); - const customFg = Object.freeze(((1 << 16) | (2 << 8) | 3)); + const customFg = Object.freeze((1 << 16) | (2 << 8) | 3); const vnode = ui.box( { width: 6, diff --git a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts index 5e448470..e7a60cc3 100644 --- a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts @@ -15,10 +15,7 @@ const data: readonly Row[] = [ { id: "r1", name: "Beta" }, ]; -function rgbEquals( - actual: unknown, - expected: number | undefined, -): boolean { +function rgbEquals(actual: unknown, expected: number | undefined): boolean { return typeof actual === "number" && expected !== undefined && actual === expected; } diff --git a/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts b/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts index 2be1a699..2cd2221b 100644 --- a/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts +++ b/packages/core/src/renderer/__tests__/textStyle.opacity.test.ts @@ -9,8 +9,8 @@ import { describe("renderer/textStyle opacity blending", () => { test("opacity >= 1 returns the original style reference", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: ((255 << 16) | (0 << 8) | 0), - bg: ((0 << 16) | (0 << 8) | 80), + fg: (255 << 16) | (0 << 8) | 0, + bg: (0 << 16) | (0 << 8) | 80, bold: true, }); assert.equal(applyOpacityToStyle(style, 1), style); @@ -19,8 +19,8 @@ describe("renderer/textStyle opacity blending", () => { test("opacity <= 0 collapses fg/bg to base background", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: ((240 << 16) | (120 << 8) | 60), - bg: ((30 << 16) | (40 << 8) | 50), + fg: (240 << 16) | (120 << 8) | 60, + bg: (30 << 16) | (40 << 8) | 50, italic: true, }); @@ -32,8 +32,8 @@ describe("renderer/textStyle opacity blending", () => { test("blends channels with rounding and preserves non-color attrs", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: ((107 << 16) | (203 << 8) | 31), - bg: ((90 << 16) | (40 << 8) | 200), + fg: (107 << 16) | (203 << 8) | 31, + bg: (90 << 16) | (40 << 8) | 200, underline: true, }); @@ -45,18 +45,18 @@ describe("renderer/textStyle opacity blending", () => { test("non-finite opacity is treated as fully opaque", () => { const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: ((200 << 16) | (100 << 8) | 90), - bg: ((10 << 16) | (40 << 8) | 80), + fg: (200 << 16) | (100 << 8) | 90, + bg: (10 << 16) | (40 << 8) | 80, }); assert.equal(applyOpacityToStyle(style, Number.NaN), style); assert.equal(applyOpacityToStyle(style, Number.NEGATIVE_INFINITY), style); }); test("blends against custom backdrop when provided", () => { - const backdrop = ((90 << 16) | (100 << 8) | 110); + const backdrop = (90 << 16) | (100 << 8) | 110; const style = mergeTextStyle(DEFAULT_BASE_STYLE, { - fg: ((240 << 16) | (80 << 8) | 20), - bg: ((10 << 16) | (30 << 8) | 50), + fg: (240 << 16) | (80 << 8) | 20, + bg: (10 << 16) | (30 << 8) | 50, }); const hidden = applyOpacityToStyle(style, 0, backdrop); diff --git a/packages/core/src/renderer/renderToDrawlist/textStyle.ts b/packages/core/src/renderer/renderToDrawlist/textStyle.ts index b3f55d2b..027b5079 100644 --- a/packages/core/src/renderer/renderToDrawlist/textStyle.ts +++ b/packages/core/src/renderer/renderToDrawlist/textStyle.ts @@ -1,5 +1,5 @@ import { perfCount } from "../../perf/perf.js"; -import { rgb, rgbBlend, type TextStyle } from "../../widgets/style.js"; +import { type TextStyle, rgb, rgbBlend } from "../../widgets/style.js"; import { sanitizeTextStyle } from "../../widgets/styleUtils.js"; const ATTR_BOLD = 1 << 0; diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts index b6e6ac47..292d3b44 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts @@ -5,13 +5,13 @@ import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { Theme } from "../../../theme/theme.js"; import { tokenizeCodeEditorLineWithCustom } from "../../../widgets/codeEditorSyntax.js"; -import type { Rgb24 } from "../../../widgets/style.js"; import { formatCost, formatDuration, formatTimestamp, formatTokenCount, } from "../../../widgets/logsConsole.js"; +import type { Rgb24 } from "../../../widgets/style.js"; import type { CodeEditorLineTokenizer, CodeEditorProps, @@ -60,11 +60,7 @@ function logLevelToThemeColor(theme: Theme, level: LogsConsoleProps["entries"][n type CodeEditorSyntaxStyleMap = Readonly>; -function resolveSyntaxThemeColor( - theme: Theme, - key: string, - fallback: Rgb24, -) { +function resolveSyntaxThemeColor(theme: Theme, key: string, fallback: Rgb24) { return theme.colors[key] ?? fallback; } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts index 677b2a12..1c2bbd53 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts @@ -4,7 +4,6 @@ import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { TerminalProfile } from "../../../terminalProfile.js"; import type { Theme } from "../../../theme/theme.js"; import { resolveColor } from "../../../theme/theme.js"; -import { rgbB, rgbG, rgbR } from "../../../widgets/style.js"; import { createCanvasDrawingSurface, resolveCanvasBlitter } from "../../../widgets/canvas.js"; import { type ImageBinaryFormat, @@ -14,6 +13,7 @@ import { normalizeImageFit, normalizeImageProtocol, } from "../../../widgets/image.js"; +import { rgbB, rgbG, rgbR } from "../../../widgets/style.js"; import type { GraphicsBlitter } from "../../../widgets/types.js"; import { isVisibleRect } from "../indices.js"; import type { ResolvedTextStyle } from "../textStyle.js"; diff --git a/packages/core/src/renderer/shadow.ts b/packages/core/src/renderer/shadow.ts index 6ddc51da..633af97a 100644 --- a/packages/core/src/renderer/shadow.ts +++ b/packages/core/src/renderer/shadow.ts @@ -11,7 +11,7 @@ import type { DrawlistBuilder } from "../drawlist/types.js"; import type { Rect } from "../layout/types.js"; -import { rgb, type Rgb24 } from "../widgets/style.js"; +import { type Rgb24, rgb } from "../widgets/style.js"; import type { ResolvedTextStyle } from "./renderToDrawlist/textStyle.js"; /** Shadow characters for different densities. */ diff --git a/packages/core/src/renderer/styles.ts b/packages/core/src/renderer/styles.ts index f2e4c05d..76b108a3 100644 --- a/packages/core/src/renderer/styles.ts +++ b/packages/core/src/renderer/styles.ts @@ -3,7 +3,7 @@ */ import { type Theme, resolveColor } from "../theme/theme.js"; -import { rgb, type TextStyle } from "../widgets/style.js"; +import { type TextStyle, rgb } from "../widgets/style.js"; /** Disabled widget foreground color (gray). */ const DISABLED_FG = rgb(128, 128, 128); diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts index c7a7a758..cf935228 100644 --- a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -73,17 +73,17 @@ test("commit: container fast reuse does not ignore parent prop changes", () => { test("commit: container fast reuse does not ignore inheritStyle changes", () => { const allocator = createInstanceIdAllocator(1); - const v0 = ui.column({ inheritStyle: { fg: ((136 << 16) | (136 << 8) | 136) } }, [ui.text("x")]); + const v0 = ui.column({ inheritStyle: { fg: (136 << 16) | (136 << 8) | 136 } }, [ui.text("x")]); const c0 = commitVNodeTree(null, v0, { allocator }); if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); - const v1 = ui.column({ inheritStyle: { fg: ((0 << 16) | (255 << 8) | 0) } }, [ui.text("x")]); + const v1 = ui.column({ inheritStyle: { fg: (0 << 16) | (255 << 8) | 0 } }, [ui.text("x")]); const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); assert.notEqual(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { inheritStyle?: { fg?: unknown } }; - assert.deepEqual(nextProps.inheritStyle?.fg, ((0 << 16) | (255 << 8) | 0)); + assert.deepEqual(nextProps.inheritStyle?.fg, (0 << 16) | (255 << 8) | 0); assert.equal(c1.value.root.children[0], c0.value.root.children[0]); }); diff --git a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts index dfacff8b..455e4556 100644 --- a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts +++ b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts @@ -97,7 +97,7 @@ describe("runtime hooks - useTheme", () => { extendTheme(darkTheme, { colors: { accent: { - primary: ((250 << 16) | (20 << 8) | 20), + primary: (250 << 16) | (20 << 8) | 20, }, }, }), @@ -125,7 +125,7 @@ describe("runtime hooks - useTheme", () => { test("resolves scoped themed overrides for composites", () => { const baseTheme = coerceToLegacyTheme(darkTheme); const override = Object.freeze({ - colors: { accent: { primary: ((18 << 16) | (164 << 8) | 245) } }, + colors: { accent: { primary: (18 << 16) | (164 << 8) | 245 } }, }); const scopedTheme = coerceToLegacyTheme(extendTheme(darkTheme, override)); const expected = requireColorTokens(getColorTokens(scopedTheme)); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index d5d69478..7d8cc571 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -222,8 +222,9 @@ function leafVNodeEqual(a: VNode, b: VNode): boolean { if (as === bs) return true; if (!as || !bs || as.length !== bs.length) return false; for (let i = 0; i < as.length; i++) { - const sa = as[i]!; - const sb = bs[i]!; + const sa = as[i]; + const sb = bs[i]; + if (!sa || !sb) return false; if (sa.text !== sb.text) return false; if ( !textStyleEqual( @@ -559,29 +560,95 @@ function canFastReuseContainerSelf(prev: VNode, next: VNode): boolean { */ function diagWhichPropFails(prev: VNode, next: VNode): string | undefined { if (prev.kind !== next.kind) return "kind"; - const ap = (prev.props ?? {}) as Record; - const bp = (next.props ?? {}) as Record; + type ReuseDiagProps = { + style?: unknown; + inheritStyle?: unknown; + [key: string]: unknown; + }; + const ap = (prev.props ?? {}) as ReuseDiagProps; + const bp = (next.props ?? {}) as ReuseDiagProps; if (prev.kind === "row" || prev.kind === "column") { for (const k of ["pad", "gap", "align", "justify", "items"] as const) { if (ap[k] !== bp[k]) return k; } - if (!textStyleEqual(ap["style"] as Parameters[0], bp["style"] as Parameters[0])) return "style"; - if (!textStyleEqual(ap["inheritStyle"] as Parameters[0], bp["inheritStyle"] as Parameters[0])) return "inheritStyle"; + if ( + !textStyleEqual( + ap.style as Parameters[0], + bp.style as Parameters[0], + ) + ) + return "style"; + if ( + !textStyleEqual( + ap.inheritStyle as Parameters[0], + bp.inheritStyle as Parameters[0], + ) + ) + return "inheritStyle"; // layout constraints - for (const k of ["width", "height", "minWidth", "maxWidth", "minHeight", "maxHeight", "flex", "aspectRatio"] as const) { + for (const k of [ + "width", + "height", + "minWidth", + "maxWidth", + "minHeight", + "maxHeight", + "flex", + "aspectRatio", + ] as const) { if (ap[k] !== bp[k]) return k; } // spacing - for (const k of ["p", "px", "py", "pt", "pb", "pl", "pr", "m", "mx", "my", "mt", "mr", "mb", "ml"] as const) { + for (const k of [ + "p", + "px", + "py", + "pt", + "pb", + "pl", + "pr", + "m", + "mx", + "my", + "mt", + "mr", + "mb", + "ml", + ] as const) { if (ap[k] !== bp[k]) return k; } } if (prev.kind === "box") { - for (const k of ["title", "titleAlign", "pad", "border", "borderTop", "borderRight", "borderBottom", "borderLeft", "opacity"] as const) { + for (const k of [ + "title", + "titleAlign", + "pad", + "border", + "borderTop", + "borderRight", + "borderBottom", + "borderLeft", + "opacity", + ] as const) { if (ap[k] !== bp[k]) return k; } - if (!textStyleEqual(ap["style"] as Parameters[0], bp["style"] as Parameters[0])) return "style"; - for (const k of ["width", "height", "minWidth", "maxWidth", "minHeight", "maxHeight", "flex", "aspectRatio"] as const) { + if ( + !textStyleEqual( + ap.style as Parameters[0], + bp.style as Parameters[0], + ) + ) + return "style"; + for (const k of [ + "width", + "height", + "minWidth", + "maxWidth", + "minHeight", + "maxHeight", + "flex", + "aspectRatio", + ] as const) { if (ap[k] !== bp[k]) return k; } } @@ -1269,8 +1336,12 @@ function commitContainer( // with dirty=true even though it returned the same reference. if (__commitDiag.enabled) { const wasDirty = prev.selfDirty; - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "fast-reuse", - detail: wasDirty ? "was-dirty" : undefined }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "fast-reuse", + detail: wasDirty ? "was-dirty" : undefined, + }); } prev.selfDirty = false; prev.dirty = hasDirtyChild(prev.children); @@ -1293,7 +1364,8 @@ function commitContainer( if (prevChildren[ci] !== (nextChildren as readonly RuntimeInstance[])[ci]) childDiffs++; } __commitDiag.push({ - id: instanceId as number, kind: vnode.kind, + id: instanceId as number, + kind: vnode.kind, reason: "fast-reuse", detail: "children-changed" as "was-dirty" | undefined, childDiffs, @@ -1314,25 +1386,38 @@ function commitContainer( // children are different — but WHY? count how many differ let childDiffs = 0; for (let ci = 0; ci < prevChildren.length; ci++) { - if (nextChildren && prevChildren[ci] !== (nextChildren as readonly RuntimeInstance[])[ci]) childDiffs++; + if ( + nextChildren && + prevChildren[ci] !== (nextChildren as readonly RuntimeInstance[])[ci] + ) + childDiffs++; } // also check if props would have passed const propsOk = canFastReuseContainerSelf(prev.vnode, vnode); __commitDiag.push({ - id: instanceId as number, kind: vnode.kind, + id: instanceId as number, + kind: vnode.kind, reason: "new-instance", detail: propsOk ? "children-changed" : "props+children", failingProp: propsOk ? undefined : diagWhichPropFails(prev.vnode, vnode), childDiffs, prevChildren: prevChildren.length, - nextChildren: nextChildren ? (nextChildren as readonly RuntimeInstance[]).length : res.value.nextChildren.length, + nextChildren: nextChildren + ? (nextChildren as readonly RuntimeInstance[]).length + : res.value.nextChildren.length, }); } else if (!childOrderStable) { - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "children-changed" }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "new-instance", + detail: "children-changed", + }); } else { // allChildrenSame && childOrderStable but canFastReuseContainerSelf failed __commitDiag.push({ - id: instanceId as number, kind: vnode.kind, + id: instanceId as number, + kind: vnode.kind, reason: "new-instance", detail: "props-changed", failingProp: diagWhichPropFails(prev.vnode, vnode), @@ -1387,6 +1472,15 @@ function commitContainer( nextChildren = reorderedChildren; committedChildVNodes = reorderedVNodes; } + if (!committedChildVNodes) { + return { + ok: false, + fatal: { + code: "ZRUI_INVALID_PROPS", + detail: "commit invariant violated: missing committed child VNodes", + }, + }; + } const propsChanged = prev === null || !canFastReuseContainerSelf(prev.vnode, vnode); const childrenChanged = prev === null || runtimeChildrenChanged(prevChildren, nextChildren); @@ -1401,19 +1495,29 @@ function commitContainer( } cDiffs += Math.abs(prevChildren.length - nextChildren.length); __commitDiag.push({ - id: instanceId as number, kind: vnode.kind, + id: instanceId as number, + kind: vnode.kind, reason: "new-instance", - detail: propsChanged && childrenChanged ? "props+children" - : propsChanged ? "props-changed" - : childrenChanged ? "children-changed" - : "general-path", + detail: + propsChanged && childrenChanged + ? "props+children" + : propsChanged + ? "props-changed" + : childrenChanged + ? "children-changed" + : "general-path", failingProp: propsChanged ? diagWhichPropFails(prev.vnode, vnode) : undefined, childDiffs: cDiffs, prevChildren: prevChildren.length, nextChildren: nextChildren.length, }); } else if (__commitDiag.enabled && prev === null) { - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "no-prev" }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "new-instance", + detail: "no-prev", + }); } // In-place mutation: when props are unchanged and only children references @@ -1421,7 +1525,7 @@ function commitContainer( // This prevents parent containers from cascading new-instance creation. if (prev !== null && !propsChanged && childrenChanged) { (prev as { children: readonly RuntimeInstance[] }).children = nextChildren; - (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, committedChildVNodes!); + (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, committedChildVNodes); prev.selfDirty = true; prev.dirty = true; return { ok: true, value: { root: prev } }; @@ -1677,10 +1781,16 @@ function commitNode( } // Temporary debug: trace commit matching (remove after investigation) - if ((globalThis as Record)["__commitDebug"]) { - const debugLog = (globalThis as Record)["__commitDebugLog"] as string[] | undefined; + const commitDebug = globalThis as Record & { + __commitDebug?: unknown; + __commitDebugLog?: string[] | undefined; + }; + if (commitDebug.__commitDebug) { + const debugLog = commitDebug.__commitDebugLog; if (debugLog) { - debugLog.push(`commitNode(${String(instanceId)}, ${vnode.kind}, prev=${prev ? `${prev.vnode.kind}:${String(prev.instanceId)}` : "null"})`); + debugLog.push( + `commitNode(${String(instanceId)}, ${vnode.kind}, prev=${prev ? `${prev.vnode.kind}:${String(prev.instanceId)}` : "null"})`, + ); } } @@ -1690,8 +1800,12 @@ function commitNode( if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { if (__commitDiag.enabled) { const wasDirty = prev.selfDirty; - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "leaf-reuse", - detail: wasDirty ? "was-dirty" : undefined }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "leaf-reuse", + detail: wasDirty ? "was-dirty" : undefined, + }); } if (ctx.collectLifecycleInstanceIds) ctx.lists.reused.push(instanceId); prev.dirty = false; @@ -1701,9 +1815,19 @@ function commitNode( // Diagnostic: leaf not reused if (__commitDiag.enabled && prev && !isContainerVNode(vnode)) { if (prev.vnode.kind !== vnode.kind) { - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "leaf-kind-mismatch" }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "new-instance", + detail: "leaf-kind-mismatch", + }); } else { - __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-instance", detail: "leaf-content-changed" }); + __commitDiag.push({ + id: instanceId as number, + kind: vnode.kind, + reason: "new-instance", + detail: "leaf-content-changed", + }); } } @@ -1772,7 +1896,8 @@ function commitNode( if (prev) ctx.lists.reused.push(instanceId); else { ctx.lists.mounted.push(instanceId); - if (__commitDiag.enabled) __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-mount" }); + if (__commitDiag.enabled) + __commitDiag.push({ id: instanceId as number, kind: vnode.kind, reason: "new-mount" }); } } diff --git a/packages/core/src/theme/__tests__/theme.contrast.test.ts b/packages/core/src/theme/__tests__/theme.contrast.test.ts index 96e676c1..6aa72f3c 100644 --- a/packages/core/src/theme/__tests__/theme.contrast.test.ts +++ b/packages/core/src/theme/__tests__/theme.contrast.test.ts @@ -31,41 +31,41 @@ function formatRatio(ratio: number): string { describe("theme contrast", () => { test("white on white ratio is 1", () => { - const white = ((255 << 16) | (255 << 8) | 255); + const white = (255 << 16) | (255 << 8) | 255; assert.equal(contrastRatio(white, white), 1); }); test("black on black ratio is 1", () => { - const black = ((0 << 16) | (0 << 8) | 0); + const black = (0 << 16) | (0 << 8) | 0; assert.equal(contrastRatio(black, black), 1); }); test("black on white ratio is 21", () => { - const black = ((0 << 16) | (0 << 8) | 0); - const white = ((255 << 16) | (255 << 8) | 255); + const black = (0 << 16) | (0 << 8) | 0; + const white = (255 << 16) | (255 << 8) | 255; assert.equal(contrastRatio(black, white), 21); }); test("contrast ratio is symmetric for fg and bg order", () => { - const fg = ((12 << 16) | (90 << 8) | 200); - const bg = ((230 << 16) | (180 << 8) | 40); + const fg = (12 << 16) | (90 << 8) | 200; + const bg = (230 << 16) | (180 << 8) | 40; assert.equal(contrastRatio(fg, bg), contrastRatio(bg, fg)); }); test("known failing pair (#777777 on #ffffff) is below AA", () => { - const ratio = contrastRatio(((119 << 16) | (119 << 8) | 119), ((255 << 16) | (255 << 8) | 255)); + const ratio = contrastRatio((119 << 16) | (119 << 8) | 119, (255 << 16) | (255 << 8) | 255); assertApprox(ratio, 4.478089453577214, 1e-12, "known failing pair ratio"); assert.ok(ratio < AA_MIN, `expected < ${AA_MIN}, got ${formatRatio(ratio)}`); }); test("near-threshold passing pair (#767676 on #ffffff) meets AA", () => { - const ratio = contrastRatio(((118 << 16) | (118 << 8) | 118), ((255 << 16) | (255 << 8) | 255)); + const ratio = contrastRatio((118 << 16) | (118 << 8) | 118, (255 << 16) | (255 << 8) | 255); assertApprox(ratio, 4.542224959605253, 1e-12, "near-threshold passing pair ratio"); assert.ok(ratio >= AA_MIN, `expected >= ${AA_MIN}, got ${formatRatio(ratio)}`); }); test("mid-gray pair (#595959 on #ffffff) has expected ratio", () => { - const ratio = contrastRatio(((89 << 16) | (89 << 8) | 89), ((255 << 16) | (255 << 8) | 255)); + const ratio = contrastRatio((89 << 16) | (89 << 8) | 89, (255 << 16) | (255 << 8) | 255); assertApprox(ratio, 7.004729208035935, 1e-12, "mid-gray pair ratio"); }); diff --git a/packages/core/src/theme/__tests__/theme.extend.test.ts b/packages/core/src/theme/__tests__/theme.extend.test.ts index 3c2e4a10..907cc2a4 100644 --- a/packages/core/src/theme/__tests__/theme.extend.test.ts +++ b/packages/core/src/theme/__tests__/theme.extend.test.ts @@ -19,7 +19,7 @@ describe("theme.extend", () => { }, }); - assert.deepEqual(next.colors.accent.primary, ((1 << 16) | (2 << 8) | 3)); + assert.deepEqual(next.colors.accent.primary, (1 << 16) | (2 << 8) | 3); assert.deepEqual(next.colors.accent.secondary, darkTheme.colors.accent.secondary); assert.deepEqual(next.colors.bg.base, darkTheme.colors.bg.base); }); @@ -159,8 +159,8 @@ describe("theme.extend", () => { }, }); - assert.deepEqual(two.colors.fg.primary, ((11 << 16) | (22 << 8) | 33)); - assert.deepEqual(two.colors.border.strong, ((44 << 16) | (55 << 8) | 66)); + assert.deepEqual(two.colors.fg.primary, (11 << 16) | (22 << 8) | 33); + assert.deepEqual(two.colors.border.strong, (44 << 16) | (55 << 8) | 66); assert.deepEqual(two.colors.accent.secondary, darkTheme.colors.accent.secondary); }); @@ -180,7 +180,7 @@ describe("theme.extend", () => { }, }); - assert.deepEqual(two.colors.accent.primary, ((99 << 16) | (20 << 8) | 30)); + assert.deepEqual(two.colors.accent.primary, (99 << 16) | (20 << 8) | 30); }); test("nested extensions keep inherited tokens unless overridden", () => { @@ -194,7 +194,7 @@ describe("theme.extend", () => { }); assert.equal(two.name, "custom-1"); - assert.deepEqual(two.colors.info, ((1 << 16) | (1 << 8) | 1)); + assert.deepEqual(two.colors.info, (1 << 16) | (1 << 8) | 1); assert.deepEqual(two.colors.warning, darkTheme.colors.warning); }); diff --git a/packages/core/src/theme/__tests__/theme.interop.test.ts b/packages/core/src/theme/__tests__/theme.interop.test.ts index 6d502f5d..cecee3af 100644 --- a/packages/core/src/theme/__tests__/theme.interop.test.ts +++ b/packages/core/src/theme/__tests__/theme.interop.test.ts @@ -68,7 +68,7 @@ describe("theme.interop spacing", () => { extendTheme(darkTheme, { colors: { accent: { - primary: ((1 << 16) | (2 << 8) | 3), + primary: (1 << 16) | (2 << 8) | 3, }, }, }), @@ -83,30 +83,30 @@ describe("theme.interop spacing", () => { const semanticTheme = extendTheme(darkTheme, { colors: { diagnostic: { - warning: ((1 << 16) | (2 << 8) | 3), + warning: (1 << 16) | (2 << 8) | 3, }, }, }); const legacyTheme = coerceToLegacyTheme(semanticTheme); - assert.deepEqual(legacyTheme.colors["diagnostic.warning"], ((1 << 16) | (2 << 8) | 3)); + assert.deepEqual(legacyTheme.colors["diagnostic.warning"], (1 << 16) | (2 << 8) | 3); }); test("mergeThemeOverride accepts nested legacy diagnostic overrides", () => { const parentTheme = createTheme({ colors: { - "diagnostic.error": ((9 << 16) | (9 << 8) | 9), + "diagnostic.error": (9 << 16) | (9 << 8) | 9, }, }); const merged = mergeThemeOverride(parentTheme, { colors: { diagnostic: { - error: ((7 << 16) | (8 << 8) | 9), + error: (7 << 16) | (8 << 8) | 9, }, }, }); - assert.deepEqual(merged.colors["diagnostic.error"], ((7 << 16) | (8 << 8) | 9)); + assert.deepEqual(merged.colors["diagnostic.error"], (7 << 16) | (8 << 8) | 9); }); }); diff --git a/packages/core/src/theme/__tests__/theme.resolution.test.ts b/packages/core/src/theme/__tests__/theme.resolution.test.ts index 827a1dcf..417008de 100644 --- a/packages/core/src/theme/__tests__/theme.resolution.test.ts +++ b/packages/core/src/theme/__tests__/theme.resolution.test.ts @@ -92,18 +92,18 @@ describe("theme resolution", () => { }); test("resolveColorOrRgb returns direct RGB unchanged", () => { - const rgb = ((1 << 16) | (2 << 8) | 3); - const fallback = ((9 << 16) | (9 << 8) | 9); + const rgb = (1 << 16) | (2 << 8) | 3; + const fallback = (9 << 16) | (9 << 8) | 9; assert.deepEqual(resolveColorOrRgb(themePresets.dark, rgb, fallback), rgb); }); test("resolveColorOrRgb uses fallback for invalid token paths", () => { - const fallback = ((9 << 16) | (8 << 8) | 7); + const fallback = (9 << 16) | (8 << 8) | 7; assert.deepEqual(resolveColorOrRgb(themePresets.dark, "not.valid", fallback), fallback); }); test("resolveColorOrRgb uses fallback for undefined input", () => { - const fallback = ((5 << 16) | (4 << 8) | 3); + const fallback = (5 << 16) | (4 << 8) | 3; assert.deepEqual(resolveColorOrRgb(themePresets.dark, undefined, fallback), fallback); }); diff --git a/packages/core/src/theme/__tests__/theme.switch.test.ts b/packages/core/src/theme/__tests__/theme.switch.test.ts index 6b844c21..76d949c4 100644 --- a/packages/core/src/theme/__tests__/theme.switch.test.ts +++ b/packages/core/src/theme/__tests__/theme.switch.test.ts @@ -49,7 +49,7 @@ async function resolveNextFrame(backend: StubBackend): Promise { function themeWithPrimary(r: number, g: number, b: number): Theme { return createTheme({ colors: { - primary: ((r << 16) | (g << 8) | b), + primary: (r << 16) | (g << 8) | b, }, }); } @@ -353,10 +353,10 @@ describe("theme runtime switching", () => { }); describe("theme scoped container overrides", () => { - const RED = Object.freeze(((210 << 16) | (40 << 8) | 40)); - const GREEN = Object.freeze(((40 << 16) | (190 << 8) | 80)); - const BLUE = Object.freeze(((40 << 16) | (100 << 8) | 210)); - const CYAN = Object.freeze(((20 << 16) | (180 << 8) | 200)); + const RED = Object.freeze((210 << 16) | (40 << 8) | 40); + const GREEN = Object.freeze((40 << 16) | (190 << 8) | 80); + const BLUE = Object.freeze((40 << 16) | (100 << 8) | 210); + const CYAN = Object.freeze((20 << 16) | (180 << 8) | 200); const baseTheme = createTheme({ colors: { primary: RED, diff --git a/packages/core/src/theme/__tests__/theme.test.ts b/packages/core/src/theme/__tests__/theme.test.ts index 9f2f1094..defdfff8 100644 --- a/packages/core/src/theme/__tests__/theme.test.ts +++ b/packages/core/src/theme/__tests__/theme.test.ts @@ -5,7 +5,7 @@ import { createTheme, defaultTheme, resolveColor, resolveSpacing } from "../inde describe("theme", () => { test("createTheme merges colors and spacing", () => { const t = createTheme({ - colors: { primary: ((1 << 16) | (2 << 8) | 3) }, + colors: { primary: (1 << 16) | (2 << 8) | 3 }, spacing: [0, 2, 4], }); @@ -19,7 +19,10 @@ describe("theme", () => { test("resolveColor returns theme color or fg fallback", () => { assert.deepEqual(resolveColor(defaultTheme, "primary"), defaultTheme.colors.primary); assert.deepEqual(resolveColor(defaultTheme, "missing"), defaultTheme.colors.fg); - assert.deepEqual(resolveColor(defaultTheme, ((9 << 16) | (8 << 8) | 7)), ((9 << 16) | (8 << 8) | 7)); + assert.deepEqual( + resolveColor(defaultTheme, (9 << 16) | (8 << 8) | 7), + (9 << 16) | (8 << 8) | 7, + ); }); test("resolveSpacing maps indices and allows raw values", () => { diff --git a/packages/core/src/theme/__tests__/theme.transition.test.ts b/packages/core/src/theme/__tests__/theme.transition.test.ts index 6787b521..06dc025d 100644 --- a/packages/core/src/theme/__tests__/theme.transition.test.ts +++ b/packages/core/src/theme/__tests__/theme.transition.test.ts @@ -64,7 +64,7 @@ async function drainPendingFrames(backend: StubBackend, maxRounds = 24): Promise function themeWithPrimary(r: number, g: number, b: number): Theme { return createTheme({ colors: { - primary: ((r << 16) | (g << 8) | b), + primary: (r << 16) | (g << 8) | b, }, }); } @@ -73,7 +73,7 @@ function semanticThemeWithAccent(r: number, g: number, b: number) { return extendTheme(darkTheme, { colors: { accent: { - primary: ((r << 16) | (g << 8) | b), + primary: (r << 16) | (g << 8) | b, }, }, }); @@ -149,7 +149,7 @@ describe("theme transition frames", () => { await resolveNextFrame(backend); assert.equal(seenPrimary.length >= 2, true); - assert.deepEqual(seenPrimary[seenPrimary.length - 1], ((0 << 16) | (255 << 8) | 0)); + assert.deepEqual(seenPrimary[seenPrimary.length - 1], (0 << 16) | (255 << 8) | 0); }); test("retargeting during active transition converges to new target theme", async () => { diff --git a/packages/core/src/theme/blend.ts b/packages/core/src/theme/blend.ts index 5b2045e0..d4fcff64 100644 --- a/packages/core/src/theme/blend.ts +++ b/packages/core/src/theme/blend.ts @@ -1,4 +1,4 @@ -import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; +import { type Rgb24, rgb, rgbB, rgbG, rgbR } from "../widgets/style.js"; function blendChannel(a: number, b: number, t: number): number { return Math.round(a + (b - a) * t); diff --git a/packages/core/src/theme/contrast.ts b/packages/core/src/theme/contrast.ts index a7ec0813..15415eb2 100644 --- a/packages/core/src/theme/contrast.ts +++ b/packages/core/src/theme/contrast.ts @@ -5,7 +5,7 @@ * foreground/background color pairs. */ -import { rgbB, rgbG, rgbR, type Rgb24 } from "../widgets/style.js"; +import { type Rgb24, rgbB, rgbG, rgbR } from "../widgets/style.js"; function srgbToLinear(channel: number): number { const srgb = channel / 255; diff --git a/packages/core/src/theme/defaultTheme.ts b/packages/core/src/theme/defaultTheme.ts index bf5fb0ab..043fe58b 100644 --- a/packages/core/src/theme/defaultTheme.ts +++ b/packages/core/src/theme/defaultTheme.ts @@ -5,8 +5,8 @@ * Kept separate from theme helpers to avoid accidental circular imports. */ -import type { Theme } from "./types.js"; import { rgb } from "../widgets/style.js"; +import type { Theme } from "./types.js"; export const defaultTheme: Theme = Object.freeze({ colors: Object.freeze({ diff --git a/packages/core/src/theme/tokens.ts b/packages/core/src/theme/tokens.ts index c3b6c58c..9583493e 100644 --- a/packages/core/src/theme/tokens.ts +++ b/packages/core/src/theme/tokens.ts @@ -15,7 +15,7 @@ * @see docs/styling/theme.md */ -import { rgb, type Rgb24 } from "../widgets/style.js"; +import { type Rgb24, rgb } from "../widgets/style.js"; /** * Surface (background) color tokens. diff --git a/packages/core/src/ui/__tests__/themed.test.ts b/packages/core/src/ui/__tests__/themed.test.ts index ae9dfc7e..eef16057 100644 --- a/packages/core/src/ui/__tests__/themed.test.ts +++ b/packages/core/src/ui/__tests__/themed.test.ts @@ -92,7 +92,7 @@ function fgByText( describe("ui.themed", () => { test("creates themed vnode and filters children", () => { - const vnode = ui.themed({ colors: { accent: { primary: ((1 << 16) | (2 << 8) | 3) } } }, [ + const vnode = ui.themed({ colors: { accent: { primary: (1 << 16) | (2 << 8) | 3 } } }, [ ui.text("a"), null, false, @@ -102,17 +102,17 @@ describe("ui.themed", () => { if (vnode.kind !== "themed") return; assert.equal(vnode.children.length, 2); assert.deepEqual((vnode.props.theme as { colors?: unknown }).colors, { - accent: { primary: ((1 << 16) | (2 << 8) | 3) }, + accent: { primary: (1 << 16) | (2 << 8) | 3 }, }); }); test("applies theme override to subtree without leaking to siblings", () => { const baseTheme = createTheme({ colors: { - primary: ((200 << 16) | (40 << 8) | 40), + primary: (200 << 16) | (40 << 8) | 40, }, }); - const scopedPrimary = ((30 << 16) | (210 << 8) | 40); + const scopedPrimary = (30 << 16) | (210 << 8) | 40; const vnode = ui.column({}, [ ui.divider({ label: "OUT", color: "primary" }), @@ -138,7 +138,7 @@ describe("ui.themed", () => { test("is layout-transparent for single-child subtrees", () => { const tree = commitAndLayout( ui.column({}, [ - ui.themed({ colors: { accent: { primary: ((10 << 16) | (20 << 8) | 30) } } }, [ + ui.themed({ colors: { accent: { primary: (10 << 16) | (20 << 8) | 30 } } }, [ ui.text("inside"), ]), ui.text("outside"), diff --git a/packages/core/src/ui/recipes.ts b/packages/core/src/ui/recipes.ts index cc8a1733..cc433c4c 100644 --- a/packages/core/src/ui/recipes.ts +++ b/packages/core/src/ui/recipes.ts @@ -38,7 +38,7 @@ import { /** Lighten or darken a color toward white/black. */ function adjustBrightness(color: Rgb24, amount: number): Rgb24 { - const target = amount > 0 ? ((255 << 16) | (255 << 8) | 255) : ((0 << 16) | (0 << 8) | 0); + const target = amount > 0 ? (255 << 16) | (255 << 8) | 255 : (0 << 16) | (0 << 8) | 0; return blendRgb(color, target, Math.abs(amount)); } diff --git a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts index a53f354c..eccc42ed 100644 --- a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts +++ b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts @@ -184,7 +184,7 @@ describe("basic widgets render to drawlist", () => { test("richText uses DRAW_TEXT_RUN and interns span strings", () => { const bytes = renderBytes( ui.richText([ - { text: "const ", style: { fg: ((90 << 16) | (160 << 8) | 255) } }, + { text: "const ", style: { fg: (90 << 16) | (160 << 8) | 255 } }, { text: "x", style: { bold: true } }, ]), ); @@ -351,14 +351,14 @@ describe("basic widgets render to drawlist", () => { ui.progress(1, { variant: "minimal", width: 10, - style: { fg: ((1 << 16) | (2 << 8) | 3) }, + style: { fg: (1 << 16) | (2 << 8) | 3 }, }), { cols: 24, rows: 3 }, { theme: createTheme(defaultTheme) }, ); const drawText = parseDrawTextCommands(bytes).find((cmd) => cmd.text.includes("━")); assert.ok(drawText, "expected drawText for filled progress glyphs"); - assert.equal(drawText.fg, packRgb(((1 << 16) | (2 << 8) | 3))); + assert.equal(drawText.fg, packRgb((1 << 16) | (2 << 8) | 3)); }); test("slider renders track and clamps displayed value to range", () => { @@ -528,7 +528,7 @@ describe("basic widgets render to drawlist", () => { test("link underlineColor theme token resolves on v3", () => { const theme = createTheme({ colors: { - "diagnostic.info": ((1 << 16) | (2 << 8) | 3), + "diagnostic.info": (1 << 16) | (2 << 8) | 3, }, }); const bytes = renderBytesV3( @@ -567,7 +567,7 @@ describe("basic widgets render to drawlist", () => { test("codeEditor diagnostics use curly underline + token color on v3", () => { const theme = createTheme({ colors: { - "diagnostic.warning": ((1 << 16) | (2 << 8) | 3), + "diagnostic.warning": (1 << 16) | (2 << 8) | 3, }, }); const vnode = ui.codeEditor({ @@ -618,9 +618,9 @@ describe("basic widgets render to drawlist", () => { test("codeEditor applies syntax token colors for mainstream language presets", () => { const theme = createTheme({ colors: { - "syntax.keyword": ((10 << 16) | (20 << 8) | 30), - "syntax.function": ((30 << 16) | (40 << 8) | 50), - "syntax.string": ((60 << 16) | (70 << 8) | 80), + "syntax.keyword": (10 << 16) | (20 << 8) | 30, + "syntax.function": (30 << 16) | (40 << 8) | 50, + "syntax.string": (60 << 16) | (70 << 8) | 80, }, }); const vnode = ui.codeEditor({ @@ -653,8 +653,8 @@ describe("basic widgets render to drawlist", () => { test("codeEditor draws a highlighted cursor cell for focused editor", () => { const theme = createTheme({ colors: { - "syntax.cursor.bg": ((1 << 16) | (2 << 8) | 3), - "syntax.cursor.fg": ((4 << 16) | (5 << 8) | 6), + "syntax.cursor.bg": (1 << 16) | (2 << 8) | 3, + "syntax.cursor.fg": (4 << 16) | (5 << 8) | 6, }, }); const vnode = ui.codeEditor({ diff --git a/packages/core/src/widgets/__tests__/canvas.primitives.test.ts b/packages/core/src/widgets/__tests__/canvas.primitives.test.ts index 2ff25087..50da33ce 100644 --- a/packages/core/src/widgets/__tests__/canvas.primitives.test.ts +++ b/packages/core/src/widgets/__tests__/canvas.primitives.test.ts @@ -27,20 +27,20 @@ describe("canvas primitives", () => { }); test("drawing surface resolves auto blitter to concrete pixel resolution", () => { - const surface = createCanvasDrawingSurface(3, 2, "auto", () => ((1 << 16) | (2 << 8) | 3)); + const surface = createCanvasDrawingSurface(3, 2, "auto", () => (1 << 16) | (2 << 8) | 3); assert.equal(surface.blitter, "braille"); assert.equal(surface.widthPx, 6); assert.equal(surface.heightPx, 8); }); test("setPixel writes RGBA bytes", () => { - const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ((10 << 16) | (20 << 8) | 30)); + const surface = createCanvasDrawingSurface(2, 2, "ascii", () => (10 << 16) | (20 << 8) | 30); surface.ctx.setPixel(1, 1, "#112233"); assert.deepEqual(rgbaAt(surface, 1, 1), { r: 17, g: 34, b: 51, a: 255 }); }); test("line draws deterministic diagonal pixels", () => { - const surface = createCanvasDrawingSurface(4, 4, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(4, 4, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.line(0, 0, 3, 3, "#ffffff"); assert.equal(rgbaAt(surface, 0, 0).a, 255); assert.equal(rgbaAt(surface, 1, 1).a, 255); @@ -49,7 +49,7 @@ describe("canvas primitives", () => { }); test("fillRect fills the requested region", () => { - const surface = createCanvasDrawingSurface(4, 3, "ascii", () => ((255 << 16) | (0 << 8) | 0)); + const surface = createCanvasDrawingSurface(4, 3, "ascii", () => (255 << 16) | (0 << 8) | 0); surface.ctx.fillRect(1, 1, 2, 1, "#ff0000"); assert.equal(rgbaAt(surface, 1, 1).r, 255); assert.equal(rgbaAt(surface, 2, 1).r, 255); @@ -57,7 +57,7 @@ describe("canvas primitives", () => { }); test("strokeRect draws perimeter only", () => { - const surface = createCanvasDrawingSurface(5, 5, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(5, 5, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.strokeRect(1, 1, 3, 3, "#ffffff"); assert.equal(rgbaAt(surface, 1, 1).a, 255); assert.equal(rgbaAt(surface, 2, 1).a, 255); @@ -66,7 +66,7 @@ describe("canvas primitives", () => { }); test("polyline connects each point with line segments", () => { - const surface = createCanvasDrawingSurface(7, 7, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(7, 7, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.polyline( [ { x: 0, y: 0 }, @@ -81,7 +81,7 @@ describe("canvas primitives", () => { }); test("roundedRect draws rounded corners and straight edges", () => { - const surface = createCanvasDrawingSurface(9, 7, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(9, 7, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.roundedRect(1, 1, 7, 5, 2, "#ffffff"); assert.equal(rgbaAt(surface, 4, 3).a, 0); assert.equal(rgbaAt(surface, 3, 1).a, 255); @@ -89,7 +89,12 @@ describe("canvas primitives", () => { }); test("circle outlines are symmetric", () => { - const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface( + 11, + 11, + "ascii", + () => (255 << 16) | (255 << 8) | 255, + ); surface.ctx.circle(5, 5, 3, "#ffffff"); assert.equal(rgbaAt(surface, 5, 2).a, 255); assert.equal(rgbaAt(surface, 5, 8).a, 255); @@ -98,7 +103,7 @@ describe("canvas primitives", () => { }); test("fillCircle paints interior pixels", () => { - const surface = createCanvasDrawingSurface(9, 9, "ascii", () => ((0 << 16) | (255 << 8) | 0)); + const surface = createCanvasDrawingSurface(9, 9, "ascii", () => (0 << 16) | (255 << 8) | 0); surface.ctx.fillCircle(4, 4, 2, "#00ff00"); assert.equal(rgbaAt(surface, 4, 4).g, 255); assert.equal(rgbaAt(surface, 4, 2).g, 255); @@ -107,7 +112,12 @@ describe("canvas primitives", () => { }); test("arc draws a partial circle segment", () => { - const surface = createCanvasDrawingSurface(11, 11, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface( + 11, + 11, + "ascii", + () => (255 << 16) | (255 << 8) | 255, + ); surface.ctx.arc(5, 5, 3, 0, Math.PI * 0.5, "#ffffff"); assert.equal(rgbaAt(surface, 8, 5).a, 255); assert.equal(rgbaAt(surface, 5, 8).a, 255); @@ -115,21 +125,26 @@ describe("canvas primitives", () => { }); test("fillTriangle paints enclosed pixels", () => { - const surface = createCanvasDrawingSurface(8, 6, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(8, 6, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.fillTriangle(1, 1, 6, 1, 3, 4, "#ffffff"); assert.equal(rgbaAt(surface, 3, 2).a, 255); assert.equal(rgbaAt(surface, 0, 0).a, 0); }); test("text overlays map subcell coordinates to cell coordinates", () => { - const surface = createCanvasDrawingSurface(4, 2, "braille", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface( + 4, + 2, + "braille", + () => (255 << 16) | (255 << 8) | 255, + ); surface.ctx.text(3, 5, "ok", "#ffaa00"); surface.ctx.text(-0.2, 0, "hidden"); assert.deepEqual(surface.overlays, [{ x: 1, y: 1, text: "ok", color: "#ffaa00" }]); }); test("clear removes pixels and overlay text", () => { - const surface = createCanvasDrawingSurface(2, 2, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(2, 2, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.setPixel(1, 1, "#ffffff"); surface.ctx.text(0, 0, "hi"); surface.ctx.clear(); @@ -141,7 +156,7 @@ describe("canvas primitives", () => { }); test("clear with color fills surface and clears overlays", () => { - const surface = createCanvasDrawingSurface(2, 1, "ascii", () => ((255 << 16) | (255 << 8) | 255)); + const surface = createCanvasDrawingSurface(2, 1, "ascii", () => (255 << 16) | (255 << 8) | 255); surface.ctx.text(0, 0, "x"); surface.ctx.clear("#123456"); assert.deepEqual(rgbaAt(surface, 0, 0), { r: 18, g: 52, b: 86, a: 255 }); diff --git a/packages/core/src/widgets/__tests__/collections.test.ts b/packages/core/src/widgets/__tests__/collections.test.ts index 9673ba37..84625121 100644 --- a/packages/core/src/widgets/__tests__/collections.test.ts +++ b/packages/core/src/widgets/__tests__/collections.test.ts @@ -90,10 +90,10 @@ describe("collections", () => { virtualized: true, overscan: 5, stripedRows: true, - stripeStyle: { odd: ((1 << 16) | (2 << 8) | 3), even: ((4 << 16) | (5 << 8) | 6) }, + stripeStyle: { odd: (1 << 16) | (2 << 8) | 3, even: (4 << 16) | (5 << 8) | 6 }, showHeader: true, border: "single", - borderStyle: { variant: "double", color: ((7 << 16) | (8 << 8) | 9) }, + borderStyle: { variant: "double", color: (7 << 16) | (8 << 8) | 9 }, onSelectionChange: () => undefined, onSort: () => undefined, onRowPress: () => undefined, @@ -108,10 +108,13 @@ describe("collections", () => { assert.equal(vnode.props.border, "single"); assert.equal(vnode.props.columns[0]?.overflow, "middle"); assert.deepEqual(vnode.props.stripeStyle, { - odd: ((1 << 16) | (2 << 8) | 3), - even: ((4 << 16) | (5 << 8) | 6), + odd: (1 << 16) | (2 << 8) | 3, + even: (4 << 16) | (5 << 8) | 6, + }); + assert.deepEqual(vnode.props.borderStyle, { + variant: "double", + color: (7 << 16) | (8 << 8) | 9, }); - assert.deepEqual(vnode.props.borderStyle, { variant: "double", color: ((7 << 16) | (8 << 8) | 9) }); }); test("ui.tree creates tree VNode with optional tree features", () => { diff --git a/packages/core/src/widgets/__tests__/containers.test.ts b/packages/core/src/widgets/__tests__/containers.test.ts index e2180fb5..1eb2577e 100644 --- a/packages/core/src/widgets/__tests__/containers.test.ts +++ b/packages/core/src/widgets/__tests__/containers.test.ts @@ -75,44 +75,44 @@ describe("container widgets - VNode construction", () => { title: "Styled", content: ui.text("Body"), frameStyle: { - background: ((20 << 16) | (22 << 8) | 24), - foreground: ((220 << 16) | (222 << 8) | 224), - border: ((120 << 16) | (122 << 8) | 124), + background: (20 << 16) | (22 << 8) | 24, + foreground: (220 << 16) | (222 << 8) | 224, + border: (120 << 16) | (122 << 8) | 124, }, backdrop: { variant: "dim", pattern: "#", - foreground: ((60 << 16) | (70 << 8) | 80), - background: ((4 << 16) | (5 << 8) | 6), + foreground: (60 << 16) | (70 << 8) | 80, + background: (4 << 16) | (5 << 8) | 6, }, }); assert.equal(modal.kind, "modal"); assert.deepEqual(modal.props.backdrop, { variant: "dim", pattern: "#", - foreground: ((60 << 16) | (70 << 8) | 80), - background: ((4 << 16) | (5 << 8) | 6), + foreground: (60 << 16) | (70 << 8) | 80, + background: (4 << 16) | (5 << 8) | 6, }); assert.deepEqual(modal.props.frameStyle, { - background: ((20 << 16) | (22 << 8) | 24), - foreground: ((220 << 16) | (222 << 8) | 224), - border: ((120 << 16) | (122 << 8) | 124), + background: (20 << 16) | (22 << 8) | 24, + foreground: (220 << 16) | (222 << 8) | 224, + border: (120 << 16) | (122 << 8) | 124, }); const layer = ui.layer({ id: "styled-layer", content: ui.text("overlay"), frameStyle: { - background: ((10 << 16) | (11 << 8) | 12), - foreground: ((230 << 16) | (231 << 8) | 232), - border: ((90 << 16) | (91 << 8) | 92), + background: (10 << 16) | (11 << 8) | 12, + foreground: (230 << 16) | (231 << 8) | 232, + border: (90 << 16) | (91 << 8) | 92, }, }); assert.equal(layer.kind, "layer"); assert.deepEqual(layer.props.frameStyle, { - background: ((10 << 16) | (11 << 8) | 12), - foreground: ((230 << 16) | (231 << 8) | 232), - border: ((90 << 16) | (91 << 8) | 92), + background: (10 << 16) | (11 << 8) | 12, + foreground: (230 << 16) | (231 << 8) | 232, + border: (90 << 16) | (91 << 8) | 92, }); }); diff --git a/packages/core/src/widgets/__tests__/graphics.golden.test.ts b/packages/core/src/widgets/__tests__/graphics.golden.test.ts index c1418865..bd133f7b 100644 --- a/packages/core/src/widgets/__tests__/graphics.golden.test.ts +++ b/packages/core/src/widgets/__tests__/graphics.golden.test.ts @@ -321,7 +321,7 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { text: "warn", style: { underlineStyle: "dashed", - underlineColor: ((0 << 16) | (170 << 8) | 255), + underlineColor: (0 << 16) | (170 << 8) | 255, }, }, ]), diff --git a/packages/core/src/widgets/__tests__/overlays.test.ts b/packages/core/src/widgets/__tests__/overlays.test.ts index 6cc34184..6785cd64 100644 --- a/packages/core/src/widgets/__tests__/overlays.test.ts +++ b/packages/core/src/widgets/__tests__/overlays.test.ts @@ -30,9 +30,9 @@ describe("overlay widgets - VNode construction", () => { test("dropdown preserves frameStyle colors", () => { const frameStyle = { - background: ((12 << 16) | (18 << 8) | 24), - foreground: ((200 << 16) | (210 << 8) | 220), - border: ((80 << 16) | (90 << 8) | 100), + background: (12 << 16) | (18 << 8) | 24, + foreground: (200 << 16) | (210 << 8) | 220, + border: (80 << 16) | (90 << 8) | 100, } as const; const vnode = ui.dropdown({ id: "styled-menu", @@ -79,9 +79,9 @@ describe("overlay widgets - VNode construction", () => { test("commandPalette preserves frameStyle colors", () => { const frameStyle = { - background: ((11 << 16) | (12 << 8) | 13), - foreground: ((210 << 16) | (211 << 8) | 212), - border: ((100 << 16) | (101 << 8) | 102), + background: (11 << 16) | (12 << 8) | 13, + foreground: (210 << 16) | (211 << 8) | 212, + border: (100 << 16) | (101 << 8) | 102, } as const; const vnode = ui.commandPalette({ id: "palette-styled", @@ -147,9 +147,9 @@ describe("overlay widgets - VNode construction", () => { test("toastContainer preserves frameStyle colors", () => { const frameStyle = { - background: ((5 << 16) | (6 << 8) | 7), - foreground: ((230 << 16) | (231 << 8) | 232), - border: ((140 << 16) | (141 << 8) | 142), + background: (5 << 16) | (6 << 8) | 7, + foreground: (230 << 16) | (231 << 8) | 232, + border: (140 << 16) | (141 << 8) | 142, } as const; const vnode = ui.toastContainer({ toasts: [], diff --git a/packages/core/src/widgets/__tests__/overlays.typecheck.ts b/packages/core/src/widgets/__tests__/overlays.typecheck.ts index af4f908c..90648864 100644 --- a/packages/core/src/widgets/__tests__/overlays.typecheck.ts +++ b/packages/core/src/widgets/__tests__/overlays.typecheck.ts @@ -10,38 +10,38 @@ const modalBackdropPreset: ModalProps["backdrop"] = "dim"; const modalBackdropObject: ModalProps["backdrop"] = { variant: "opaque", pattern: "#", - foreground: ((10 << 16) | (20 << 8) | 30), - background: ((1 << 16) | (2 << 8) | 3), + foreground: (10 << 16) | (20 << 8) | 30, + background: (1 << 16) | (2 << 8) | 3, }; // @ts-expect-error invalid backdrop variant const modalBackdropInvalid: ModalProps["backdrop"] = { variant: "blur" }; const dropdownFrame: DropdownProps["frameStyle"] = { - background: ((1 << 16) | (2 << 8) | 3), - foreground: ((4 << 16) | (5 << 8) | 6), - border: ((7 << 16) | (8 << 8) | 9), + background: (1 << 16) | (2 << 8) | 3, + foreground: (4 << 16) | (5 << 8) | 6, + border: (7 << 16) | (8 << 8) | 9, }; // @ts-expect-error missing b component const dropdownFrameInvalid: DropdownProps["frameStyle"] = { border: { r: 1, g: 2 } }; const layerFrame: LayerProps["frameStyle"] = { - background: ((9 << 16) | (9 << 8) | 9), - foreground: ((200 << 16) | (200 << 8) | 200), - border: ((120 << 16) | (120 << 8) | 120), + background: (9 << 16) | (9 << 8) | 9, + foreground: (200 << 16) | (200 << 8) | 200, + border: (120 << 16) | (120 << 8) | 120, }; const commandPaletteFrame: CommandPaletteProps["frameStyle"] = { - background: ((12 << 16) | (13 << 8) | 14), - foreground: ((220 << 16) | (221 << 8) | 222), - border: ((100 << 16) | (110 << 8) | 120), + background: (12 << 16) | (13 << 8) | 14, + foreground: (220 << 16) | (221 << 8) | 222, + border: (100 << 16) | (110 << 8) | 120, }; const toastFrame: ToastContainerProps["frameStyle"] = { - background: ((15 << 16) | (16 << 8) | 17), - foreground: ((230 << 16) | (231 << 8) | 232), - border: ((111 << 16) | (112 << 8) | 113), + background: (15 << 16) | (16 << 8) | 17, + foreground: (230 << 16) | (231 << 8) | 232, + border: (111 << 16) | (112 << 8) | 113, }; void modalBackdropPreset; diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index ba3b7075..513e8be2 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -348,8 +348,8 @@ describe("renderer regressions", () => { getRowKey: (row) => row.name, stripedRows: false, stripeStyle: { - odd: ((1 << 16) | (2 << 8) | 3), - even: ((4 << 16) | (5 << 8) | 6), + odd: (1 << 16) | (2 << 8) | 3, + even: (4 << 16) | (5 << 8) | 6, }, border: "none", }), @@ -377,7 +377,7 @@ describe("renderer regressions", () => { border: "single", borderStyle: { variant: "double", - color: ((201 << 16) | (202 << 8) | 203), + color: (201 << 16) | (202 << 8) | 203, }, }), { cols: 40, rows: 8 }, @@ -406,7 +406,7 @@ describe("renderer regressions", () => { border: "none", borderStyle: { variant: "double", - color: ((111 << 16) | (112 << 8) | 113), + color: (111 << 16) | (112 << 8) | 113, }, }), { cols: 40, rows: 8 }, @@ -448,8 +448,8 @@ describe("renderer regressions", () => { backdrop: { variant: "dim", pattern: "#", - foreground: ((1 << 16) | (2 << 8) | 3), - background: ((4 << 16) | (5 << 8) | 6), + foreground: (1 << 16) | (2 << 8) | 3, + background: (4 << 16) | (5 << 8) | 6, }, }), { cols: 60, rows: 20 }, @@ -491,9 +491,9 @@ describe("renderer regressions", () => { content: ui.text("Styled modal"), backdrop: "none", frameStyle: { - background: ((12 << 16) | (13 << 8) | 14), - foreground: ((210 << 16) | (211 << 8) | 212), - border: ((90 << 16) | (91 << 8) | 92), + background: (12 << 16) | (13 << 8) | 14, + foreground: (210 << 16) | (211 << 8) | 212, + border: (90 << 16) | (91 << 8) | 92, }, }), { cols: 60, rows: 20 }, @@ -520,9 +520,9 @@ describe("renderer regressions", () => { id: "layer-frame-style", backdrop: "none", frameStyle: { - background: ((21 << 16) | (22 << 8) | 23), - foreground: ((181 << 16) | (182 << 8) | 183), - border: ((101 << 16) | (102 << 8) | 103), + background: (21 << 16) | (22 << 8) | 23, + foreground: (181 << 16) | (182 << 8) | 183, + border: (101 << 16) | (102 << 8) | 103, }, content: ui.text("Layer styled"), }), @@ -551,7 +551,7 @@ describe("renderer regressions", () => { id: "layer-clip-inner", backdrop: "none", frameStyle: { - border: ((130 << 16) | (131 << 8) | 132), + border: (130 << 16) | (131 << 8) | 132, }, content: ui.text("edge"), }), @@ -577,9 +577,9 @@ describe("renderer regressions", () => { { id: "open", label: "Open" }, ], frameStyle: { - background: ((31 << 16) | (32 << 8) | 33), - foreground: ((191 << 16) | (192 << 8) | 193), - border: ((111 << 16) | (112 << 8) | 113), + background: (31 << 16) | (32 << 8) | 33, + foreground: (191 << 16) | (192 << 8) | 193, + border: (111 << 16) | (112 << 8) | 113, }, }), ]), @@ -610,9 +610,9 @@ describe("renderer regressions", () => { sources: [{ id: "cmd", name: "Commands", getItems: () => [] }], selectedIndex: 0, frameStyle: { - background: ((41 << 16) | (42 << 8) | 43), - foreground: ((201 << 16) | (202 << 8) | 203), - border: ((121 << 16) | (122 << 8) | 123), + background: (41 << 16) | (42 << 8) | 43, + foreground: (201 << 16) | (202 << 8) | 203, + border: (121 << 16) | (122 << 8) | 123, }, onQueryChange: noop, onSelect: noop, @@ -642,9 +642,9 @@ describe("renderer regressions", () => { toasts: [{ id: "toast-1", message: "Saved", type: "success" }], onDismiss: noop, frameStyle: { - background: ((51 << 16) | (52 << 8) | 53), - foreground: ((211 << 16) | (212 << 8) | 213), - border: ((131 << 16) | (132 << 8) | 133), + background: (51 << 16) | (52 << 8) | 53, + foreground: (211 << 16) | (212 << 8) | 213, + border: (131 << 16) | (132 << 8) | 133, }, }), { cols: 70, rows: 24 }, diff --git a/packages/core/src/widgets/__tests__/style.attributes.test.ts b/packages/core/src/widgets/__tests__/style.attributes.test.ts index c5c3aa5d..ef2e8247 100644 --- a/packages/core/src/widgets/__tests__/style.attributes.test.ts +++ b/packages/core/src/widgets/__tests__/style.attributes.test.ts @@ -62,12 +62,12 @@ describe("TextStyle attributes", () => { test("mergeStyles preserves fg/bg while updating attrs", () => { const merged = mergeStyles( - { fg: ((1 << 16) | (2 << 8) | 3), bg: ((4 << 16) | (5 << 8) | 6), bold: true }, + { fg: (1 << 16) | (2 << 8) | 3, bg: (4 << 16) | (5 << 8) | 6, bold: true }, { bold: false, underline: true }, ); assert.deepEqual(merged, { - fg: ((1 << 16) | (2 << 8) | 3), - bg: ((4 << 16) | (5 << 8) | 6), + fg: (1 << 16) | (2 << 8) | 3, + bg: (4 << 16) | (5 << 8) | 6, bold: false, underline: true, }); diff --git a/packages/core/src/widgets/__tests__/style.inheritance.test.ts b/packages/core/src/widgets/__tests__/style.inheritance.test.ts index c99d34bb..f398feeb 100644 --- a/packages/core/src/widgets/__tests__/style.inheritance.test.ts +++ b/packages/core/src/widgets/__tests__/style.inheritance.test.ts @@ -8,14 +8,14 @@ import { type ChainLevelStyle = TextStyle | undefined; -const ROOT_FG = ((11 << 16) | (22 << 8) | 33); -const ROOT_BG = ((44 << 16) | (55 << 8) | 66); -const BOX_FG = ((77 << 16) | (88 << 8) | 99); -const BOX_BG = ((101 << 16) | (111 << 8) | 121); -const ROW_BG = ((131 << 16) | (141 << 8) | 151); -const ROW_FG = ((152 << 16) | (162 << 8) | 172); -const TEXT_FG = ((181 << 16) | (191 << 8) | 201); -const DEEP_BG = ((211 << 16) | (212 << 8) | 213); +const ROOT_FG = (11 << 16) | (22 << 8) | 33; +const ROOT_BG = (44 << 16) | (55 << 8) | 66; +const BOX_FG = (77 << 16) | (88 << 8) | 99; +const BOX_BG = (101 << 16) | (111 << 8) | 121; +const ROW_BG = (131 << 16) | (141 << 8) | 151; +const ROW_FG = (152 << 16) | (162 << 8) | 172; +const TEXT_FG = (181 << 16) | (191 << 8) | 201; +const DEEP_BG = (211 << 16) | (212 << 8) | 213; function resolveChain(levels: readonly ChainLevelStyle[]): ResolvedTextStyle { let resolved = DEFAULT_BASE_STYLE; @@ -233,12 +233,12 @@ describe("mergeTextStyle deep inheritance chains", () => { let override: TextStyle | undefined; if (level % 64 === 0) { const base = (level / 2) % 256; - const nextFg = ((base << 16) | (((base + 1) % 256) << 8) | ((base + 2) % 256)); + const nextFg = (base << 16) | (((base + 1) % 256) << 8) | ((base + 2) % 256); override = { fg: nextFg }; expectedFg = nextFg; } else if (level % 45 === 0) { const base = (level * 3) % 256; - const nextBg = ((base << 16) | (((base + 7) % 256) << 8) | ((base + 14) % 256)); + const nextBg = (base << 16) | (((base + 7) % 256) << 8) | ((base + 14) % 256); override = { bg: nextBg }; expectedBg = nextBg; } else if (level % 11 === 0) { diff --git a/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts b/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts index 90d8b0d9..16d7ba97 100644 --- a/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge-fuzz.test.ts @@ -51,7 +51,7 @@ function randomBool(rng: Rng): boolean { } function randomRgb(rng: Rng): NonNullable { - return (((rng.u32() & 255) << 16) | ((rng.u32() & 255) << 8) | (rng.u32() & 255)); + return ((rng.u32() & 255) << 16) | ((rng.u32() & 255) << 8) | (rng.u32() & 255); } function randomBase(rng: Rng): ResolvedTextStyle { diff --git a/packages/core/src/widgets/__tests__/style.merge.test.ts b/packages/core/src/widgets/__tests__/style.merge.test.ts index ac9e4d7d..4d0e3a63 100644 --- a/packages/core/src/widgets/__tests__/style.merge.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge.test.ts @@ -250,17 +250,17 @@ describe("mergeTextStyle cache correctness for DEFAULT_BASE_STYLE", () => { }); test("non-default base path does not stale-reuse entries across differing colors", () => { - const redBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: ((200 << 16) | (10 << 8) | 20) }); - const blueBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: ((20 << 16) | (10 << 8) | 200) }); + const redBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: (200 << 16) | (10 << 8) | 20 }); + const blueBase = mergeTextStyle(DEFAULT_BASE_STYLE, { fg: (20 << 16) | (10 << 8) | 200 }); const redBoldA = mergeTextStyle(redBase, { bold: true }); const redBoldB = mergeTextStyle(redBase, { bold: true }); const blueBold = mergeTextStyle(blueBase, { bold: true }); assert.equal(redBoldA === redBoldB, false); assert.equal(redBoldA === blueBold, false); - assert.deepEqual(redBoldA.fg, ((200 << 16) | (10 << 8) | 20)); - assert.deepEqual(redBoldB.fg, ((200 << 16) | (10 << 8) | 20)); - assert.deepEqual(blueBold.fg, ((20 << 16) | (10 << 8) | 200)); + assert.deepEqual(redBoldA.fg, (200 << 16) | (10 << 8) | 20); + assert.deepEqual(redBoldB.fg, (200 << 16) | (10 << 8) | 20); + assert.deepEqual(blueBold.fg, (20 << 16) | (10 << 8) | 200); }); test("invalid style channels are sanitized before merge", () => { @@ -353,16 +353,16 @@ describe("mergeTextStyle extended underline fields", () => { const base = mergeTextStyle(DEFAULT_BASE_STYLE, { underline: true, underlineStyle: "double", - underlineColor: ((1 << 16) | (2 << 8) | 3), + underlineColor: (1 << 16) | (2 << 8) | 3, }); const merged = mergeTextStyle(base, { underlineStyle: "curly", - underlineColor: ((4 << 16) | (5 << 8) | 6), + underlineColor: (4 << 16) | (5 << 8) | 6, }); assert.equal(merged.underline, true); assert.equal(merged.underlineStyle, "curly"); - assert.deepEqual(merged.underlineColor, ((4 << 16) | (5 << 8) | 6)); + assert.deepEqual(merged.underlineColor, (4 << 16) | (5 << 8) | 6); }); test("merge retains token-string underlineColor values", () => { diff --git a/packages/core/src/widgets/__tests__/style.utils.test.ts b/packages/core/src/widgets/__tests__/style.utils.test.ts index 5f40f7c7..2db2c8f4 100644 --- a/packages/core/src/widgets/__tests__/style.utils.test.ts +++ b/packages/core/src/widgets/__tests__/style.utils.test.ts @@ -4,9 +4,9 @@ import { mergeStyles, sanitizeRgb, sanitizeTextStyle, styleWhen, styles } from " describe("style utils contracts", () => { test("mergeStyles performs a deterministic 3-way left-to-right merge", () => { - const a = { bold: true, underline: false, fg: ((1 << 16) | (2 << 8) | 3) } as const; + const a = { bold: true, underline: false, fg: (1 << 16) | (2 << 8) | 3 } as const; const b = { bold: false, italic: true } as const; - const c = { fg: ((9 << 16) | (8 << 8) | 7), dim: true } as const; + const c = { fg: (9 << 16) | (8 << 8) | 7, dim: true } as const; const merged = mergeStyles(a, b, c); @@ -15,7 +15,7 @@ describe("style utils contracts", () => { underline: false, italic: true, dim: true, - fg: ((9 << 16) | (8 << 8) | 7), + fg: (9 << 16) | (8 << 8) | 7, }); }); @@ -41,7 +41,7 @@ describe("style utils contracts", () => { }); test("composition via styleWhen + mergeStyles does not mutate inputs", () => { - const base = { bold: true, fg: ((5 << 16) | (6 << 8) | 7) } as const; + const base = { bold: true, fg: (5 << 16) | (6 << 8) | 7 } as const; const conditional: TextStyle = { italic: true }; const fallback: TextStyle = { dim: true }; @@ -51,12 +51,12 @@ describe("style utils contracts", () => { styleWhen(false, styles.underline), ); - assert.deepEqual(base, { bold: true, fg: ((5 << 16) | (6 << 8) | 7) }); + assert.deepEqual(base, { bold: true, fg: (5 << 16) | (6 << 8) | 7 }); assert.deepEqual(conditional, { italic: true }); assert.deepEqual(fallback, { dim: true }); assert.deepEqual(merged, { bold: true, - fg: ((5 << 16) | (6 << 8) | 7), + fg: (5 << 16) | (6 << 8) | 7, italic: true, }); }); @@ -75,7 +75,7 @@ describe("style utils contracts", () => { test("sanitizeRgb clamps channels and accepts numeric strings", () => { const out = sanitizeRgb({ r: "260", g: -2, b: "127.6" }); - assert.deepEqual(out, ((255 << 16) | (0 << 8) | 128)); + assert.deepEqual(out, (255 << 16) | (0 << 8) | 128); }); test("sanitizeTextStyle drops invalid fields and coerces booleans", () => { @@ -89,20 +89,20 @@ describe("style utils contracts", () => { }); assert.deepEqual(out, { - fg: ((1 << 16) | (2 << 8) | 4), + fg: (1 << 16) | (2 << 8) | 4, bold: true, italic: false, }); }); test("mergeStyles sanitizes incoming style values", () => { - const merged = mergeStyles({ fg: ((0 << 16) | (0 << 8) | 0), bold: true }, { + const merged = mergeStyles({ fg: (0 << 16) | (0 << 8) | 0, bold: true }, { fg: { r: 512, g: "-10", b: "3.2" }, bold: "false", } as unknown as TextStyle); assert.deepEqual(merged, { - fg: ((255 << 16) | (0 << 8) | 3), + fg: (255 << 16) | (0 << 8) | 3, bold: false, }); }); diff --git a/packages/core/src/widgets/__tests__/styleUtils.test.ts b/packages/core/src/widgets/__tests__/styleUtils.test.ts index 617d42d5..2219cd24 100644 --- a/packages/core/src/widgets/__tests__/styleUtils.test.ts +++ b/packages/core/src/widgets/__tests__/styleUtils.test.ts @@ -3,11 +3,11 @@ import { extendStyle, mergeStyles, sanitizeTextStyle, styleWhen, styles } from " describe("styleUtils", () => { test("mergeStyles applies later overrides", () => { - const a = { bold: true, fg: ((1 << 16) | (1 << 8) | 1) } as const; + const a = { bold: true, fg: (1 << 16) | (1 << 8) | 1 } as const; const b = { bold: false } as const; const merged = mergeStyles(a, b); assert.equal(merged.bold, false); - assert.deepEqual(merged.fg, ((1 << 16) | (1 << 8) | 1)); + assert.deepEqual(merged.fg, (1 << 16) | (1 << 8) | 1); }); test("extendStyle delegates to mergeStyles", () => { @@ -34,11 +34,14 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle preserves underlineColor rgb", () => { - assert.deepEqual(sanitizeTextStyle({ underlineColor: ((1 << 16) | (2 << 8) | 3) }).underlineColor, { - r: 1, - g: 2, - b: 3, - }); + assert.deepEqual( + sanitizeTextStyle({ underlineColor: (1 << 16) | (2 << 8) | 3 }).underlineColor, + { + r: 1, + g: 2, + b: 3, + }, + ); }); test("sanitizeTextStyle preserves underlineColor theme token", () => { @@ -61,11 +64,11 @@ describe("styleUtils", () => { test("mergeStyles merges underlineStyle and underlineColor", () => { const merged = mergeStyles( - { underlineStyle: "curly", underlineColor: ((255 << 16) | (0 << 8) | 0) }, + { underlineStyle: "curly", underlineColor: (255 << 16) | (0 << 8) | 0 }, { bold: true }, ); assert.equal(merged.underlineStyle, "curly"); - assert.deepEqual(merged.underlineColor, ((255 << 16) | (0 << 8) | 0)); + assert.deepEqual(merged.underlineColor, (255 << 16) | (0 << 8) | 0); assert.equal(merged.bold, true); }); diff --git a/packages/core/src/widgets/__tests__/styled.test.ts b/packages/core/src/widgets/__tests__/styled.test.ts index f97f7b2d..cd3623d2 100644 --- a/packages/core/src/widgets/__tests__/styled.test.ts +++ b/packages/core/src/widgets/__tests__/styled.test.ts @@ -7,8 +7,8 @@ describe("styled", () => { base: { bold: true }, variants: { intent: { - primary: { fg: ((1 << 16) | (2 << 8) | 3) }, - danger: { fg: ((9 << 16) | (9 << 8) | 9) }, + primary: { fg: (1 << 16) | (2 << 8) | 3 }, + danger: { fg: (9 << 16) | (9 << 8) | 9 }, }, }, defaults: { intent: "primary" }, @@ -18,7 +18,7 @@ describe("styled", () => { assert.equal(v.kind, "button"); assert.deepEqual((v.props as { style?: unknown }).style, { bold: true, - fg: ((1 << 16) | (2 << 8) | 3), + fg: (1 << 16) | (2 << 8) | 3, }); }); }); diff --git a/packages/core/src/widgets/__tests__/table.typecheck.ts b/packages/core/src/widgets/__tests__/table.typecheck.ts index bd1bc020..6d14b075 100644 --- a/packages/core/src/widgets/__tests__/table.typecheck.ts +++ b/packages/core/src/widgets/__tests__/table.typecheck.ts @@ -16,8 +16,8 @@ const columnWithInvalidOverflow: TableColumn = { }; const stripeStyle: NonNullable["stripeStyle"]> = { - odd: ((1 << 16) | (2 << 8) | 3), - even: ((4 << 16) | (5 << 8) | 6), + odd: (1 << 16) | (2 << 8) | 3, + even: (4 << 16) | (5 << 8) | 6, }; // @ts-expect-error missing b component @@ -25,7 +25,7 @@ const stripeStyleInvalid: NonNullable["stripeStyle"]> = { odd: { const borderStyle: TableBorderStyle = { variant: "heavy-dashed", - color: ((10 << 16) | (11 << 8) | 12), + color: (10 << 16) | (11 << 8) | 12, }; // @ts-expect-error invalid border style variant @@ -37,7 +37,7 @@ const tableWithStyleOnly: TableProps = { data: [{ id: "r0", path: "/tmp/file.txt" }], getRowKey: (row) => row.id, stripeStyle, - borderStyle: { variant: "double", color: ((20 << 16) | (21 << 8) | 22) }, + borderStyle: { variant: "double", color: (20 << 16) | (21 << 8) | 22 }, }; void columnWithMiddleOverflow; diff --git a/packages/core/src/widgets/canvas.ts b/packages/core/src/widgets/canvas.ts index a453aa68..f78a72af 100644 --- a/packages/core/src/widgets/canvas.ts +++ b/packages/core/src/widgets/canvas.ts @@ -1,4 +1,4 @@ -import { rgbB, rgbG, rgbR, type Rgb24 } from "./style.js"; +import { type Rgb24, rgbB, rgbG, rgbR } from "./style.js"; import type { CanvasContext, GraphicsBlitter } from "./types.js"; export type CanvasOverlayText = Readonly<{ diff --git a/packages/core/src/widgets/heatmap.ts b/packages/core/src/widgets/heatmap.ts index a3e884e2..fc3255be 100644 --- a/packages/core/src/widgets/heatmap.ts +++ b/packages/core/src/widgets/heatmap.ts @@ -1,4 +1,4 @@ -import { rgb, rgbB, rgbG, rgbR, type Rgb24 } from "./style.js"; +import { type Rgb24, rgb, rgbB, rgbG, rgbR } from "./style.js"; import type { HeatmapColorScale } from "./types.js"; type ScaleStop = Readonly<{ t: number; rgb: Rgb24 }>; diff --git a/packages/core/src/widgets/logsConsole.ts b/packages/core/src/widgets/logsConsole.ts index 1ecfe7e3..d6f6c258 100644 --- a/packages/core/src/widgets/logsConsole.ts +++ b/packages/core/src/widgets/logsConsole.ts @@ -7,7 +7,7 @@ * @see docs/widgets/logs-console.md */ -import { rgb, type Rgb24 } from "./style.js"; +import { type Rgb24, rgb } from "./style.js"; import type { LogEntry, LogLevel } from "./types.js"; /** Default max log entries to keep. */ diff --git a/packages/core/src/widgets/toast.ts b/packages/core/src/widgets/toast.ts index 055330a9..5a1cbb51 100644 --- a/packages/core/src/widgets/toast.ts +++ b/packages/core/src/widgets/toast.ts @@ -8,7 +8,7 @@ * @see docs/widgets/toast.md */ -import { rgb, type Rgb24 } from "./style.js"; +import { type Rgb24, rgb } from "./style.js"; import type { Toast, ToastPosition } from "./types.js"; /** Height of a single toast in cells. */ diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index 1b6ffd5e..650c76f5 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -455,7 +455,8 @@ function countSelfDirty(root: RuntimeInstance | null): number { let count = 0; const stack: RuntimeInstance[] = [root]; while (stack.length > 0) { - const node = stack.pop()!; + const node = stack.pop(); + if (!node) continue; if (node.selfDirty) count++; for (let i = node.children.length - 1; i >= 0; i--) { const child = node.children[i]; @@ -522,7 +523,11 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { // tree is unchanged. Skip layout, draw, and collect entirely. if (!isFirstFrame && !forceLayout && !prevRoot.dirty && cachedLayoutTree !== null) { const totalMs = performance.now() - t0; - return { ops: cachedOps, nodes: cachedNodes, timings: { commitMs, layoutMs: 0, drawMs: 0, totalMs, layoutSkipped: true } }; + return { + ops: cachedOps, + nodes: cachedNodes, + timings: { commitMs, layoutMs: 0, drawMs: 0, totalMs, layoutSkipped: true }, + }; } // ─── DIRTY SET ─── diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 8e8a8893..d77a5af0 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1,21 +1,16 @@ import { appendFileSync } from "node:fs"; import type { Readable, Writable } from "node:stream"; import { format as formatConsoleMessage } from "node:util"; -import { - type Rgb24, - type TextStyle, - type VNode, - measureTextCells, -} from "@rezi-ui/core"; +import { type Rgb24, type TextStyle, type VNode, measureTextCells } from "@rezi-ui/core"; import React from "react"; import { type KittyFlagName, resolveKittyFlags } from "../kitty-keyboard.js"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; import { enableTranslationTrace, flushTranslationTrace } from "../translation/traceCollector.js"; import { checkAllResizeObservers } from "./ResizeObserver.js"; -import { type InkRendererTraceEvent, createInkRenderer } from "./createInkRenderer.js"; import { createBridge } from "./bridge.js"; import { InkContext } from "./context.js"; +import { type InkRendererTraceEvent, createInkRenderer } from "./createInkRenderer.js"; import { commitSync, createReactRoot } from "./reactHelpers.js"; export interface KittyKeyboardOptions { @@ -902,8 +897,10 @@ function snapshotCellGridRows( for (let col = 0; col < captureTo; col++) { const cell = row[col]!; const entry: Record = { c: cell.char }; - if (cell.style?.bg) entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; - if (cell.style?.fg) entry["fg"] = `${rgbR(cell.style.fg)},${rgbG(cell.style.fg)},${rgbB(cell.style.fg)}`; + if (cell.style?.bg) + entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; + if (cell.style?.fg) + entry["fg"] = `${rgbR(cell.style.fg)},${rgbG(cell.style.fg)},${rgbB(cell.style.fg)}`; if (cell.style?.bold) entry["bold"] = true; if (cell.style?.dim) entry["dim"] = true; if (cell.style?.inverse) entry["inv"] = true; @@ -1221,9 +1218,7 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s if (colorSupport.level > 0) { if (style.fg) { if (colorSupport.level >= 3) { - codes.push( - `38;2;${rgbR(style.fg)};${rgbG(style.fg)};${rgbB(style.fg)}`, - ); + codes.push(`38;2;${rgbR(style.fg)};${rgbG(style.fg)};${rgbB(style.fg)}`); } else if (colorSupport.level === 2) { codes.push(`38;5;${toAnsi256Code(style.fg)}`); } else { @@ -1232,9 +1227,7 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s } if (style.bg) { if (colorSupport.level >= 3) { - codes.push( - `48;2;${rgbR(style.bg)};${rgbG(style.bg)};${rgbB(style.bg)}`, - ); + codes.push(`48;2;${rgbR(style.bg)};${rgbG(style.bg)};${rgbB(style.bg)}`); } else if (colorSupport.level === 2) { codes.push(`48;5;${toAnsi256Code(style.bg)}`); } else { @@ -2175,7 +2168,10 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions parentSize: viewport, parentMainAxis: "column", }); - const staticResult = staticRenderer.render(translatedStaticWithPercent, { viewport, forceLayout: true }); + const staticResult = staticRenderer.render(translatedStaticWithPercent, { + viewport, + forceLayout: true, + }); const staticHasAnsiSgr = hostTreeContainsAnsiSgr(bridge.rootNode); const staticColorSupport = staticHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; const { ansi: staticAnsi } = renderOpsToAnsi( @@ -2243,7 +2239,10 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions rootHeightCoerced = coerced.coerced; let t0 = performance.now(); - let result = renderer.render(vnode, { viewport: layoutViewport, forceLayout: viewportChanged }); + let result = renderer.render(vnode, { + viewport: layoutViewport, + forceLayout: viewportChanged, + }); coreRenderMs += performance.now() - t0; t0 = performance.now(); @@ -2516,10 +2515,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions if (frameProfileFile) { const _r = (v: number): number => Math.round(v * 1000) / 1000; + const layoutProfile = (result.timings as { _layoutProfile?: unknown } | undefined) + ?._layoutProfile; try { appendFileSync( frameProfileFile, - JSON.stringify({ + `${JSON.stringify({ frame: frameCount, ts: Date.now(), totalMs: _r(renderTime), @@ -2536,8 +2537,8 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions passes: coreRenderPassesThisFrame, ops: result.ops.length, nodes: result.nodes.length, - ...(((result.timings as any)?._layoutProfile) ? { _lp: (result.timings as any)._layoutProfile } : {}), - }) + "\n", + ...(layoutProfile === undefined ? {} : { _lp: layoutProfile }), + })}\n`, ); } catch {} } diff --git a/scripts/guardrails.sh b/scripts/guardrails.sh index 560b2e6e..c2c71d2d 100755 --- a/scripts/guardrails.sh +++ b/scripts/guardrails.sh @@ -74,8 +74,8 @@ if ! rg -n "Source of truth: scripts/drawlist-spec.ts" \ has_violations=1 fi if ! rg -n "from \"\\./writers\\.gen\\.js\"" \ - "${repo_root}/packages/core/src/drawlist/builder_v3.ts" >/dev/null 2>&1; then - echo "builder_v3.ts is not wired to import ./writers.gen.js" + "${repo_root}/packages/core/src/drawlist/builder.ts" >/dev/null 2>&1; then + echo "builder.ts is not wired to import ./writers.gen.js" has_violations=1 fi if [[ "${has_violations}" -eq 0 ]]; then From 8f0978beb66bd2bbca01e133d9e14a5b3ed64795 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:32:09 +0400 Subject: [PATCH 09/20] fix(review): address outstanding PR threads and CI regressions --- docs/backend/native.md | 25 ++++++ packages/bench/src/profile-packed-style.ts | 6 +- packages/core/src/app/widgetRenderer.ts | 6 +- packages/core/src/drawlist/builder.ts | 10 ++- packages/core/src/theme/defaultTheme.ts | 16 ++++ packages/core/src/theme/extract.ts | 2 +- packages/core/src/theme/heatmapPalettes.ts | 49 ++++++++++++ .../src/widgets/__tests__/colorScales.test.ts | 77 ++++++++++--------- .../widgets/__tests__/graphicsWidgets.test.ts | 30 +++----- .../widgets/__tests__/overlays.typecheck.ts | 2 +- packages/core/src/widgets/canvas.ts | 2 +- packages/core/src/widgets/diffViewer.ts | 22 +++--- packages/core/src/widgets/heatmap.ts | 55 ++----------- packages/core/src/widgets/logsConsole.ts | 15 ++-- packages/core/src/widgets/style.ts | 2 +- packages/core/src/widgets/toast.ts | 13 ++-- .../src/runtime/createInkRenderer.ts | 46 ++++++++++- .../ink-compat/src/translation/colorMap.ts | 2 +- .../src/translation/propsToVNode.ts | 16 ++-- .../zireael/src/core/zr_engine_present.inc | 10 ++- .../vendor/zireael/src/core/zr_framebuffer.c | 8 ++ scripts/guardrails.sh | 2 +- 22 files changed, 263 insertions(+), 153 deletions(-) create mode 100644 packages/core/src/theme/heatmapPalettes.ts diff --git a/docs/backend/native.md b/docs/backend/native.md index a7715e55..854414c6 100644 --- a/docs/backend/native.md +++ b/docs/backend/native.md @@ -79,6 +79,31 @@ The addon exposes a small set of functions at the N-API boundary: - `engineGetCaps(engineId)` -- Returns a `TerminalCaps` object describing detected terminal capabilities (color mode, mouse, paste, cursor shape, etc.). +## Native Resource Lifecycle + +Drawlist execution in Zireael tracks persistent resources (images, glyph caches, +and link intern tables) through `zr_dl_resources_t`. + +- `zr_dl_resources_init` -- Initializes empty owned resource storage. +- `zr_dl_resources_release` -- Releases owned resource memory and resets lengths. +- `zr_dl_resources_swap` -- Constant-time ownership swap between two stores. +- `zr_dl_resources_clone` -- Deep clone (duplicates owned bytes/arrays). +- `zr_dl_resources_clone_shallow` -- Shallow clone (borrows existing backing + storage and metadata references without duplicating payload). + +`zr_dl_preflight_resources` validates and stages resource effects before execute. +Preflight uses shallow snapshots so stage resources can borrow baseline data +without duplicate allocations during validation. `zr_dl_execute` then applies +the already-validated command stream against that staged state. On successful +commit the engine swaps staged resources into the live set; on failure it +retains the pre-commit set and releases stage state. + +Use deep clone when an independent lifetime is required (for example, caching +or cross-frame ownership transfer). Use shallow clone only for bounded +preflight/execute windows where the source lifetime is guaranteed to outlive the +borrow. Always pair `init`/`release` on owned stores and prefer `swap` for +commit paths to avoid duplicate frees and partial ownership transfer bugs. + ### User Events - `enginePostUserEvent(engineId, tag, payload)` -- Posts a custom user event diff --git a/packages/bench/src/profile-packed-style.ts b/packages/bench/src/profile-packed-style.ts index 1e182b18..b990da32 100644 --- a/packages/bench/src/profile-packed-style.ts +++ b/packages/bench/src/profile-packed-style.ts @@ -81,7 +81,11 @@ async function main() { } const snap = perfSnapshot(); - const counters = snap.counters; + const counters = snap.counters as { + style_merges_performed?: number; + style_objects_created?: number; + packRgb_calls?: number; + }; const merges = counters.style_merges_performed ?? 0; const styleObjects = counters.style_objects_created ?? 0; const packRgbCalls = counters.packRgb_calls ?? 0; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 0629e442..15179919 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -538,10 +538,6 @@ function monotonicNowMs(): number { return Date.now(); } -function isV2Builder(builder: DrawlistBuilder | DrawlistBuilder): builder is DrawlistBuilder { - return typeof (builder as DrawlistBuilder).setCursor === "function"; -} - function cloneFocusManagerState(state: FocusManagerState): FocusManagerState { return Object.freeze({ focusedId: state.focusedId, @@ -570,7 +566,7 @@ type ErrorBoundaryState = Readonly<{ */ export class WidgetRenderer { private readonly backend: RuntimeBackend; - private readonly builder: DrawlistBuilder | DrawlistBuilder | DrawlistBuilder; + private readonly builder: DrawlistBuilder; private readonly cursorShape: CursorShape; private readonly cursorBlink: boolean; private collectRuntimeBreadcrumbs: boolean; diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index d7f87ad1..270db4d7 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -185,11 +185,15 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D if (this.error) return; if (xi === null || yi === null) return; - const shape = state.shape & 0xff; - if (this.validateParams && (shape < 0 || shape > 2)) { - this.fail("ZRDL_BAD_PARAMS", `setCursor: shape must be 0, 1, or 2 (got ${shape})`); + const shapeRaw = state.shape; + if ( + this.validateParams && + (!Number.isFinite(shapeRaw) || !Number.isInteger(shapeRaw) || shapeRaw < 0 || shapeRaw > 2) + ) { + this.fail("ZRDL_BAD_PARAMS", `setCursor: shape must be 0, 1, or 2 (got ${shapeRaw})`); return; } + const shape = Number(shapeRaw) & 0xff; if (!this.beginCommandWrite("setCursor", SET_CURSOR_SIZE)) return; this.cmdLen = writeSetCursor( diff --git a/packages/core/src/theme/defaultTheme.ts b/packages/core/src/theme/defaultTheme.ts index 043fe58b..c1ddc2ea 100644 --- a/packages/core/src/theme/defaultTheme.ts +++ b/packages/core/src/theme/defaultTheme.ts @@ -35,6 +35,22 @@ export const defaultTheme: Theme = Object.freeze({ "syntax.variable": rgb(139, 233, 253), "syntax.cursor.fg": rgb(40, 42, 54), "syntax.cursor.bg": rgb(139, 233, 253), + "widget.diff.add.bg": rgb(35, 65, 35), + "widget.diff.delete.bg": rgb(65, 35, 35), + "widget.diff.add.fg": rgb(150, 255, 150), + "widget.diff.delete.fg": rgb(255, 150, 150), + "widget.diff.hunkHeader": rgb(100, 149, 237), + "widget.diff.lineNumber": rgb(100, 100, 100), + "widget.diff.border": rgb(80, 80, 80), + "widget.logs.level.trace": rgb(100, 100, 100), + "widget.logs.level.debug": rgb(150, 150, 150), + "widget.logs.level.info": rgb(255, 255, 255), + "widget.logs.level.warn": rgb(255, 200, 50), + "widget.logs.level.error": rgb(255, 80, 80), + "widget.toast.info": rgb(50, 150, 255), + "widget.toast.success": rgb(50, 200, 100), + "widget.toast.warning": rgb(255, 200, 50), + "widget.toast.error": rgb(255, 80, 80), }), spacing: Object.freeze([0, 1, 2, 4, 8, 16]), }); diff --git a/packages/core/src/theme/extract.ts b/packages/core/src/theme/extract.ts index ced96809..514ed46b 100644 --- a/packages/core/src/theme/extract.ts +++ b/packages/core/src/theme/extract.ts @@ -5,7 +5,7 @@ import type { ColorTokens } from "./tokens.js"; function extractColorTokens(theme: Theme): ColorTokens | null { const c = theme.colors; const bgBase = c["bg.base"] as Rgb24 | undefined; - if (!bgBase) return null; + if (bgBase === undefined) return null; return { bg: { diff --git a/packages/core/src/theme/heatmapPalettes.ts b/packages/core/src/theme/heatmapPalettes.ts new file mode 100644 index 00000000..723c4c25 --- /dev/null +++ b/packages/core/src/theme/heatmapPalettes.ts @@ -0,0 +1,49 @@ +import { type Rgb24, rgb } from "../widgets/style.js"; + +type HeatmapPaletteScale = "viridis" | "plasma" | "inferno" | "magma" | "turbo" | "grayscale"; + +export type HeatmapScaleStop = Readonly<{ t: number; rgb: Rgb24 }>; + +export const HEATMAP_SCALE_ANCHORS: Readonly< + Record +> = Object.freeze({ + viridis: Object.freeze([ + { t: 0, rgb: rgb(68, 1, 84) }, + { t: 0.25, rgb: rgb(59, 82, 139) }, + { t: 0.5, rgb: rgb(33, 145, 140) }, + { t: 0.75, rgb: rgb(94, 201, 98) }, + { t: 1, rgb: rgb(253, 231, 37) }, + ]), + plasma: Object.freeze([ + { t: 0, rgb: rgb(13, 8, 135) }, + { t: 0.25, rgb: rgb(126, 3, 168) }, + { t: 0.5, rgb: rgb(203, 71, 119) }, + { t: 0.75, rgb: rgb(248, 149, 64) }, + { t: 1, rgb: rgb(240, 249, 33) }, + ]), + inferno: Object.freeze([ + { t: 0, rgb: rgb(0, 0, 4) }, + { t: 0.25, rgb: rgb(87, 15, 109) }, + { t: 0.5, rgb: rgb(187, 55, 84) }, + { t: 0.75, rgb: rgb(249, 142, 8) }, + { t: 1, rgb: rgb(252, 255, 164) }, + ]), + magma: Object.freeze([ + { t: 0, rgb: rgb(0, 0, 4) }, + { t: 0.25, rgb: rgb(79, 18, 123) }, + { t: 0.5, rgb: rgb(182, 54, 121) }, + { t: 0.75, rgb: rgb(251, 140, 60) }, + { t: 1, rgb: rgb(252, 253, 191) }, + ]), + turbo: Object.freeze([ + { t: 0, rgb: rgb(48, 18, 59) }, + { t: 0.25, rgb: rgb(63, 128, 234) }, + { t: 0.5, rgb: rgb(34, 201, 169) }, + { t: 0.75, rgb: rgb(246, 189, 39) }, + { t: 1, rgb: rgb(122, 4, 3) }, + ]), + grayscale: Object.freeze([ + { t: 0, rgb: rgb(0, 0, 0) }, + { t: 1, rgb: rgb(255, 255, 255) }, + ]), +}); diff --git a/packages/core/src/widgets/__tests__/colorScales.test.ts b/packages/core/src/widgets/__tests__/colorScales.test.ts index 38fe4b8e..1f15d9f8 100644 --- a/packages/core/src/widgets/__tests__/colorScales.test.ts +++ b/packages/core/src/widgets/__tests__/colorScales.test.ts @@ -5,6 +5,7 @@ import { getHeatmapRange, normalizeHeatmapScale, } from "../heatmap.js"; +import { rgb } from "../style.js"; describe("heatmap color scales", () => { test("all scales expose 256 entries", () => { @@ -20,9 +21,9 @@ describe("heatmap color scales", () => { const c0 = colorForHeatmapValue(0, range, "viridis"); const c50 = colorForHeatmapValue(50, range, "viridis"); const c100 = colorForHeatmapValue(100, range, "viridis"); - assert.deepEqual(c0, { r: 68, g: 1, b: 84 }); - assert.deepEqual(c50, { r: 33, g: 145, b: 140 }); - assert.deepEqual(c100, { r: 253, g: 231, b: 37 }); + assert.equal(c0, rgb(68, 1, 84)); + assert.equal(c50, rgb(33, 145, 140)); + assert.equal(c100, rgb(253, 231, 37)); }); test("scale checkpoints remain deterministic across palettes", () => { @@ -31,71 +32,71 @@ describe("heatmap color scales", () => { { scale: "viridis" as const, expected: Object.freeze({ - 0: { r: 68, g: 1, b: 84 }, - 25: { r: 59, g: 82, b: 139 }, - 50: { r: 33, g: 145, b: 140 }, - 75: { r: 94, g: 201, b: 98 }, - 100: { r: 253, g: 231, b: 37 }, + 0: rgb(68, 1, 84), + 25: rgb(59, 82, 139), + 50: rgb(33, 145, 140), + 75: rgb(94, 201, 98), + 100: rgb(253, 231, 37), }), }, { scale: "plasma" as const, expected: Object.freeze({ - 0: { r: 13, g: 8, b: 135 }, - 25: { r: 126, g: 3, b: 168 }, - 50: { r: 203, g: 71, b: 119 }, - 75: { r: 248, g: 149, b: 64 }, - 100: { r: 240, g: 249, b: 33 }, + 0: rgb(13, 8, 135), + 25: rgb(126, 3, 168), + 50: rgb(203, 71, 119), + 75: rgb(248, 149, 64), + 100: rgb(240, 249, 33), }), }, { scale: "inferno" as const, expected: Object.freeze({ - 0: { r: 0, g: 0, b: 4 }, - 25: { r: 87, g: 15, b: 109 }, - 50: { r: 187, g: 55, b: 84 }, - 75: { r: 249, g: 142, b: 8 }, - 100: { r: 252, g: 255, b: 164 }, + 0: rgb(0, 0, 4), + 25: rgb(87, 15, 109), + 50: rgb(187, 55, 84), + 75: rgb(249, 142, 8), + 100: rgb(252, 255, 164), }), }, { scale: "magma" as const, expected: Object.freeze({ - 0: { r: 0, g: 0, b: 4 }, - 25: { r: 79, g: 18, b: 123 }, - 50: { r: 182, g: 54, b: 121 }, - 75: { r: 251, g: 140, b: 60 }, - 100: { r: 252, g: 253, b: 191 }, + 0: rgb(0, 0, 4), + 25: rgb(79, 18, 123), + 50: rgb(182, 54, 121), + 75: rgb(251, 140, 60), + 100: rgb(252, 253, 191), }), }, { scale: "turbo" as const, expected: Object.freeze({ - 0: { r: 48, g: 18, b: 59 }, - 25: { r: 63, g: 128, b: 234 }, - 50: { r: 34, g: 201, b: 169 }, - 75: { r: 246, g: 189, b: 39 }, - 100: { r: 122, g: 4, b: 3 }, + 0: rgb(48, 18, 59), + 25: rgb(63, 128, 234), + 50: rgb(34, 201, 169), + 75: rgb(246, 189, 39), + 100: rgb(122, 4, 3), }), }, { scale: "grayscale" as const, expected: Object.freeze({ - 0: { r: 0, g: 0, b: 0 }, - 25: { r: 64, g: 64, b: 64 }, - 50: { r: 128, g: 128, b: 128 }, - 75: { r: 191, g: 191, b: 191 }, - 100: { r: 255, g: 255, b: 255 }, + 0: rgb(0, 0, 0), + 25: rgb(64, 64, 64), + 50: rgb(128, 128, 128), + 75: rgb(191, 191, 191), + 100: rgb(255, 255, 255), }), }, ]); for (const { scale, expected } of checkpoints) { - assert.deepEqual(colorForHeatmapValue(0, range, scale), expected[0]); - assert.deepEqual(colorForHeatmapValue(25, range, scale), expected[25]); - assert.deepEqual(colorForHeatmapValue(50, range, scale), expected[50]); - assert.deepEqual(colorForHeatmapValue(75, range, scale), expected[75]); - assert.deepEqual(colorForHeatmapValue(100, range, scale), expected[100]); + assert.equal(colorForHeatmapValue(0, range, scale), expected[0]); + assert.equal(colorForHeatmapValue(25, range, scale), expected[25]); + assert.equal(colorForHeatmapValue(50, range, scale), expected[50]); + assert.equal(colorForHeatmapValue(75, range, scale), expected[75]); + assert.equal(colorForHeatmapValue(100, range, scale), expected[100]); } }); diff --git a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts index e9c54880..f9e05b7f 100644 --- a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts +++ b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts @@ -166,18 +166,14 @@ function renderBytes( } describe("graphics widgets", () => { - test("link encodes hyperlink refs in v3 and degrades to text in v1", () => { + test("link encodes hyperlink refs and keeps label text payload", () => { const vnode = ui.link({ url: "https://example.com", label: "Docs", id: "docs-link" }); - const v3 = renderBytes(vnode, () => createDrawlistBuilder()); - const v1 = renderBytes(vnode, () => createDrawlistBuilder()); - assert.equal(parseOpcodes(v3).includes(8), false); - assert.equal(parseOpcodes(v1).includes(8), false); - assert.equal(parseStrings(v3).includes("Docs"), true); - assert.equal(parseStrings(v1).includes("https://example.com"), false); - const v3TextPayload = findCommandPayload(v3, 3); - assert.equal(v3TextPayload !== null, true); - if (v3TextPayload === null) return; - assert.equal(u32(v3, v3TextPayload + 40) > 0, true); + const bytes = renderBytes(vnode, () => createDrawlistBuilder()); + assert.equal(parseStrings(bytes).includes("Docs"), true); + const textPayload = findCommandPayload(bytes, 3); + assert.equal(textPayload !== null, true); + if (textPayload === null) return; + assert.equal(u32(bytes, textPayload + 40) > 0, true); }); test("canvas emits DRAW_CANVAS", () => { @@ -478,19 +474,15 @@ describe("graphics widgets", () => { ); }); - test("image degrades to placeholder on v1", () => { + test("image auto on RGBA emits graphics commands on the unified builder", () => { const bytes = renderBytes( ui.image({ src: new Uint8Array([0, 0, 0, 0]), width: 20, height: 4, alt: "Logo" }), () => createDrawlistBuilder(), ); - const strings = parseStrings(bytes); + assert.equal(parseOpcodes(bytes).includes(8) || parseOpcodes(bytes).includes(9), true); assert.equal( - strings.some((value) => value.includes("Image")), - true, - ); - assert.equal( - strings.some((value) => value.includes("Logo")), - true, + parseStrings(bytes).some((value) => value.includes("Logo")), + false, ); }); diff --git a/packages/core/src/widgets/__tests__/overlays.typecheck.ts b/packages/core/src/widgets/__tests__/overlays.typecheck.ts index 90648864..a86a94ef 100644 --- a/packages/core/src/widgets/__tests__/overlays.typecheck.ts +++ b/packages/core/src/widgets/__tests__/overlays.typecheck.ts @@ -23,7 +23,7 @@ const dropdownFrame: DropdownProps["frameStyle"] = { border: (7 << 16) | (8 << 8) | 9, }; -// @ts-expect-error missing b component +// @ts-expect-error frameStyle.border expects packed Rgb24, not an object const dropdownFrameInvalid: DropdownProps["frameStyle"] = { border: { r: 1, g: 2 } }; const layerFrame: LayerProps["frameStyle"] = { diff --git a/packages/core/src/widgets/canvas.ts b/packages/core/src/widgets/canvas.ts index f78a72af..4e72ad9c 100644 --- a/packages/core/src/widgets/canvas.ts +++ b/packages/core/src/widgets/canvas.ts @@ -73,7 +73,7 @@ function resolvePixel( ): Readonly<{ r: number; g: number; b: number; a: number }> { if (color === undefined) return DEFAULT_SOLID_PIXEL; const parsedHex = parseHexColor(color); - if (parsedHex) { + if (parsedHex !== null) { return Object.freeze({ r: clampU8(rgbR(parsedHex)), g: clampU8(rgbG(parsedHex)), diff --git a/packages/core/src/widgets/diffViewer.ts b/packages/core/src/widgets/diffViewer.ts index 4503c8f6..4af8ebeb 100644 --- a/packages/core/src/widgets/diffViewer.ts +++ b/packages/core/src/widgets/diffViewer.ts @@ -7,7 +7,7 @@ * @see docs/widgets/diff-viewer.md */ -import { rgb } from "./style.js"; +import { defaultTheme } from "../theme/defaultTheme.js"; import type { DiffData, DiffHunk, DiffLine } from "./types.js"; /** Default number of context lines around changes. */ @@ -228,12 +228,14 @@ export function getHunkScrollPosition(hunkIndex: number, hunks: readonly DiffHun } /** Diff color constants. */ -export const DIFF_COLORS = { - addBg: rgb(35, 65, 35), - deleteBg: rgb(65, 35, 35), - addFg: rgb(150, 255, 150), - deleteFg: rgb(255, 150, 150), - hunkHeader: rgb(100, 149, 237), - lineNumber: rgb(100, 100, 100), - border: rgb(80, 80, 80), -} as const; +const WIDGET_PALETTE = defaultTheme.colors; + +export const DIFF_COLORS = Object.freeze({ + addBg: WIDGET_PALETTE["widget.diff.add.bg"] ?? WIDGET_PALETTE.success, + deleteBg: WIDGET_PALETTE["widget.diff.delete.bg"] ?? WIDGET_PALETTE.danger, + addFg: WIDGET_PALETTE["widget.diff.add.fg"] ?? WIDGET_PALETTE.fg, + deleteFg: WIDGET_PALETTE["widget.diff.delete.fg"] ?? WIDGET_PALETTE.fg, + hunkHeader: WIDGET_PALETTE["widget.diff.hunkHeader"] ?? WIDGET_PALETTE.info, + lineNumber: WIDGET_PALETTE["widget.diff.lineNumber"] ?? WIDGET_PALETTE.muted, + border: WIDGET_PALETTE["widget.diff.border"] ?? WIDGET_PALETTE.border, +}); diff --git a/packages/core/src/widgets/heatmap.ts b/packages/core/src/widgets/heatmap.ts index fc3255be..4d4dda7e 100644 --- a/packages/core/src/widgets/heatmap.ts +++ b/packages/core/src/widgets/heatmap.ts @@ -1,50 +1,9 @@ +import { HEATMAP_SCALE_ANCHORS } from "../theme/heatmapPalettes.js"; import { type Rgb24, rgb, rgbB, rgbG, rgbR } from "./style.js"; import type { HeatmapColorScale } from "./types.js"; type ScaleStop = Readonly<{ t: number; rgb: Rgb24 }>; -const SCALE_ANCHORS: Readonly> = Object.freeze({ - viridis: Object.freeze([ - { t: 0, rgb: rgb(68, 1, 84) }, - { t: 0.25, rgb: rgb(59, 82, 139) }, - { t: 0.5, rgb: rgb(33, 145, 140) }, - { t: 0.75, rgb: rgb(94, 201, 98) }, - { t: 1, rgb: rgb(253, 231, 37) }, - ]), - plasma: Object.freeze([ - { t: 0, rgb: rgb(13, 8, 135) }, - { t: 0.25, rgb: rgb(126, 3, 168) }, - { t: 0.5, rgb: rgb(203, 71, 119) }, - { t: 0.75, rgb: rgb(248, 149, 64) }, - { t: 1, rgb: rgb(240, 249, 33) }, - ]), - inferno: Object.freeze([ - { t: 0, rgb: rgb(0, 0, 4) }, - { t: 0.25, rgb: rgb(87, 15, 109) }, - { t: 0.5, rgb: rgb(187, 55, 84) }, - { t: 0.75, rgb: rgb(249, 142, 8) }, - { t: 1, rgb: rgb(252, 255, 164) }, - ]), - magma: Object.freeze([ - { t: 0, rgb: rgb(0, 0, 4) }, - { t: 0.25, rgb: rgb(79, 18, 123) }, - { t: 0.5, rgb: rgb(182, 54, 121) }, - { t: 0.75, rgb: rgb(251, 140, 60) }, - { t: 1, rgb: rgb(252, 253, 191) }, - ]), - turbo: Object.freeze([ - { t: 0, rgb: rgb(48, 18, 59) }, - { t: 0.25, rgb: rgb(63, 128, 234) }, - { t: 0.5, rgb: rgb(34, 201, 169) }, - { t: 0.75, rgb: rgb(246, 189, 39) }, - { t: 1, rgb: rgb(122, 4, 3) }, - ]), - grayscale: Object.freeze([ - { t: 0, rgb: rgb(0, 0, 0) }, - { t: 1, rgb: rgb(255, 255, 255) }, - ]), -}); - function readFinite(value: number | undefined): number | undefined { if (!Number.isFinite(value)) return undefined; return value; @@ -94,12 +53,12 @@ function buildScaleTable(stops: readonly ScaleStop[]): readonly Rgb24[] { } const SCALE_TABLES: Readonly> = Object.freeze({ - viridis: buildScaleTable(SCALE_ANCHORS.viridis), - plasma: buildScaleTable(SCALE_ANCHORS.plasma), - inferno: buildScaleTable(SCALE_ANCHORS.inferno), - magma: buildScaleTable(SCALE_ANCHORS.magma), - turbo: buildScaleTable(SCALE_ANCHORS.turbo), - grayscale: buildScaleTable(SCALE_ANCHORS.grayscale), + viridis: buildScaleTable(HEATMAP_SCALE_ANCHORS.viridis), + plasma: buildScaleTable(HEATMAP_SCALE_ANCHORS.plasma), + inferno: buildScaleTable(HEATMAP_SCALE_ANCHORS.inferno), + magma: buildScaleTable(HEATMAP_SCALE_ANCHORS.magma), + turbo: buildScaleTable(HEATMAP_SCALE_ANCHORS.turbo), + grayscale: buildScaleTable(HEATMAP_SCALE_ANCHORS.grayscale), }); export function getHeatmapColorTable(scale: HeatmapColorScale): readonly Rgb24[] { diff --git a/packages/core/src/widgets/logsConsole.ts b/packages/core/src/widgets/logsConsole.ts index d6f6c258..ebb57276 100644 --- a/packages/core/src/widgets/logsConsole.ts +++ b/packages/core/src/widgets/logsConsole.ts @@ -7,7 +7,8 @@ * @see docs/widgets/logs-console.md */ -import { type Rgb24, rgb } from "./style.js"; +import { defaultTheme } from "../theme/defaultTheme.js"; +import type { Rgb24 } from "./style.js"; import type { LogEntry, LogLevel } from "./types.js"; /** Default max log entries to keep. */ @@ -207,12 +208,14 @@ export function formatCost(costCents: number): string { } /** Level color map (packed RGB24). */ +const WIDGET_PALETTE = defaultTheme.colors; + export const LEVEL_COLORS: Record = { - trace: rgb(100, 100, 100), - debug: rgb(150, 150, 150), - info: rgb(255, 255, 255), - warn: rgb(255, 200, 50), - error: rgb(255, 80, 80), + trace: WIDGET_PALETTE["widget.logs.level.trace"] ?? WIDGET_PALETTE.muted, + debug: WIDGET_PALETTE["widget.logs.level.debug"] ?? WIDGET_PALETTE.secondary, + info: WIDGET_PALETTE["widget.logs.level.info"] ?? WIDGET_PALETTE.fg, + warn: WIDGET_PALETTE["widget.logs.level.warn"] ?? WIDGET_PALETTE.warning, + error: WIDGET_PALETTE["widget.logs.level.error"] ?? WIDGET_PALETTE.danger, }; /** Level priority for filtering. */ diff --git a/packages/core/src/widgets/style.ts b/packages/core/src/widgets/style.ts index a3a6dc79..2bb879ee 100644 --- a/packages/core/src/widgets/style.ts +++ b/packages/core/src/widgets/style.ts @@ -34,7 +34,7 @@ function clampChannel(value: number): number { return Math.round(value); } -/** Create a packed RGB color value. */ +/** Create a packed RGB color value. Note: `rgb(0, 0, 0)` encodes sentinel `0`. */ export function rgb(r: number, g: number, b: number): Rgb24 { const rr = clampChannel(r); const gg = clampChannel(g); diff --git a/packages/core/src/widgets/toast.ts b/packages/core/src/widgets/toast.ts index 5a1cbb51..d8e1d5f2 100644 --- a/packages/core/src/widgets/toast.ts +++ b/packages/core/src/widgets/toast.ts @@ -8,7 +8,8 @@ * @see docs/widgets/toast.md */ -import { type Rgb24, rgb } from "./style.js"; +import { defaultTheme } from "../theme/defaultTheme.js"; +import type { Rgb24 } from "./style.js"; import type { Toast, ToastPosition } from "./types.js"; /** Height of a single toast in cells. */ @@ -170,9 +171,11 @@ export const TOAST_ICONS: Record = { }; /** Border color for each toast type (packed RGB24). */ +const WIDGET_PALETTE = defaultTheme.colors; + export const TOAST_COLORS: Record = { - info: rgb(50, 150, 255), - success: rgb(50, 200, 100), - warning: rgb(255, 200, 50), - error: rgb(255, 80, 80), + info: WIDGET_PALETTE["widget.toast.info"] ?? WIDGET_PALETTE.info, + success: WIDGET_PALETTE["widget.toast.success"] ?? WIDGET_PALETTE.success, + warning: WIDGET_PALETTE["widget.toast.warning"] ?? WIDGET_PALETTE.warning, + error: WIDGET_PALETTE["widget.toast.error"] ?? WIDGET_PALETTE.danger, }; diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index 650c76f5..7414ba51 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -498,6 +498,7 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { // Cached result for early-skip optimization let cachedOps: readonly InkRenderOp[] = []; let cachedNodes: readonly InkRenderNode[] = []; + let cachedViewport: InkRendererViewport | null = null; const render = (vnode: VNode, renderOpts: InkRenderOptions = {}): InkRenderResult => { const t0 = performance.now(); @@ -505,6 +506,10 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { const viewport = normalizeViewport(renderOpts.viewport ?? defaultViewport); const forceLayout = renderOpts.forceLayout === true; + const viewportChanged = + cachedViewport === null || + cachedViewport.cols !== viewport.cols || + cachedViewport.rows !== viewport.rows; // ─── COMMIT ─── const commitStartedAt = performance.now(); @@ -521,8 +526,43 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { // ─── EARLY SKIP: nothing changed ─── // After commit with in-place mutation, if root.dirty is false, the entire // tree is unchanged. Skip layout, draw, and collect entirely. - if (!isFirstFrame && !forceLayout && !prevRoot.dirty && cachedLayoutTree !== null) { + if ( + !isFirstFrame && + !forceLayout && + !prevRoot.dirty && + cachedLayoutTree !== null && + !viewportChanged + ) { const totalMs = performance.now() - t0; + if (trace) { + const textStartedAt = performance.now(); + const screenText = opsToText(cachedOps, viewport); + const textMs = performance.now() - textStartedAt; + const opSummary = summarizeOps(cachedOps); + const nodeSummary = summarizeNodes(cachedNodes); + const textSummary = summarizeText(screenText); + trace({ + renderId, + viewport, + focusedId: null, + tick: 0, + timings: { commitMs, layoutMs: 0, drawMs: 0, textMs, totalMs }, + nodeCount: cachedNodes.length, + opCount: cachedOps.length, + opCounts: opSummary.opCounts, + clipDepthMax: opSummary.clipDepthMax, + textChars: textSummary.textChars, + textLines: textSummary.textLines, + nonBlankLines: textSummary.nonBlankLines, + widestLine: textSummary.widestLine, + minRectY: nodeSummary.minRectY, + maxRectBottom: nodeSummary.maxRectBottom, + zeroHeightRects: nodeSummary.zeroHeightRects, + detailIncluded: defaultTraceDetail, + layoutSkipped: true, + ...(defaultTraceDetail ? { nodes: cachedNodes, ops: cachedOps, text: screenText } : {}), + }); + } return { ops: cachedOps, nodes: cachedNodes, @@ -582,6 +622,7 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { const nodes = collectNodes(cachedLayoutTree); cachedOps = ops; cachedNodes = nodes; + cachedViewport = viewport; const totalMs = performance.now() - t0; // ─── TRACE (when configured) ─── @@ -626,6 +667,9 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { const reset = (): void => { prevRoot = null; cachedLayoutTree = null; + cachedOps = []; + cachedNodes = []; + cachedViewport = null; allocator = createInstanceIdAllocator(1); }; diff --git a/packages/ink-compat/src/translation/colorMap.ts b/packages/ink-compat/src/translation/colorMap.ts index 0c39d2e3..c07caa51 100644 --- a/packages/ink-compat/src/translation/colorMap.ts +++ b/packages/ink-compat/src/translation/colorMap.ts @@ -55,7 +55,7 @@ export function parseColor(color: string | undefined): Rgb24 | undefined { pushTranslationTrace({ kind: "color-parse", input: color, - result: result ? { r: rgbR(result), g: rgbG(result), b: rgbB(result) } : null, + result: result !== undefined ? { r: rgbR(result), g: rgbG(result), b: rgbB(result) } : null, }); } return result; diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index 1c49c480..29fd3406 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -652,7 +652,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (scrollY != null) layoutProps.scrollY = scrollY; const scrollbarThumbColor = parseColor(p.scrollbarThumbColor as string | undefined); - if (scrollbarThumbColor) { + if (scrollbarThumbColor !== undefined) { layoutProps.scrollbarStyle = { fg: scrollbarThumbColor }; } } else if (hasHiddenOverflow) { @@ -693,7 +693,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul const style: Record = {}; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg) style["bg"] = bg; + if (bg !== undefined) style["bg"] = bg; if (Object.keys(style).length > 0) layoutProps.style = style; const explicitBorderColor = parseColor(p.borderColor as string | undefined); @@ -712,7 +712,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul }; const borderColor = explicitBorderColor; - if (borderColor) { + if (borderColor !== undefined) { layoutProps.borderStyle = { ...(typeof layoutProps.borderStyle === "object" && layoutProps.borderStyle !== null ? layoutProps.borderStyle @@ -737,7 +737,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (!hasColorOverride && !hasDimOverride) continue; const sideStyle: Record = {}; const resolvedColor = edgeBorderColors[side] ?? explicitBorderColor; - if (resolvedColor) sideStyle["fg"] = resolvedColor; + if (resolvedColor !== undefined) sideStyle["fg"] = resolvedColor; if (globalBorderDim || hasDimOverride) sideStyle["dim"] = true; if (Object.keys(sideStyle).length > 0) { borderStyleSides[side] = sideStyle; @@ -820,9 +820,9 @@ function translateText(node: InkHostNode): VNode { const style: TextStyleMap = {}; const fg = parseColor(p.color as string | undefined); - if (fg) style.fg = fg; + if (fg !== undefined) style.fg = fg; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg) style.bg = bg; + if (bg !== undefined) style.bg = bg; if (p.bold) style.bold = true; if (p.italic) style.italic = true; if (p.underline) style.underline = true; @@ -924,9 +924,9 @@ function flattenTextChildren( const childStyle: TextStyleMap = { ...parentStyle }; const fg = parseColor(cp.color as string | undefined); - if (fg) childStyle.fg = fg; + if (fg !== undefined) childStyle.fg = fg; const bg = parseColor(cp.backgroundColor as string | undefined); - if (bg) childStyle.bg = bg; + if (bg !== undefined) childStyle.bg = bg; if (cp.bold) childStyle.bold = true; if (cp.italic) childStyle.italic = true; if (cp.underline) childStyle.underline = true; diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index b9aa12d5..cc7ff98d 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -326,6 +326,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ if (!e || !final_ts || !stats || !image_state_stage) { return; } + bool invalidate_prev_hashes = false; const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; @@ -340,7 +341,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ */ if (!e->fb_prev.cells || !presented_fb->cells || e->fb_prev.cols != presented_fb->cols || e->fb_prev.rows != presented_fb->rows) { - e->diff_prev_hashes_valid = 0u; + invalidate_prev_hashes = true; } else { if (!use_damage_rect_copy) { const size_t n = zr_engine_cells_bytes(presented_fb); @@ -355,17 +356,20 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ if (n != 0u) { memcpy(e->fb_prev.cells, presented_fb->cells, n); } - e->diff_prev_hashes_valid = 0u; + invalidate_prev_hashes = true; } } zr_fb_links_reset(&e->fb_prev); if (zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { - e->diff_prev_hashes_valid = 0u; + invalidate_prev_hashes = true; } } zr_engine_swap_diff_hashes_on_commit(e); + if (invalidate_prev_hashes) { + e->diff_prev_hashes_valid = 0u; + } e->term_state = *final_ts; e->image_state = *image_state_stage; diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.c b/packages/native/vendor/zireael/src/core/zr_framebuffer.c index 7870bf8c..69cd9a16 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.c +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.c @@ -225,6 +225,14 @@ zr_result_t zr_fb_copy_damage_rects(zr_fb_t* dst, const zr_fb_t* src, const zr_d if (!dst->cells || !src->cells) { return ZR_ERR_INVALID_ARGUMENT; } + /* Copying cells also copies link_ref indices, so sync intern tables first. */ + zr_fb_links_reset(dst); + { + const zr_result_t links_rc = zr_fb_links_clone_from(dst, src); + if (links_rc != ZR_OK) { + return links_rc; + } + } const uint32_t max_x = dst->cols - 1u; const uint32_t max_y = dst->rows - 1u; diff --git a/scripts/guardrails.sh b/scripts/guardrails.sh index c2c71d2d..9fae343d 100755 --- a/scripts/guardrails.sh +++ b/scripts/guardrails.sh @@ -73,7 +73,7 @@ if ! rg -n "Source of truth: scripts/drawlist-spec.ts" \ echo "missing source-of-truth marker in packages/core/src/drawlist/writers.gen.ts" has_violations=1 fi -if ! rg -n "from \"\\./writers\\.gen\\.js\"" \ +if ! rg -n "from ['\"]\\./writers\\.gen\\.js['\"]" \ "${repo_root}/packages/core/src/drawlist/builder.ts" >/dev/null 2>&1; then echo "builder.ts is not wired to import ./writers.gen.js" has_violations=1 From c9ed0462cd3660188ad8d548fd46e876d8dc2a14 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:45:34 +0400 Subject: [PATCH 10/20] feat: land blit-rect pipeline and regression audit tooling --- examples/regression-dashboard/README.md | 47 ++ .../regression-dashboard/package-lock.json | 55 ++ examples/regression-dashboard/package.json | 21 + .../src/__tests__/keybindings.test.ts | 12 + .../src/__tests__/reducer.test.ts | 19 + .../src/__tests__/render.test.ts | 21 + .../src/__tests__/telemetry.test.ts | 48 ++ .../src/helpers/formatters.ts | 53 ++ .../src/helpers/keybindings.ts | 27 + .../regression-dashboard/src/helpers/state.ts | 204 +++++++ .../src/helpers/telemetry.ts | 22 + examples/regression-dashboard/src/main.ts | 521 ++++++++++++++++++ .../src/screens/overview.ts | 206 +++++++ examples/regression-dashboard/src/theme.ts | 51 ++ examples/regression-dashboard/src/types.ts | 36 ++ examples/regression-dashboard/tsconfig.json | 12 + packages/core/src/__tests__/drawlistDecode.ts | 348 ++++++++++++ .../integration/integration.dashboard.test.ts | 27 +- .../integration.file-manager.test.ts | 23 +- .../integration.form-editor.test.ts | 26 +- .../integration/integration.reflow.test.ts | 125 +---- .../integration/integration.resize.test.ts | 26 +- .../src/app/__tests__/hotStateReload.test.ts | 27 +- .../__tests__/inspectorOverlayHelper.test.ts | 27 +- .../core/src/app/__tests__/resilience.test.ts | 27 +- packages/core/src/app/rawRenderer.ts | 51 +- packages/core/src/app/widgetRenderer.ts | 471 +++++++++++++++- packages/core/src/drawlist/builder.ts | 262 ++++++++- packages/core/src/perf/frameAudit.ts | 178 ++++++ .../renderer/__tests__/overlay.edge.test.ts | 34 +- .../renderer/__tests__/render.golden.test.ts | 29 +- .../renderer/__tests__/renderer.text.test.ts | 242 ++++---- packages/core/src/runtime/commit.ts | 5 + .../__tests__/basicWidgets.render.test.ts | 64 +-- .../widgets/__tests__/graphicsWidgets.test.ts | 56 +- .../__tests__/inspectorOverlay.render.test.ts | 33 +- .../__tests__/renderer.regressions.test.ts | 26 +- .../__tests__/widgetRenderSmoke.test.ts | 31 +- .../templates/starship/src/main.ts | 229 +++++++- .../templates/starship/src/screens/cargo.ts | 6 +- .../templates/starship/src/screens/comms.ts | 6 +- .../templates/starship/src/screens/crew.ts | 6 +- .../starship/src/screens/engineering.ts | 6 +- .../starship/src/screens/settings.ts | 1 + .../templates/starship/src/theme.ts | 141 +++-- packages/native/src/lib.rs | 151 +++-- packages/native/vendor/VENDOR_COMMIT.txt | 2 +- .../vendor/zireael/include/zr/zr_drawlist.h | 14 +- .../vendor/zireael/include/zr/zr_version.h | 6 +- .../vendor/zireael/src/core/zr_config.c | 8 +- .../native/vendor/zireael/src/core/zr_diff.c | 45 ++ .../vendor/zireael/src/core/zr_drawlist.c | 262 +++++++-- .../vendor/zireael/src/core/zr_engine.c | 158 ++++++ .../zireael/src/core/zr_engine_present.inc | 43 +- .../vendor/zireael/src/core/zr_framebuffer.c | 9 +- packages/node/src/backend/nodeBackend.ts | 312 ++++++++++- .../node/src/backend/nodeBackendInline.ts | 66 +++ packages/node/src/frameAudit.ts | 290 ++++++++++ packages/node/src/worker/engineWorker.ts | 360 +++++++++++- scripts/frame-audit-report.mjs | 337 +++++++++++ 60 files changed, 5088 insertions(+), 863 deletions(-) create mode 100644 examples/regression-dashboard/README.md create mode 100644 examples/regression-dashboard/package-lock.json create mode 100644 examples/regression-dashboard/package.json create mode 100644 examples/regression-dashboard/src/__tests__/keybindings.test.ts create mode 100644 examples/regression-dashboard/src/__tests__/reducer.test.ts create mode 100644 examples/regression-dashboard/src/__tests__/render.test.ts create mode 100644 examples/regression-dashboard/src/__tests__/telemetry.test.ts create mode 100644 examples/regression-dashboard/src/helpers/formatters.ts create mode 100644 examples/regression-dashboard/src/helpers/keybindings.ts create mode 100644 examples/regression-dashboard/src/helpers/state.ts create mode 100644 examples/regression-dashboard/src/helpers/telemetry.ts create mode 100644 examples/regression-dashboard/src/main.ts create mode 100644 examples/regression-dashboard/src/screens/overview.ts create mode 100644 examples/regression-dashboard/src/theme.ts create mode 100644 examples/regression-dashboard/src/types.ts create mode 100644 examples/regression-dashboard/tsconfig.json create mode 100644 packages/core/src/__tests__/drawlistDecode.ts create mode 100644 packages/core/src/perf/frameAudit.ts create mode 100644 packages/node/src/frameAudit.ts create mode 100755 scripts/frame-audit-report.mjs diff --git a/examples/regression-dashboard/README.md b/examples/regression-dashboard/README.md new file mode 100644 index 00000000..698b7393 --- /dev/null +++ b/examples/regression-dashboard/README.md @@ -0,0 +1,47 @@ +# Regression Dashboard Example + +Dashboard-based regression app for manually validating rendering behavior after core refactors. + +## Run (from repo root) + +```bash +npm --prefix examples/regression-dashboard run start +``` + +Interactive mode requires a real TTY terminal. + +## Run with HSR + +```bash +npm --prefix examples/regression-dashboard run dev +``` + +## Headless Preview (non-TTY safe) + +```bash +npm --prefix examples/regression-dashboard run preview +``` + +## Build / Typecheck / Test + +```bash +npm --prefix examples/regression-dashboard run build +npm --prefix examples/regression-dashboard run typecheck +npm --prefix examples/regression-dashboard run test +``` + +## What to check + +- Scroll service lanes with wheel/keys and verify no visual tearing. +- Change filters/theme while telemetry ticks are active. +- Open/close help modal repeatedly. +- Verify focus/selection remains stable during rapid updates. + +## Controls + +- `up` / `down` or `j` / `k`: Move selection +- `f`: Cycle fleet filter +- `t`: Cycle theme +- `p` or `space`: Pause/resume telemetry stream +- `h` or `?`: Toggle help modal +- `q` or `ctrl+c`: Quit diff --git a/examples/regression-dashboard/package-lock.json b/examples/regression-dashboard/package-lock.json new file mode 100644 index 00000000..27fb03e1 --- /dev/null +++ b/examples/regression-dashboard/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "regression-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "regression-dashboard", + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "../../packages/core": { + "name": "@rezi-ui/core", + "version": "0.1.0-alpha.34", + "license": "Apache-2.0", + "devDependencies": { + "@rezi-ui/testkit": "0.1.0-alpha.34" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "../../packages/node": { + "name": "@rezi-ui/node", + "version": "0.1.0-alpha.34", + "license": "Apache-2.0", + "dependencies": { + "@rezi-ui/core": "0.1.0-alpha.34", + "@rezi-ui/native": "0.1.0-alpha.34" + }, + "devDependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.1.0" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "node_modules/@rezi-ui/core": { + "resolved": "../../packages/core", + "link": true + }, + "node_modules/@rezi-ui/node": { + "resolved": "../../packages/node", + "link": true + } + } +} diff --git a/examples/regression-dashboard/package.json b/examples/regression-dashboard/package.json new file mode 100644 index 00000000..8e5cf64e --- /dev/null +++ b/examples/regression-dashboard/package.json @@ -0,0 +1,21 @@ +{ + "name": "regression-dashboard", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/main.ts", + "dev": "tsx src/main.ts --hsr", + "preview": "tsx src/main.ts --headless", + "build": "tsc -p tsconfig.json --pretty false", + "typecheck": "tsc --noEmit", + "test": "tsx --test src/__tests__/*.test.ts" + }, + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "node": ">=18", + "bun": ">=1.3.0" + } +} diff --git a/examples/regression-dashboard/src/__tests__/keybindings.test.ts b/examples/regression-dashboard/src/__tests__/keybindings.test.ts new file mode 100644 index 00000000..f42c3542 --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/keybindings.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveDashboardCommand } from "../helpers/keybindings.js"; + +test("dashboard keybinding map resolves canonical keys", () => { + assert.equal(resolveDashboardCommand("q"), "quit"); + assert.equal(resolveDashboardCommand("j"), "move-down"); + assert.equal(resolveDashboardCommand("k"), "move-up"); + assert.equal(resolveDashboardCommand("f"), "cycle-filter"); + assert.equal(resolveDashboardCommand("t"), "cycle-theme"); + assert.equal(resolveDashboardCommand("x"), undefined); +}); diff --git a/examples/regression-dashboard/src/__tests__/reducer.test.ts b/examples/regression-dashboard/src/__tests__/reducer.test.ts new file mode 100644 index 00000000..213055cf --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/reducer.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createInitialState, reduceDashboardState } from "../helpers/state.js"; + +test("dashboard reducer toggles pause state", () => { + const initial = createInitialState(0); + const next = reduceDashboardState(initial, { type: "toggle-pause" }); + assert.equal(next.paused, true); + const resumed = reduceDashboardState(next, { type: "toggle-pause" }); + assert.equal(resumed.paused, false); +}); + +test("dashboard reducer tick updates counters and services", () => { + const initial = createInitialState(0); + const next = reduceDashboardState(initial, { type: "tick", nowMs: 1000 }); + assert.equal(next.tick, 1); + assert.equal(next.lastUpdatedMs, 1000); + assert.notDeepEqual(next.services, initial.services); +}); diff --git a/examples/regression-dashboard/src/__tests__/render.test.ts b/examples/regression-dashboard/src/__tests__/render.test.ts new file mode 100644 index 00000000..0e61cdbf --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/render.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTestRenderer } from "@rezi-ui/core/testing"; +import { createInitialState } from "../helpers/state.js"; +import { renderOverviewScreen } from "../screens/overview.js"; + +test("dashboard overview render includes core markers", () => { + const state = createInitialState(0); + const renderer = createTestRenderer({ viewport: { cols: 120, rows: 34 } }); + const tree = renderOverviewScreen(state, { + onTogglePause: () => {}, + onCycleFilter: () => {}, + onCycleTheme: () => {}, + onToggleHelp: () => {}, + onSelectService: () => {}, + }); + + const output = renderer.render(tree).toText(); + assert.match(output, /Regression Dashboard/); + assert.match(output, /Service Fleet/); +}); diff --git a/examples/regression-dashboard/src/__tests__/telemetry.test.ts b/examples/regression-dashboard/src/__tests__/telemetry.test.ts new file mode 100644 index 00000000..3787f74c --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/telemetry.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTelemetryStream } from "../helpers/telemetry.js"; + +async function nextWithTimeout( + iterator: AsyncIterator, + timeoutMs = 1000, +): Promise> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + iterator.next(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`next() timed out after ${String(timeoutMs)}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +test("telemetry stream yields ticks with timestamps", async () => { + const stream = createTelemetryStream(10); + const iterator = stream[Symbol.asyncIterator](); + + const first = await nextWithTimeout(iterator); + assert.equal(first.done, false); + assert.ok(first.value.nowMs > 0); + + const second = await nextWithTimeout(iterator); + assert.equal(second.done, false); + assert.ok(second.value.nowMs >= first.value.nowMs); + + await iterator.return?.(); +}); + +test("telemetry stream stops after return()", async () => { + const stream = createTelemetryStream(10); + const iterator = stream[Symbol.asyncIterator](); + + await iterator.return?.(); + const next = await nextWithTimeout(iterator); + assert.equal(next.done, true); +}); diff --git a/examples/regression-dashboard/src/helpers/formatters.ts b/examples/regression-dashboard/src/helpers/formatters.ts new file mode 100644 index 00000000..a92ad0c6 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/formatters.ts @@ -0,0 +1,53 @@ +import type { BadgeVariant } from "@rezi-ui/core"; +import type { Service, ServiceFilter, ServiceStatus } from "../types.js"; + +export function formatLatency(ms: number): string { + return `${Math.round(ms)} ms`; +} + +export function formatErrorRate(percent: number): string { + return `${percent.toFixed(2)}%`; +} + +export function formatTraffic(rpm: number): string { + if (rpm >= 1000) return `${(rpm / 1000).toFixed(1)}k rpm`; + return `${rpm.toFixed(0)} rpm`; +} + +export function statusBadge( + status: ServiceStatus, +): Readonly<{ label: string; variant: BadgeVariant }> { + if (status === "healthy") return { label: "Healthy", variant: "success" }; + if (status === "warning") return { label: "Warning", variant: "warning" }; + return { label: "Critical", variant: "error" }; +} + +export function statusGlyph(status: ServiceStatus): string { + if (status === "healthy") return "●"; + if (status === "warning") return "▲"; + return "■"; +} + +export function filterLabel(filter: ServiceFilter): string { + if (filter === "all") return "All"; + if (filter === "healthy") return "Healthy"; + if (filter === "warning") return "Warning"; + return "Down"; +} + +export function fleetCounts( + services: readonly Service[], +): Readonly<{ healthy: number; warning: number; down: number }> { + return Object.freeze({ + healthy: services.filter((service) => service.status === "healthy").length, + warning: services.filter((service) => service.status === "warning").length, + down: services.filter((service) => service.status === "down").length, + }); +} + +export function overallStatus(services: readonly Service[]): ServiceStatus { + const counts = fleetCounts(services); + if (counts.down > 0) return "down"; + if (counts.warning > 0) return "warning"; + return "healthy"; +} diff --git a/examples/regression-dashboard/src/helpers/keybindings.ts b/examples/regression-dashboard/src/helpers/keybindings.ts new file mode 100644 index 00000000..6c345ad8 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/keybindings.ts @@ -0,0 +1,27 @@ +export type DashboardCommand = + | "quit" + | "move-up" + | "move-down" + | "toggle-help" + | "toggle-pause" + | "cycle-filter" + | "cycle-theme"; + +const COMMAND_BY_KEY: Readonly> = Object.freeze({ + q: "quit", + "ctrl+c": "quit", + up: "move-up", + k: "move-up", + down: "move-down", + j: "move-down", + h: "toggle-help", + "shift+/": "toggle-help", + p: "toggle-pause", + space: "toggle-pause", + f: "cycle-filter", + t: "cycle-theme", +}); + +export function resolveDashboardCommand(key: string): DashboardCommand | undefined { + return COMMAND_BY_KEY[key.toLowerCase()]; +} diff --git a/examples/regression-dashboard/src/helpers/state.ts b/examples/regression-dashboard/src/helpers/state.ts new file mode 100644 index 00000000..b2fb0b05 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/state.ts @@ -0,0 +1,204 @@ +import { DEFAULT_THEME_NAME, cycleThemeName } from "../theme.js"; +import type { + DashboardAction, + DashboardState, + Service, + ServiceFilter, + ServiceStatus, +} from "../types.js"; + +const FILTER_ORDER: readonly ServiceFilter[] = Object.freeze(["all", "warning", "down", "healthy"]); +const MAX_HISTORY = 18; + +const SEED_SERVICES: readonly Service[] = Object.freeze([ + { + id: "auth", + name: "Auth Gateway", + region: "us-east-1", + owner: "Identity", + status: "healthy", + latencyMs: 23, + errorRate: 0.2, + trafficRpm: 14320, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 23)), + }, + { + id: "billing", + name: "Billing API", + region: "us-west-2", + owner: "Commerce", + status: "warning", + latencyMs: 83, + errorRate: 1.1, + trafficRpm: 7390, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 83)), + }, + { + id: "search", + name: "Search Index", + region: "eu-central-1", + owner: "Discovery", + status: "healthy", + latencyMs: 37, + errorRate: 0.34, + trafficRpm: 9880, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 37)), + }, + { + id: "notify", + name: "Notification Bus", + region: "eu-west-1", + owner: "Messaging", + status: "healthy", + latencyMs: 31, + errorRate: 0.27, + trafficRpm: 8120, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 31)), + }, + { + id: "exports", + name: "Export Workers", + region: "us-east-1", + owner: "Data Platform", + status: "down", + latencyMs: 152, + errorRate: 4.4, + trafficRpm: 810, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 152)), + }, +]); + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function nextFilter(current: ServiceFilter): ServiceFilter { + const index = FILTER_ORDER.indexOf(current); + const next = index < 0 ? 0 : (index + 1) % FILTER_ORDER.length; + return FILTER_ORDER[next] ?? "all"; +} + +function deriveStatus(latencyMs: number, errorRate: number): ServiceStatus { + if (latencyMs >= 130 || errorRate >= 3.2) return "down"; + if (latencyMs >= 78 || errorRate >= 1.0) return "warning"; + return "healthy"; +} + +function evolveService(service: Service, index: number, nextTick: number): Service { + const phase = nextTick * 0.28 + index * 0.91; + const latency = clamp( + Math.round(service.latencyMs + Math.sin(phase) * 8 + Math.cos(phase * 0.66) * 6), + 12, + 220, + ); + const errorRate = round2(clamp(service.errorRate + Math.sin(phase * 0.73) * 0.15, 0.03, 6.4)); + const trafficRpm = clamp( + Math.round(service.trafficRpm + Math.sin(phase) * 420 + Math.cos(phase * 0.33) * 220), + 400, + 26000, + ); + const status = deriveStatus(latency, errorRate); + + return { + ...service, + latencyMs: latency, + errorRate, + trafficRpm, + status, + history: Object.freeze([...service.history, latency].slice(-MAX_HISTORY)), + }; +} + +function resolveSelectedId(state: DashboardState): string { + const visible = visibleServices(state); + if (visible.length === 0) return ""; + if (visible.some((service) => service.id === state.selectedId)) return state.selectedId; + return visible[0]?.id ?? ""; +} + +export function createInitialState(nowMs = Date.now()): DashboardState { + return { + services: SEED_SERVICES, + selectedId: + SEED_SERVICES.find((service) => service.status !== "healthy")?.id ?? + SEED_SERVICES[0]?.id ?? + "", + filter: "all", + paused: false, + showHelp: false, + themeName: DEFAULT_THEME_NAME, + tick: 0, + startedAtMs: nowMs, + lastUpdatedMs: nowMs, + }; +} + +export function visibleServices(state: DashboardState): readonly Service[] { + if (state.filter === "all") return state.services; + return state.services.filter((service) => service.status === state.filter); +} + +export function selectedService(state: DashboardState): Service | undefined { + const visible = visibleServices(state); + return visible.find((service) => service.id === state.selectedId) ?? visible[0]; +} + +export function reduceDashboardState( + state: DashboardState, + action: DashboardAction, +): DashboardState { + if (action.type === "toggle-pause") { + return { ...state, paused: !state.paused }; + } + + if (action.type === "toggle-help") { + return { ...state, showHelp: !state.showHelp }; + } + + if (action.type === "cycle-filter") { + const next = { ...state, filter: nextFilter(state.filter) }; + return { ...next, selectedId: resolveSelectedId(next) }; + } + + if (action.type === "cycle-theme") { + return { ...state, themeName: cycleThemeName(state.themeName) }; + } + + if (action.type === "set-selected-id") { + if (!state.services.some((service) => service.id === action.serviceId)) return state; + return { ...state, selectedId: action.serviceId }; + } + + if (action.type === "move-selection") { + const visible = visibleServices(state); + if (visible.length === 0) return state; + const current = visible.findIndex((service) => service.id === state.selectedId); + const from = current < 0 ? 0 : current; + const next = clamp(from + action.delta, 0, visible.length - 1); + const selected = visible[next]; + return selected ? { ...state, selectedId: selected.id } : state; + } + + if (action.type === "tick") { + if (state.paused) { + return { ...state, lastUpdatedMs: action.nowMs }; + } + const nextTick = state.tick + 1; + const services = state.services.map((service, index) => + evolveService(service, index, nextTick), + ); + const next = { + ...state, + services, + tick: nextTick, + lastUpdatedMs: action.nowMs, + }; + return { ...next, selectedId: resolveSelectedId(next) }; + } + + return state; +} diff --git a/examples/regression-dashboard/src/helpers/telemetry.ts b/examples/regression-dashboard/src/helpers/telemetry.ts new file mode 100644 index 00000000..50d48ab5 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/telemetry.ts @@ -0,0 +1,22 @@ +export type TelemetryTick = Readonly<{ + nowMs: number; +}>; + +function normalizeIntervalMs(intervalMs: number): number { + if (!Number.isFinite(intervalMs) || intervalMs <= 0) return 1000; + return Math.floor(intervalMs); +} + +/** + * Create an endless async telemetry stream that yields at a fixed cadence. + */ +export async function* createTelemetryStream( + intervalMs: number, +): AsyncGenerator { + const cadenceMs = normalizeIntervalMs(intervalMs); + + while (true) { + await new Promise((resolve) => setTimeout(resolve, cadenceMs)); + yield { nowMs: Date.now() }; + } +} diff --git a/examples/regression-dashboard/src/main.ts b/examples/regression-dashboard/src/main.ts new file mode 100644 index 00000000..82850c08 --- /dev/null +++ b/examples/regression-dashboard/src/main.ts @@ -0,0 +1,521 @@ +import { appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { exit } from "node:process"; +import { createDrawlistBuilder } from "@rezi-ui/core"; +import { parsePayload, parseRecordHeader } from "@rezi-ui/core/debug"; +import { createNodeApp } from "@rezi-ui/node"; +import { resolveDashboardCommand } from "./helpers/keybindings.js"; +import { reduceDashboardState, selectedService } from "./helpers/state.js"; +import { createInitialState } from "./helpers/state.js"; +import { renderOverviewScreen } from "./screens/overview.js"; +import { themeSpec } from "./theme.js"; +import type { DashboardAction, DashboardState } from "./types.js"; + +const UI_FPS_CAP = 30; +const TICK_MS = 900; +const DRAWLIST_HEADER_SIZE = 64; +const DEBUG_HEADER_SIZE = 40; +const DEBUG_QUERY_MAX_RECORDS = 64; +const ENABLE_BACKEND_DEBUG = process.env["REZI_REGRESSION_BACKEND_DEBUG"] !== "0"; +const DEBUG_LOG_PATH = + process.env["REZI_REGRESSION_DEBUG_LOG"] ?? `${tmpdir()}/rezi-regression-dashboard.log`; + +const initialState = createInitialState(); +const enableHsr = process.argv.includes("--hsr") || process.env["REZI_HSR"] === "1"; +const forceHeadless = process.argv.includes("--headless"); +const hasInteractiveTty = process.stdout.isTTY === true && process.stdin.isTTY === true; + +type OverviewRenderer = typeof renderOverviewScreen; +type OverviewModule = Readonly<{ + renderOverviewScreen?: OverviewRenderer; +}>; + +function serializeDetail(detail: unknown): string { + if (detail instanceof Error) { + return JSON.stringify({ + name: detail.name, + message: detail.message, + stack: detail.stack, + }); + } + if (typeof detail === "string") return detail; + try { + return JSON.stringify(detail); + } catch { + return String(detail); + } +} + +function describeError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return serializeDetail(error); +} + +function stderrLog(message: string): void { + try { + process.stderr.write(`${message}\n`); + } catch { + // best-effort diagnostics only + } +} + +function toSignedI32(value: number): number { + return value > 0x7fff_ffff ? value - 0x1_0000_0000 : value; +} + +type DrawlistHeaderSummary = Readonly<{ + magic: number; + version: number; + headerSize: number; + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; + reserved0: number; +}>; + +function summarizeDrawlistHeader(bytes: Uint8Array): DrawlistHeaderSummary | null { + if (bytes.byteLength < DRAWLIST_HEADER_SIZE) return null; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const u32 = (offset: number) => dv.getUint32(offset, true); + return { + magic: u32(0), + version: u32(4), + headerSize: u32(8), + totalSize: u32(12), + cmdOffset: u32(16), + cmdBytes: u32(20), + cmdCount: u32(24), + stringsSpanOffset: u32(28), + stringsCount: u32(32), + stringsBytesOffset: u32(36), + stringsBytesLen: u32(40), + blobsSpanOffset: u32(44), + blobsCount: u32(48), + blobsBytesOffset: u32(52), + blobsBytesLen: u32(56), + reserved0: u32(60), + }; +} + +function summarizeDebugPayload(payload: unknown): unknown { + if (!payload || typeof payload !== "object") return payload; + const value = payload as Readonly>; + if (value["kind"] === "drawlistBytes") { + const bytes = value["bytes"]; + if (bytes instanceof Uint8Array) { + return { + kind: "drawlistBytes", + byteLength: bytes.byteLength, + header: summarizeDrawlistHeader(bytes), + }; + } + } + if ( + typeof value["validationResult"] === "number" && + typeof value["executionResult"] === "number" + ) { + return { + ...value, + validationResultSigned: toSignedI32(value["validationResult"]), + executionResultSigned: toSignedI32(value["executionResult"]), + }; + } + return value; +} + +function probeDrawlistHeader(): DrawlistHeaderSummary | null { + const builder = createDrawlistBuilder({}); + builder.clear(); + builder.drawText(0, 0, "probe"); + const built = builder.build(); + if (!built.ok) return null; + return summarizeDrawlistHeader(built.bytes); +} + +function debugLog(step: string, detail?: unknown): void { + try { + const payload = + detail === undefined + ? "" + : ` ${typeof detail === "string" ? detail : serializeDetail(detail)}`; + appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${step}${payload}\n`); + } catch { + // best-effort diagnostics only + } +} + +debugLog("boot", { + pid: process.pid, + cwd: process.cwd(), + term: process.env["TERM"] ?? null, + stdoutTTY: process.stdout.isTTY === true, + stdinTTY: process.stdin.isTTY === true, + stdoutCols: process.stdout.columns ?? null, + stdoutRows: process.stdout.rows ?? null, + argv: process.argv.slice(2), +}); + +process.on("uncaughtException", (error) => { + debugLog("uncaughtException", error); + stderrLog(`Regression dashboard uncaught exception: ${describeError(error)}`); + process.exitCode = 1; +}); +process.on("unhandledRejection", (reason) => { + debugLog("unhandledRejection", reason); + stderrLog(`Regression dashboard unhandled rejection: ${describeError(reason)}`); + process.exitCode = 1; +}); +process.on("beforeExit", (code) => { + debugLog("beforeExit", { code }); +}); +process.on("exit", (code) => { + debugLog("exit", { code }); +}); +process.on("SIGTERM", () => { + debugLog("signal", "SIGTERM"); +}); +process.on("SIGINT", () => { + debugLog("signal", "SIGINT"); +}); + +if (forceHeadless || !hasInteractiveTty) { + debugLog("mode.headless", { forceHeadless, hasInteractiveTty }); + const { createTestRenderer } = await import("@rezi-ui/core/testing"); + const renderer = createTestRenderer({ viewport: { cols: 120, rows: 34 } }); + const tree = renderOverviewScreen(initialState, { + onTogglePause: () => {}, + onCycleFilter: () => {}, + onCycleTheme: () => {}, + onToggleHelp: () => {}, + onSelectService: () => {}, + }); + process.stdout.write(`${renderer.render(tree).toText()}\n`); + if (!forceHeadless) { + process.stderr.write( + "Regression dashboard: interactive mode needs a real TTY. Run this in a terminal, or use --headless.\n", + ); + } + debugLog("mode.headless.exit"); + exit(0); +} + +const ttyCols = + typeof process.stdout.columns === "number" && Number.isInteger(process.stdout.columns) + ? process.stdout.columns + : 0; +const ttyRows = + typeof process.stdout.rows === "number" && Number.isInteger(process.stdout.rows) + ? process.stdout.rows + : 0; +if (ttyCols <= 0 || ttyRows <= 0) { + const message = + `Regression dashboard: terminal reports invalid size cols=${String(ttyCols)} rows=${String(ttyRows)}.` + + " Run `stty rows 24 cols 80` and retry, or run with --headless."; + stderrLog(message); + debugLog("mode.invalid-tty-size", { ttyCols, ttyRows }); + exit(1); +} + +debugLog("app.create.begin"); +const app = createNodeApp({ + config: { + fpsCap: UI_FPS_CAP, + emojiWidthPolicy: "auto", + executionMode: "inline", + }, + initialState, + theme: themeSpec(initialState.themeName).theme, + ...(enableHsr + ? { + hotReload: { + viewModule: new URL("./screens/overview.ts", import.meta.url), + moduleRoot: new URL("./", import.meta.url), + resolveView: (moduleNs: unknown) => { + const render = (moduleNs as OverviewModule).renderOverviewScreen; + if (typeof render !== "function") { + throw new Error( + "HSR: ./screens/overview.ts must export renderOverviewScreen(state, actions)", + ); + } + return buildOverviewView(render); + }, + }, + } + : {}), +}); +debugLog("app.create.ok"); +let protocolMismatchReported = false; +const drawlistHeaderProbe = probeDrawlistHeader(); +debugLog("drawlist.probe.header", drawlistHeaderProbe); + +function buildOverviewView(renderer: OverviewRenderer) { + return (state: DashboardState) => + renderer(state, { + onTogglePause: () => dispatch({ type: "toggle-pause" }), + onCycleFilter: () => dispatch({ type: "cycle-filter" }), + onCycleTheme: () => dispatch({ type: "cycle-theme" }), + onToggleHelp: () => dispatch({ type: "toggle-help" }), + onSelectService: (serviceId) => dispatch({ type: "set-selected-id", serviceId }), + }); +} + +function dispatch(action: DashboardAction): void { + let nextThemeName = initialState.themeName; + let themeChanged = false; + + app.update((previous) => { + const next = reduceDashboardState(previous, action); + if (next.themeName !== previous.themeName) { + nextThemeName = next.themeName; + themeChanged = true; + } + return next; + }); + + if (themeChanged) { + app.setTheme(themeSpec(nextThemeName).theme); + } +} + +let stopping = false; +let telemetryTimer: ReturnType | null = null; +let fatalStopScheduled = false; + +async function stopApp(exitCode = 0): Promise { + if (stopping) return; + stopping = true; + + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + + try { + await app.stop(); + } catch { + // Ignore shutdown races. + } + + app.dispose(); + exit(exitCode); +} + +function applyCommand(command: ReturnType): void { + if (!command) return; + + if (command === "quit") { + void stopApp(); + return; + } + + if (command === "move-up") { + dispatch({ type: "move-selection", delta: -1 }); + return; + } + + if (command === "move-down") { + dispatch({ type: "move-selection", delta: 1 }); + return; + } + + if (command === "toggle-help") { + dispatch({ type: "toggle-help" }); + return; + } + + if (command === "toggle-pause") { + dispatch({ type: "toggle-pause" }); + return; + } + + if (command === "cycle-filter") { + dispatch({ type: "cycle-filter" }); + return; + } + + if (command === "cycle-theme") { + dispatch({ type: "cycle-theme" }); + } +} + +app.view(buildOverviewView(renderOverviewScreen)); +debugLog("app.view.set"); + +app.keys({ + q: () => applyCommand(resolveDashboardCommand("q")), + "ctrl+c": () => applyCommand(resolveDashboardCommand("ctrl+c")), + up: () => applyCommand(resolveDashboardCommand("up")), + down: () => applyCommand(resolveDashboardCommand("down")), + j: () => applyCommand(resolveDashboardCommand("j")), + k: () => applyCommand(resolveDashboardCommand("k")), + h: () => applyCommand(resolveDashboardCommand("h")), + "shift+/": () => applyCommand(resolveDashboardCommand("shift+/")), + f: () => applyCommand(resolveDashboardCommand("f")), + t: () => applyCommand(resolveDashboardCommand("t")), + p: () => applyCommand(resolveDashboardCommand("p")), + space: () => applyCommand(resolveDashboardCommand("space")), + escape: () => { + app.update((state) => (state.showHelp ? { ...state, showHelp: false } : state)); + }, + enter: () => { + app.update((state) => { + const selected = selectedService(state); + if (!selected) return state; + return { ...state, selectedId: selected.id }; + }); + }, +}); +debugLog("app.keys.set"); + +async function dumpBackendDebug(reason: string): Promise { + if (!ENABLE_BACKEND_DEBUG) return; + try { + const queried = await app.backend.debug.debugQuery({ maxRecords: DEBUG_QUERY_MAX_RECORDS }); + debugLog("backend.debug.query", { + reason, + result: queried.result, + headersByteLength: queried.headers.byteLength, + }); + + let lastDrawlistHeader: DrawlistHeaderSummary | null = null; + for (let offset = 0; offset + DEBUG_HEADER_SIZE <= queried.headers.byteLength; offset += DEBUG_HEADER_SIZE) { + const headerParsed = parseRecordHeader(queried.headers, offset); + if (!headerParsed.ok) { + debugLog("backend.debug.header.parse.error", { reason, offset, error: headerParsed.error }); + continue; + } + + const header = headerParsed.value; + const payloadBytes = + header.payloadSize > 0 + ? ((await app.backend.debug.debugGetPayload(header.recordId)) ?? new Uint8Array(0)) + : new Uint8Array(0); + const payloadParsed = parsePayload(header.category, payloadBytes); + if (!payloadParsed.ok) { + debugLog("backend.debug.payload.parse.error", { + reason, + header, + error: payloadParsed.error, + }); + continue; + } + + const payloadSummary = summarizeDebugPayload(payloadParsed.value); + debugLog("backend.debug.record", { reason, header, payload: payloadSummary }); + + if (payloadSummary && typeof payloadSummary === "object") { + const record = payloadSummary as Readonly>; + if (record["kind"] === "drawlistBytes") { + const headerSummary = record["header"]; + if (headerSummary && typeof headerSummary === "object") { + lastDrawlistHeader = headerSummary as DrawlistHeaderSummary; + } + } else if ( + !protocolMismatchReported && + typeof record["validationResultSigned"] === "number" && + record["validationResultSigned"] === -5 && + lastDrawlistHeader !== null && + (lastDrawlistHeader.stringsCount !== 0 || lastDrawlistHeader.blobsCount !== 0) + ) { + protocolMismatchReported = true; + const message = + "Regression dashboard: native drawlist validation failed with ZR_ERR_FORMAT. " + + `Captured header uses strings/blobs sections (stringsCount=${String(lastDrawlistHeader.stringsCount)}, blobsCount=${String(lastDrawlistHeader.blobsCount)}), ` + + "but the current native expects these header fields to be zero in drawlist v1. " + + "This indicates @rezi-ui/core and @rezi-ui/native drawlist wire formats are out of sync."; + stderrLog(message); + debugLog("diagnostic.drawlist-wire-mismatch", { + reason, + validationResultSigned: record["validationResultSigned"], + header: lastDrawlistHeader, + }); + } + } + } + } catch (error) { + debugLog("backend.debug.query.error", { reason, error: describeError(error) }); + } +} + +if (ENABLE_BACKEND_DEBUG) { + try { + debugLog("backend.debug.enable.begin"); + await app.backend.debug.debugEnable({ + enabled: true, + ringCapacity: 2048, + minSeverity: "trace", + captureDrawlistBytes: true, + }); + debugLog("backend.debug.enable.ok"); + } catch (error) { + debugLog("backend.debug.enable.error", error); + stderrLog(`Regression dashboard: failed to enable backend debug trace: ${describeError(error)}`); + } +} + +app.onEvent((event) => { + if (event.kind === "fatal") { + debugLog("event.fatal", event); + stderrLog(`Regression dashboard fatal: ${event.code}: ${event.detail}`); + process.exitCode = 1; + if ( + !protocolMismatchReported && + event.detail.includes("engine_submit_drawlist failed: code=-5") && + drawlistHeaderProbe !== null && + (drawlistHeaderProbe.stringsCount !== 0 || drawlistHeaderProbe.blobsCount !== 0) + ) { + protocolMismatchReported = true; + const message = + "Regression dashboard: detected drawlist wire-format mismatch. " + + `Builder probe emits non-zero header string/blob sections (stringsCount=${String(drawlistHeaderProbe.stringsCount)}, blobsCount=${String(drawlistHeaderProbe.blobsCount)}), ` + + "while native submit is failing with ZR_ERR_FORMAT (-5). " + + "This indicates @rezi-ui/core and @rezi-ui/native are out of sync."; + stderrLog(message); + debugLog("diagnostic.drawlist-wire-mismatch", { event, drawlistHeaderProbe }); + } + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + if (!fatalStopScheduled) { + fatalStopScheduled = true; + void (async () => { + await dumpBackendDebug("fatal-event"); + await stopApp(1); + })(); + } + } +}); + +telemetryTimer = setInterval(() => { + dispatch({ type: "tick", nowMs: Date.now() }); +}, TICK_MS); +debugLog("timer.started", { tickMs: TICK_MS }); + +try { + debugLog("app.start.begin"); + await app.start(); + debugLog("app.start.ok"); +} catch (error) { + debugLog("app.start.error", error); + stderrLog(`Regression dashboard startup failed: ${describeError(error)}`); + process.exitCode = 1; + await dumpBackendDebug("app-start-error"); + await stopApp(1); +} finally { + debugLog("app.start.finally"); + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + debugLog("timer.stopped"); +} diff --git a/examples/regression-dashboard/src/screens/overview.ts b/examples/regression-dashboard/src/screens/overview.ts new file mode 100644 index 00000000..fd49250b --- /dev/null +++ b/examples/regression-dashboard/src/screens/overview.ts @@ -0,0 +1,206 @@ +import type { VNode } from "@rezi-ui/core"; +import { ui, when } from "@rezi-ui/core"; +import { + filterLabel, + fleetCounts, + formatErrorRate, + formatLatency, + formatTraffic, + overallStatus, + statusBadge, + statusGlyph, +} from "../helpers/formatters.js"; +import { selectedService, visibleServices } from "../helpers/state.js"; +import { PRODUCT_NAME, PRODUCT_TAGLINE, TEMPLATE_LABEL, stylesForTheme, themeSpec } from "../theme.js"; +import type { DashboardState } from "../types.js"; + +type DashboardScreenHandlers = Readonly<{ + onTogglePause: () => void; + onCycleFilter: () => void; + onCycleTheme: () => void; + onToggleHelp: () => void; + onSelectService: (serviceId: string) => void; +}>; + +function panel(title: string, body: readonly VNode[], style: Readonly>): VNode { + return ui.panel({ title, style }, body); +} + +export function renderOverviewScreen(state: DashboardState, handlers: DashboardScreenHandlers): VNode { + const styles = stylesForTheme(state.themeName); + const visible = visibleServices(state); + const selected = selectedService(state); + const counts = fleetCounts(state.services); + const health = statusBadge(overallStatus(state.services)); + const theme = themeSpec(state.themeName); + + const serviceRows: readonly VNode[] = + visible.length === 0 + ? [ui.text("No services match the current filter.", { style: styles.mutedStyle })] + : visible.map((service) => { + const selectedMarker = service.id === selected?.id ? "▸" : " "; + return ui.row({ key: service.id, gap: 1, items: "center", wrap: true }, [ + ui.text(selectedMarker, { style: styles.accentStyle }), + ui.badge(statusGlyph(service.status), { variant: statusBadge(service.status).variant }), + ui.button({ + id: `service-${service.id}`, + label: `${service.name} · ${service.region}`, + onPress: () => handlers.onSelectService(service.id), + }), + ui.tag(formatLatency(service.latencyMs), { + variant: service.status === "down" ? "error" : service.status === "warning" ? "warning" : "info", + }), + ui.tag(formatErrorRate(service.errorRate), { + variant: service.errorRate >= 3 ? "error" : service.errorRate >= 1 ? "warning" : "default", + }), + ui.text(formatTraffic(service.trafficRpm), { style: styles.mutedStyle }), + ]); + }); + + const uptimeSec = Math.max(1, Math.floor((Date.now() - state.startedAtMs) / 1000)); + const updateRate = (state.tick / uptimeSec).toFixed(2); + const inspectorContent = + when( + Boolean(selected), + () => { + const service = selected as NonNullable; + return ui.column({ gap: 1 }, [ + ui.row({ gap: 1, wrap: true }, [ + ui.badge(service.name, { variant: statusBadge(service.status).variant }), + ui.tag(service.owner, { variant: "default" }), + ui.tag(service.region, { variant: "info" }), + ]), + ui.text(`Latency: ${formatLatency(service.latencyMs)}`), + ui.text(`Error Rate: ${formatErrorRate(service.errorRate)}`), + ui.text(`Traffic: ${formatTraffic(service.trafficRpm)}`), + ui.text(`Update rate: ${updateRate} Hz`, { style: styles.mutedStyle }), + ui.sparkline(service.history, { width: 18, min: 0, max: 220 }), + ]); + }, + () => ui.text("No service selected.", { style: styles.mutedStyle }), + ) ?? ui.text("No service selected.", { style: styles.mutedStyle }); + + const content = ui.page({ + p: 1, + gap: 1, + header: ui.header({ + title: PRODUCT_NAME, + subtitle: PRODUCT_TAGLINE, + actions: [ + ui.badge(TEMPLATE_LABEL, { variant: "info" }), + ui.badge(`Fleet ${health.label}`, { variant: health.variant }), + ui.status(state.paused ? "away" : "online", { + label: state.paused ? "Paused" : "Streaming", + }), + ui.tag(`Theme ${theme.label}`, { variant: theme.badge }), + ], + }), + body: ui.column({ gap: 1 }, [ + panel( + "Actions", + [ + ui.actions([ + ui.button({ + id: "filter", + label: `Filter: ${filterLabel(state.filter)}`, + intent: "secondary", + onPress: handlers.onCycleFilter, + }), + ui.button({ + id: "theme", + label: "Cycle Theme", + intent: "secondary", + onPress: handlers.onCycleTheme, + }), + ui.button({ + id: "pause", + label: state.paused ? "Resume Stream" : "Pause Stream", + intent: state.paused ? "primary" : "warning", + onPress: handlers.onTogglePause, + }), + ui.button({ + id: "help", + label: "Help", + intent: "link", + onPress: handlers.onToggleHelp, + }), + ]), + ], + styles.panelStyle, + ), + ui.row({ gap: 1, wrap: true, items: "stretch" }, [ + panel( + "Service Fleet", + [ + ui.row({ gap: 1, wrap: true }, [ + ui.badge(`Healthy ${String(counts.healthy)}`, { variant: "success" }), + ui.badge(`Warning ${String(counts.warning)}`, { variant: "warning" }), + ui.badge(`Down ${String(counts.down)}`, { variant: "error" }), + ]), + ui.box({ height: 10, overflow: "scroll", border: "none" }, [...serviceRows]), + ui.table({ + id: "fleet-table", + columns: [ + { key: "name", header: "Service", flex: 1 }, + { key: "status", header: "Status", width: 8 }, + { key: "latencyMs", header: "Latency", width: 9, align: "right" }, + ], + data: visible, + getRowKey: (service) => service.id, + selection: selected ? [selected.id] : [], + selectionMode: "single", + onSelectionChange: (keys) => { + const key = keys[0]; + if (typeof key === "string") handlers.onSelectService(key); + }, + onRowPress: (row) => handlers.onSelectService(row.id), + dsSize: "sm", + dsTone: "default", + }), + ], + styles.panelStyle, + ), + panel( + "Inspector", + [inspectorContent], + styles.panelStyle, + ), + ]), + ]), + footer: ui.statusBar({ + left: [ui.text("Keys: q quit · j/k or arrows move · f filter · t theme · p pause · h/? help", { + style: styles.mutedStyle, + })], + right: [ui.text(`Tick ${String(state.tick)}`, { style: styles.mutedStyle })], + }), + }); + + if (!state.showHelp) return content; + + return ui.layers([ + content, + ui.modal({ + id: "dashboard-help", + title: `${PRODUCT_NAME} Commands`, + width: 70, + backdrop: "none", + returnFocusTo: "help", + content: ui.column({ gap: 1 }, [ + ui.text("q, ctrl+c : quit"), + ui.text("j/k, up/down : move selection"), + ui.text("f : cycle service filter"), + ui.text("t : cycle theme"), + ui.text("p or space : pause stream"), + ui.text("h, ? or escape : close help"), + ]), + actions: [ + ui.button({ + id: "help-close", + label: "Close", + onPress: handlers.onToggleHelp, + }), + ], + onClose: handlers.onToggleHelp, + }), + ]); +} diff --git a/examples/regression-dashboard/src/theme.ts b/examples/regression-dashboard/src/theme.ts new file mode 100644 index 00000000..a80b912f --- /dev/null +++ b/examples/regression-dashboard/src/theme.ts @@ -0,0 +1,51 @@ +import type { BadgeVariant, TextStyle, ThemeDefinition } from "@rezi-ui/core"; +import { darkTheme, lightTheme, nordTheme } from "@rezi-ui/core"; +import type { ThemeName } from "./types.js"; + +type ThemeSpec = Readonly<{ + label: string; + badge: BadgeVariant; + theme: ThemeDefinition; +}>; + +export const PRODUCT_NAME = "Regression Dashboard"; +export const TEMPLATE_LABEL = "dashboard"; +export const PRODUCT_TAGLINE = "Deterministic incident dashboard starter"; +export const DEFAULT_THEME_NAME: ThemeName = "nord"; + +const THEME_ORDER: readonly ThemeName[] = Object.freeze(["nord", "dark", "light"]); + +const THEME_BY_NAME: Record = { + nord: { label: "Nord", badge: "info", theme: nordTheme }, + dark: { label: "Dark", badge: "default", theme: darkTheme }, + light: { label: "Light", badge: "success", theme: lightTheme }, +}; + +export function themeSpec(themeName: ThemeName): ThemeSpec { + return THEME_BY_NAME[themeName]; +} + +export function cycleThemeName(current: ThemeName): ThemeName { + const index = THEME_ORDER.indexOf(current); + const next = index < 0 ? 0 : (index + 1) % THEME_ORDER.length; + return THEME_ORDER[next] ?? DEFAULT_THEME_NAME; +} + +export type DashboardStyles = Readonly<{ + rootStyle: TextStyle; + panelStyle: TextStyle; + stripStyle: TextStyle; + mutedStyle: TextStyle; + accentStyle: TextStyle; +}>; + +export function stylesForTheme(themeName: ThemeName): DashboardStyles { + const colors = themeSpec(themeName).theme.colors; + return Object.freeze({ + rootStyle: { bg: colors.bg.base, fg: colors.fg.primary }, + panelStyle: { bg: colors.bg.elevated, fg: colors.fg.primary }, + stripStyle: { bg: colors.bg.subtle, fg: colors.fg.primary }, + mutedStyle: { fg: colors.fg.secondary, dim: true }, + accentStyle: { fg: colors.accent.primary, bold: true }, + }); +} diff --git a/examples/regression-dashboard/src/types.ts b/examples/regression-dashboard/src/types.ts new file mode 100644 index 00000000..9715dadd --- /dev/null +++ b/examples/regression-dashboard/src/types.ts @@ -0,0 +1,36 @@ +export type ServiceStatus = "healthy" | "warning" | "down"; +export type ServiceFilter = "all" | ServiceStatus; +export type ThemeName = "nord" | "dark" | "light"; + +export type Service = Readonly<{ + id: string; + name: string; + region: string; + owner: string; + status: ServiceStatus; + latencyMs: number; + errorRate: number; + trafficRpm: number; + history: readonly number[]; +}>; + +export type DashboardState = Readonly<{ + services: readonly Service[]; + selectedId: string; + filter: ServiceFilter; + paused: boolean; + showHelp: boolean; + themeName: ThemeName; + tick: number; + startedAtMs: number; + lastUpdatedMs: number; +}>; + +export type DashboardAction = + | Readonly<{ type: "tick"; nowMs: number }> + | Readonly<{ type: "toggle-pause" }> + | Readonly<{ type: "toggle-help" }> + | Readonly<{ type: "cycle-filter" }> + | Readonly<{ type: "cycle-theme" }> + | Readonly<{ type: "move-selection"; delta: -1 | 1 }> + | Readonly<{ type: "set-selected-id"; serviceId: string }>; diff --git a/examples/regression-dashboard/tsconfig.json b/examples/regression-dashboard/tsconfig.json new file mode 100644 index 00000000..96f0f5a8 --- /dev/null +++ b/examples/regression-dashboard/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../../packages/core" }, { "path": "../../packages/node" }] +} diff --git a/packages/core/src/__tests__/drawlistDecode.ts b/packages/core/src/__tests__/drawlistDecode.ts new file mode 100644 index 00000000..db28396f --- /dev/null +++ b/packages/core/src/__tests__/drawlistDecode.ts @@ -0,0 +1,348 @@ +export const OP_CLEAR = 1; +export const OP_FILL_RECT = 2; +export const OP_DRAW_TEXT = 3; +export const OP_PUSH_CLIP = 4; +export const OP_POP_CLIP = 5; +export const OP_DRAW_TEXT_RUN = 6; +export const OP_SET_CURSOR = 7; +export const OP_DRAW_CANVAS = 8; +export const OP_DRAW_IMAGE = 9; +export const OP_DEF_STRING = 10; +export const OP_FREE_STRING = 11; +export const OP_DEF_BLOB = 12; +export const OP_FREE_BLOB = 13; +export const OP_BLIT_RECT = 14; + +export type DrawlistCommandHeader = Readonly<{ + opcode: number; + size: number; + offset: number; + payloadOffset: number; + payloadSize: number; +}>; + +export type DrawTextCommand = Readonly<{ + x: number; + y: number; + stringId: number; + byteOff: number; + byteLen: number; + text: string; +}>; + +export type DrawTextRunCommand = Readonly<{ + x: number; + y: number; + blobId: number; + blobBytes: Uint8Array | null; +}>; + +type ResourceState = Readonly<{ + strings: ReadonlyMap; + blobs: ReadonlyMap; +}>; + +const HEADER_SIZE = 64; +const ZRDL_MAGIC = 0x4c44_525a; +const MIN_CMD_SIZE = 8; +const DECODER = new TextDecoder(); + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function i32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getInt32(off, true); +} + +export function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function cloneBytes(bytes: Uint8Array): Uint8Array { + return Uint8Array.from(bytes); +} + +function readCommandBounds(bytes: Uint8Array): Readonly<{ cmdOffset: number; cmdEnd: number }> { + if (bytes.byteLength < HEADER_SIZE) { + throw new Error(`drawlist too small for header (len=${String(bytes.byteLength)})`); + } + + const cmdOffset = u32(bytes, 16); + const cmdBytes = u32(bytes, 20); + const cmdEnd = cmdOffset + cmdBytes; + if (cmdBytes === 0) return { cmdOffset, cmdEnd }; + if (cmdOffset < HEADER_SIZE || cmdOffset > bytes.byteLength) { + throw new Error( + `drawlist cmdOffset out of bounds (cmdOffset=${String(cmdOffset)}, len=${String(bytes.byteLength)})`, + ); + } + if (cmdEnd < cmdOffset || cmdEnd > bytes.byteLength) { + throw new Error( + `drawlist cmd range out of bounds (cmdOffset=${String(cmdOffset)}, cmdBytes=${String(cmdBytes)}, len=${String(bytes.byteLength)})`, + ); + } + return { cmdOffset, cmdEnd }; +} + +export function parseCommandHeaders(bytes: Uint8Array): readonly DrawlistCommandHeader[] { + const { cmdOffset, cmdEnd } = readCommandBounds(bytes); + const out: DrawlistCommandHeader[] = []; + + let off = cmdOffset; + while (off < cmdEnd) { + const opcode = u16(bytes, off); + const size = u32(bytes, off + 4); + if (size < MIN_CMD_SIZE) { + throw new Error(`command size below minimum at offset ${String(off)} (size=${String(size)})`); + } + if ((size & 3) !== 0) { + throw new Error(`command size not 4-byte aligned at offset ${String(off)} (size=${String(size)})`); + } + const next = off + size; + if (next > cmdEnd) { + throw new Error( + `command overruns cmd section at offset ${String(off)} (size=${String(size)}, cmdEnd=${String(cmdEnd)})`, + ); + } + out.push({ + opcode, + size, + offset: off, + payloadOffset: off + MIN_CMD_SIZE, + payloadSize: size - MIN_CMD_SIZE, + }); + off = next; + } + + if (off !== cmdEnd) { + throw new Error( + `command parse did not end exactly at cmdEnd (off=${String(off)}, cmdEnd=${String(cmdEnd)})`, + ); + } + + return Object.freeze(out); +} + +function parseResourceState(bytes: Uint8Array): ResourceState { + const headers = parseCommandHeaders(bytes); + const strings = new Map(); + const blobs = new Map(); + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_STRING: { + if (cmd.size < 16) { + throw new Error(`DEF_STRING too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + } + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd > cmd.offset + cmd.size) { + throw new Error( + `DEF_STRING payload overflow at offset ${String(cmd.offset)} (len=${String(byteLen)}, size=${String(cmd.size)})`, + ); + } + strings.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_STRING: { + if (cmd.size !== 12) { + throw new Error(`FREE_STRING wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + } + strings.delete(u32(bytes, cmd.offset + 8)); + break; + } + case OP_DEF_BLOB: { + if (cmd.size < 16) { + throw new Error(`DEF_BLOB too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + } + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd > cmd.offset + cmd.size) { + throw new Error( + `DEF_BLOB payload overflow at offset ${String(cmd.offset)} (len=${String(byteLen)}, size=${String(cmd.size)})`, + ); + } + blobs.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_BLOB: { + if (cmd.size !== 12) { + throw new Error(`FREE_BLOB wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + } + blobs.delete(u32(bytes, cmd.offset + 8)); + break; + } + default: + break; + } + } + + return Object.freeze({ + strings: strings as ReadonlyMap, + blobs: blobs as ReadonlyMap, + }); +} + +function parseInternedStringsSingle(bytes: Uint8Array): readonly string[] { + const resources = parseResourceState(bytes); + const ids = [...resources.strings.keys()].sort((a, b) => a - b); + const out: string[] = []; + for (const id of ids) { + const strBytes = resources.strings.get(id); + if (!strBytes) continue; + out.push(DECODER.decode(strBytes)); + } + return Object.freeze(out); +} + +export function parseInternedStrings(bytes: Uint8Array): readonly string[] { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < HEADER_SIZE) { + return Object.freeze([]); + } + + const merged: string[] = []; + let off = 0; + let parsedAny = false; + while (off + HEADER_SIZE <= bytes.byteLength) { + const magic = u32(bytes, off + 0); + const headerSize = u32(bytes, off + 8); + const totalSize = u32(bytes, off + 12); + if ( + magic !== ZRDL_MAGIC || + headerSize !== HEADER_SIZE || + totalSize < HEADER_SIZE || + (totalSize & 3) !== 0 || + off + totalSize > bytes.byteLength + ) { + break; + } + + parsedAny = true; + try { + const list = parseInternedStringsSingle(bytes.subarray(off, off + totalSize)); + merged.push(...list); + } catch { + return Object.freeze([]); + } + off += totalSize; + } + + if (parsedAny && off === bytes.byteLength) { + return Object.freeze(merged); + } + + try { + return parseInternedStringsSingle(bytes); + } catch { + return Object.freeze([]); + } +} + +export function parseBlobById(bytes: Uint8Array, blobId: number): Uint8Array | null { + const resources = parseResourceState(bytes); + const blob = resources.blobs.get(blobId); + if (!blob) return null; + return cloneBytes(blob); +} + +export function parseDrawTextCommands(bytes: Uint8Array): readonly DrawTextCommand[] { + const headers = parseCommandHeaders(bytes); + const strings = new Map(); + const out: DrawTextCommand[] = []; + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_STRING: { + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + strings.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_STRING: { + const id = u32(bytes, cmd.offset + 8); + strings.delete(id); + break; + } + case OP_DRAW_TEXT: { + if (cmd.size < 28) break; + const stringId = u32(bytes, cmd.offset + 16); + const byteOff = u32(bytes, cmd.offset + 20); + const byteLen = u32(bytes, cmd.offset + 24); + const str = strings.get(stringId); + let text = ""; + if (str) { + const end = byteOff + byteLen; + if (end <= str.byteLength) { + text = DECODER.decode(str.subarray(byteOff, end)); + } + } + out.push( + Object.freeze({ + x: i32(bytes, cmd.offset + 8), + y: i32(bytes, cmd.offset + 12), + stringId, + byteOff, + byteLen, + text, + }), + ); + break; + } + default: + break; + } + } + + return Object.freeze(out); +} + +export function parseDrawTextRunCommands(bytes: Uint8Array): readonly DrawTextRunCommand[] { + const headers = parseCommandHeaders(bytes); + const blobs = new Map(); + const out: DrawTextRunCommand[] = []; + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_BLOB: { + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + blobs.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_BLOB: { + blobs.delete(u32(bytes, cmd.offset + 8)); + break; + } + case OP_DRAW_TEXT_RUN: { + if (cmd.size < 24) break; + const blobId = u32(bytes, cmd.offset + 16); + const blobBytes = blobs.get(blobId) ?? null; + out.push( + Object.freeze({ + x: i32(bytes, cmd.offset + 8), + y: i32(bytes, cmd.offset + 12), + blobId, + blobBytes: blobBytes ? cloneBytes(blobBytes) : null, + }), + ); + break; + } + default: + break; + } + } + + return Object.freeze(out); +} diff --git a/packages/core/src/__tests__/integration/integration.dashboard.test.ts b/packages/core/src/__tests__/integration/integration.dashboard.test.ts index 7bc516bc..adbb74c9 100644 --- a/packages/core/src/__tests__/integration/integration.dashboard.test.ts +++ b/packages/core/src/__tests__/integration/integration.dashboard.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -90,30 +91,6 @@ function u32(bytes: Uint8Array, off: number): number { return dv.getUint32(off, true); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function parseHeader(bytes: Uint8Array): Readonly<{ totalSize: number; cmdOffset: number; @@ -126,7 +103,7 @@ function parseHeader(bytes: Uint8Array): Readonly<{ cmdOffset: u32(bytes, 16), cmdBytes: u32(bytes, 20), cmdCount: u32(bytes, 24), - stringCount: u32(bytes, 32), + stringCount: parseInternedStrings(bytes).length, }); } diff --git a/packages/core/src/__tests__/integration/integration.file-manager.test.ts b/packages/core/src/__tests__/integration/integration.file-manager.test.ts index 0cdcd2e1..7f33de1c 100644 --- a/packages/core/src/__tests__/integration/integration.file-manager.test.ts +++ b/packages/core/src/__tests__/integration/integration.file-manager.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -336,28 +337,6 @@ function u32(bytes: Uint8Array, off: number): number { return dv.getUint32(off, true); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true); - - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.equal(end <= tableEnd, true); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function containsText(strings: readonly string[], needle: string): boolean { return strings.some((entry) => entry.includes(needle)); } diff --git a/packages/core/src/__tests__/integration/integration.form-editor.test.ts b/packages/core/src/__tests__/integration/integration.form-editor.test.ts index afabe5bc..f7338762 100644 --- a/packages/core/src/__tests__/integration/integration.form-editor.test.ts +++ b/packages/core/src/__tests__/integration/integration.form-editor.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -677,31 +678,6 @@ function splitDrawlists(bundle: Uint8Array): readonly Uint8Array[] { return out; } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return []; - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength); - - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return out; -} - function latestFrameStrings(backend: StubBackend): readonly string[] { const frame = backend.requestedFrames[backend.requestedFrames.length - 1]; if (!frame) { diff --git a/packages/core/src/__tests__/integration/integration.reflow.test.ts b/packages/core/src/__tests__/integration/integration.reflow.test.ts index 266b9852..01368bd1 100644 --- a/packages/core/src/__tests__/integration/integration.reflow.test.ts +++ b/packages/core/src/__tests__/integration/integration.reflow.test.ts @@ -1,4 +1,13 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_CLEAR, + OP_DRAW_TEXT, + OP_FILL_RECT, + OP_PUSH_CLIP, + parseCommandHeaders, + parseDrawTextCommands, + parseInternedStrings, +} from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -13,23 +22,10 @@ type EncodedEvent = NonNullable[0]["events" type Viewport = Readonly<{ cols: number; rows: number }>; type Rect = Readonly<{ x: number; y: number; w: number; h: number }>; type DrawTextCommand = Readonly<{ x: number; y: number; text: string }>; -type StringHeader = Readonly<{ - spanOffset: number; - count: number; - bytesOffset: number; - bytesLen: number; -}>; type TableRow = Readonly<{ id: string; name: string; score: number }>; type TreeNode = Readonly<{ id: string; children: readonly TreeNode[] }>; -const OP_CLEAR = 1; -const OP_FILL_RECT = 2; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; - -const DECODER = new TextDecoder(); - function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint16(off, true); @@ -46,44 +42,7 @@ function i32(bytes: Uint8Array, off: number): number { } function parseOpcodes(bytes: Uint8Array): readonly number[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const end = cmdOffset + cmdBytes; - - const out: number[] = []; - let off = cmdOffset; - while (off < end) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - out.push(opcode); - off += size; - } - assert.equal(off, end, "commands must parse exactly to cmd end"); - return Object.freeze(out); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true, "string table must be in-bounds"); - - const out: string[] = []; - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.equal(end <= tableEnd, true, "string span must be in-bounds"); - out.push(DECODER.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); + return Object.freeze(parseCommandHeaders(bytes).map((cmd) => cmd.opcode)); } function parseFillRects(bytes: Uint8Array): readonly Rect[] { @@ -136,62 +95,16 @@ function parsePushClips(bytes: Uint8Array): readonly Rect[] { return Object.freeze(out); } -function readStringHeader(bytes: Uint8Array): StringHeader { - return { - spanOffset: u32(bytes, 28), - count: u32(bytes, 32), - bytesOffset: u32(bytes, 36), - bytesLen: u32(bytes, 40), - }; -} - -function decodeStringSlice( - bytes: Uint8Array, - header: StringHeader, - stringIndex: number, - byteOff: number, - byteLen: number, -): string { - assert.equal( - stringIndex >= 0 && stringIndex < header.count, - true, - "string index must be in-bounds", - ); - const span = header.spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - assert.equal(byteOff + byteLen <= strLen, true, "string slice must be in-bounds"); - const start = header.bytesOffset + strOff + byteOff; - const end = start + byteLen; - return DECODER.decode(bytes.subarray(start, end)); -} - function parseDrawTexts(bytes: Uint8Array): readonly DrawTextCommand[] { - const header = readStringHeader(bytes); - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - - const out: DrawTextCommand[] = []; - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - if (opcode === OP_DRAW_TEXT && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - out.push({ - x: i32(bytes, off + 8), - y: i32(bytes, off + 12), - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), - }); - } - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - return Object.freeze(out); + return Object.freeze( + parseDrawTextCommands(bytes).map((cmd) => + Object.freeze({ + x: cmd.x, + y: cmd.y, + text: cmd.text, + }), + ), + ); } function countOpcode(opcodes: readonly number[], opcode: number): number { diff --git a/packages/core/src/__tests__/integration/integration.resize.test.ts b/packages/core/src/__tests__/integration/integration.resize.test.ts index 8419c442..98379844 100644 --- a/packages/core/src/__tests__/integration/integration.resize.test.ts +++ b/packages/core/src/__tests__/integration/integration.resize.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -51,31 +52,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true, "string table must be in-bounds"); - const decoder = new TextDecoder(); - const out: string[] = []; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.equal(end <= tableEnd, true, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function parseFillRects(bytes: Uint8Array): readonly Rect[] { const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); diff --git a/packages/core/src/app/__tests__/hotStateReload.test.ts b/packages/core/src/app/__tests__/hotStateReload.test.ts index 510e3434..a7fa4c5c 100644 --- a/packages/core/src/app/__tests__/hotStateReload.test.ts +++ b/packages/core/src/app/__tests__/hotStateReload.test.ts @@ -1,4 +1,5 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { ZrUiError } from "../../abi.js"; import { defineWidget, ui } from "../../index.js"; import { ZR_KEY_ENTER, ZR_KEY_HOME, ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; @@ -6,32 +7,6 @@ import { createApp } from "../createApp.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function latestFrameStrings(backend: StubBackend): readonly string[] { const frame = backend.requestedFrames[backend.requestedFrames.length - 1]; return parseInternedStrings(frame ?? new Uint8Array()); diff --git a/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts index f9355a94..d928ca94 100644 --- a/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts +++ b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts @@ -1,4 +1,5 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { ZrUiError } from "../../abi.js"; import { ZR_MOD_CTRL, ZR_MOD_SHIFT, charToKeyCode } from "../../keybindings/keyCodes.js"; import { ui } from "../../widgets/ui.js"; @@ -6,32 +7,6 @@ import { createAppWithInspectorOverlay } from "../inspectorOverlayHelper.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - async function pushEvents( backend: StubBackend, events: NonNullable[0]["events"]>, diff --git a/packages/core/src/app/__tests__/resilience.test.ts b/packages/core/src/app/__tests__/resilience.test.ts index 915c7401..a8e9b5c5 100644 --- a/packages/core/src/app/__tests__/resilience.test.ts +++ b/packages/core/src/app/__tests__/resilience.test.ts @@ -1,35 +1,10 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { defineWidget, ui } from "../../index.js"; import { createApp } from "../createApp.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - async function pushEvents( backend: StubBackend, events: NonNullable[0]["events"]>, diff --git a/packages/core/src/app/rawRenderer.ts b/packages/core/src/app/rawRenderer.ts index 7bb448c4..61b161c4 100644 --- a/packages/core/src/app/rawRenderer.ts +++ b/packages/core/src/app/rawRenderer.ts @@ -10,9 +10,10 @@ * @see docs/guide/lifecycle-and-updates.md */ -import type { RuntimeBackend } from "../backend.js"; +import { FRAME_ACCEPTED_ACK_MARKER, type RuntimeBackend } from "../backend.js"; import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; import { perfMarkEnd, perfMarkStart } from "../perf/perf.js"; +import { FRAME_AUDIT_ENABLED, drawlistFingerprint, emitFrameAudit } from "../perf/frameAudit.js"; import type { DrawFn } from "./types.js"; /** Callbacks for render lifecycle tracking (used by app to set inRender flag). */ @@ -48,6 +49,7 @@ function describeThrown(v: unknown): string { export class RawRenderer { private readonly backend: RuntimeBackend; private readonly builder: DrawlistBuilder; + private frameAuditSeq = 0; constructor( opts: Readonly<{ @@ -115,7 +117,54 @@ export class RawRenderer { } try { + const auditSeq = this.frameAuditSeq + 1; + this.frameAuditSeq = auditSeq; + const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(built.bytes) : null; + if (fingerprint !== null) { + emitFrameAudit("rawRenderer", "drawlist.built", { + frameSeq: auditSeq, + ...fingerprint, + }); + } const inFlight = this.backend.requestFrame(built.bytes); + if (fingerprint !== null) { + emitFrameAudit("rawRenderer", "backend.request", { + frameSeq: auditSeq, + ...fingerprint, + }); + const acceptedAck = ( + inFlight as Promise & + Partial>> + )[FRAME_ACCEPTED_ACK_MARKER]; + if (acceptedAck !== undefined) { + void acceptedAck.then( + () => + emitFrameAudit("rawRenderer", "backend.accepted", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("rawRenderer", "backend.accepted_error", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } + void inFlight.then( + () => + emitFrameAudit("rawRenderer", "backend.completed", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("rawRenderer", "backend.completed_error", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } return { ok: true, inFlight }; } catch (e: unknown) { return { ok: false, code: "ZRUI_BACKEND_ERROR", detail: describeThrown(e) }; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 15179919..6af1f033 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -22,7 +22,12 @@ */ import type { CursorShape } from "../abi.js"; -import { BACKEND_RAW_WRITE_MARKER, type BackendRawWrite, type RuntimeBackend } from "../backend.js"; +import { + BACKEND_RAW_WRITE_MARKER, + FRAME_ACCEPTED_ACK_MARKER, + type BackendRawWrite, + type RuntimeBackend, +} from "../backend.js"; import { CURSOR_DEFAULTS } from "../cursor/index.js"; import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; import type { ZrevEvent } from "../events.js"; @@ -55,10 +60,6 @@ import { } from "../keybindings/keyCodes.js"; import type { LayoutOverflowMetadata } from "../layout/constraints.js"; import { computeDropdownGeometry } from "../layout/dropdownGeometry.js"; -import { - computeDirtyLayoutSet, - instanceDirtySetToVNodeDirtySet, -} from "../layout/engine/dirtySet.js"; import { hitTestAnyId, hitTestFocusable } from "../layout/hitTest.js"; import { type LayoutTree, layout } from "../layout/layout.js"; import { @@ -69,6 +70,7 @@ import { } from "../layout/responsive.js"; import { measureTextCells } from "../layout/textMeasure.js"; import type { Rect } from "../layout/types.js"; +import { FRAME_AUDIT_ENABLED, drawlistFingerprint, emitFrameAudit } from "../perf/frameAudit.js"; import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import { type CursorInfo, renderToDrawlist } from "../renderer/renderToDrawlist.js"; import { getRuntimeNodeDamageRect } from "../renderer/renderToDrawlist/damageBounds.js"; @@ -490,6 +492,13 @@ const LAYOUT_WARNINGS_ENV_RAW = const LAYOUT_WARNINGS_ENV = LAYOUT_WARNINGS_ENV_RAW.toLowerCase(); const DEV_LAYOUT_WARNINGS = DEV_MODE && (LAYOUT_WARNINGS_ENV === "1" || LAYOUT_WARNINGS_ENV === "true"); +const FRAME_AUDIT_TREE_ENABLED = + FRAME_AUDIT_ENABLED && + ( + globalThis as { + process?: { env?: { REZI_FRAME_AUDIT_TREE?: string } }; + } + ).process?.env?.REZI_FRAME_AUDIT_TREE === "1"; function warnDev(message: string): void { const c = (globalThis as { console?: { warn?: (msg: string) => void } }).console; @@ -538,6 +547,294 @@ function monotonicNowMs(): number { return Date.now(); } +function pushLimited(list: string[], value: string, max: number): void { + if (list.length >= max) return; + list.push(value); +} + +function normalizeAuditText(value: string, maxChars = 96): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function describeAuditVNode(vnode: VNode): string { + const kind = vnode.kind; + const props = vnode.props as Readonly<{ id?: unknown; title?: unknown }> | undefined; + const id = typeof props?.id === "string" && props.id.length > 0 ? props.id : null; + const title = + typeof props?.title === "string" && props.title.length > 0 + ? normalizeAuditText(props.title, 24) + : null; + if (id !== null) return `${kind}#${id}`; + if (title !== null) return `${kind}[${title}]`; + return kind; +} + +function summarizeRuntimeTreeForAudit( + root: RuntimeInstance, + layoutRoot: LayoutTree, +): Readonly> { + const kindCounts = new Map(); + const zeroAreaKindCounts = new Map(); + const textSamples: string[] = []; + const titleSamples: string[] = []; + const titleRectSamples: string[] = []; + const zeroAreaTitleSamples: string[] = []; + const mismatchSamples: string[] = []; + const needleHits = new Set(); + const needles = [ + "Engineering Controls", + "Subsystem Tree", + "Crew Manifest", + "Search Crew", + "Channel Controls", + "Ship Settings", + ]; + + let nodeCount = 0; + let textNodeCount = 0; + let boxTitleCount = 0; + let compositeNodeCount = 0; + let zeroAreaNodes = 0; + let maxDepth = 0; + let maxChildrenDelta = 0; + + const stack: Array< + Readonly<{ node: RuntimeInstance; layout: LayoutTree; depth: number; path: string }> + > = [Object.freeze({ node: root, layout: layoutRoot, depth: 0, path: "root" })]; + const rootRect = layoutRoot.rect; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + const { node, layout, depth, path } = current; + nodeCount += 1; + if (depth > maxDepth) maxDepth = depth; + + const kind = node.vnode.kind; + const layoutKind = layout.vnode.kind; + kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1); + if ("__composite" in (node.vnode as object)) { + compositeNodeCount += 1; + } + if (kind !== layoutKind) { + const runtimeLabel = describeAuditVNode(node.vnode); + const layoutLabel = describeAuditVNode(layout.vnode); + pushLimited( + mismatchSamples, + `${path}:${runtimeLabel}!${layoutLabel}`, + 24, + ); + } + + const rect = layout.rect; + if (rect.w <= 0 || rect.h <= 0) { + zeroAreaNodes += 1; + zeroAreaKindCounts.set(kind, (zeroAreaKindCounts.get(kind) ?? 0) + 1); + } + + if (kind === "text") { + textNodeCount += 1; + const text = (node.vnode as Readonly<{ text: string }>).text; + pushLimited(textSamples, normalizeAuditText(text), 24); + for (const needle of needles) { + if (text.includes(needle)) needleHits.add(needle); + } + } else { + const props = node.vnode.props as Readonly<{ title?: unknown }> | undefined; + if (typeof props?.title === "string" && props.title.length > 0) { + boxTitleCount += 1; + pushLimited(titleSamples, normalizeAuditText(props.title), 24); + const offRoot = + rect.x + rect.w <= rootRect.x || + rect.y + rect.h <= rootRect.y || + rect.x >= rootRect.x + rootRect.w || + rect.y >= rootRect.y + rootRect.h; + const titleRectSummary = `${normalizeAuditText(props.title, 48)}@${String(rect.x)},${String(rect.y)},${String(rect.w)},${String(rect.h)}${offRoot ? ":off-root" : ""}`; + pushLimited(titleRectSamples, titleRectSummary, 24); + if (rect.w <= 0 || rect.h <= 0) { + pushLimited(zeroAreaTitleSamples, titleRectSummary, 24); + } + for (const needle of needles) { + if (props.title.includes(needle)) needleHits.add(needle); + } + } + } + + const childCount = Math.min(node.children.length, layout.children.length); + const delta = Math.abs(node.children.length - layout.children.length); + if (delta > 0) { + const id = (node.vnode.props as Readonly<{ id?: unknown }> | undefined)?.id; + const props = node.vnode.props as Readonly<{ title?: unknown }> | undefined; + const label = + typeof id === "string" && id.length > 0 + ? `${kind}#${id}` + : typeof props?.title === "string" && props.title.length > 0 + ? `${kind}[${normalizeAuditText(props.title, 32)}]` + : kind; + pushLimited( + mismatchSamples, + `${path}/${label}:runtimeChildren=${String(node.children.length)} layoutChildren=${String(layout.children.length)} layoutNode=${describeAuditVNode(layout.vnode)}`, + 24, + ); + } + if (delta > maxChildrenDelta) maxChildrenDelta = delta; + for (let i = childCount - 1; i >= 0; i--) { + const child = node.children[i]; + const childLayout = layout.children[i]; + if (!child || !childLayout) continue; + stack.push( + Object.freeze({ + node: child, + layout: childLayout, + depth: depth + 1, + path: `${path}/${child.vnode.kind}[${String(i)}]`, + }), + ); + } + } + + const topKinds = Object.fromEntries( + [...kindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12), + ); + const topZeroAreaKinds = Object.fromEntries( + [...zeroAreaKindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12), + ); + + return Object.freeze({ + nodeCount, + textNodeCount, + boxTitleCount, + compositeNodeCount, + zeroAreaNodes, + maxDepth, + maxChildrenDelta, + topKinds, + topZeroAreaKinds, + textSamples, + titleSamples, + titleRectSamples, + zeroAreaTitleSamples, + mismatchSamples, + needleHits: [...needleHits].sort(), + }); +} + +type RuntimeLayoutShapeMismatch = Readonly<{ + path: string; + depth: number; + reason: "kind" | "children"; + runtimeKind: string; + layoutKind: string; + runtimeChildCount: number; + layoutChildCount: number; + runtimeTrail: readonly string[]; + layoutTrail: readonly string[]; +}>; + +function findRuntimeLayoutShapeMismatch( + root: RuntimeInstance, + layoutRoot: LayoutTree, +): RuntimeLayoutShapeMismatch | null { + const queue: Array< + Readonly<{ + runtimeNode: RuntimeInstance; + layoutNode: LayoutTree; + path: string; + depth: number; + runtimeTrail: readonly string[]; + layoutTrail: readonly string[]; + }> + > = [ + Object.freeze({ + runtimeNode: root, + layoutNode: layoutRoot, + path: "root", + depth: 0, + runtimeTrail: Object.freeze([describeAuditVNode(root.vnode)]), + layoutTrail: Object.freeze([describeAuditVNode(layoutRoot.vnode)]), + }), + ]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const { runtimeNode, layoutNode, path, depth, runtimeTrail, layoutTrail } = current; + const runtimeKind = runtimeNode.vnode.kind; + const layoutKind = layoutNode.vnode.kind; + const runtimeChildCount = runtimeNode.children.length; + const layoutChildCount = layoutNode.children.length; + if (runtimeKind !== layoutKind) { + return Object.freeze({ + path, + depth, + reason: "kind", + runtimeKind, + layoutKind, + runtimeChildCount, + layoutChildCount, + runtimeTrail, + layoutTrail, + }); + } + if (runtimeChildCount !== layoutChildCount) { + return Object.freeze({ + path, + depth, + reason: "children", + runtimeKind, + layoutKind, + runtimeChildCount, + layoutChildCount, + runtimeTrail, + layoutTrail, + }); + } + + for (let i = 0; i < runtimeChildCount; i++) { + const runtimeChild = runtimeNode.children[i]; + const layoutChild = layoutNode.children[i]; + if (!runtimeChild || !layoutChild) { + return Object.freeze({ + path: `${path}/${runtimeChild ? runtimeChild.vnode.kind : "missing"}[${String(i)}]`, + depth: depth + 1, + reason: "children", + runtimeKind: runtimeChild?.vnode.kind ?? "", + layoutKind: layoutChild?.vnode.kind ?? "", + runtimeChildCount: runtimeChild?.children.length ?? -1, + layoutChildCount: layoutChild?.children.length ?? -1, + runtimeTrail, + layoutTrail, + }); + } + const nextRuntimeTrail = Object.freeze([ + ...runtimeTrail.slice(-11), + describeAuditVNode(runtimeChild.vnode), + ]); + const nextLayoutTrail = Object.freeze([ + ...layoutTrail.slice(-11), + describeAuditVNode(layoutChild.vnode), + ]); + queue.push( + Object.freeze({ + runtimeNode: runtimeChild, + layoutNode: layoutChild, + path: `${path}/${runtimeChild.vnode.kind}[${String(i)}]`, + depth: depth + 1, + runtimeTrail: nextRuntimeTrail, + layoutTrail: nextLayoutTrail, + }), + ); + } + } + + return null; +} + +function hasRuntimeLayoutShapeMismatch(root: RuntimeInstance, layoutRoot: LayoutTree): boolean { + return findRuntimeLayoutShapeMismatch(root, layoutRoot) !== null; +} + function cloneFocusManagerState(state: FocusManagerState): FocusManagerState { return Object.freeze({ focusedId: state.focusedId, @@ -2392,7 +2689,6 @@ export class WidgetRenderer { let hasRoutingWidgets = hadRoutingWidgets; let didRoutingRebuild = false; let identityDamageFromCommit: IdentityDiffDamageResult | null = null; - let layoutDirtyVNodeSet: Set | null = null; if (doCommit) { let commitReadViewport = false; @@ -2469,6 +2765,13 @@ export class WidgetRenderer { doLayout = true; } } + if (!doLayout && this.layoutTree !== null) { + // Defensive guard: never render a newly committed runtime tree against + // a stale layout tree with different shape/kinds. + if (findRuntimeLayoutShapeMismatch(this.committedRoot, this.layoutTree) !== null) { + doLayout = true; + } + } this.cleanupUnmountedInstanceIds(commitRes.unmountedInstanceIds); this.scheduleExitAnimations( commitRes.pendingExitAnimations, @@ -2504,21 +2807,14 @@ export class WidgetRenderer { this._lastRenderedViewport.rows !== viewport.rows || this._lastRenderedThemeRef !== theme; - if (doLayout && doCommit && commitRes !== null && !forceFullRelayout) { - this.collectSelfDirtyInstanceIds(this.committedRoot, this._pooledDirtyLayoutInstanceIds); - const dirtyInstanceIds = computeDirtyLayoutSet( - this.committedRoot, - commitRes.mountedInstanceIds, - this._pooledDirtyLayoutInstanceIds, - ); - layoutDirtyVNodeSet = instanceDirtySetToVNodeDirtySet(this.committedRoot, dirtyInstanceIds); - } - if (doLayout) { const rootPad = this.rootPadding; const rootW = Math.max(0, viewport.cols - rootPad * 2); const rootH = Math.max(0, viewport.rows - rootPad * 2); const layoutToken = perfMarkStart("layout"); + // Force a cold layout pass whenever layout is requested; partial cache reuse can + // produce runtime/layout shape divergence under complex composite updates. + this._layoutTreeCache = new WeakMap(); const layoutRootVNode = this.scrollOverrides.size > 0 ? this.applyScrollOverridesToVNode(this.committedRoot.vnode) @@ -2533,13 +2829,89 @@ export class WidgetRenderer { "column", this._layoutMeasureCache, this._layoutTreeCache, - layoutDirtyVNodeSet, + null, ); perfMarkEnd("layout", layoutToken); if (!layoutRes.ok) { return { ok: false, code: layoutRes.fatal.code, detail: layoutRes.fatal.detail }; } - const nextLayoutTree = layoutRes.value; + let nextLayoutTree = layoutRes.value; + let shapeMismatch = doCommit + ? findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree) + : null; + if (doCommit && shapeMismatch !== null) { + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit("widgetRenderer", "layout.shape_mismatch", { + reason: "post-layout-cache-hit", + path: shapeMismatch.path, + depth: shapeMismatch.depth, + mismatchKind: shapeMismatch.reason, + runtimeKind: shapeMismatch.runtimeKind, + layoutKind: shapeMismatch.layoutKind, + runtimeChildCount: shapeMismatch.runtimeChildCount, + layoutChildCount: shapeMismatch.layoutChildCount, + runtimeTrail: shapeMismatch.runtimeTrail, + layoutTrail: shapeMismatch.layoutTrail, + }); + } + // Cache can become stale under structural changes; force a cold relayout. + this._layoutTreeCache = new WeakMap(); + const fallbackLayoutRes = layout( + layoutRootVNode, + rootPad, + rootPad, + rootW, + rootH, + "column", + this._layoutMeasureCache, + this._layoutTreeCache, + null, + ); + if (!fallbackLayoutRes.ok) { + return { + ok: false, + code: fallbackLayoutRes.fatal.code, + detail: fallbackLayoutRes.fatal.detail, + }; + } + nextLayoutTree = fallbackLayoutRes.value; + shapeMismatch = findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree); + if (shapeMismatch !== null && layoutRootVNode !== this.committedRoot.vnode) { + const directLayoutRes = layout( + this.committedRoot.vnode, + rootPad, + rootPad, + rootW, + rootH, + "column", + this._layoutMeasureCache, + this._layoutTreeCache, + null, + ); + if (!directLayoutRes.ok) { + return { + ok: false, + code: directLayoutRes.fatal.code, + detail: directLayoutRes.fatal.detail, + }; + } + nextLayoutTree = directLayoutRes.value; + shapeMismatch = findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree); + } + if (shapeMismatch !== null && FRAME_AUDIT_ENABLED) { + emitFrameAudit("widgetRenderer", "layout.shape_mismatch.persisted", { + path: shapeMismatch.path, + depth: shapeMismatch.depth, + mismatchKind: shapeMismatch.reason, + runtimeKind: shapeMismatch.runtimeKind, + layoutKind: shapeMismatch.layoutKind, + runtimeChildCount: shapeMismatch.runtimeChildCount, + layoutChildCount: shapeMismatch.layoutChildCount, + runtimeTrail: shapeMismatch.runtimeTrail, + layoutTrail: shapeMismatch.layoutTrail, + }); + } + } this.layoutTree = nextLayoutTree; this.emitDevLayoutWarnings(nextLayoutTree, viewport); @@ -3618,7 +3990,70 @@ export class WidgetRenderer { try { const backendToken = PERF_ENABLED ? perfMarkStart("backend_request") : 0; try { + const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(built.bytes) : null; + if (fingerprint !== null) { + emitFrameAudit("widgetRenderer", "drawlist.built", { + tick, + commit: doCommit, + layout: doLayout, + incremental: usedIncrementalRender, + damageMode: runtimeDamageMode, + damageRectCount: runtimeDamageRectCount, + damageArea: runtimeDamageArea, + ...fingerprint, + }); + if (FRAME_AUDIT_TREE_ENABLED) { + emitFrameAudit( + "widgetRenderer", + "runtime.tree.summary", + Object.freeze({ + tick, + ...summarizeRuntimeTreeForAudit(this.committedRoot, this.layoutTree), + }), + ); + } + } const inFlight = this.backend.requestFrame(built.bytes); + if (fingerprint !== null) { + emitFrameAudit("widgetRenderer", "backend.request", { + tick, + hash32: fingerprint.hash32, + prefixHash32: fingerprint.prefixHash32, + byteLen: fingerprint.byteLen, + }); + const acceptedAck = ( + inFlight as Promise & + Partial>> + )[FRAME_ACCEPTED_ACK_MARKER]; + if (acceptedAck !== undefined) { + void acceptedAck.then( + () => + emitFrameAudit("widgetRenderer", "backend.accepted", { + tick, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("widgetRenderer", "backend.accepted_error", { + tick, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } + void inFlight.then( + () => + emitFrameAudit("widgetRenderer", "backend.completed", { + tick, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("widgetRenderer", "backend.completed_error", { + tick, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } return { ok: true, inFlight }; } finally { if (PERF_ENABLED) perfMarkEnd("backend_request", backendToken); diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index 270db4d7..07ce54f7 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -1,6 +1,11 @@ -import { ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; import type { TextStyle } from "../widgets/style.js"; -import { DrawlistBuilderBase, type DrawlistBuilderBaseOpts } from "./builderBase.js"; +import { + HEADER_SIZE, + DrawlistBuilderBase, + align4, + type DrawlistBuilderBaseOpts, +} from "./builderBase.js"; import type { CursorState, DrawlistBuildResult, @@ -13,20 +18,28 @@ import type { } from "./types.js"; import { CLEAR_SIZE, + DEF_BLOB_BASE_SIZE, + DEF_STRING_BASE_SIZE, DRAW_CANVAS_SIZE, DRAW_IMAGE_SIZE, DRAW_TEXT_RUN_SIZE, DRAW_TEXT_SIZE, FILL_RECT_SIZE, + FREE_BLOB_SIZE, + FREE_STRING_SIZE, POP_CLIP_SIZE, PUSH_CLIP_SIZE, SET_CURSOR_SIZE, writeClear, + writeDefBlob, + writeDefString, writeDrawCanvas, writeDrawImage, writeDrawText, writeDrawTextRun, writeFillRect, + writeFreeBlob, + writeFreeString, writePopClip, writePushClip, writeSetCursor, @@ -64,6 +77,16 @@ const IMAGE_FIT_CODE: Readonly> = Object.freeze type LinkRefs = Readonly<{ uriRef: number; idRef: number }>; type CanvasPixelSize = Readonly<{ pxWidth: number; pxHeight: number }>; +type ResourceBuildPlan = Readonly<{ + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsCount: number; + blobsCount: number; + freeStringsCount: number; + freeBlobsCount: number; + totalSize: number; +}>; const MAX_U16 = 0xffff; @@ -172,6 +195,8 @@ export function createDrawlistBuilder(opts: DrawlistBuilderOpts = {}): DrawlistB class DrawlistBuilderImpl extends DrawlistBuilderBase implements DrawlistBuilder { private activeLinkUriRef = 0; private activeLinkIdRef = 0; + private prevBuiltStringsCount = 0; + private prevBuiltBlobsCount = 0; constructor(opts: DrawlistBuilderOpts) { super(opts, "DrawlistBuilder"); @@ -285,9 +310,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - const blobOff = this.blobSpanOffs[bi]; const blobLen = this.blobSpanLens[bi]; - if (blobOff === undefined || blobLen === undefined) { + if (blobLen === undefined) { this.fail("ZRDL_INTERNAL", "drawCanvas: blob span table is inconsistent"); return; } @@ -352,8 +376,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D hi, resolvedPxW, resolvedPxH, - blobOff, - blobLen, + bi + 1, + 0, blitterCode, 0, 0, @@ -429,9 +453,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - const blobOff = this.blobSpanOffs[bi]; const blobLen = this.blobSpanLens[bi]; - if (blobOff === undefined || blobLen === undefined) { + if (blobLen === undefined) { this.fail("ZRDL_INTERNAL", "drawImage: blob span table is inconsistent"); return; } @@ -515,8 +538,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D hi, resolvedPxW, resolvedPxH, - blobOff, - blobLen, + bi + 1, + 0, imageIdU32, formatCode, protocolCode, @@ -532,11 +555,48 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D } buildInto(dst: Uint8Array): DrawlistBuildResult { - return this.buildIntoWithVersion(ZR_DRAWLIST_VERSION_V1, dst); + if (this.error) { + return { ok: false, error: this.error }; + } + if (!(dst instanceof Uint8Array)) { + return { + ok: false, + error: { code: "ZRDL_BAD_PARAMS", detail: "buildInto: dst must be a Uint8Array" }, + }; + } + const planned = this.planResourceStream(); + if (!planned.ok) { + return planned; + } + const plan = planned.plan; + if (dst.byteLength < plan.totalSize) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `buildInto: dst is too small (required=${plan.totalSize}, got=${dst.byteLength})`, + }, + }; + } + return this.writeBuiltStream(dst.subarray(0, plan.totalSize), plan, ZR_DRAWLIST_VERSION_V1); } build(): DrawlistBuildResult { - return this.buildWithVersion(ZR_DRAWLIST_VERSION_V1); + if (this.error) { + return { ok: false, error: this.error }; + } + const planned = this.planResourceStream(); + if (!planned.ok) { + return planned; + } + const plan = planned.plan; + const out = this.reuseOutputBuffer + ? this.ensureOutputCapacity(plan.totalSize) + : new Uint8Array(plan.totalSize); + if (this.error) { + return { ok: false, error: this.error }; + } + return this.writeBuiltStream(out.subarray(0, plan.totalSize), plan, ZR_DRAWLIST_VERSION_V1); } override reset(): void { @@ -587,7 +647,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D this.cmdLen, x, y, - stringIndex, + stringIndex + 1, 0, byteLen, style, @@ -610,7 +670,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D protected override appendDrawTextRunCommand(x: number, y: number, blobIndex: number): void { if (!this.beginCommandWrite("drawTextRun", DRAW_TEXT_RUN_SIZE)) return; - this.cmdLen = writeDrawTextRun(this.cmdBuf, this.cmdDv, this.cmdLen, x, y, blobIndex, 0); + this.cmdLen = writeDrawTextRun(this.cmdBuf, this.cmdDv, this.cmdLen, x, y, blobIndex + 1, 0); this.cmdCount += 1; } @@ -632,7 +692,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D dv.setUint32(off + 16, style.underlineRgb >>> 0, true); dv.setUint32(off + 20, style.linkUriRef >>> 0, true); dv.setUint32(off + 24, style.linkIdRef >>> 0, true); - dv.setUint32(off + 28, stringIndex >>> 0, true); + dv.setUint32(off + 28, (stringIndex + 1) >>> 0, true); dv.setUint32(off + 32, 0, true); dv.setUint32(off + 36, byteLen >>> 0, true); return off + 40; @@ -675,4 +735,176 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D idRef: this.activeLinkIdRef >>> 0, }); } + + private planResourceStream(): + | Readonly<{ ok: true; plan: ResourceBuildPlan }> + | Readonly<{ ok: false; error: { code: "ZRDL_TOO_LARGE" | "ZRDL_INTERNAL"; detail: string } }> { + if ((this.cmdLen & 3) !== 0) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: command stream is not 4-byte aligned" }, + }; + } + + const stringsCount = this.stringSpanOffs.length; + const blobsCount = this.blobSpanOffs.length; + const freeStringsCount = Math.max(0, this.prevBuiltStringsCount - stringsCount); + const freeBlobsCount = Math.max(0, this.prevBuiltBlobsCount - blobsCount); + + let defStringsBytes = 0; + for (let i = 0; i < stringsCount; i++) { + const len = this.stringSpanLens[i]; + if (len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: string span table is inconsistent" }, + }; + } + defStringsBytes += align4(DEF_STRING_BASE_SIZE + len); + } + + let defBlobsBytes = 0; + for (let i = 0; i < blobsCount; i++) { + const len = this.blobSpanLens[i]; + if (len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: blob span table is inconsistent" }, + }; + } + defBlobsBytes += align4(DEF_BLOB_BASE_SIZE + len); + } + + const cmdCount = stringsCount + blobsCount + this.cmdCount + freeStringsCount + freeBlobsCount; + const cmdBytes = + defStringsBytes + + defBlobsBytes + + this.cmdLen + + freeStringsCount * FREE_STRING_SIZE + + freeBlobsCount * FREE_BLOB_SIZE; + const cmdOffset = cmdCount === 0 ? 0 : HEADER_SIZE; + const totalSize = HEADER_SIZE + cmdBytes; + + if (cmdCount > this.maxCmdCount) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `build: maxCmdCount exceeded (count=${cmdCount}, max=${this.maxCmdCount})`, + }, + }; + } + + if ((cmdBytes & 3) !== 0 || (totalSize & 3) !== 0) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: command stream alignment is invalid" }, + }; + } + + if (totalSize > this.maxDrawlistBytes) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `build: maxDrawlistBytes exceeded (total=${totalSize}, max=${this.maxDrawlistBytes})`, + }, + }; + } + + return { + ok: true, + plan: { + cmdOffset, + cmdBytes, + cmdCount, + stringsCount, + blobsCount, + freeStringsCount, + freeBlobsCount, + totalSize, + }, + }; + } + + private writeBuiltStream( + out: Uint8Array, + plan: ResourceBuildPlan, + version: number, + ): DrawlistBuildResult { + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + + dv.setUint32(0, ZRDL_MAGIC, true); + dv.setUint32(4, version >>> 0, true); + dv.setUint32(8, HEADER_SIZE, true); + dv.setUint32(12, plan.totalSize >>> 0, true); + dv.setUint32(16, plan.cmdOffset >>> 0, true); + dv.setUint32(20, plan.cmdBytes >>> 0, true); + dv.setUint32(24, plan.cmdCount >>> 0, true); + dv.setUint32(28, 0, true); + dv.setUint32(32, 0, true); + dv.setUint32(36, 0, true); + dv.setUint32(40, 0, true); + dv.setUint32(44, 0, true); + dv.setUint32(48, 0, true); + dv.setUint32(52, 0, true); + dv.setUint32(56, 0, true); + dv.setUint32(60, 0, true); + + let pos = plan.cmdOffset; + + for (let i = 0; i < plan.stringsCount; i++) { + const off = this.stringSpanOffs[i]; + const len = this.stringSpanLens[i]; + if (off === undefined || len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: string span table is inconsistent" }, + }; + } + const bytes = this.stringBytesBuf.subarray(off, off + len); + pos = writeDefString(out, dv, pos, i + 1, len, bytes); + } + + for (let i = 0; i < plan.blobsCount; i++) { + const off = this.blobSpanOffs[i]; + const len = this.blobSpanLens[i]; + if (off === undefined || len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: blob span table is inconsistent" }, + }; + } + const bytes = this.blobBytesBuf.subarray(off, off + len); + pos = writeDefBlob(out, dv, pos, i + 1, len, bytes); + } + + out.set(this.cmdBuf.subarray(0, this.cmdLen), pos); + pos += this.cmdLen; + + for (let i = 0; i < plan.freeStringsCount; i++) { + const id = plan.stringsCount + i + 1; + pos = writeFreeString(out, dv, pos, id); + } + + for (let i = 0; i < plan.freeBlobsCount; i++) { + const id = plan.blobsCount + i + 1; + pos = writeFreeBlob(out, dv, pos, id); + } + + const expectedEnd = plan.cmdOffset + plan.cmdBytes; + if (pos !== expectedEnd) { + return { + ok: false, + error: { + code: "ZRDL_INTERNAL", + detail: `build: command stream size mismatch (expected=${expectedEnd}, got=${pos})`, + }, + }; + } + + this.prevBuiltStringsCount = plan.stringsCount; + this.prevBuiltBlobsCount = plan.blobsCount; + return { ok: true, bytes: out }; + } } diff --git a/packages/core/src/perf/frameAudit.ts b/packages/core/src/perf/frameAudit.ts new file mode 100644 index 00000000..6d34f1b1 --- /dev/null +++ b/packages/core/src/perf/frameAudit.ts @@ -0,0 +1,178 @@ +/** + * packages/core/src/perf/frameAudit.ts — Optional drawlist frame-audit logging. + * + * Purpose: + * - Provide deterministic fingerprints for drawlist bytes at core submit points. + * - Emit lightweight NDJSON records only when explicitly enabled. + * + * Enable with: + * REZI_FRAME_AUDIT=1 + */ + +export type DrawlistFingerprint = Readonly<{ + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + opcodeHistogram: Readonly>; + cmdStreamValid: boolean; +}>; + +function envFlag(name: "REZI_FRAME_AUDIT"): boolean { + try { + const g = globalThis as { + process?: { env?: { REZI_FRAME_AUDIT?: string } }; + }; + const raw = g.process?.env?.[name]; + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; + } catch { + return false; + } +} + +function nowMs(): number { + try { + const g = globalThis as { performance?: { now?: () => number } }; + const fn = g.performance?.now; + if (typeof fn === "function") return fn.call(g.performance); + } catch { + // no-op + } + return Date.now(); +} + +function toHex32(v: number): string { + return `0x${(v >>> 0).toString(16).padStart(8, "0")}`; +} + +function hashFnv1a32(bytes: Uint8Array, end: number): number { + const n = Math.max(0, Math.min(end, bytes.byteLength)); + let h = 0x811c9dc5; + for (let i = 0; i < n; i++) { + h ^= bytes[i] ?? 0; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function sliceHex(bytes: Uint8Array, start: number, end: number): string { + const s = Math.max(0, Math.min(start, bytes.byteLength)); + const e = Math.max(s, Math.min(end, bytes.byteLength)); + let out = ""; + for (let i = s; i < e; i++) { + out += (bytes[i] ?? 0).toString(16).padStart(2, "0"); + } + return out; +} + +function readHeaderU32(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + try { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); + } catch { + return null; + } +} + +function decodeOpcodeHistogram(bytes: Uint8Array): Readonly<{ + histogram: Readonly>; + valid: boolean; +}> { + const cmdCount = readHeaderU32(bytes, 24); + const cmdOffset = readHeaderU32(bytes, 16); + const cmdBytes = readHeaderU32(bytes, 20); + if (cmdCount === null || cmdOffset === null || cmdBytes === null) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + if (cmdCount === 0) { + return Object.freeze({ histogram: Object.freeze({}), valid: true }); + } + if (cmdOffset < 0 || cmdBytes < 0 || cmdOffset + cmdBytes > bytes.byteLength) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let off = cmdOffset; + const end = cmdOffset + cmdBytes; + const hist: Record = Object.create(null) as Record; + for (let i = 0; i < cmdCount; i++) { + if (off + 8 > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const opcode = dv.getUint16(off + 0, true); + const size = dv.getUint32(off + 4, true); + if (size < 8 || off + size > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const key = String(opcode); + hist[key] = (hist[key] ?? 0) + 1; + off += size; + } + return Object.freeze({ histogram: Object.freeze(hist), valid: off === end }); +} + +export const FRAME_AUDIT_ENABLED = envFlag("REZI_FRAME_AUDIT"); + +export function drawlistFingerprint(bytes: Uint8Array): DrawlistFingerprint { + const byteLen = bytes.byteLength; + const prefixLen = Math.min(4096, byteLen); + const cmdCount = readHeaderU32(bytes, 24); + const totalSize = readHeaderU32(bytes, 12); + const head16 = sliceHex(bytes, 0, Math.min(16, byteLen)); + const tailStart = Math.max(0, byteLen - 16); + const tail16 = sliceHex(bytes, tailStart, byteLen); + const decoded = decodeOpcodeHistogram(bytes); + return Object.freeze({ + byteLen, + hash32: toHex32(hashFnv1a32(bytes, byteLen)), + prefixHash32: toHex32(hashFnv1a32(bytes, prefixLen)), + cmdCount, + totalSize, + head16, + tail16, + opcodeHistogram: decoded.histogram, + cmdStreamValid: decoded.valid, + }); +} + +type AuditFields = Readonly>; + +export function emitFrameAudit(scope: string, stage: string, fields: AuditFields): void { + if (!FRAME_AUDIT_ENABLED) return; + try { + const g = globalThis as { + __reziFrameAuditSink?: (line: string) => void; + __reziFrameAuditContext?: () => Readonly>; + process?: { pid?: number; stderr?: { write?: (text: string) => void } }; + console?: { error?: (msg?: unknown) => void }; + }; + const context = + typeof g.__reziFrameAuditContext === "function" ? g.__reziFrameAuditContext() : null; + const pid = g.process?.pid; + const line = JSON.stringify({ + ts: new Date().toISOString(), + tMs: nowMs(), + pid: typeof pid === "number" && Number.isInteger(pid) ? pid : undefined, + layer: "core", + scope, + stage, + ...(context ?? {}), + ...fields, + }); + if (typeof g.__reziFrameAuditSink === "function") { + g.__reziFrameAuditSink(line); + return; + } + if (typeof g.process?.stderr?.write === "function") { + g.process.stderr.write(`${line}\n`); + return; + } + g.console?.error?.(line); + } catch { + // Never break rendering due to optional diagnostics. + } +} diff --git a/packages/core/src/renderer/__tests__/overlay.edge.test.ts b/packages/core/src/renderer/__tests__/overlay.edge.test.ts index bd190769..0a253025 100644 --- a/packages/core/src/renderer/__tests__/overlay.edge.test.ts +++ b/packages/core/src/renderer/__tests__/overlay.edge.test.ts @@ -1,43 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function renderStrings( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }>, diff --git a/packages/core/src/renderer/__tests__/render.golden.test.ts b/packages/core/src/renderer/__tests__/render.golden.test.ts index 588da217..e812db33 100644 --- a/packages/core/src/renderer/__tests__/render.golden.test.ts +++ b/packages/core/src/renderer/__tests__/render.golden.test.ts @@ -1,4 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { truncateMiddle, truncateWithEllipsis } from "../../layout/textMeasure.js"; @@ -62,34 +63,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function commitTree(vnode: VNode) { const allocator = createInstanceIdAllocator(1); const res = commitVNodeTree(null, vnode, { allocator }); diff --git a/packages/core/src/renderer/__tests__/renderer.text.test.ts b/packages/core/src/renderer/__tests__/renderer.text.test.ts index b0bab201..8a6bfb59 100644 --- a/packages/core/src/renderer/__tests__/renderer.text.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.text.test.ts @@ -1,22 +1,24 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DEF_BLOB, + OP_DEF_STRING, + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + OP_FREE_BLOB, + OP_FREE_STRING, + OP_POP_CLIP, + OP_PUSH_CLIP, + parseCommandHeaders, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; -const OP_POP_CLIP = 5; -const OP_DRAW_TEXT_RUN = 6; - const decoder = new TextDecoder(); -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); @@ -27,19 +29,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -type Header = Readonly<{ - cmdOffset: number; - cmdBytes: number; - stringsSpanOffset: number; - stringsCount: number; - stringsBytesOffset: number; - stringsBytesLen: number; - blobsSpanOffset: number; - blobsCount: number; - blobsBytesOffset: number; - blobsBytesLen: number; -}>; - type DrawTextCommand = Readonly<{ x: number; y: number; @@ -88,169 +77,150 @@ type ParsedFrame = Readonly<{ textRunBlobs: readonly TextRunBlob[]; }>; -function readHeader(bytes: Uint8Array): Header { - return { - cmdOffset: u32(bytes, 16), - cmdBytes: u32(bytes, 20), - stringsSpanOffset: u32(bytes, 28), - stringsCount: u32(bytes, 32), - stringsBytesOffset: u32(bytes, 36), - stringsBytesLen: u32(bytes, 40), - blobsSpanOffset: u32(bytes, 44), - blobsCount: u32(bytes, 48), - blobsBytesOffset: u32(bytes, 52), - blobsBytesLen: u32(bytes, 56), - }; -} - -function parseInternedStrings(bytes: Uint8Array, header: Header): readonly string[] { - if (header.stringsCount === 0) return Object.freeze([]); - - const tableEnd = header.stringsBytesOffset + header.stringsBytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - for (let i = 0; i < header.stringsCount; i++) { - const span = header.stringsSpanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = header.stringsBytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} +type StringResources = ReadonlyMap; function decodeStringSlice( - bytes: Uint8Array, - header: Header, - stringIndex: number, + strings: StringResources, + stringId: number, byteOff: number, byteLen: number, ): string { - assert.ok(stringIndex >= 0 && stringIndex < header.stringsCount, "string index in bounds"); - - const span = header.stringsSpanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - assert.ok(byteOff + byteLen <= strLen, "string slice must be in-bounds"); - - const start = header.stringsBytesOffset + strOff + byteOff; - const end = start + byteLen; - return decoder.decode(bytes.subarray(start, end)); + const raw = strings.get(stringId); + if (!raw) return ""; + const end = byteOff + byteLen; + if (end > raw.byteLength) return ""; + return decoder.decode(raw.subarray(byteOff, end)); } -function parseTextRunBlobs(bytes: Uint8Array, header: Header): readonly TextRunBlob[] { - if (header.blobsCount === 0) return Object.freeze([]); - - const blobsEnd = header.blobsBytesOffset + header.blobsBytesLen; - assert.ok(blobsEnd <= bytes.byteLength, "blob table must be in-bounds"); - - const out: TextRunBlob[] = []; - for (let i = 0; i < header.blobsCount; i++) { - const span = header.blobsSpanOffset + i * 8; - const blobOff = header.blobsBytesOffset + u32(bytes, span); - const blobLen = u32(bytes, span + 4); - const blobEnd = blobOff + blobLen; - assert.ok(blobEnd <= blobsEnd, "blob span must be in-bounds"); - - const segCount = u32(bytes, blobOff); - const segments: TextRunSegment[] = []; - - let segOff = blobOff + 4; - for (let seg = 0; seg < segCount; seg++) { - assert.ok(segOff + 28 <= blobEnd, "text run segment must be in-bounds"); - const stringIndex = u32(bytes, segOff + 16); - const byteOff = u32(bytes, segOff + 20); - const byteLen = u32(bytes, segOff + 24); - - segments.push({ - fg: u32(bytes, segOff + 0), - bg: u32(bytes, segOff + 4), - attrs: u32(bytes, segOff + 8), - stringIndex, +function decodeTextRunBlob(blob: Uint8Array, strings: StringResources): TextRunBlob { + if (blob.byteLength < 4) return Object.freeze({ segments: Object.freeze([]) }); + const segCount = u32(blob, 0); + const remaining = blob.byteLength - 4; + const stride = segCount > 0 && remaining === segCount * 40 ? 40 : 28; + const stringFieldOffset = stride === 40 ? 28 : 16; + const byteOffFieldOffset = stride === 40 ? 32 : 20; + const byteLenFieldOffset = stride === 40 ? 36 : 24; + + const segments: TextRunSegment[] = []; + let segOff = 4; + for (let i = 0; i < segCount; i++) { + if (segOff + stride > blob.byteLength) break; + const stringId = u32(blob, segOff + stringFieldOffset); + const byteOff = u32(blob, segOff + byteOffFieldOffset); + const byteLen = u32(blob, segOff + byteLenFieldOffset); + segments.push( + Object.freeze({ + fg: u32(blob, segOff + 0), + bg: u32(blob, segOff + 4), + attrs: u32(blob, segOff + 8), + stringIndex: stringId > 0 ? stringId - 1 : -1, byteOff, byteLen, - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), - }); - - segOff += 28; - } - - out.push({ segments: Object.freeze(segments) }); + text: decodeStringSlice(strings, stringId, byteOff, byteLen), + }), + ); + segOff += stride; } - return Object.freeze(out); + return Object.freeze({ segments: Object.freeze(segments) }); } function parseFrame(bytes: Uint8Array): ParsedFrame { - const header = readHeader(bytes); - const strings = parseInternedStrings(bytes, header); - const textRunBlobs = parseTextRunBlobs(bytes, header); - + const stringsById = new Map(); + const textRunBlobsByIndex: TextRunBlob[] = []; const drawTexts: DrawTextCommand[] = []; const drawTextRuns: DrawTextRunCommand[] = []; const pushClips: PushClipCommand[] = []; let popClipCount = 0; - const cmdEnd = header.cmdOffset + header.cmdBytes; - let off = header.cmdOffset; - - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.ok(size >= 8, "command size must be >= 8"); + for (const cmd of parseCommandHeaders(bytes)) { + const off = cmd.offset; + if (cmd.opcode === OP_DEF_STRING) { + if (cmd.size < 16) continue; + const stringId = u32(bytes, off + 8); + const byteLen = u32(bytes, off + 12); + const dataStart = off + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd <= off + cmd.size) { + stringsById.set(stringId, Uint8Array.from(bytes.subarray(dataStart, dataEnd))); + } + continue; + } + if (cmd.opcode === OP_FREE_STRING) { + if (cmd.size >= 12) stringsById.delete(u32(bytes, off + 8)); + continue; + } + if (cmd.opcode === OP_DEF_BLOB) { + if (cmd.size < 16) continue; + const blobId = u32(bytes, off + 8); + const byteLen = u32(bytes, off + 12); + const dataStart = off + 16; + const dataEnd = dataStart + byteLen; + if (blobId > 0 && dataEnd <= off + cmd.size) { + const blob = Uint8Array.from(bytes.subarray(dataStart, dataEnd)); + textRunBlobsByIndex[blobId - 1] = decodeTextRunBlob(blob, stringsById); + } + continue; + } + if (cmd.opcode === OP_FREE_BLOB) { + const blobId = u32(bytes, off + 8); + if (blobId > 0) textRunBlobsByIndex[blobId - 1] = Object.freeze({ segments: Object.freeze([]) }); + continue; + } - if (opcode === OP_DRAW_TEXT) { - assert.ok(size >= 48, "DRAW_TEXT command size"); - const stringIndex = u32(bytes, off + 16); + if (cmd.opcode === OP_DRAW_TEXT) { + assert.ok(cmd.size >= 48, "DRAW_TEXT command size"); + const stringId = u32(bytes, off + 16); const byteOff = u32(bytes, off + 20); const byteLen = u32(bytes, off + 24); drawTexts.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), - stringIndex, + stringIndex: stringId > 0 ? stringId - 1 : -1, byteOff, byteLen, - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), + text: decodeStringSlice(stringsById, stringId, byteOff, byteLen), fg: u32(bytes, off + 28), bg: u32(bytes, off + 32), attrs: u32(bytes, off + 36), }); - } else if (opcode === OP_DRAW_TEXT_RUN) { - assert.ok(size >= 24, "DRAW_TEXT_RUN command size"); + continue; + } + + if (cmd.opcode === OP_DRAW_TEXT_RUN) { + assert.ok(cmd.size >= 24, "DRAW_TEXT_RUN command size"); + const blobId = u32(bytes, off + 16); drawTextRuns.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), - blobIndex: u32(bytes, off + 16), + blobIndex: blobId > 0 ? blobId - 1 : -1, }); - } else if (opcode === OP_PUSH_CLIP) { - assert.ok(size >= 24, "PUSH_CLIP command size"); + continue; + } + + if (cmd.opcode === OP_PUSH_CLIP) { + assert.ok(cmd.size >= 24, "PUSH_CLIP command size"); pushClips.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), w: i32(bytes, off + 16), h: i32(bytes, off + 20), }); - } else if (opcode === OP_POP_CLIP) { - popClipCount++; + continue; } - off += size; + if (cmd.opcode === OP_POP_CLIP) { + popClipCount++; + } } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - return { - strings, + strings: parseInternedStrings(bytes), drawTexts: Object.freeze(drawTexts), drawTextRuns: Object.freeze(drawTextRuns), pushClips: Object.freeze(pushClips), popClipCount, - textRunBlobs, + textRunBlobs: Object.freeze(textRunBlobsByIndex), }; } diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index 7d8cc571..fbdc16dd 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -1331,6 +1331,11 @@ function commitContainer( childOrderStable && canFastReuseContainerSelf(prev.vnode, vnode) ) { + // Even when child RuntimeInstance references are stable, child VNodes may have + // been updated via in-place child commits. Keep the parent VNode's committed + // child wiring in sync so layout traverses the same tree shape as runtime. + const fastReuseCommittedChildren = prev.children.map((child) => child.vnode); + (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, fastReuseCommittedChildren); // All children are identical references → reuse parent entirely. // Propagate dirty from children: a child may have been mutated in-place // with dirty=true even though it returned the same reference. diff --git a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts index eccc42ed..96fe3414 100644 --- a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts +++ b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts @@ -1,4 +1,8 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + parseDrawTextCommands as parseDecodedDrawTextCommands, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { type DrawlistBuilder, type Theme, @@ -39,32 +43,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table in bounds"); - - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd, "string span in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - type DrawTextCommand = Readonly<{ text: string; fg: number; @@ -77,22 +55,24 @@ type DrawTextCommand = Readonly<{ }>; function parseDrawTextCommands(bytes: Uint8Array): readonly DrawTextCommand[] { - const strings = parseInternedStrings(bytes); + const decoded = parseDecodedDrawTextCommands(bytes); const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); const end = cmdOffset + cmdBytes; const out: DrawTextCommand[] = []; + let drawTextIndex = 0; let off = cmdOffset; while (off < end) { const opcode = u16(bytes, off); const size = u32(bytes, off + 4); if (opcode === 3 && size >= 48) { - const stringIndex = u32(bytes, off + 16); + const decodedCmd = decoded[drawTextIndex]; + drawTextIndex += 1; const isV3 = size >= 60; const reserved = u32(bytes, off + 40); out.push({ - text: strings[stringIndex] ?? "", + text: decodedCmd?.text ?? "", fg: u32(bytes, off + 28), bg: u32(bytes, off + 32), attrs: u32(bytes, off + 36), @@ -552,19 +532,15 @@ describe("basic widgets render to drawlist", () => { assert.equal(docs.underlineColorRgb, 0x010203); }); - test("link encodes hyperlink refs on v3 and degrades on v1", () => { + test("link encodes hyperlink refs", () => { const v3 = renderBytesV3(ui.link("https://example.com", "Docs"), { cols: 40, rows: 4 }); - const v1 = renderBytes(ui.link("https://example.com", "Docs"), { cols: 40, rows: 4 }); assert.equal(parseOpcodes(v3).includes(8), false); - assert.equal(parseOpcodes(v1).includes(8), false); assert.equal(parseInternedStrings(v3).includes("https://example.com"), true); const v3Docs = parseDrawTextCommands(v3).find((cmd) => cmd.text === "Docs"); - const v1Docs = parseDrawTextCommands(v1).find((cmd) => cmd.text === "Docs"); assert.equal((v3Docs?.linkUriRef ?? 0) > 0, true); - assert.equal(v1Docs?.linkUriRef ?? 0, 0); }); - test("codeEditor diagnostics use curly underline + token color on v3", () => { + test("codeEditor diagnostics use curly underline + token color", () => { const theme = createTheme({ colors: { "diagnostic.warning": (1 << 16) | (2 << 8) | 3, @@ -584,8 +560,6 @@ describe("basic widgets render to drawlist", () => { onScroll: () => undefined, }); const v3 = renderBytesV3(vnode, { cols: 30, rows: 4 }, { theme }); - const v1 = renderBytes(vnode, { cols: 30, rows: 4 }, { theme }); - const v3WarnStyles = parseDrawTextCommands(v3).filter((cmd) => cmd.text === "warn"); assert.equal( v3WarnStyles.some((cmd) => { @@ -597,22 +571,6 @@ describe("basic widgets render to drawlist", () => { }), true, ); - - const v1WarnStyles = parseDrawTextCommands(v1).filter((cmd) => cmd.text === "warn"); - assert.equal( - v1WarnStyles.some((cmd) => (cmd.attrs & ATTR_UNDERLINE) !== 0), - true, - ); - assert.equal( - v1WarnStyles.some( - (cmd) => - cmd.underlineStyle !== 0 || - cmd.underlineColorRgb !== 0 || - cmd.linkUriRef !== 0 || - cmd.linkIdRef !== 0, - ), - false, - ); }); test("codeEditor applies syntax token colors for mainstream language presets", () => { diff --git a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts index f9e05b7f..603f71f7 100644 --- a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts +++ b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseBlobById, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import type { DrawlistBuilder, VNode } from "../../index.js"; import { createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; @@ -43,26 +44,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - const tableEnd = bytesOffset + bytesLen; - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const off = u32(bytes, spanOffset + i * 8); - const len = u32(bytes, spanOffset + i * 8 + 4); - const start = bytesOffset + off; - const end = start + len; - assert.equal(end <= tableEnd, true); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function findCommandPayload(bytes: Uint8Array, opcode: number): number | null { const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); @@ -169,7 +150,7 @@ describe("graphics widgets", () => { test("link encodes hyperlink refs and keeps label text payload", () => { const vnode = ui.link({ url: "https://example.com", label: "Docs", id: "docs-link" }); const bytes = renderBytes(vnode, () => createDrawlistBuilder()); - assert.equal(parseStrings(bytes).includes("Docs"), true); + assert.equal(parseInternedStrings(bytes).includes("Docs"), true); const textPayload = findCommandPayload(bytes, 3); assert.equal(textPayload !== null, true); if (textPayload === null) return; @@ -246,15 +227,16 @@ describe("graphics widgets", () => { const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobOff = u32(bytes, payloadOff + 12); - const blobLen = u32(bytes, payloadOff + 16); + const blobId = u32(bytes, payloadOff + 12); + const blobReserved = u32(bytes, payloadOff + 16); const blitterCode = u8(bytes, payloadOff + 20); assert.equal(blitterCode, 2); - assert.equal(blobLen, width * 2 * height * 4 * 4); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - assert.equal(blobOff + blobLen <= blobsBytesLen, true); - assert.equal(blobsBytesOffset + blobOff + blobLen <= bytes.byteLength, true); + assert.equal(blobReserved, 0); + assert.equal(blobId > 0, true); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true); + if (!blob) return; + assert.equal(blob.length, width * 2 * height * 4 * 4); }); test("image route detects PNG format for DRAW_IMAGE", () => { @@ -349,7 +331,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), true, ); }); @@ -449,7 +431,7 @@ describe("graphics widgets", () => { assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("sourceWidth/sourceHeight")), + parseInternedStrings(bytes).some((value) => value.includes("sourceWidth/sourceHeight")), true, ); }); @@ -469,7 +451,7 @@ describe("graphics widgets", () => { assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), true, ); }); @@ -481,7 +463,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(8) || parseOpcodes(bytes).includes(9), true); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), false, ); }); @@ -501,7 +483,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Broken image")), + parseInternedStrings(bytes).some((value) => value.includes("Broken image")), true, ); }); @@ -562,10 +544,10 @@ describe("graphics widgets", () => { const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobOff = u32(bytes, payloadOff + 12); - const blobLen = u32(bytes, payloadOff + 16); - const blobsBytesOffset = u32(bytes, 52); - const rgba = bytes.subarray(blobsBytesOffset + blobOff, blobsBytesOffset + blobOff + blobLen); + const blobId = u32(bytes, payloadOff + 12); + const rgba = parseBlobById(bytes, blobId); + assert.equal(rgba !== null, true); + if (!rgba) return; const hasVisiblePixel = rgba.some((value, index) => index % 4 === 3 && value !== 0); assert.equal(hasVisiblePixel, true); }); diff --git a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts index 8d10f432..fca30cd4 100644 --- a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts +++ b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import type { RuntimeBreadcrumbSnapshot } from "../../app/runtimeBreadcrumbs.js"; import { type VNode, ZR_CURSOR_SHAPE_BAR, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; @@ -7,38 +8,6 @@ import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { inspectorOverlay } from "../inspectorOverlay.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function renderStrings( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 30 }, diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index 513e8be2..e171a9e8 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -40,31 +41,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table in bounds"); - - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd, "string span in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - type CommandStyle = Readonly<{ opcode: number; fg: number; diff --git a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts index 2fa57c3d..6bd2b245 100644 --- a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts +++ b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -8,36 +9,6 @@ import { ui } from "../ui.js"; type TreeNode = Readonly<{ id: string; children: readonly TreeNode[] }>; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function renderBytes( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 40 }, diff --git a/packages/create-rezi/templates/starship/src/main.ts b/packages/create-rezi/templates/starship/src/main.ts index 730213fa..3ac650ca 100644 --- a/packages/create-rezi/templates/starship/src/main.ts +++ b/packages/create-rezi/templates/starship/src/main.ts @@ -1,4 +1,9 @@ -import { exit } from "node:process"; +import { + categoriesToMask, + createDebugController, + type DebugController, + type DebugRecord, +} from "@rezi-ui/core"; import { createNodeApp } from "@rezi-ui/node"; import { debugSnapshot } from "./helpers/debug.js"; import { resolveStarshipCommand } from "./helpers/keybindings.js"; @@ -14,10 +19,21 @@ import type { RouteDeps, RouteId, StarshipAction, StarshipState } from "./types. const UI_FPS_CAP = 30; const TICK_MS = 800; const TOAST_PRUNE_MS = 3000; +const DEBUG_TRACE_ENABLED = process.env.REZI_STARSHIP_DEBUG_TRACE === "1"; +const EXECUTION_MODE: "inline" | "worker" = + process.env.REZI_STARSHIP_EXECUTION_MODE === "worker" ? "worker" : "inline"; + +function clampViewportAxis(value: number | undefined, fallback: number): number { + const safeFallback = Math.max(1, Math.trunc(fallback)); + if (!Number.isFinite(value)) return safeFallback; + const raw = Math.trunc(value ?? safeFallback); + if (raw <= 0) return safeFallback; + return raw; +} const initialState = createInitialStateWithViewport(Date.now(), { - cols: process.stdout.columns ?? 120, - rows: process.stdout.rows ?? 40, + cols: clampViewportAxis(process.stdout.columns, 120), + rows: clampViewportAxis(process.stdout.rows, 40), }); const enableHsr = process.argv.includes("--hsr") || process.env.REZI_HSR === "1"; const hasInteractiveTty = Boolean(process.stdin.isTTY && process.stdout.isTTY); @@ -41,6 +57,14 @@ let app!: ReturnType>; let stopping = false; let tickTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; +let debugTraceTimer: ReturnType | null = null; +let debugController: DebugController | null = null; +let debugLastRecordId = 0n; +let stopCode = 0; +let stopResolve: (() => void) | null = null; +const stopPromise = new Promise((resolve) => { + stopResolve = resolve; +}); let lastViewport = { cols: initialState.viewportCols, rows: initialState.viewportRows, @@ -68,20 +92,22 @@ function dispatch(action: StarshipAction): void { } function syncViewport(cols: number, rows: number): void { - if (cols === lastViewport.cols && rows === lastViewport.rows) return; - lastViewport = { cols, rows }; + const safeCols = clampViewportAxis(cols, lastViewport.cols); + const safeRows = clampViewportAxis(rows, lastViewport.rows); + if (safeCols === lastViewport.cols && safeRows === lastViewport.rows) return; + lastViewport = { cols: safeCols, rows: safeRows }; debugSnapshot("runtime.viewport", { - cols, - rows, + cols: safeCols, + rows: safeRows, route: currentRouteId(), }); - dispatch({ type: "set-viewport", cols, rows }); + dispatch({ type: "set-viewport", cols: safeCols, rows: safeRows }); } function syncViewportFromStdout(): void { if (!process.stdout.isTTY) return; - const cols = process.stdout.columns ?? lastViewport.cols; - const rows = process.stdout.rows ?? lastViewport.rows; + const cols = clampViewportAxis(process.stdout.columns, lastViewport.cols); + const rows = clampViewportAxis(process.stdout.rows, lastViewport.rows); syncViewport(cols, rows); } @@ -96,6 +122,17 @@ function currentRouteId(): RouteId { return "bridge"; } +const frameAuditGlobal = globalThis as { + __reziFrameAuditContext?: () => Readonly>; +}; +frameAuditGlobal.__reziFrameAuditContext = () => + Object.freeze({ + route: currentRouteId(), + viewportCols: lastViewport.cols, + viewportRows: lastViewport.rows, + executionMode: EXECUTION_MODE, + }); + function navigate(routeId: RouteId): void { const router = app.router; if (!router) return; @@ -126,6 +163,7 @@ function buildRoutes(factory: CreateRoutesFn) { async function stopApp(code = 0): Promise { if (stopping) return; stopping = true; + stopCode = code; if (tickTimer) { clearInterval(tickTimer); @@ -137,18 +175,28 @@ async function stopApp(code = 0): Promise { toastTimer = null; } - try { - await app.stop(); - } catch { - // Ignore stop races. + if (debugTraceTimer) { + clearInterval(debugTraceTimer); + debugTraceTimer = null; + } + + if (debugController) { + try { + await debugController.disable(); + } catch { + // Ignore debug shutdown races. + } + debugController = null; } try { - app.dispose(); + await app.stop(); } catch { - // Ignore disposal races during shutdown. + // Ignore stop races. } - exit(code); + frameAuditGlobal.__reziFrameAuditContext = undefined; + stopResolve?.(); + stopResolve = null; } function applyCommand(command: ReturnType): void { @@ -464,13 +512,150 @@ function bindKeys(): void { app.keys(bindingMap); } +function snapshotDebugRecord(record: DebugRecord): void { + const { header } = record; + if (header.category === "frame") { + if ( + record.payload && + typeof record.payload === "object" && + "drawlistBytes" in record.payload && + "drawlistCmds" in record.payload + ) { + debugSnapshot("runtime.debug.frame", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + route: currentRouteId(), + drawlistBytes: record.payload.drawlistBytes, + drawlistCmds: record.payload.drawlistCmds, + diffBytesEmitted: "diffBytesEmitted" in record.payload ? record.payload.diffBytesEmitted : null, + dirtyLines: "dirtyLines" in record.payload ? record.payload.dirtyLines : null, + dirtyCells: "dirtyCells" in record.payload ? record.payload.dirtyCells : null, + damageRects: "damageRects" in record.payload ? record.payload.damageRects : null, + usDrawlist: "usDrawlist" in record.payload ? record.payload.usDrawlist : null, + usDiff: "usDiff" in record.payload ? record.payload.usDiff : null, + usWrite: "usWrite" in record.payload ? record.payload.usWrite : null, + }); + return; + } + + debugSnapshot("runtime.debug.frame.raw", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + payloadSize: header.payloadSize, + route: currentRouteId(), + }); + } + + if (header.category === "drawlist") { + if ( + record.payload && + typeof record.payload === "object" && + "totalBytes" in record.payload && + "cmdCount" in record.payload + ) { + debugSnapshot("runtime.debug.drawlist", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + route: currentRouteId(), + totalBytes: record.payload.totalBytes, + cmdCount: record.payload.cmdCount, + validationResult: + "validationResult" in record.payload ? record.payload.validationResult : null, + executionResult: "executionResult" in record.payload ? record.payload.executionResult : null, + clipStackMaxDepth: + "clipStackMaxDepth" in record.payload ? record.payload.clipStackMaxDepth : null, + textRuns: "textRuns" in record.payload ? record.payload.textRuns : null, + fillRects: "fillRects" in record.payload ? record.payload.fillRects : null, + }); + return; + } + + if ( + record.payload && + typeof record.payload === "object" && + "kind" in record.payload && + record.payload.kind === "drawlistBytes" && + "bytes" in record.payload + ) { + debugSnapshot("runtime.debug.drawlistBytes", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + route: currentRouteId(), + bytes: record.payload.bytes.byteLength, + }); + return; + } + + debugSnapshot("runtime.debug.drawlist.raw", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + payloadSize: header.payloadSize, + route: currentRouteId(), + }); + } +} + +async function setupDebugTrace(): Promise { + if (!DEBUG_TRACE_ENABLED) return; + debugController = createDebugController({ + backend: app.backend.debug, + terminalCapsProvider: () => app.backend.getCaps(), + maxFrames: 512, + }); + + await debugController.enable({ + minSeverity: "trace", + categoryMask: categoriesToMask(["frame", "drawlist", "error"]), + captureRawEvents: false, + captureDrawlistBytes: false, + }); + await debugController.reset(); + debugLastRecordId = 0n; + + debugSnapshot("runtime.debug.enable", { + route: currentRouteId(), + viewportCols: lastViewport.cols, + viewportRows: lastViewport.rows, + }); + + const pump = async () => { + if (!debugController) return; + try { + const records = await debugController.query({ maxRecords: 256 }); + if (records.length === 0) return; + for (const record of records) { + if (record.header.recordId <= debugLastRecordId) continue; + debugLastRecordId = record.header.recordId; + snapshotDebugRecord(record); + } + } catch (error) { + debugSnapshot("runtime.debug.query.error", { + message: error instanceof Error ? error.message : String(error), + route: currentRouteId(), + }); + } + }; + + debugTraceTimer = setInterval(() => { + void pump(); + }, 250); +} + const routes = buildRoutes(createStarshipRoutes); debugSnapshot("runtime.app.create", { routeCount: routes.length, initialRoute: "bridge", fpsCap: UI_FPS_CAP, - executionMode: "inline", + executionMode: EXECUTION_MODE, }); app = createNodeApp({ @@ -479,7 +664,7 @@ app = createNodeApp({ initialRoute: "bridge", config: { fpsCap: UI_FPS_CAP, - executionMode: "inline", + executionMode: EXECUTION_MODE, }, theme: themeSpec(initialState.themeName).theme, ...(enableHsr @@ -545,5 +730,9 @@ debugSnapshot("runtime.app.start", { viewportRows: lastViewport.rows, }); +await setupDebugTrace(); await app.start(); -await new Promise(() => {}); +await stopPromise; +if (stopCode !== 0) { + process.exitCode = stopCode; +} diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index 2d70817d..f5735e23 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -31,7 +31,11 @@ export function renderCargoScreen( context, deps, body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CargoDeck({ state: context.state, dispatch: deps.dispatch }), + CargoDeck({ + key: "cargo-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index abd4fa66..1a115088 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -463,7 +463,11 @@ export function renderCommsScreen( context, deps, body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CommsDeck({ state: context.state, dispatch: deps.dispatch }), + CommsDeck({ + key: "comms-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 37530d9c..44f71ff1 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -453,7 +453,11 @@ export function renderCrewScreen(context: RouteRenderContext, dep context, deps, body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CrewDeck({ state: context.state, dispatch: deps.dispatch }), + CrewDeck({ + key: "crew-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 9173a487..6247de81 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -517,7 +517,11 @@ export function renderEngineeringScreen( context, deps, body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - EngineeringDeck({ state: context.state, dispatch: deps.dispatch }), + EngineeringDeck({ + key: "engineering-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index eb8bf888..9b9aac93 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -279,6 +279,7 @@ export function renderSettingsScreen( deps, body: ui.column({ gap: SPACE.sm, width: "100%" }, [ SettingsDeck({ + key: "settings-deck", state: context.state, dispatch: deps.dispatch, }), diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index a0f84ae7..91d1ba95 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,9 +1,9 @@ import { type BadgeVariant, type Rgb, + type Rgb24, type TextStyle, type ThemeDefinition, - darkTheme, draculaTheme, extendTheme, nordTheme, @@ -244,18 +244,49 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -function blend(a: Rgb, b: Rgb, weight: number): Rgb { +type ColorInput = Rgb | Rgb24; + +function packRgb(value: Rgb): Rgb24 { + return ( + ((clampChannel(value.r) & 0xff) << 16) | + ((clampChannel(value.g) & 0xff) << 8) | + (clampChannel(value.b) & 0xff) + ); +} + +function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { + return (value >>> shift) & 0xff; +} + +function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { + if (typeof value === "number") { + return Object.freeze({ + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), + }); + } + return Object.freeze({ + r: clampChannel(value.r), + g: clampChannel(value.g), + b: clampChannel(value.b), + }); +} + +function blend(a: ColorInput, b: ColorInput, weight: number): Rgb24 { const safe = Math.max(0, Math.min(1, weight)); - return { - r: clampChannel(a.r + (b.r - a.r) * safe), - g: clampChannel(a.g + (b.g - a.g) * safe), - b: clampChannel(a.b + (b.b - a.b) * safe), - }; + const left = unpackRgb(a); + const right = unpackRgb(b); + return ( + ((clampChannel(left.r + (right.r - left.r) * safe) & 0xff) << 16) | + ((clampChannel(left.g + (right.g - left.g) * safe) & 0xff) << 8) | + (clampChannel(left.b + (right.b - left.b) * safe) & 0xff) + ); } -export function toHex(color: Rgb): string { +export function toHex(color: Rgb24): string { const channel = (value: number) => clampChannel(value).toString(16).padStart(2, "0"); - return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`; + return `#${channel(rgbChannel(color, 16))}${channel(rgbChannel(color, 8))}${channel(rgbChannel(color, 0))}`; } export function cycleThemeName(current: ThemeName): ThemeName { @@ -285,52 +316,52 @@ export type StarshipStyles = Readonly<{ export type StarshipThemeTokens = Readonly<{ bg: Readonly<{ - app: Rgb; + app: Rgb24; panel: Readonly<{ - base: Rgb; - inset: Rgb; - elevated: Rgb; + base: Rgb24; + inset: Rgb24; + elevated: Rgb24; }>; - modal: Rgb; + modal: Rgb24; }>; border: Readonly<{ - default: Rgb; - muted: Rgb; - focus: Rgb; - danger: Rgb; + default: Rgb24; + muted: Rgb24; + focus: Rgb24; + danger: Rgb24; }>; text: Readonly<{ - primary: Rgb; - muted: Rgb; - dim: Rgb; + primary: Rgb24; + muted: Rgb24; + dim: Rgb24; }>; accent: Readonly<{ - info: Rgb; - success: Rgb; - warn: Rgb; - danger: Rgb; - brand: Rgb; + info: Rgb24; + success: Rgb24; + warn: Rgb24; + danger: Rgb24; + brand: Rgb24; }>; state: Readonly<{ - selectedBg: Rgb; - selectedText: Rgb; - hoverBg: Rgb; - focusRing: Rgb; + selectedBg: Rgb24; + selectedText: Rgb24; + hoverBg: Rgb24; + focusRing: Rgb24; }>; progress: Readonly<{ - track: Rgb; - fill: Rgb; + track: Rgb24; + fill: Rgb24; }>; table: Readonly<{ - headerBg: Rgb; - rowAltBg: Rgb; - rowHoverBg: Rgb; - rowSelectedBg: Rgb; + headerBg: Rgb24; + rowAltBg: Rgb24; + rowHoverBg: Rgb24; + rowSelectedBg: Rgb24; }>; log: Readonly<{ - info: Rgb; - warn: Rgb; - error: Rgb; + info: Rgb24; + warn: Rgb24; + error: Rgb24; }>; }>; @@ -384,37 +415,37 @@ export function themeTokens(themeName: ThemeName): StarshipThemeTokens { : blend(colors.accent.primary, colors.accent.secondary, mode === "day" ? 0.18 : 0.1); return Object.freeze({ bg: Object.freeze({ - app: colors.bg.base, + app: packRgb(colors.bg.base), panel: Object.freeze({ base: panelBase, inset: panelInset, elevated: panelElevated, }), - modal: colors.bg.overlay, + modal: packRgb(colors.bg.overlay), }), border: Object.freeze({ - default: colors.border.default, - muted: colors.border.subtle, - focus: mode === "alert" ? colors.error : colors.focus.ring, - danger: colors.error, + default: packRgb(colors.border.default), + muted: packRgb(colors.border.subtle), + focus: mode === "alert" ? packRgb(colors.error) : packRgb(colors.focus.ring), + danger: packRgb(colors.error), }), text: Object.freeze({ - primary: colors.fg.primary, - muted: colors.fg.secondary, - dim: colors.fg.muted, + primary: packRgb(colors.fg.primary), + muted: packRgb(colors.fg.secondary), + dim: packRgb(colors.fg.muted), }), accent: Object.freeze({ - info: colors.info, - success: colors.success, - warn: colors.warning, - danger: colors.error, - brand: colors.accent.primary, + info: packRgb(colors.info), + success: packRgb(colors.success), + warn: packRgb(colors.warning), + danger: packRgb(colors.error), + brand: packRgb(colors.accent.primary), }), state: Object.freeze({ selectedBg, - selectedText: colors.selected.fg, + selectedText: packRgb(colors.selected.fg), hoverBg, - focusRing: mode === "alert" ? colors.error : colors.focus.ring, + focusRing: mode === "alert" ? packRgb(colors.error) : packRgb(colors.focus.ring), }), progress: Object.freeze({ track: blend( diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index 7d3f4534..572b4f5d 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -113,9 +113,9 @@ mod ffi { pub _pad2: [u8; 3], } - #[repr(C)] - #[derive(Copy, Clone)] - pub struct zr_terminal_caps_t { + #[repr(C)] + #[derive(Copy, Clone)] + pub struct zr_terminal_caps_t { pub color_mode: u8, pub supports_mouse: u8, pub supports_bracketed_paste: u8, @@ -134,23 +134,25 @@ mod ffi { pub cap_flags: u32, pub cap_force_flags: u32, pub cap_suppress_flags: u32, - } - - #[repr(C)] - #[derive(Copy, Clone)] - pub struct plat_caps_t { - pub color_mode: u8, - pub supports_mouse: u8, - pub supports_bracketed_paste: u8, - pub supports_focus_events: u8, - pub supports_osc52: u8, - pub supports_sync_update: u8, - pub supports_scroll_region: u8, - pub supports_cursor_shape: u8, - pub supports_output_wait_writable: u8, - pub _pad0: [u8; 3], - pub sgr_attrs_supported: u32, - } + } + + #[repr(C)] + #[derive(Copy, Clone)] + pub struct plat_caps_t { + pub color_mode: u8, + pub supports_mouse: u8, + pub supports_bracketed_paste: u8, + pub supports_focus_events: u8, + pub supports_osc52: u8, + pub supports_sync_update: u8, + pub supports_scroll_region: u8, + pub supports_cursor_shape: u8, + pub supports_output_wait_writable: u8, + pub supports_underline_styles: u8, + pub supports_colored_underlines: u8, + pub supports_hyperlinks: u8, + pub sgr_attrs_supported: u32, + } #[repr(C)] #[derive(Copy, Clone)] @@ -159,6 +161,8 @@ mod ffi { pub bg_rgb: u32, pub attrs: u32, pub reserved: u32, + pub underline_rgb: u32, + pub link_ref: u32, } #[repr(C)] @@ -186,6 +190,21 @@ mod ffi { pub cols: u32, pub rows: u32, pub cells: *mut zr_cell_t, + pub links: *mut zr_fb_link_t, + pub links_len: u32, + pub links_cap: u32, + pub link_bytes: *mut u8, + pub link_bytes_len: u32, + pub link_bytes_cap: u32, + } + + #[repr(C)] + #[derive(Copy, Clone)] + pub struct zr_fb_link_t { + pub uri_off: u32, + pub uri_len: u32, + pub id_off: u32, + pub id_len: u32, } #[repr(C)] @@ -216,7 +235,7 @@ mod ffi { pub cursor_visible: u8, pub cursor_shape: u8, pub cursor_blink: u8, - pub _pad0: u8, + pub flags: u8, pub style: zr_style_t, } @@ -1542,6 +1561,8 @@ mod tests { bg_rgb: 0, attrs, reserved: 0, + underline_rgb: 0, + link_ref: 0, } } @@ -1551,6 +1572,8 @@ mod tests { bg_rgb: 0, attrs: 0, reserved: 0, + underline_rgb: 0, + link_ref: 0, } } @@ -1564,6 +1587,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let rc = unsafe { ffi::zr_fb_init(&mut raw as *mut _, 1, 1) }; @@ -1600,6 +1629,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let rc = unsafe { ffi::zr_fb_init(&mut raw as *mut _, cols, rows) }; assert_eq!(rc, ffi::ZR_OK, "zr_fb_init must succeed for test framebuffer"); @@ -1640,19 +1675,21 @@ mod tests { next: &ffi::zr_fb_t, initial_style: ffi::zr_style_t, ) -> Vec { - let caps = ffi::plat_caps_t { - color_mode: 3, - supports_mouse: 0, - supports_bracketed_paste: 0, - supports_focus_events: 0, - supports_osc52: 0, - supports_sync_update: 0, - supports_scroll_region: 0, - supports_cursor_shape: 1, - supports_output_wait_writable: 0, - _pad0: [0, 0, 0], - sgr_attrs_supported: u32::MAX, - }; + let caps = ffi::plat_caps_t { + color_mode: 3, + supports_mouse: 0, + supports_bracketed_paste: 0, + supports_focus_events: 0, + supports_osc52: 0, + supports_sync_update: 0, + supports_scroll_region: 0, + supports_cursor_shape: 1, + supports_output_wait_writable: 0, + supports_underline_styles: 0, + supports_colored_underlines: 0, + supports_hyperlinks: 0, + sgr_attrs_supported: u32::MAX, + }; let limits = unsafe { ffi::zr_engine_config_default() }.limits; let initial_term_state = ffi::zr_term_state_t { cursor_x: 0, @@ -1660,7 +1697,7 @@ mod tests { cursor_visible: 1, cursor_shape: 0, cursor_blink: 0, - _pad0: 0, + flags: 0, style: initial_style, }; let desired_cursor_state = ffi::zr_cursor_state_t { @@ -1721,12 +1758,50 @@ mod tests { unsafe { ((*cell).glyph[0], (*cell).width) } } + #[test] + fn ffi_layout_matches_vendored_headers() { + use std::mem::{align_of, size_of}; + use std::ptr::addr_of; + + assert_eq!(size_of::(), 24); + assert_eq!(align_of::(), 4); + assert_eq!(size_of::(), 60); + assert_eq!(size_of::(), 36); + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + + let caps = std::mem::MaybeUninit::::uninit(); + let base = caps.as_ptr(); + unsafe { + assert_eq!(addr_of!((*base).color_mode) as usize - base as usize, 0); + assert_eq!(addr_of!((*base).supports_output_wait_writable) as usize - base as usize, 8); + assert_eq!(addr_of!((*base).supports_underline_styles) as usize - base as usize, 9); + assert_eq!(addr_of!((*base).supports_colored_underlines) as usize - base as usize, 10); + assert_eq!(addr_of!((*base).supports_hyperlinks) as usize - base as usize, 11); + assert_eq!(addr_of!((*base).sgr_attrs_supported) as usize - base as usize, 12); + } + + if cfg!(target_pointer_width = "64") { + assert_eq!(size_of::(), 48); + assert_eq!(align_of::(), 8); + } else if cfg!(target_pointer_width = "32") { + assert_eq!(size_of::(), 36); + assert_eq!(align_of::(), 4); + } + } + #[test] fn clip_edge_write_over_continuation_cleans_lead_pair() { let mut fb = ffi::zr_fb_t { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let init_rc = unsafe { ffi::zr_fb_init(&mut fb as *mut _, 4, 1) }; assert_eq!(init_rc, ffi::ZR_OK); @@ -1824,6 +1899,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let init_rc = unsafe { ffi::zr_fb_init(&mut fb as *mut _, 4, 1) }; assert_eq!(init_rc, ffi::ZR_OK); diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index 4a3b7b22..886d5b72 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -52f3fd81a437e5a351dd026a75b2f9b01ca44a76 +3e70a0f8407cdc073432372d42af7a854e176f5c diff --git a/packages/native/vendor/zireael/include/zr/zr_drawlist.h b/packages/native/vendor/zireael/include/zr/zr_drawlist.h index 5aea6fb0..4ceffb80 100644 --- a/packages/native/vendor/zireael/include/zr/zr_drawlist.h +++ b/packages/native/vendor/zireael/include/zr/zr_drawlist.h @@ -1,5 +1,5 @@ /* - include/zr/zr_drawlist.h — Drawlist ABI structs (v1). + include/zr/zr_drawlist.h — Drawlist ABI structs (v1/v2). Why: Defines the little-endian drawlist command stream used by wrappers to drive rendering through engine_submit_drawlist(). @@ -63,7 +63,8 @@ typedef enum zr_dl_opcode_t { ZR_DL_OP_DEF_STRING = 10, ZR_DL_OP_FREE_STRING = 11, ZR_DL_OP_DEF_BLOB = 12, - ZR_DL_OP_FREE_BLOB = 13 + ZR_DL_OP_FREE_BLOB = 13, + ZR_DL_OP_BLIT_RECT = 14 } zr_dl_opcode_t; /* @@ -178,6 +179,15 @@ typedef struct zr_dl_cmd_push_clip_t { int32_t h; } zr_dl_cmd_push_clip_t; +typedef struct zr_dl_cmd_blit_rect_t { + int32_t src_x; + int32_t src_y; + int32_t w; + int32_t h; + int32_t dst_x; + int32_t dst_y; +} zr_dl_cmd_blit_rect_t; + typedef struct zr_dl_cmd_draw_text_run_t { int32_t x; int32_t y; diff --git a/packages/native/vendor/zireael/include/zr/zr_version.h b/packages/native/vendor/zireael/include/zr/zr_version.h index dfe2471a..f6261076 100644 --- a/packages/native/vendor/zireael/include/zr/zr_version.h +++ b/packages/native/vendor/zireael/include/zr/zr_version.h @@ -14,8 +14,7 @@ */ #if defined(ZR_LIBRARY_VERSION_MAJOR) || defined(ZR_LIBRARY_VERSION_MINOR) || defined(ZR_LIBRARY_VERSION_PATCH) || \ defined(ZR_ENGINE_ABI_MAJOR) || defined(ZR_ENGINE_ABI_MINOR) || defined(ZR_ENGINE_ABI_PATCH) || \ - defined(ZR_DRAWLIST_VERSION_V1) || \ - defined(ZR_EVENT_BATCH_VERSION_V1) + defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_DRAWLIST_VERSION_V2) || defined(ZR_EVENT_BATCH_VERSION_V1) #error "Zireael version pins are locked; do not override ZR_*_VERSION_* macros." #endif @@ -29,8 +28,9 @@ #define ZR_ENGINE_ABI_MINOR (2u) #define ZR_ENGINE_ABI_PATCH (0u) -/* Drawlist binary format version (current protocol baseline). */ +/* Drawlist binary format versions. */ #define ZR_DRAWLIST_VERSION_V1 (1u) +#define ZR_DRAWLIST_VERSION_V2 (2u) /* Packed event batch binary format versions. */ #define ZR_EVENT_BATCH_VERSION_V1 (1u) diff --git a/packages/native/vendor/zireael/src/core/zr_config.c b/packages/native/vendor/zireael/src/core/zr_config.c index 62d8e15a..05ad8ee3 100644 --- a/packages/native/vendor/zireael/src/core/zr_config.c +++ b/packages/native/vendor/zireael/src/core/zr_config.c @@ -139,8 +139,12 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg) { return ZR_ERR_UNSUPPORTED; } - if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 || - cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { + if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 && + cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V2) { + return ZR_ERR_UNSUPPORTED; + } + + if (cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } diff --git a/packages/native/vendor/zireael/src/core/zr_diff.c b/packages/native/vendor/zireael/src/core/zr_diff.c index 63ace7b0..59e33840 100644 --- a/packages/native/vendor/zireael/src/core/zr_diff.c +++ b/packages/native/vendor/zireael/src/core/zr_diff.c @@ -335,6 +335,43 @@ static uint64_t zr_row_hash64(const zr_fb_t* fb, uint32_t y) { return zr_hash_bytes_fnv1a64(row, row_bytes); } +static bool zr_fb_links_payload_equal(const zr_fb_t* a, const zr_fb_t* b) { + if (!a || !b) { + return false; + } + if (a->links_len != b->links_len || a->link_bytes_len != b->link_bytes_len) { + return false; + } + if (a->links_len == 0u && a->link_bytes_len == 0u) { + return true; + } + if ((a->links_len != 0u && (!a->links || !b->links)) || + (a->link_bytes_len != 0u && (!a->link_bytes || !b->link_bytes))) { + return false; + } + if (a->links_len != 0u && + memcmp(a->links, b->links, (size_t)a->links_len * sizeof(zr_fb_link_t)) != 0) { + return false; + } + if (a->link_bytes_len != 0u && memcmp(a->link_bytes, b->link_bytes, (size_t)a->link_bytes_len) != 0) { + return false; + } + return true; +} + +static bool zr_row_has_link_ref(const zr_fb_t* fb, uint32_t y) { + if (!fb || !fb->cells || y >= fb->rows) { + return false; + } + for (uint32_t x = 0u; x < fb->cols; x++) { + const zr_cell_t* c = zr_fb_cell_const(fb, x, y); + if (c && c->style.link_ref != 0u) { + return true; + } + } + return false; +} + /* Return display width of cell at (x,y): 0 for continuation, 2 for wide, 1 otherwise. */ static uint8_t zr_cell_width_in_next(const zr_fb_t* fb, uint32_t x, uint32_t y) { const zr_cell_t* c = zr_fb_cell_const(fb, x, y); @@ -1123,6 +1160,7 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr ctx->has_row_cache = true; const bool reuse_prev_hashes = (scratch->prev_hashes_valid != 0u); + const bool links_payload_changed = !zr_fb_links_payload_equal(ctx->prev, ctx->next); for (uint32_t y = 0u; y < ctx->next->rows; y++) { uint64_t prev_hash = 0u; @@ -1142,6 +1180,13 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr /* Collision guard: equal hash must still pass exact row-byte compare. */ dirty = 1u; ctx->stats.collision_guard_hits++; + } else if (links_payload_changed && + (zr_row_has_link_ref(ctx->prev, y) || zr_row_has_link_ref(ctx->next, y))) { + /* + Row bytes can remain identical while OSC8 payload bytes change underneath + reused link_ref values. Force linked rows dirty on link table changes. + */ + dirty = 1u; } ctx->dirty_rows[y] = dirty; diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index b34a15ed..f1637a11 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1,5 +1,5 @@ /* - src/core/zr_drawlist.c — Drawlist validator + executor (v1). + src/core/zr_drawlist.c — Drawlist validator + executor (v1/v2). Why: Validates wrapper-provided drawlist bytes (bounds/caps/version) and executes deterministic drawing into the framebuffer without UB. @@ -89,8 +89,8 @@ static uint32_t zr_dl_cmd_fill_rect_size(void) { } static uint32_t zr_dl_cmd_draw_text_size(void) { - const uint32_t payload = (uint32_t)ZR_DL_DRAW_TEXT_FIELDS_BYTES + zr_dl_style_wire_bytes() + - (uint32_t)ZR_DL_DRAW_TEXT_TRAILER_BYTES; + const uint32_t payload = + (uint32_t)ZR_DL_DRAW_TEXT_FIELDS_BYTES + zr_dl_style_wire_bytes() + (uint32_t)ZR_DL_DRAW_TEXT_TRAILER_BYTES; return (uint32_t)sizeof(zr_dl_cmd_header_t) + payload; } @@ -176,6 +176,50 @@ static void zr_dl_store_release(zr_dl_resource_store_t* store) { memset(store, 0, sizeof(*store)); } +static zr_result_t zr_dl_store_clone_deep(zr_dl_resource_store_t* dst, const zr_dl_resource_store_t* src) { + zr_dl_resource_store_t tmp; + if (!dst || !src) { + return ZR_ERR_INVALID_ARGUMENT; + } + + memset(&tmp, 0, sizeof(tmp)); + if (src->len != 0u) { + zr_result_t rc = zr_dl_store_ensure_cap(&tmp, src->len); + if (rc != ZR_OK) { + return rc; + } + } + + for (uint32_t i = 0u; i < src->len; i++) { + const zr_dl_resource_entry_t* e = &src->entries[i]; + uint8_t* copy = NULL; + if (e->len != 0u) { + if (!e->bytes) { + zr_dl_store_release(&tmp); + return ZR_ERR_FORMAT; + } + copy = (uint8_t*)malloc((size_t)e->len); + if (!copy) { + zr_dl_store_release(&tmp); + return ZR_ERR_OOM; + } + memcpy(copy, e->bytes, (size_t)e->len); + } + tmp.entries[i].id = e->id; + tmp.entries[i].bytes = copy; + tmp.entries[i].len = e->len; + tmp.entries[i].owned = 1u; + memset(tmp.entries[i].reserved0, 0, sizeof(tmp.entries[i].reserved0)); + } + + tmp.len = src->len; + tmp.total_bytes = src->total_bytes; + + zr_dl_store_release(dst); + *dst = tmp; + return ZR_OK; +} + static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id, const uint8_t* bytes, uint32_t byte_len) { int32_t idx = -1; @@ -186,18 +230,35 @@ static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id if (!store || id == 0u || (!bytes && byte_len != 0u)) { return ZR_ERR_FORMAT; } - - if (byte_len != 0u) { - copy = (uint8_t*)malloc((size_t)byte_len); - if (!copy) { - return ZR_ERR_OOM; - } - memcpy(copy, bytes, (size_t)byte_len); + if (store->len != 0u && !store->entries) { + return ZR_ERR_LIMIT; } idx = zr_dl_store_find_index(store, id); if (idx >= 0) { - old_len = store->entries[(uint32_t)idx].len; + const zr_dl_resource_entry_t* existing = &store->entries[(uint32_t)idx]; + if (!store->entries) { + return ZR_ERR_FORMAT; + } + old_len = existing->len; + + if (old_len == byte_len) { + if (byte_len == 0u) { + return ZR_OK; + } + if (existing->bytes && memcmp(existing->bytes, bytes, (size_t)byte_len) == 0) { + return ZR_OK; + } + } + + if (byte_len != 0u) { + copy = (uint8_t*)malloc((size_t)byte_len); + if (!copy) { + return ZR_ERR_OOM; + } + memcpy(copy, bytes, (size_t)byte_len); + } + if (old_len > store->total_bytes) { free(copy); return ZR_ERR_LIMIT; @@ -218,6 +279,14 @@ static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id return ZR_OK; } + if (byte_len != 0u) { + copy = (uint8_t*)malloc((size_t)byte_len); + if (!copy) { + return ZR_ERR_OOM; + } + memcpy(copy, bytes, (size_t)byte_len); + } + if (store->total_bytes > (UINT32_MAX - byte_len)) { free(copy); return ZR_ERR_LIMIT; @@ -305,23 +374,20 @@ void zr_dl_resources_swap(zr_dl_resources_t* a, zr_dl_resources_t* b) { zr_result_t zr_dl_resources_clone(zr_dl_resources_t* dst, const zr_dl_resources_t* src) { zr_dl_resources_t tmp; - zr_result_t rc = ZR_OK; if (!dst || !src) { return ZR_ERR_INVALID_ARGUMENT; } zr_dl_resources_init(&tmp); - for (uint32_t i = 0u; i < src->strings.len; i++) { - const zr_dl_resource_entry_t* e = &src->strings.entries[i]; - rc = zr_dl_store_define(&tmp.strings, e->id, e->bytes, e->len); + { + const zr_result_t rc = zr_dl_store_clone_deep(&tmp.strings, &src->strings); if (rc != ZR_OK) { zr_dl_resources_release(&tmp); return rc; } } - for (uint32_t i = 0u; i < src->blobs.len; i++) { - const zr_dl_resource_entry_t* e = &src->blobs.entries[i]; - rc = zr_dl_store_define(&tmp.blobs, e->id, e->bytes, e->len); + { + const zr_result_t rc = zr_dl_store_clone_deep(&tmp.blobs, &src->blobs); if (rc != ZR_OK) { zr_dl_resources_release(&tmp); return rc; @@ -527,6 +593,38 @@ static zr_result_t zr_dl_read_cmd_push_clip(zr_byte_reader_t* r, zr_dl_cmd_push_ return ZR_OK; } +static zr_result_t zr_dl_read_cmd_blit_rect(zr_byte_reader_t* r, zr_dl_cmd_blit_rect_t* out) { + zr_result_t rc = ZR_OK; + if (!out) { + return ZR_ERR_INVALID_ARGUMENT; + } + rc = zr_dl_read_i32le(r, &out->src_x); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_read_i32le(r, &out->src_y); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_read_i32le(r, &out->w); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_read_i32le(r, &out->h); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_read_i32le(r, &out->dst_x); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_read_i32le(r, &out->dst_y); + if (rc != ZR_OK) { + return rc; + } + return ZR_OK; +} + static zr_result_t zr_dl_read_cmd_draw_text_run(zr_byte_reader_t* r, zr_dl_cmd_draw_text_run_t* out) { zr_result_t rc = ZR_OK; if (!out) { @@ -702,6 +800,14 @@ typedef struct zr_dl_range_t { uint32_t len; } zr_dl_range_t; +static bool zr_dl_version_supported(uint32_t version) { + return version == ZR_DRAWLIST_VERSION_V1 || version == ZR_DRAWLIST_VERSION_V2; +} + +static bool zr_dl_version_supports_blit_rect(uint32_t version) { + return version >= ZR_DRAWLIST_VERSION_V2; +} + static bool zr_dl_range_is_empty(zr_dl_range_t r) { return r.len == 0u; } @@ -760,7 +866,7 @@ static zr_result_t zr_dl_validate_header(const zr_dl_header_t* hdr, size_t bytes if (hdr->magic != ZR_DL_MAGIC) { return ZR_ERR_FORMAT; } - if (hdr->version != ZR_DRAWLIST_VERSION_V1) { + if (!zr_dl_version_supported(hdr->version)) { return ZR_ERR_UNSUPPORTED; } if (hdr->header_size != (uint32_t)sizeof(zr_dl_header_t)) { @@ -985,6 +1091,24 @@ static zr_result_t zr_dl_validate_cmd_push_clip(const zr_dl_cmd_header_t* ch, zr return ZR_OK; } +static zr_result_t zr_dl_validate_cmd_blit_rect(const zr_dl_cmd_header_t* ch, zr_byte_reader_t* r) { + if (!ch || !r) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (ch->size != (uint32_t)(sizeof(zr_dl_cmd_header_t) + sizeof(zr_dl_cmd_blit_rect_t))) { + return ZR_ERR_FORMAT; + } + zr_dl_cmd_blit_rect_t cmd; + zr_result_t rc = zr_dl_read_cmd_blit_rect(r, &cmd); + if (rc != ZR_OK) { + return rc; + } + if (cmd.w <= 0 || cmd.h <= 0) { + return ZR_ERR_FORMAT; + } + return ZR_OK; +} + static zr_result_t zr_dl_validate_cmd_pop_clip(const zr_dl_cmd_header_t* ch, uint32_t* clip_depth) { if (!ch || !clip_depth) { return ZR_ERR_INVALID_ARGUMENT; @@ -1073,7 +1197,8 @@ static zr_result_t zr_dl_validate_cmd_draw_canvas(const zr_dl_view_t* view, cons } if (cmd.flags != 0u || cmd.reserved != 0u || cmd.reserved0 != 0u || cmd.blob_id == 0u || cmd.dst_cols == 0u || - cmd.dst_rows == 0u || cmd.px_width == 0u || cmd.px_height == 0u || zr_dl_canvas_blitter_valid(cmd.blitter) == 0u) { + cmd.dst_rows == 0u || cmd.px_width == 0u || cmd.px_height == 0u || + zr_dl_canvas_blitter_valid(cmd.blitter) == 0u) { return ZR_ERR_FORMAT; } @@ -1168,6 +1293,11 @@ static zr_result_t zr_dl_validate_cmd_payload(const zr_dl_view_t* view, const zr return zr_dl_validate_cmd_draw_text(view, ch, r); case ZR_DL_OP_PUSH_CLIP: return zr_dl_validate_cmd_push_clip(ch, r, lim, clip_depth); + case ZR_DL_OP_BLIT_RECT: + if (!zr_dl_version_supports_blit_rect(view->hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } + return zr_dl_validate_cmd_blit_rect(ch, r); case ZR_DL_OP_POP_CLIP: return zr_dl_validate_cmd_pop_clip(ch, clip_depth); case ZR_DL_OP_DRAW_TEXT_RUN: @@ -1342,8 +1472,8 @@ static zr_result_t zr_dl_validate_span_slice_u32(uint32_t byte_off, uint32_t byt return ZR_OK; } -static zr_result_t zr_dl_resolve_string_slice(const zr_dl_resource_store_t* strings, uint32_t string_id, uint32_t byte_off, - uint32_t byte_len, const uint8_t** out_bytes) { +static zr_result_t zr_dl_resolve_string_slice(const zr_dl_resource_store_t* strings, uint32_t string_id, + uint32_t byte_off, uint32_t byte_len, const uint8_t** out_bytes) { const uint8_t* bytes = NULL; uint32_t total_len = 0u; static const uint8_t kEmptySlice[1] = {0u}; @@ -1408,7 +1538,7 @@ static zr_result_t zr_dl_style_resolve_link(const zr_dl_resource_store_t* string } static zr_result_t zr_dl_preflight_style_links(const zr_dl_resource_store_t* strings, zr_fb_t* fb, - const zr_dl_style_wire_t* style) { + const zr_dl_style_wire_t* style) { uint32_t link_ref = 0u; if (!strings || !fb || !style) { return ZR_ERR_INVALID_ARGUMENT; @@ -1438,9 +1568,9 @@ static zr_result_t zr_style_from_dl(const zr_dl_resource_store_t* strings, zr_fb } static zr_result_t zr_dl_preflight_draw_text_run_links(const zr_dl_view_t* v, zr_fb_t* fb, - const zr_dl_resource_store_t* strings, - const zr_dl_resource_store_t* blobs, uint32_t blob_id, - const zr_limits_t* lim) { + const zr_dl_resource_store_t* strings, + const zr_dl_resource_store_t* blobs, uint32_t blob_id, + const zr_limits_t* lim) { const uint8_t* blob = NULL; uint32_t blob_len = 0u; zr_byte_reader_t br; @@ -1561,6 +1691,32 @@ static zr_result_t zr_dl_apply_free_resource(zr_dl_resource_store_t* store, zr_b return zr_dl_store_free_id(store, cmd.id); } +static zr_result_t zr_dl_validate_blit_rect_bounds(const zr_fb_t* fb, const zr_dl_cmd_blit_rect_t* cmd) { + uint32_t src_x_end = 0u; + uint32_t src_y_end = 0u; + uint32_t dst_x_end = 0u; + uint32_t dst_y_end = 0u; + + if (!fb || !cmd) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (cmd->w <= 0 || cmd->h <= 0 || cmd->src_x < 0 || cmd->src_y < 0 || cmd->dst_x < 0 || cmd->dst_y < 0) { + return ZR_ERR_INVALID_ARGUMENT; + } + + if (!zr_checked_add_u32((uint32_t)cmd->src_x, (uint32_t)cmd->w, &src_x_end) || + !zr_checked_add_u32((uint32_t)cmd->src_y, (uint32_t)cmd->h, &src_y_end) || + !zr_checked_add_u32((uint32_t)cmd->dst_x, (uint32_t)cmd->w, &dst_x_end) || + !zr_checked_add_u32((uint32_t)cmd->dst_y, (uint32_t)cmd->h, &dst_y_end)) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (src_x_end > fb->cols || src_y_end > fb->rows || dst_x_end > fb->cols || dst_y_end > fb->rows) { + return ZR_ERR_INVALID_ARGUMENT; + } + + return ZR_OK; +} + zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_image_frame_t* image_stage, const zr_limits_t* lim, const zr_terminal_profile_t* term_profile, zr_dl_resources_t* resources) { @@ -1647,6 +1803,21 @@ zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_ima } break; } + case ZR_DL_OP_BLIT_RECT: { + if (!zr_dl_version_supports_blit_rect(v->hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } + zr_dl_cmd_blit_rect_t cmd; + rc = zr_dl_read_cmd_blit_rect(&r, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_validate_blit_rect_bounds(fb, &cmd); + if (rc != ZR_OK) { + return rc; + } + break; + } case ZR_DL_OP_POP_CLIP: break; case ZR_DL_OP_DRAW_TEXT_RUN: { @@ -1834,8 +2005,8 @@ static zr_result_t zr_dl_draw_text_utf8(zr_fb_painter_t* p, int32_t y, int32_t* return ZR_OK; } -static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resource_store_t* strings, - zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* v, + const zr_dl_resource_store_t* strings, zr_fb_painter_t* p) { zr_dl_cmd_fill_rect_wire_t cmd; zr_result_t rc = zr_dl_read_cmd_fill_rect(r, v->hdr.version, &cmd); if (rc != ZR_OK) { @@ -1850,8 +2021,8 @@ static zr_result_t zr_dl_exec_fill_rect(zr_byte_reader_t* r, const zr_dl_view_t* return zr_fb_fill_rect(p, rr, &s); } -static zr_result_t zr_dl_exec_draw_text(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resource_store_t* strings, - zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_draw_text(zr_byte_reader_t* r, const zr_dl_view_t* v, + const zr_dl_resource_store_t* strings, zr_fb_painter_t* p) { zr_dl_cmd_draw_text_wire_t cmd; zr_result_t rc = zr_dl_read_cmd_draw_text(r, v->hdr.version, &cmd); if (rc != ZR_OK) { @@ -1872,6 +2043,22 @@ static zr_result_t zr_dl_exec_draw_text(zr_byte_reader_t* r, const zr_dl_view_t* return zr_dl_draw_text_utf8(p, cmd.y, &cx, sbytes, (size_t)cmd.byte_len, v->text.tab_width, v->text.width_policy, &s); } +static zr_result_t zr_dl_exec_blit_rect(zr_byte_reader_t* r, zr_fb_painter_t* p) { + zr_dl_cmd_blit_rect_t cmd; + zr_result_t rc = zr_dl_read_cmd_blit_rect(r, &cmd); + if (rc != ZR_OK) { + return rc; + } + rc = zr_dl_validate_blit_rect_bounds(p->fb, &cmd); + if (rc != ZR_OK) { + return rc; + } + + zr_rect_t src = {cmd.src_x, cmd.src_y, cmd.w, cmd.h}; + zr_rect_t dst = {cmd.dst_x, cmd.dst_y, cmd.w, cmd.h}; + return zr_fb_blit_rect(p, dst, src); +} + static zr_result_t zr_dl_exec_push_clip(zr_byte_reader_t* r, zr_fb_painter_t* p) { zr_dl_cmd_push_clip_t cmd; zr_result_t rc = zr_dl_read_cmd_push_clip(r, &cmd); @@ -1913,8 +2100,9 @@ static zr_result_t zr_dl_exec_draw_text_run_segment(const zr_dl_view_t* v, const return zr_dl_draw_text_utf8(p, y, inout_x, sbytes, (size_t)seg.byte_len, v->text.tab_width, v->text.width_policy, &s); } -static zr_result_t zr_dl_exec_draw_text_run(zr_byte_reader_t* r, const zr_dl_view_t* v, const zr_dl_resources_t* resources, - const zr_limits_t* lim, zr_fb_painter_t* p) { +static zr_result_t zr_dl_exec_draw_text_run(zr_byte_reader_t* r, const zr_dl_view_t* v, + const zr_dl_resources_t* resources, const zr_limits_t* lim, + zr_fb_painter_t* p) { zr_dl_cmd_draw_text_run_t cmd; const uint8_t* blob = NULL; uint32_t blob_len = 0u; @@ -2296,6 +2484,16 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t } break; } + case ZR_DL_OP_BLIT_RECT: { + if (!zr_dl_version_supports_blit_rect(view.hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } + rc = zr_dl_exec_blit_rect(&r, &painter); + if (rc != ZR_OK) { + return rc; + } + break; + } case ZR_DL_OP_POP_CLIP: { rc = zr_dl_exec_pop_clip(&painter); if (rc != ZR_OK) { diff --git a/packages/native/vendor/zireael/src/core/zr_engine.c b/packages/native/vendor/zireael/src/core/zr_engine.c index 44e66405..04625f77 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine.c +++ b/packages/native/vendor/zireael/src/core/zr_engine.c @@ -33,7 +33,9 @@ #include #include #include +#include #include +#include #include #include @@ -147,6 +149,13 @@ struct zr_engine_t { uint8_t* debug_ring_buf; uint32_t* debug_record_offsets; uint32_t* debug_record_sizes; + + /* --- Optional native audit log sink (file-backed, env-driven) --- */ + FILE* native_audit_fp; + uint8_t native_audit_enabled; + uint8_t native_audit_flush; + uint8_t native_audit_verbose; + uint8_t _pad_native_audit0; }; enum { @@ -159,6 +168,10 @@ enum { /* Forward declaration for cleanup helper. */ static void zr_engine_debug_free(zr_engine_t* e); +static uint32_t zr_engine_fnv1a32(const uint8_t* bytes, size_t len); +static void zr_engine_native_audit_write(zr_engine_t* e, const char* stage, const char* fmt, ...); +static void zr_engine_native_audit_close(zr_engine_t* e); +static void zr_engine_native_audit_init(zr_engine_t* e); static const uint8_t ZR_SYNC_BEGIN[] = "\x1b[?2026h"; static const uint8_t ZR_SYNC_END[] = "\x1b[?2026l"; @@ -1278,9 +1291,19 @@ zr_result_t engine_create(zr_engine_t** out_engine, const zr_engine_config_t* cf zr_engine_runtime_from_create_cfg(e, cfg); zr_engine_metrics_init(e, cfg); + zr_engine_native_audit_init(e); + + zr_engine_native_audit_write( + e, "engine.create.begin", + "requested_drawlist_version=%u out_max_bytes_per_frame=%u target_fps=%u enable_scroll_optimizations=%u " + "wait_for_output_drain=%u", + (unsigned)e->cfg_create.requested_drawlist_version, (unsigned)e->cfg_create.limits.out_max_bytes_per_frame, + (unsigned)e->cfg_runtime.target_fps, (unsigned)e->cfg_runtime.enable_scroll_optimizations, + (unsigned)e->cfg_runtime.wait_for_output_drain); rc = zr_engine_init_runtime_state(e); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "engine.create.error", "rc=%d", (int)rc); goto cleanup; } @@ -1293,6 +1316,8 @@ zr_result_t engine_create(zr_engine_t** out_engine, const zr_engine_config_t* cf the initial size so callers can render the full framebuffer immediately. */ zr_engine_enqueue_initial_resize(e); + zr_engine_native_audit_write(e, "engine.create.ready", "cols=%u rows=%u", (unsigned)e->size.cols, + (unsigned)e->size.rows); *out_engine = e; return ZR_OK; @@ -1318,17 +1343,23 @@ static void zr_engine_release_heap_state(zr_engine_t* e) { return; } + zr_engine_native_audit_write(e, "engine.destroy.begin", "frame_index=%llu", (unsigned long long)e->metrics.frame_index); + zr_fb_release(&e->fb_prev); zr_fb_release(&e->fb_next); zr_fb_release(&e->fb_stage); + zr_engine_native_audit_write(e, "engine.destroy.step", "released=framebuffers"); zr_image_frame_release(&e->image_frame_next); zr_image_frame_release(&e->image_frame_stage); zr_image_state_init(&e->image_state); + zr_engine_native_audit_write(e, "engine.destroy.step", "released=image_state"); zr_dl_resources_release(&e->dl_resources_next); zr_dl_resources_release(&e->dl_resources_stage); + zr_engine_native_audit_write(e, "engine.destroy.step", "released=drawlist_resources"); zr_arena_release(&e->arena_frame); zr_arena_release(&e->arena_persistent); + zr_engine_native_audit_write(e, "engine.destroy.step", "released=arenas"); free(e->out_buf); e->out_buf = NULL; @@ -1360,6 +1391,8 @@ static void zr_engine_release_heap_state(zr_engine_t* e) { e->input_pending_len = 0u; zr_engine_debug_free(e); + zr_engine_native_audit_write(e, "engine.destroy.step", "released=debug_state"); + zr_engine_native_audit_close(e); } void engine_destroy(zr_engine_t* e) { @@ -1387,6 +1420,101 @@ static uint64_t zr_engine_now_us(void) { return (uint64_t)plat_now_ms() * 1000u; } +static bool zr_engine_env_truthy(const char* value) { + if (!value || value[0] == '\0') { + return false; + } + if (strcmp(value, "1") == 0 || strcmp(value, "true") == 0 || strcmp(value, "TRUE") == 0 || + strcmp(value, "yes") == 0 || strcmp(value, "YES") == 0 || strcmp(value, "on") == 0 || + strcmp(value, "ON") == 0) { + return true; + } + return false; +} + +static uint32_t zr_engine_fnv1a32(const uint8_t* bytes, size_t len) { + uint32_t h = 0x811C9DC5u; + if (!bytes || len == 0u) { + return h; + } + for (size_t i = 0u; i < len; i++) { + h ^= (uint32_t)bytes[i]; + h *= 0x01000193u; + } + return h; +} + +static void zr_engine_native_audit_write(zr_engine_t* e, const char* stage, const char* fmt, ...) { + if (!e || !stage || !e->native_audit_enabled || !e->native_audit_fp) { + return; + } + + const uint64_t ts_us = zr_engine_now_us(); + const uint64_t next_frame_id = (e->metrics.frame_index == UINT64_MAX) ? UINT64_MAX : (e->metrics.frame_index + 1u); + const uint64_t presented_frames = e->metrics.frame_index; + + (void)fprintf(e->native_audit_fp, + "REZI_NATIVE_AUDIT ts_us=%llu next_frame_id=%llu presented_frames=%llu stage=%s", + (unsigned long long)ts_us, (unsigned long long)next_frame_id, (unsigned long long)presented_frames, + stage); + + if (fmt && fmt[0] != '\0') { + va_list args; + va_start(args, fmt); + (void)fputc(' ', e->native_audit_fp); + (void)vfprintf(e->native_audit_fp, fmt, args); + va_end(args); + } + + (void)fputc('\n', e->native_audit_fp); + if (e->native_audit_flush != 0u) { + (void)fflush(e->native_audit_fp); + } +} + +static void zr_engine_native_audit_close(zr_engine_t* e) { + if (!e) { + return; + } + if (e->native_audit_fp) { + (void)fclose(e->native_audit_fp); + e->native_audit_fp = NULL; + } + e->native_audit_enabled = 0u; + e->native_audit_flush = 0u; + e->native_audit_verbose = 0u; +} + +static void zr_engine_native_audit_init(zr_engine_t* e) { + if (!e) { + return; + } + + const char* path = getenv("REZI_NATIVE_ENGINE_LOG"); + const bool enabled_flag = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_AUDIT")); + if ((!path || path[0] == '\0') && !enabled_flag) { + return; + } + if (!path || path[0] == '\0') { + path = "/tmp/rezi-native-engine.log"; + } + + FILE* fp = fopen(path, "ab"); + if (!fp) { + return; + } + (void)setvbuf(fp, NULL, _IOLBF, 0u); + + e->native_audit_fp = fp; + e->native_audit_enabled = 1u; + e->native_audit_flush = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_LOG_FLUSH")) ? 1u : 0u; + e->native_audit_verbose = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_LOG_VERBOSE")) ? 1u : 0u; + + zr_engine_native_audit_write( + e, "engine.audit.init", "path=%s verbose=%u flush=%u", path, (unsigned)e->native_audit_verbose, + (unsigned)e->native_audit_flush); +} + /* Compute the debug-trace frame id for the next present. @@ -1463,19 +1591,32 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt if (bytes_len < 0) { return ZR_ERR_INVALID_ARGUMENT; } + const uint64_t frame_id = zr_engine_trace_frame_id(e); + const uint32_t bytes_hash = zr_engine_fnv1a32(bytes, (size_t)bytes_len); + zr_engine_native_audit_write(e, "submit.begin", "frame_id=%llu bytes=%d hash32=0x%08x", + (unsigned long long)frame_id, bytes_len, (unsigned)bytes_hash); zr_dl_view_t v; zr_result_t rc = zr_dl_validate(bytes, (size_t)bytes_len, &e->cfg_runtime.limits, &v); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.validate.error", "frame_id=%llu rc=%d bytes=%d hash32=0x%08x", + (unsigned long long)frame_id, (int)rc, bytes_len, (unsigned)bytes_hash); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, bytes, (uint32_t)bytes_len, 0u, 0u, rc, ZR_OK); return rc; } + zr_engine_native_audit_write( + e, "submit.validate.ok", "frame_id=%llu version=%u cmd_count=%u bytes=%d hash32=0x%08x", + (unsigned long long)frame_id, (unsigned)v.hdr.version, (unsigned)v.hdr.cmd_count, bytes_len, (unsigned)bytes_hash); /* Enforce create-time drawlist version negotiation before any framebuffer staging mutation to preserve the no-partial-effects contract. */ if (v.hdr.version != e->cfg_create.requested_drawlist_version) { + zr_engine_native_audit_write(e, "submit.version_mismatch", + "frame_id=%llu requested_version=%u drawlist_version=%u cmd_count=%u", + (unsigned long long)frame_id, (unsigned)e->cfg_create.requested_drawlist_version, + (unsigned)v.hdr.version, (unsigned)v.hdr.cmd_count); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_ERR_UNSUPPORTED, ZR_OK); return ZR_ERR_UNSUPPORTED; @@ -1488,12 +1629,16 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt zr_dl_resources_release(&e->dl_resources_stage); rc = zr_dl_resources_clone(&e->dl_resources_stage, &e->dl_resources_next); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.resources.clone.error", "frame_id=%llu rc=%d", + (unsigned long long)frame_id, (int)rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); return rc; } rc = zr_dl_resources_clone_shallow(&preflight_resources, &e->dl_resources_stage); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.resources.clone_shallow.error", "frame_id=%llu rc=%d", + (unsigned long long)frame_id, (int)rc); zr_dl_resources_release(&e->dl_resources_stage); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); @@ -1505,10 +1650,14 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt &preflight_resources); zr_dl_resources_release(&preflight_resources); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.preflight.error", "frame_id=%llu rc=%d", + (unsigned long long)frame_id, (int)rc); const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.rollback.error", "frame_id=%llu rollback_rc=%d", + (unsigned long long)frame_id, (int)rollback_rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rollback_rc); return rollback_rc; @@ -1523,10 +1672,14 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt rc = zr_dl_execute(&v, &e->fb_next, &e->cfg_runtime.limits, e->cfg_runtime.tab_width, e->cfg_runtime.width_policy, &blit_caps, &e->term_profile, &e->image_frame_stage, &e->dl_resources_stage, &cursor_stage); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.execute.error", "frame_id=%llu rc=%d", + (unsigned long long)frame_id, (int)rc); const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { + zr_engine_native_audit_write(e, "submit.rollback.error", "frame_id=%llu rollback_rc=%d", + (unsigned long long)frame_id, (int)rollback_rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rollback_rc); return rollback_rc; @@ -1544,6 +1697,11 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, ZR_OK); + zr_engine_native_audit_write( + e, "submit.success", + "frame_id=%llu cmd_count=%u version=%u bytes=%d hash32=0x%08x cursor_x=%d cursor_y=%d cursor_visible=%u", + (unsigned long long)frame_id, (unsigned)v.hdr.cmd_count, (unsigned)v.hdr.version, bytes_len, (unsigned)bytes_hash, + (int)e->cursor_desired.x, (int)e->cursor_desired.y, (unsigned)e->cursor_desired.visible); return ZR_OK; } diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index cc7ff98d..2f4dcb09 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -332,6 +332,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; const bool use_damage_rect_copy = (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && (stats->damage_rects <= e->damage_rect_cap); + bool links_synced = false; /* Resync fb_prev to the framebuffer that was actually presented. @@ -357,11 +358,12 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ memcpy(e->fb_prev.cells, presented_fb->cells, n); } invalidate_prev_hashes = true; + } else { + links_synced = true; } } - zr_fb_links_reset(&e->fb_prev); - if (zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { + if (!links_synced && zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { invalidate_prev_hashes = true; } } @@ -401,6 +403,17 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ zr_engine_trace_diff_telemetry(e, frame_id_presented, stats); zr_debug_trace_set_frame(e->debug_trace, zr_engine_trace_frame_id(e)); } + + zr_engine_native_audit_write( + e, "present.commit", + "frame_id=%llu out_len=%zu presented_stage=%u use_damage_rect_copy=%u links_synced=%u invalidate_prev_hashes=%u " + "dirty_lines=%u dirty_cells=%u damage_rects=%u damage_cells=%u damage_full_frame=%u scroll_attempted=%u " + "scroll_hit=%u collision_guard_hits=%u diff_us=%u write_us=%u", + (unsigned long long)frame_id_presented, out_len, (unsigned)presented_stage, (unsigned)use_damage_rect_copy, + (unsigned)links_synced, (unsigned)invalidate_prev_hashes, (unsigned)stats->dirty_lines, + (unsigned)stats->dirty_cells, (unsigned)stats->damage_rects, (unsigned)stats->damage_cells, + (unsigned)stats->damage_full_frame, (unsigned)stats->scroll_opt_attempted, (unsigned)stats->scroll_opt_hit, + (unsigned)stats->collision_guard_hits, (unsigned)diff_us, (unsigned)write_us); } /* @@ -413,6 +426,11 @@ zr_result_t engine_present(zr_engine_t* e) { if (!e || !e->plat) { return ZR_ERR_INVALID_ARGUMENT; } + const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); + zr_engine_native_audit_write(e, "present.begin", + "frame_id=%llu overlay=%u wait_for_output_drain=%u diff_prev_hashes_valid=%u", + (unsigned long long)frame_id_presented, (unsigned)e->cfg_runtime.enable_debug_overlay, + (unsigned)e->cfg_runtime.wait_for_output_drain, (unsigned)e->diff_prev_hashes_valid); /* Enforced contract: the per-frame arena is reset exactly once per present. */ zr_arena_reset(&e->arena_frame); @@ -421,6 +439,8 @@ zr_result_t engine_present(zr_engine_t* e) { const int32_t timeout_ms = zr_engine_output_wait_timeout_ms(&e->cfg_runtime); zr_result_t rc = plat_wait_output_writable(e->plat, timeout_ms); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "present.wait_output.error", "frame_id=%llu rc=%d timeout_ms=%d", + (unsigned long long)frame_id_presented, (int)rc, (int)timeout_ms); return rc; } } @@ -436,16 +456,31 @@ zr_result_t engine_present(zr_engine_t* e) { zr_result_t rc = zr_engine_present_pick_fb(e, &present_fb, &presented_stage); if (rc != ZR_OK) { + zr_engine_native_audit_write(e, "present.pick_fb.error", "frame_id=%llu rc=%d", (unsigned long long)frame_id_presented, + (int)rc); return rc; } + zr_engine_native_audit_write(e, "present.pick_fb.ok", "frame_id=%llu presented_stage=%u cols=%u rows=%u", + (unsigned long long)frame_id_presented, (unsigned)presented_stage, + (unsigned)present_fb->cols, (unsigned)present_fb->rows); const uint64_t diff_start_us = zr_engine_now_us(); rc = zr_engine_present_render(e, present_fb, &out_len, &final_ts, &stats, &image_state_stage); if (rc != ZR_OK) { /* Diff scratch may have been used as transient indexed-coalescing storage. */ e->diff_prev_hashes_valid = 0u; + zr_engine_native_audit_write(e, "present.render.error", "frame_id=%llu rc=%d", + (unsigned long long)frame_id_presented, (int)rc); return rc; } + zr_engine_native_audit_write( + e, "present.render.ok", + "frame_id=%llu out_len=%zu dirty_lines=%u dirty_cells=%u damage_rects=%u damage_cells=%u damage_full_frame=%u " + "path_sweep_used=%u path_damage_used=%u scroll_attempted=%u scroll_hit=%u collision_guard_hits=%u", + (unsigned long long)frame_id_presented, out_len, (unsigned)stats.dirty_lines, (unsigned)stats.dirty_cells, + (unsigned)stats.damage_rects, (unsigned)stats.damage_cells, (unsigned)stats.damage_full_frame, + (unsigned)stats.path_sweep_used, (unsigned)stats.path_damage_used, (unsigned)stats.scroll_opt_attempted, + (unsigned)stats.scroll_opt_hit, (unsigned)stats.collision_guard_hits); { const uint64_t diff_end_us = zr_engine_now_us(); @@ -460,6 +495,8 @@ zr_result_t engine_present(zr_engine_t* e) { if (rc != ZR_OK) { /* Keep reuse conservative when present fails before prev/next commit. */ e->diff_prev_hashes_valid = 0u; + zr_engine_native_audit_write(e, "present.write.error", "frame_id=%llu rc=%d out_len=%zu", + (unsigned long long)frame_id_presented, (int)rc, out_len); return rc; } { @@ -471,5 +508,7 @@ zr_result_t engine_present(zr_engine_t* e) { } zr_engine_present_commit(e, presented_stage, out_len, &final_ts, &stats, &image_state_stage, diff_us, write_us); + zr_engine_native_audit_write(e, "present.done", "frame_id=%llu rc=0 out_len=%zu", (unsigned long long)frame_id_presented, + out_len); return ZR_OK; } diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.c b/packages/native/vendor/zireael/src/core/zr_framebuffer.c index 69cd9a16..487b5e55 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.c +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.c @@ -226,7 +226,6 @@ zr_result_t zr_fb_copy_damage_rects(zr_fb_t* dst, const zr_fb_t* src, const zr_d return ZR_ERR_INVALID_ARGUMENT; } /* Copying cells also copies link_ref indices, so sync intern tables first. */ - zr_fb_links_reset(dst); { const zr_result_t links_rc = zr_fb_links_clone_from(dst, src); if (links_rc != ZR_OK) { @@ -1244,7 +1243,13 @@ zr_result_t zr_fb_blit_rect(zr_fb_painter_t* p, zr_rect_t dst, zr_rect_t src) { continue; } - (void)zr_fb_put_grapheme(p, dx, dy, c->glyph, (size_t)c->glyph_len, c->width, &c->style); + /* + Snapshot source before write: overlapping blits can mutate source cells + (wide-pair repair), so reads must not alias framebuffer after destination + write begins. + */ + zr_cell_t snap = *c; + (void)zr_fb_put_grapheme(p, dx, dy, snap.glyph, (size_t)snap.glyph_len, snap.width, &snap.style); } } diff --git a/packages/node/src/backend/nodeBackend.ts b/packages/node/src/backend/nodeBackend.ts index afe8a9ea..2007a4c1 100644 --- a/packages/node/src/backend/nodeBackend.ts +++ b/packages/node/src/backend/nodeBackend.ts @@ -20,7 +20,9 @@ import type { TerminalCaps, TerminalProfile, } from "@rezi-ui/core"; +import type { BackendBeginFrame } from "@rezi-ui/core/backend"; import { + BACKEND_BEGIN_FRAME_MARKER, BACKEND_DRAWLIST_VERSION_MARKER, BACKEND_FPS_CAP_MARKER, BACKEND_MAX_EVENT_BYTES_MARKER, @@ -61,6 +63,7 @@ import { import { applyEmojiWidthPolicy, resolveBackendEmojiWidthPolicy } from "./emojiWidthPolicy.js"; import { createNodeBackendInlineInternal } from "./nodeBackendInline.js"; import { terminalProfileFromNodeEnv } from "./terminalProfile.js"; +import { createFrameAuditLogger, drawlistFingerprint, maybeDumpDrawlistBytes } from "../frameAudit.js"; export type NodeBackendConfig = Readonly<{ /** @@ -154,6 +157,23 @@ type SabFrameTransport = Readonly<{ nextSlot: { value: number }; }>; +type FrameAuditEntry = { + frameSeq: number; + submitAtMs: number; + submitPath: "requestFrame" | "beginFrame"; + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1; + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + slotIndex?: number; + slotToken?: number; + acceptedLogged?: boolean; +}; + const WIDTH_POLICY_KEY = "widthPolicy" as const; const DEFAULT_NATIVE_LIMITS: Readonly> = Object.freeze({ @@ -379,6 +399,24 @@ function acquireSabSlot(t: SabFrameTransport): number { return -1; } +function acquireSabFreeSlot(t: SabFrameTransport): number { + const start = t.nextSlot.value % t.slotCount; + for (let i = 0; i < t.slotCount; i++) { + const slot = (start + i) % t.slotCount; + const prev = Atomics.compareExchange( + t.states, + slot, + FRAME_SAB_SLOT_STATE_FREE, + FRAME_SAB_SLOT_STATE_WRITING, + ); + if (prev === FRAME_SAB_SLOT_STATE_FREE) { + t.nextSlot.value = (slot + 1) % t.slotCount; + return slot; + } + } + return -1; +} + function publishSabFrame( t: SabFrameTransport, frameSeq: number, @@ -393,6 +431,7 @@ function publishSabFrame( } export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): NodeBackend { + const frameAudit = createFrameAuditLogger("backend"); const cfg = opts.config ?? {}; const fpsCap = parseBoundedPositiveIntOrThrow( "fpsCap", @@ -481,6 +520,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N let nextFrameSeq = 1; const frameAcceptedWaiters = new Map>(); const frameCompletionWaiters = new Map>(); + const frameAuditBySeq = new Map(); const eventQueue: Array< Readonly<{ batch: ArrayBuffer; byteLen: number; droppedSinceLast: number }> @@ -541,10 +581,89 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N waiter.reject(err); } frameCompletionWaiters.clear(); + if (frameAudit.enabled) { + for (const [seq, meta] of frameAuditBySeq.entries()) { + frameAudit.emit("frame.aborted", { + reason: err.message, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + } + frameAuditBySeq.clear(); + } + } + + function registerFrameAudit( + frameSeq: number, + submitPath: "requestFrame" | "beginFrame", + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1, + bytes: Uint8Array, + slotIndex?: number, + slotToken?: number, + ): void { + if (!frameAudit.enabled) return; + const fp = drawlistFingerprint(bytes); + const meta: FrameAuditEntry = { + frameSeq, + submitAtMs: Date.now(), + submitPath, + transport, + byteLen: fp.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + head16: fp.head16, + tail16: fp.tail16, + ...(slotIndex === undefined ? {} : { slotIndex }), + ...(slotToken === undefined ? {} : { slotToken }), + }; + frameAuditBySeq.set(frameSeq, meta); + maybeDumpDrawlistBytes("backend", submitPath, frameSeq, bytes); + frameAudit.emit("frame.submitted", meta); + } + + function markAcceptedFramesUpTo(acceptedSeq: number): void { + if (!frameAudit.enabled) return; + for (const [seq, meta] of frameAuditBySeq.entries()) { + if (seq > acceptedSeq) continue; + if (meta.acceptedLogged === true) continue; + frameAudit.emit("frame.accepted", { + acceptedSeq, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + meta.acceptedLogged = true; + } + } + + function markCoalescedFramesBefore(acceptedSeq: number): void { + if (!frameAudit.enabled) return; + for (const [seq, meta] of frameAuditBySeq.entries()) { + if (seq >= acceptedSeq) continue; + frameAudit.emit("frame.coalesced", { + acceptedSeq, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + frameAuditBySeq.delete(seq); + } + } + + function markCompletedFrame(frameSeq: number, completedResult: number): void { + if (!frameAudit.enabled) return; + const meta = frameAuditBySeq.get(frameSeq); + frameAudit.emit("frame.completed", { + completedResult, + ageMs: meta ? Math.max(0, Date.now() - meta.submitAtMs) : null, + ...(meta ?? {}), + }); + frameAuditBySeq.delete(frameSeq); } function resolveAcceptedFramesUpTo(acceptedSeq: number): void { if (!Number.isInteger(acceptedSeq) || acceptedSeq <= 0) return; + markAcceptedFramesUpTo(acceptedSeq); for (const [seq, waiter] of frameAcceptedWaiters.entries()) { if (seq > acceptedSeq) continue; frameAcceptedWaiters.delete(seq); @@ -554,6 +673,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N function resolveCoalescedCompletionFramesUpTo(acceptedSeq: number): void { if (!Number.isInteger(acceptedSeq) || acceptedSeq <= 0) return; + markCoalescedFramesBefore(acceptedSeq); for (const [seq, waiter] of frameCompletionWaiters.entries()) { if (seq >= acceptedSeq) continue; frameCompletionWaiters.delete(seq); @@ -562,6 +682,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N } function settleCompletedFrame(frameSeq: number, completedResult: number): void { + markCompletedFrame(frameSeq, completedResult); const waiter = frameCompletionWaiters.get(frameSeq); if (waiter === undefined) return; frameCompletionWaiters.delete(frameSeq); @@ -577,6 +698,30 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N waiter.resolve(undefined); } + function reserveFramePromise( + frameSeq: number, + ): Promise & Partial>> { + const frameAcceptedDef = deferred(); + frameAcceptedWaiters.set(frameSeq, frameAcceptedDef); + const frameCompletionDef = deferred(); + frameCompletionWaiters.set(frameSeq, frameCompletionDef); + const framePromise = frameCompletionDef.promise as Promise & + Partial>>; + Object.defineProperty(framePromise, FRAME_ACCEPTED_ACK_MARKER, { + value: frameAcceptedDef.promise, + configurable: false, + enumerable: false, + writable: false, + }); + return framePromise; + } + + function releaseFrameReservation(frameSeq: number): void { + frameAcceptedWaiters.delete(frameSeq); + frameCompletionWaiters.delete(frameSeq); + if (frameAudit.enabled) frameAuditBySeq.delete(frameSeq); + } + function failAll(err: Error): void { while (eventWaiters.length > 0) eventWaiters.shift()?.reject(err); while (capsWaiters.length > 0) capsWaiters.shift()?.reject(err); @@ -635,6 +780,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N } case "frameStatus": { + if (frameAudit.enabled) { + frameAudit.emit("worker.frameStatus", { + acceptedSeq: msg.acceptedSeq, + completedSeq: msg.completedSeq ?? null, + completedResult: msg.completedResult ?? null, + }); + } if (!Number.isInteger(msg.acceptedSeq) || msg.acceptedSeq <= 0) { fatal = new ZrUiError( "ZRUI_BACKEND_ERROR", @@ -969,6 +1121,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N ? undefined : { nativeShimModule: opts.nativeShimModule }; worker = new Worker(entry, { workerData }); + if (frameAudit.enabled) { + frameAudit.emit("worker.spawn", { + frameTransport: frameTransportWire.kind, + frameSabSlotCount: frameSabSlotCount, + frameSabSlotBytes: frameSabSlotBytes, + }); + } exitDef = deferred(); worker.on("message", handleWorkerMessage); worker.on("error", (err) => { @@ -1041,33 +1200,45 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N if (worker === null) return Promise.reject(new Error("NodeBackend: worker not available")); const frameSeq = nextFrameSeq++; - const frameAcceptedDef = deferred(); - frameAcceptedWaiters.set(frameSeq, frameAcceptedDef); - const frameCompletionDef = deferred(); - frameCompletionWaiters.set(frameSeq, frameCompletionDef); - const framePromise = frameCompletionDef.promise as Promise & - Partial>>; - Object.defineProperty(framePromise, FRAME_ACCEPTED_ACK_MARKER, { - value: frameAcceptedDef.promise, - configurable: false, - enumerable: false, - writable: false, - }); + const framePromise = reserveFramePromise(frameSeq); if (sabFrameTransport !== null && drawlist.byteLength <= sabFrameTransport.slotBytes) { const slotIndex = acquireSabSlot(sabFrameTransport); if (slotIndex >= 0) { const slotToken = frameSeqToSlotToken(frameSeq); + registerFrameAudit( + frameSeq, + "requestFrame", + FRAME_TRANSPORT_SAB_V1, + drawlist, + slotIndex, + slotToken, + ); const slotOffset = slotIndex * sabFrameTransport.slotBytes; sabFrameTransport.dataBytes.set(drawlist, slotOffset); Atomics.store(sabFrameTransport.tokens, slotIndex, slotToken); Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_READY); publishSabFrame(sabFrameTransport, frameSeq, slotIndex, slotToken, drawlist.byteLength); + if (frameAudit.enabled) { + frameAudit.emit("frame.sab.publish", { + frameSeq, + slotIndex, + slotToken, + byteLen: drawlist.byteLength, + }); + } // SAB consumers wake on futex notify instead of per-frame // MessagePort frameKick round-trips. Atomics.notify(sabFrameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 1); return framePromise; } + if (frameAudit.enabled) { + frameAudit.emit("frame.sab.fallback_transfer", { + frameSeq, + byteLen: drawlist.byteLength, + reason: "no-slot-available", + }); + } } // Transfer fallback participates in the same ACK model: @@ -1075,6 +1246,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N // - completion promise settles on worker completion/coalescing status const buf = new ArrayBuffer(drawlist.byteLength); copyInto(buf, drawlist); + registerFrameAudit(frameSeq, "requestFrame", FRAME_TRANSPORT_TRANSFER_V1, drawlist); try { send( { @@ -1087,10 +1259,21 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N [buf], ); } catch (err) { - frameAcceptedWaiters.delete(frameSeq); - frameCompletionWaiters.delete(frameSeq); + releaseFrameReservation(frameSeq); + if (frameAudit.enabled) { + frameAudit.emit("frame.transfer.publish_error", { + frameSeq, + detail: safeErr(err).message, + }); + } return Promise.reject(safeErr(err)); } + if (frameAudit.enabled) { + frameAudit.emit("frame.transfer.publish", { + frameSeq, + byteLen: drawlist.byteLength, + }); + } return framePromise; }, @@ -1344,13 +1527,104 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N }), }; + const beginFrame: BackendBeginFrame | null = + sabFrameTransport === null + ? null + : (minCapacity?: number) => { + if (disposed) return null; + if (fatal !== null) return null; + if (stopRequested || !started || worker === null) return null; + + const required = + typeof minCapacity === "number" && Number.isInteger(minCapacity) && minCapacity > 0 + ? minCapacity + : 0; + if (required > sabFrameTransport.slotBytes) return null; + + const slotIndex = acquireSabFreeSlot(sabFrameTransport); + if (slotIndex < 0) return null; + const slotOffset = slotIndex * sabFrameTransport.slotBytes; + const buf = sabFrameTransport.dataBytes.subarray( + slotOffset, + slotOffset + sabFrameTransport.slotBytes, + ); + let finalized = false; + + return { + buf, + commit: (byteLen: number) => { + if (finalized) { + return Promise.reject(new Error("NodeBackend: beginFrame writer already finalized")); + } + finalized = true; + if (disposed) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(new Error("NodeBackend: disposed")); + } + if (fatal !== null) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(fatal); + } + if (stopRequested || !started || worker === null) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(new Error("NodeBackend: stopped")); + } + if (!Number.isInteger(byteLen) || byteLen < 0 || byteLen > sabFrameTransport.slotBytes) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(new Error("NodeBackend: beginFrame commit byteLen out of range")); + } + + const frameSeq = nextFrameSeq++; + const framePromise = reserveFramePromise(frameSeq); + const slotToken = frameSeqToSlotToken(frameSeq); + registerFrameAudit( + frameSeq, + "beginFrame", + FRAME_TRANSPORT_SAB_V1, + buf.subarray(0, byteLen), + slotIndex, + slotToken, + ); + Atomics.store(sabFrameTransport.tokens, slotIndex, slotToken); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_READY); + publishSabFrame(sabFrameTransport, frameSeq, slotIndex, slotToken, byteLen); + if (frameAudit.enabled) { + frameAudit.emit("frame.beginFrame.publish", { + frameSeq, + slotIndex, + slotToken, + byteLen, + }); + } + Atomics.notify(sabFrameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 1); + return framePromise; + }, + abort: () => { + if (finalized) return; + finalized = true; + if (frameAudit.enabled) { + frameAudit.emit("frame.beginFrame.abort", { + slotIndex, + }); + } + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + }, + }; + }; + const out = Object.assign(backend, { debug, perf }) as NodeBackend & Record< + | typeof BACKEND_BEGIN_FRAME_MARKER | typeof BACKEND_DRAWLIST_VERSION_MARKER | typeof BACKEND_MAX_EVENT_BYTES_MARKER | typeof BACKEND_FPS_CAP_MARKER | typeof BACKEND_RAW_WRITE_MARKER, - boolean | number | BackendRawWrite + boolean | number | BackendRawWrite | BackendBeginFrame >; Object.defineProperties(out, { [BACKEND_DRAWLIST_VERSION_MARKER]: { @@ -1385,5 +1659,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N configurable: false, }, }); + if (beginFrame !== null) { + Object.defineProperty(out, BACKEND_BEGIN_FRAME_MARKER, { + value: beginFrame, + writable: false, + enumerable: false, + configurable: false, + }); + } return out; } diff --git a/packages/node/src/backend/nodeBackendInline.ts b/packages/node/src/backend/nodeBackendInline.ts index 0cac702c..2fb88793 100644 --- a/packages/node/src/backend/nodeBackendInline.ts +++ b/packages/node/src/backend/nodeBackendInline.ts @@ -37,6 +37,7 @@ import { severityToNum, } from "@rezi-ui/core"; import { applyEmojiWidthPolicy, resolveBackendEmojiWidthPolicy } from "./emojiWidthPolicy.js"; +import { createFrameAuditLogger, drawlistFingerprint, maybeDumpDrawlistBytes } from "../frameAudit.js"; import type { NodeBackend, NodeBackendInternalOpts, @@ -358,6 +359,7 @@ async function loadNative(shimModule: string | undefined): Promise { } export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = {}): NodeBackend { + const frameAudit = createFrameAuditLogger("backend-inline"); const cfg = opts.config ?? {}; const requestedDrawlistVersion = ZR_DRAWLIST_VERSION_V1; const fpsCap = parseBoundedPositiveIntOrThrow( @@ -420,6 +422,7 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = const perfSamples: PerfSample[] = []; let cachedCaps: TerminalCaps | null = null; + let nextFrameSeq = 1; function perfRecord(phase: string, durationMs: number): void { if (!PERF_ENABLED) return; @@ -486,6 +489,9 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } function failWith(where: string, code: number, detail: string): void { + if (frameAudit.enabled) { + frameAudit.emit("fatal", { where, code, detail }); + } const err = new ZrUiError("ZRUI_BACKEND_ERROR", `${where} (${String(code)}): ${detail}`); fatal = err; rejectWaiters(err); @@ -704,6 +710,14 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } engineId = id; started = true; + if (frameAudit.enabled) { + frameAudit.emit("engine.ready", { + engineId: id, + executionMode: "inline", + fpsCap, + maxEventBytes, + }); + } cachedCaps = null; eventQueue = []; eventPool = []; @@ -804,8 +818,36 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } try { + const frameSeq = nextFrameSeq++; + const fp = frameAudit.enabled ? drawlistFingerprint(drawlist) : null; + maybeDumpDrawlistBytes("backend-inline", "requestFrame", frameSeq, drawlist); + if (fp !== null) { + frameAudit.emit("frame.submitted", { + frameSeq, + submitPath: "requestFrame", + transport: "inline-v1", + ...fp, + }); + frameAudit.emit("frame.submit.payload", { + frameSeq, + transport: "inline-v1", + ...fp, + }); + } const submitRc = native.engineSubmitDrawlist(engineId, drawlist); + if (frameAudit.enabled) { + frameAudit.emit("frame.submit.result", { + frameSeq, + submitResult: submitRc, + }); + } if (submitRc < 0) { + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: submitRc, + }); + } return Promise.reject( new ZrUiError( "ZRUI_BACKEND_ERROR", @@ -813,13 +855,37 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = ), ); } + if (frameAudit.enabled) { + frameAudit.emit("frame.accepted", { frameSeq }); + } const presentRc = native.enginePresent(engineId); + if (frameAudit.enabled) { + frameAudit.emit("frame.present.result", { + frameSeq, + presentResult: presentRc, + }); + } if (presentRc < 0) { + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: presentRc, + }); + } return Promise.reject( new ZrUiError("ZRUI_BACKEND_ERROR", `engine_present failed: code=${String(presentRc)}`), ); } + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: 0, + }); + } } catch (err) { + if (frameAudit.enabled) { + frameAudit.emit("frame.throw", { detail: safeDetail(err) }); + } return Promise.reject(safeErr(err)); } return RESOLVED_SYNC_FRAME_ACK; diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts new file mode 100644 index 00000000..c7118d96 --- /dev/null +++ b/packages/node/src/frameAudit.ts @@ -0,0 +1,290 @@ +/** + * packages/node/src/frameAudit.ts — Optional end-to-end frame audit utilities. + * + * Enable with: + * REZI_FRAME_AUDIT=1 + * + * Optional: + * REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson + * REZI_FRAME_AUDIT_NATIVE=1 + * REZI_FRAME_AUDIT_NATIVE_RING= + * + * Defaults: + * - When REZI_FRAME_AUDIT=1 and REZI_FRAME_AUDIT_LOG is unset, records are + * written to /tmp/rezi-frame-audit.ndjson to avoid polluting terminal output. + */ + +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import { join } from "node:path"; + +export type DrawlistFingerprint = Readonly<{ + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + opcodeHistogram: Readonly>; + cmdStreamValid: boolean; +}>; + +type AuditRecord = Readonly>; + +function readEnv(name: string): string | null { + const raw = process.env[name]; + if (typeof raw !== "string") return null; + const value = raw.trim(); + return value.length > 0 ? value : null; +} + +function envFlag(name: string, fallback = false): boolean { + const value = readEnv(name); + if (value === null) return fallback; + const norm = value.toLowerCase(); + return norm === "1" || norm === "true" || norm === "yes" || norm === "on"; +} + +function envPositiveInt(name: string, fallback: number): number { + const value = readEnv(name); + if (value === null) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function toHex32(v: number): string { + return `0x${(v >>> 0).toString(16).padStart(8, "0")}`; +} + +function hashFnv1a32(bytes: Uint8Array, end: number): number { + const n = Math.max(0, Math.min(end, bytes.byteLength)); + let h = 0x811c9dc5; + for (let i = 0; i < n; i++) { + h ^= bytes[i] ?? 0; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function sliceHex(bytes: Uint8Array, start: number, end: number): string { + const s = Math.max(0, Math.min(start, bytes.byteLength)); + const e = Math.max(s, Math.min(end, bytes.byteLength)); + let out = ""; + for (let i = s; i < e; i++) { + out += (bytes[i] ?? 0).toString(16).padStart(2, "0"); + } + return out; +} + +function readHeaderU32(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + try { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); + } catch { + return null; + } +} + +function decodeOpcodeHistogram(bytes: Uint8Array): Readonly<{ + histogram: Readonly>; + valid: boolean; +}> { + const cmdCount = readHeaderU32(bytes, 24); + const cmdOffset = readHeaderU32(bytes, 16); + const cmdBytes = readHeaderU32(bytes, 20); + if (cmdCount === null || cmdOffset === null || cmdBytes === null) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + if (cmdCount === 0) { + return Object.freeze({ histogram: Object.freeze({}), valid: true }); + } + if (cmdOffset < 0 || cmdBytes < 0 || cmdOffset + cmdBytes > bytes.byteLength) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let off = cmdOffset; + const end = cmdOffset + cmdBytes; + const hist: Record = Object.create(null) as Record; + for (let i = 0; i < cmdCount; i++) { + if (off + 8 > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const opcode = dv.getUint16(off + 0, true); + const size = dv.getUint32(off + 4, true); + if (size < 8 || off + size > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const key = String(opcode); + hist[key] = (hist[key] ?? 0) + 1; + off += size; + } + return Object.freeze({ histogram: Object.freeze(hist), valid: off === end }); +} + +function nowUs(): number { + return Math.round(performance.now() * 1000); +} + +const FRAME_AUDIT_ENABLED = envFlag("REZI_FRAME_AUDIT", false); +const FRAME_AUDIT_LOG_PATH = + readEnv("REZI_FRAME_AUDIT_LOG") ?? + (FRAME_AUDIT_ENABLED ? "/tmp/rezi-frame-audit.ndjson" : null); +const FRAME_AUDIT_STDERR_MIRROR = envFlag("REZI_FRAME_AUDIT_STDERR_MIRROR", false); +const FRAME_AUDIT_DUMP_DIR = readEnv("REZI_FRAME_AUDIT_DUMP_DIR"); +const FRAME_AUDIT_DUMP_ROUTE = readEnv("REZI_FRAME_AUDIT_DUMP_ROUTE"); +const FRAME_AUDIT_DUMP_MAX = envPositiveInt("REZI_FRAME_AUDIT_DUMP_MAX", 0); +let frameAuditDumpCount = 0; + +export { FRAME_AUDIT_ENABLED }; +export const FRAME_AUDIT_NATIVE_ENABLED = FRAME_AUDIT_ENABLED && envFlag("REZI_FRAME_AUDIT_NATIVE", true); +export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt("REZI_FRAME_AUDIT_NATIVE_RING", 4 << 20); + +// Match zr_debug category/code values. +export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; +export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; +export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; + +function readContextRoute(): string | null { + const g = globalThis as { + __reziFrameAuditContext?: () => Readonly>; + }; + if (typeof g.__reziFrameAuditContext !== "function") return null; + try { + const ctx = g.__reziFrameAuditContext(); + const route = ctx["route"]; + return typeof route === "string" && route.length > 0 ? route : null; + } catch { + return null; + } +} + +function sanitizeFileToken(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +export function maybeDumpDrawlistBytes( + scope: string, + stage: string, + frameSeq: number, + bytes: Uint8Array, +): void { + if (!FRAME_AUDIT_ENABLED) return; + if (FRAME_AUDIT_DUMP_DIR === null) return; + if (FRAME_AUDIT_DUMP_MAX <= 0) return; + if (frameAuditDumpCount >= FRAME_AUDIT_DUMP_MAX) return; + const route = readContextRoute(); + if (FRAME_AUDIT_DUMP_ROUTE !== null && route !== FRAME_AUDIT_DUMP_ROUTE) return; + try { + mkdirSync(FRAME_AUDIT_DUMP_DIR, { recursive: true }); + const seq = Number.isInteger(frameSeq) && frameSeq > 0 ? frameSeq : 0; + const routeToken = route ? sanitizeFileToken(route) : "unknown"; + const scopeToken = sanitizeFileToken(scope); + const stageToken = sanitizeFileToken(stage); + const stamp = `${Date.now()}`.padStart(13, "0"); + const base = `${stamp}-pid${process.pid}-seq${seq}-${routeToken}-${scopeToken}-${stageToken}`; + writeFileSync(join(FRAME_AUDIT_DUMP_DIR, `${base}.bin`), bytes); + const meta = JSON.stringify( + { + ts: new Date().toISOString(), + pid: process.pid, + frameSeq: seq, + route, + scope, + stage, + byteLen: bytes.byteLength, + hash32: toHex32(hashFnv1a32(bytes, bytes.byteLength)), + }, + null, + 2, + ); + writeFileSync(join(FRAME_AUDIT_DUMP_DIR, `${base}.json`), `${meta}\n`, "utf8"); + frameAuditDumpCount += 1; + } catch { + // Optional diagnostics must never affect runtime behavior. + } +} + +export function drawlistFingerprint(bytes: Uint8Array): DrawlistFingerprint { + const byteLen = bytes.byteLength; + const prefixLen = Math.min(4096, byteLen); + const cmdCount = readHeaderU32(bytes, 24); + const totalSize = readHeaderU32(bytes, 12); + const head16 = sliceHex(bytes, 0, Math.min(16, byteLen)); + const tailStart = Math.max(0, byteLen - 16); + const tail16 = sliceHex(bytes, tailStart, byteLen); + const decoded = decodeOpcodeHistogram(bytes); + return Object.freeze({ + byteLen, + hash32: toHex32(hashFnv1a32(bytes, byteLen)), + prefixHash32: toHex32(hashFnv1a32(bytes, prefixLen)), + cmdCount, + totalSize, + head16, + tail16, + opcodeHistogram: decoded.histogram, + cmdStreamValid: decoded.valid, + }); +} + +export type FrameAuditLogger = Readonly<{ + enabled: boolean; + emit: (stage: string, fields?: AuditRecord) => void; +}>; + +export function createFrameAuditLogger(scope: string): FrameAuditLogger { + if (!FRAME_AUDIT_ENABLED) { + return Object.freeze({ + enabled: false, + emit: () => {}, + }); + } + + const writeLine = (line: string): void => { + try { + if (FRAME_AUDIT_LOG_PATH !== null) { + appendFileSync(FRAME_AUDIT_LOG_PATH, `${line}\n`, "utf8"); + if (!FRAME_AUDIT_STDERR_MIRROR) { + return; + } + } else if (!FRAME_AUDIT_STDERR_MIRROR) { + return; + } + process.stderr.write(`${line}\n`); + } catch { + // Optional diagnostics must never affect runtime behavior. + } + }; + const g = globalThis as { + __reziFrameAuditSink?: (line: string) => void; + __reziFrameAuditContext?: () => Readonly>; + }; + if (typeof g.__reziFrameAuditSink !== "function") { + g.__reziFrameAuditSink = writeLine; + } + + return Object.freeze({ + enabled: true, + emit: (stage: string, fields: AuditRecord = Object.freeze({})) => { + try { + const context = + typeof g.__reziFrameAuditContext === "function" ? g.__reziFrameAuditContext() : null; + const line = JSON.stringify({ + ts: new Date().toISOString(), + tUs: nowUs(), + pid: process.pid, + layer: "node", + scope, + stage, + ...(context ?? {}), + ...fields, + }); + writeLine(line); + } catch { + // Optional diagnostics must never affect runtime behavior. + } + }, + }); +} diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index e6d70b37..2964accd 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -29,6 +29,16 @@ import { type WorkerToMainMessage, } from "./protocol.js"; import { computeNextIdleDelay, computeTickTiming } from "./tickTiming.js"; +import { + FRAME_AUDIT_NATIVE_ENABLED, + FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CODE_DRAWLIST_CMD, + ZR_DEBUG_CODE_DRAWLIST_EXECUTE, + ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + createFrameAuditLogger, + drawlistFingerprint, +} from "../frameAudit.js"; /** * Perf tracking for worker-side event polling. @@ -307,6 +317,20 @@ const DEBUG_HEADER_BYTES = 40; const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); +const DEBUG_DRAWLIST_RECORD_BYTES = 48; + +type FrameAuditMeta = { + frameSeq: number; + enqueuedAtMs: number; + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1; + byteLen: number; + slotIndex?: number; + slotToken?: number; + hash32?: string; + prefixHash32?: string; + cmdCount?: number | null; + totalSize?: number | null; +}; let engineId: number | null = null; let running = false; @@ -314,6 +338,10 @@ let haveSubmittedDrawlist = false; let pendingFrame: PendingFrame | null = null; let lastConsumedSabPublishedSeq = 0; let frameTransport: WorkerFrameTransport = Object.freeze({ kind: FRAME_TRANSPORT_TRANSFER_V1 }); +const frameAudit = createFrameAuditLogger("worker"); +const frameAuditBySeq = new Map(); +let nativeFrameAuditEnabled = false; +let nativeFrameAuditNextRecordId = 1n; let eventPool: ArrayBuffer[] = []; let discardBuffer: ArrayBuffer | null = null; @@ -327,6 +355,205 @@ let maxIdleDelayMs = 0; let sabWakeArmed = false; let sabWakeEpoch = 0; +function u64FromView(v: DataView, offset: number): bigint { + const lo = BigInt(v.getUint32(offset, true)); + const hi = BigInt(v.getUint32(offset + 4, true)); + return (hi << 32n) | lo; +} + +function setFrameAuditMeta( + frameSeq: number, + patch: Readonly>>, +): void { + if (!frameAudit.enabled) return; + const prev = frameAuditBySeq.get(frameSeq); + const next: FrameAuditMeta = { + frameSeq, + enqueuedAtMs: prev?.enqueuedAtMs ?? Date.now(), + transport: prev?.transport ?? FRAME_TRANSPORT_TRANSFER_V1, + byteLen: prev?.byteLen ?? 0, + ...(prev ?? {}), + ...patch, + }; + frameAuditBySeq.set(frameSeq, next); +} + +function emitFrameAudit(stage: string, frameSeq: number, fields: Readonly> = {}): void { + if (!frameAudit.enabled) return; + const meta = frameAuditBySeq.get(frameSeq); + frameAudit.emit(stage, { + frameSeq, + ageMs: meta ? Math.max(0, Date.now() - meta.enqueuedAtMs) : null, + ...(meta ?? {}), + ...fields, + }); +} + +function deleteFrameAudit(frameSeq: number): void { + if (!frameAudit.enabled) return; + frameAuditBySeq.delete(frameSeq); +} + +function maybeEnableNativeFrameAudit(): void { + if (!frameAudit.enabled) return; + if (!FRAME_AUDIT_NATIVE_ENABLED) return; + if (engineId === null) return; + let rc = -1; + try { + rc = native.engineDebugEnable(engineId, { + enabled: true, + ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, + minSeverity: 0, + categoryMask: 0xffff_ffff, + captureRawEvents: false, + captureDrawlistBytes: true, + }); + } catch (err) { + frameAudit.emit("native.debug.enable_error", { detail: safeDetail(err) }); + nativeFrameAuditEnabled = false; + return; + } + nativeFrameAuditEnabled = rc >= 0; + nativeFrameAuditNextRecordId = 1n; + frameAudit.emit("native.debug.enable", { + rc, + enabled: nativeFrameAuditEnabled, + ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, + }); +} + +function drainNativeFrameAudit(reason: string): void { + if (!frameAudit.enabled || !nativeFrameAuditEnabled) return; + if (engineId === null) return; + const headersCap = DEBUG_HEADER_BYTES * 64; + const headersBuf = new Uint8Array(headersCap); + for (let iter = 0; iter < 8; iter++) { + let result: DebugQueryResultNative; + try { + result = native.engineDebugQuery( + engineId, + { + minRecordId: nativeFrameAuditNextRecordId, + categoryMask: 1 << ZR_DEBUG_CAT_DRAWLIST, + minSeverity: 0, + maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), + }, + headersBuf, + ); + } catch (err) { + frameAudit.emit("native.debug.query_error", { reason, detail: safeDetail(err) }); + return; + } + const recordsReturned = + Number.isInteger(result.recordsReturned) && result.recordsReturned > 0 + ? Math.min(result.recordsReturned, Math.floor(headersCap / DEBUG_HEADER_BYTES)) + : 0; + if (recordsReturned <= 0) return; + + let advanced = false; + for (let i = 0; i < recordsReturned; i++) { + const off = i * DEBUG_HEADER_BYTES; + const dv = new DataView(headersBuf.buffer, headersBuf.byteOffset + off, DEBUG_HEADER_BYTES); + const recordId = u64FromView(dv, 0); + const timestampUs = u64FromView(dv, 8); + const frameId = u64FromView(dv, 16); + const category = dv.getUint32(24, true); + const severity = dv.getUint32(28, true); + const code = dv.getUint32(32, true); + const payloadSize = dv.getUint32(36, true); + if (recordId < nativeFrameAuditNextRecordId) { + continue; + } + if (recordId === 0n && frameId === 0n && category === 0 && code === 0 && payloadSize === 0) { + continue; + } + advanced = true; + nativeFrameAuditNextRecordId = recordId + 1n; + + frameAudit.emit("native.debug.header", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + timestampUs: timestampUs.toString(), + category, + severity, + code, + payloadSize, + }); + + if (code === ZR_DEBUG_CODE_DRAWLIST_CMD && payloadSize > 0) { + const cap = Math.min(Math.max(payloadSize, 1), 4096); + const payload = new Uint8Array(cap); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote > 0) { + const view = payload.subarray(0, Math.min(wrote, payload.byteLength)); + const fp = drawlistFingerprint(view); + frameAudit.emit("native.drawlist.payload", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + payloadSize: view.byteLength, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + head16: fp.head16, + tail16: fp.tail16, + }); + } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_DRAWLIST_VALIDATE || code === ZR_DEBUG_CODE_DRAWLIST_EXECUTE) && + payloadSize >= DEBUG_DRAWLIST_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_DRAWLIST_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DRAWLIST_RECORD_BYTES) { + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DRAWLIST_RECORD_BYTES, + ); + frameAudit.emit("native.drawlist.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + totalBytes: dvPayload.getUint32(8, true), + cmdCount: dvPayload.getUint32(12, true), + version: dvPayload.getUint32(16, true), + validationResult: dvPayload.getInt32(20, true), + executionResult: dvPayload.getInt32(24, true), + clipStackMaxDepth: dvPayload.getUint32(28, true), + textRuns: dvPayload.getUint32(32, true), + fillRects: dvPayload.getUint32(36, true), + }); + } + } + } + if (!advanced) return; + } +} + function writeResizeBatchV1(buf: ArrayBuffer, cols: number, rows: number): number { // Batch header (24) + RESIZE record (32) = 56 bytes. const totalSize = 56; @@ -489,6 +716,9 @@ function startTickLoop(fpsCap: number): void { } function fatal(where: string, code: number, detail: string): void { + if (frameAudit.enabled) { + frameAudit.emit("fatal", { where, code, detail }); + } postToMain({ type: "fatal", where, code, detail }); } @@ -515,6 +745,8 @@ function releasePendingFrame(frame: PendingFrame, expectedSabState: number): voi function postFrameStatus(frameSeq: number, completedResult: number): void { if (!Number.isInteger(frameSeq) || frameSeq <= 0) return; + emitFrameAudit("frame.completed", frameSeq, { completedResult }); + deleteFrameAudit(frameSeq); postToMain({ type: "frameStatus", acceptedSeq: frameSeq, @@ -526,6 +758,7 @@ function postFrameStatus(frameSeq: number, completedResult: number): void { function postFrameAccepted(frameSeq: number): void { if (!Number.isInteger(frameSeq) || frameSeq <= 0) return; + emitFrameAudit("frame.accepted", frameSeq); postToMain({ type: "frameStatus", acceptedSeq: frameSeq, @@ -587,9 +820,22 @@ function syncPendingSabFrameFromMailbox(): void { const latest = readLatestSabFrame(); if (latest === null) return; if (pendingFrame !== null) { + emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { reason: "mailbox-latest-wins" }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); } pendingFrame = latest; + setFrameAuditMeta(latest.frameSeq, { + transport: latest.transport, + byteLen: latest.byteLen, + slotIndex: latest.slotIndex, + slotToken: latest.slotToken, + }); + emitFrameAudit("frame.mailbox.latest", latest.frameSeq, { + slotIndex: latest.slotIndex, + slotToken: latest.slotToken, + byteLen: latest.byteLen, + }); } function destroyEngineBestEffort(): void { @@ -607,9 +853,17 @@ function shutdownNow(): void { running = false; stopTickLoop(); if (pendingFrame !== null) { + emitFrameAudit("frame.dropped", pendingFrame.frameSeq, { reason: "shutdown" }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); pendingFrame = null; } + if (frameAudit.enabled) { + for (const [seq] of frameAuditBySeq.entries()) { + emitFrameAudit("frame.dropped", seq, { reason: "shutdown_pending" }); + } + frameAuditBySeq.clear(); + } destroyEngineBestEffort(); shutdownComplete(); @@ -637,12 +891,32 @@ function tick(): void { if (pendingFrame !== null) { const f = pendingFrame; pendingFrame = null; + emitFrameAudit("frame.submit.begin", f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + ...(f.transport === FRAME_TRANSPORT_SAB_V1 + ? { slotIndex: f.slotIndex, slotToken: f.slotToken } + : {}), + }); let res = -1; let sabInUse = false; let staleSabFrame = false; try { if (f.transport === FRAME_TRANSPORT_TRANSFER_V1) { - res = native.engineSubmitDrawlist(engineId, new Uint8Array(f.buf, 0, f.byteLen)); + const view = new Uint8Array(f.buf, 0, f.byteLen); + if (frameAudit.enabled) { + const fp = drawlistFingerprint(view); + setFrameAuditMeta(f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.submit.payload", f.frameSeq, fp); + } + res = native.engineSubmitDrawlist(engineId, view); } else { if (frameTransport.kind !== FRAME_TRANSPORT_SAB_V1) { throw new Error("SAB frame transport unavailable"); @@ -673,13 +947,29 @@ function tick(): void { sabInUse = true; const offset = f.slotIndex * frameTransport.slotBytes; const view = frameTransport.data.subarray(offset, offset + f.byteLen); + if (frameAudit.enabled) { + const fp = drawlistFingerprint(view); + setFrameAuditMeta(f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + slotIndex: f.slotIndex, + slotToken: f.slotToken, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.submit.payload", f.frameSeq, fp); + } res = native.engineSubmitDrawlist(engineId, view); } } } } catch (err) { releasePendingFrame(f, sabInUse ? FRAME_SAB_SLOT_STATE_IN_USE : FRAME_SAB_SLOT_STATE_READY); + emitFrameAudit("frame.submit.throw", f.frameSeq, { detail: safeDetail(err) }); postFrameStatus(f.frameSeq, -1); + drainNativeFrameAudit("submit-throw"); fatal("engineSubmitDrawlist", -1, `engine_submit_drawlist threw: ${safeDetail(err)}`); running = false; return; @@ -688,6 +978,8 @@ function tick(): void { // This frame was superseded in the shared mailbox before submit. // Keep latest-wins behavior without surfacing a fatal protocol error. didFrameWork = true; + emitFrameAudit("frame.submit.stale", f.frameSeq, { reason: "slot-token-mismatch" }); + deleteFrameAudit(f.frameSeq); syncPendingSabFrameFromMailbox(); // Continue with present/event processing on this tick. } else { @@ -695,6 +987,8 @@ function tick(): void { haveSubmittedDrawlist = haveSubmittedDrawlist || didSubmitDrawlistThisTick; didFrameWork = true; releasePendingFrame(f, FRAME_SAB_SLOT_STATE_IN_USE); + emitFrameAudit("frame.submit.result", f.frameSeq, { submitResult: res }); + drainNativeFrameAudit("post-submit"); if (res < 0) { postFrameStatus(f.frameSeq, res); fatal("engineSubmitDrawlist", res, "engine_submit_drawlist failed"); @@ -717,21 +1011,27 @@ function tick(): void { try { pres = native.enginePresent(engineId); } catch (err) { + if (submittedFrameSeq !== null) emitFrameAudit("frame.present.throw", submittedFrameSeq, { detail: safeDetail(err) }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, -1); + drainNativeFrameAudit("present-throw"); fatal("enginePresent", -1, `engine_present threw: ${safeDetail(err)}`); running = false; return; } if (pres < 0) { + if (submittedFrameSeq !== null) emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, pres); + drainNativeFrameAudit("present-failed"); fatal("enginePresent", pres, "engine_present failed"); running = false; return; } + if (submittedFrameSeq !== null) emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); } if (submittedFrameSeq !== null) { postFrameStatus(submittedFrameSeq, 0); + drainNativeFrameAudit("frame-complete"); } // 3) drain events (bounded) @@ -859,6 +1159,9 @@ function onMessage(msg: MainToWorkerMessage): void { running = true; pendingFrame = null; lastConsumedSabPublishedSeq = 0; + frameAuditBySeq.clear(); + nativeFrameAuditEnabled = false; + nativeFrameAuditNextRecordId = 1n; if (frameTransport.kind === FRAME_TRANSPORT_SAB_V1) { Atomics.store(frameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 0); Atomics.store(frameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SLOT_WORD, 0); @@ -891,6 +1194,16 @@ function onMessage(msg: MainToWorkerMessage): void { maybeInjectInitialResize(maxEventBytes); } + if (frameAudit.enabled) { + frameAudit.emit("engine.ready", { + engineId: id, + frameTransport: frameTransport.kind, + maxEventBytes, + fpsCap: parsePositiveInt(msg.config.fpsCap) ?? 60, + }); + } + maybeEnableNativeFrameAudit(); + postToMain({ type: "ready", engineId: id }); const fpsCap = parsePositiveInt(msg.config.fpsCap) ?? 60; @@ -903,6 +1216,8 @@ function onMessage(msg: MainToWorkerMessage): void { // latest-wins overwrite for transfer-path fallback. if (pendingFrame !== null) { + emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { reason: "message-latest-wins" }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); } @@ -943,6 +1258,18 @@ function onMessage(msg: MainToWorkerMessage): void { slotToken: msg.slotToken as number, byteLen: msg.byteLen, }; + setFrameAuditMeta(msg.frameSeq, { + transport: FRAME_TRANSPORT_SAB_V1, + byteLen: msg.byteLen, + slotIndex: msg.slotIndex as number, + slotToken: msg.slotToken as number, + }); + emitFrameAudit("frame.received", msg.frameSeq, { + transport: FRAME_TRANSPORT_SAB_V1, + byteLen: msg.byteLen, + slotIndex: msg.slotIndex as number, + slotToken: msg.slotToken as number, + }); } else { if (!(msg.drawlist instanceof ArrayBuffer)) { fatal("frame", -1, "invalid transfer frame payload: missing drawlist"); @@ -964,6 +1291,21 @@ function onMessage(msg: MainToWorkerMessage): void { buf: msg.drawlist, byteLen: msg.byteLen, }; + if (frameAudit.enabled) { + const fp = drawlistFingerprint(new Uint8Array(msg.drawlist, 0, msg.byteLen)); + setFrameAuditMeta(msg.frameSeq, { + transport: FRAME_TRANSPORT_TRANSFER_V1, + byteLen: msg.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.received", msg.frameSeq, { + transport: FRAME_TRANSPORT_TRANSFER_V1, + ...fp, + }); + } } idleDelayMs = tickIntervalMs; scheduleTickNow(); @@ -1073,6 +1415,14 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugEnable", -1, `engine_debug_enable threw: ${safeDetail(err)}`); return; } + if (frameAudit.enabled) { + frameAudit.emit("native.debug.enable.user", { + rc, + captureDrawlistBytes: msg.config.captureDrawlistBytes ?? false, + }); + } + nativeFrameAuditEnabled = rc >= 0 && frameAudit.enabled; + if (nativeFrameAuditEnabled) nativeFrameAuditNextRecordId = 1n; postToMain({ type: "debug:enableResult", result: rc }); return; } @@ -1086,6 +1436,10 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugDisable", -1, `engine_debug_disable threw: ${safeDetail(err)}`); return; } + nativeFrameAuditEnabled = false; + if (frameAudit.enabled) { + frameAudit.emit("native.debug.disable.user", { rc }); + } postToMain({ type: "debug:disableResult", result: rc }); return; } @@ -1213,6 +1567,10 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugReset", -1, `engine_debug_reset threw: ${safeDetail(err)}`); return; } + if (frameAudit.enabled && rc >= 0) { + nativeFrameAuditNextRecordId = 1n; + frameAudit.emit("native.debug.reset", { rc }); + } postToMain({ type: "debug:resetResult", result: rc }); return; } diff --git a/scripts/frame-audit-report.mjs b/scripts/frame-audit-report.mjs new file mode 100755 index 00000000..e3c3244c --- /dev/null +++ b/scripts/frame-audit-report.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +/** + * scripts/frame-audit-report.mjs — Quick analyzer for REZI_FRAME_AUDIT NDJSON logs. + * + * Usage: + * node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson + */ + +import fs from "node:fs"; + +function usage() { + process.stderr.write( + "Usage: node scripts/frame-audit-report.mjs [--pid=|--latest-pid]\n", + ); +} + +const argv = process.argv.slice(2); +const file = argv[0]; +if (!file) { + usage(); + process.exit(1); +} + +let pidFilter = null; +let latestPidOnly = false; +for (const arg of argv.slice(1)) { + if (arg === "--latest-pid") { + latestPidOnly = true; + continue; + } + if (arg.startsWith("--pid=")) { + const n = Number(arg.slice("--pid=".length)); + if (Number.isInteger(n) && n > 0) { + pidFilter = n; + continue; + } + } + usage(); + process.exit(1); +} + +let text = ""; +try { + text = fs.readFileSync(file, "utf8"); +} catch (err) { + process.stderr.write(`Failed to read ${file}: ${String(err)}\n`); + process.exit(1); +} + +const lines = text.split(/\r?\n/); +let records = []; +for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const rec = JSON.parse(trimmed); + if (rec && typeof rec === "object") records.push(rec); + } catch { + // ignore malformed lines + } +} + +const recordsByPid = new Map(); +for (const rec of records) { + if (!Number.isInteger(rec.pid)) continue; + const pid = rec.pid; + const state = recordsByPid.get(pid) ?? { count: 0, firstTs: null, lastTs: null, modes: new Set() }; + state.count += 1; + if (typeof rec.ts === "string") { + if (state.firstTs === null || rec.ts < state.firstTs) state.firstTs = rec.ts; + if (state.lastTs === null || rec.ts > state.lastTs) state.lastTs = rec.ts; + } + if (typeof rec.executionMode === "string" && rec.executionMode.length > 0) { + state.modes.add(rec.executionMode); + } + recordsByPid.set(pid, state); +} + +if (latestPidOnly && pidFilter === null) { + let latest = null; + for (let i = records.length - 1; i >= 0; i--) { + const rec = records[i]; + if (Number.isInteger(rec.pid)) { + latest = rec.pid; + break; + } + } + pidFilter = latest; +} + +if (pidFilter !== null) { + records = records.filter((rec) => rec.pid === pidFilter); +} + +const OPCODE_NAMES = Object.freeze({ + 0: "INVALID", + 1: "CLEAR", + 2: "FILL_RECT", + 3: "DRAW_TEXT", + 4: "PUSH_CLIP", + 5: "POP_CLIP", + 6: "DRAW_TEXT_RUN", + 7: "SET_CURSOR", + 8: "DRAW_CANVAS", + 9: "DRAW_IMAGE", + 10: "DEF_STRING", + 11: "FREE_STRING", + 12: "DEF_BLOB", + 13: "FREE_BLOB", + 14: "BLIT_RECT", +}); + +function routeId(rec, fallback = null) { + if (typeof rec.route === "string" && rec.route.length > 0) return rec.route; + if (typeof fallback === "string" && fallback.length > 0) return fallback; + return ""; +} + +function seqKey(rec) { + if (!Number.isInteger(rec.frameSeq)) return null; + const pid = Number.isInteger(rec.pid) ? rec.pid : "nopid"; + return `${pid}:${rec.frameSeq}`; +} + +function addOpcodeHistogram(dst, hist) { + if (typeof hist !== "object" || hist === null) return; + for (const [k, v] of Object.entries(hist)) { + const count = Number(v); + if (!Number.isFinite(count) || count <= 0) continue; + dst.set(k, (dst.get(k) ?? 0) + count); + } +} + +function createRouteSummary() { + return { + framesSubmitted: 0, + framesCompleted: 0, + bytesTotal: 0, + cmdsTotal: 0, + cmdsSamples: 0, + invalidCmdStreams: 0, + opcodeCounts: new Map(), + }; +} + +const backendSubmitted = new Map(); +const submitPayloadBySeq = new Map(); +const completedBySeq = new Map(); +const acceptedBySeq = new Map(); +const coreBuilt = []; +const coreCompleted = []; +const nativePayload = []; +const nativeSummaries = []; +const nativeHeaders = []; +const stageCounts = new Map(); +const globalOpcodeCounts = new Map(); +const routeSummaries = new Map(); +const routeBySeq = new Map(); +const submittedFramesSeen = new Set(); +const completedFramesSeen = new Set(); + +for (const rec of records) { + const stage = typeof rec.stage === "string" ? rec.stage : ""; + stageCounts.set(stage, (stageCounts.get(stage) ?? 0) + 1); + + if ((rec.scope === "backend" || rec.scope === "backend-inline") && stage === "frame.submitted") { + const key = seqKey(rec); + if (key !== null) backendSubmitted.set(key, rec); + const route = routeId(rec); + if (route !== "" && key !== null) routeBySeq.set(key, route); + } + + if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.submit.payload") { + const key = seqKey(rec); + if (key !== null) submitPayloadBySeq.set(key, rec); + addOpcodeHistogram(globalOpcodeCounts, rec.opcodeHistogram); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + addOpcodeHistogram(summary.opcodeCounts, rec.opcodeHistogram); + routeSummaries.set(route, summary); + } + + if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.accepted") { + const key = seqKey(rec); + if (key !== null) acceptedBySeq.set(key, rec); + } + + if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.completed") { + const key = seqKey(rec); + if (key !== null) completedBySeq.set(key, rec); + } + + if (rec.scope === "worker" && stage === "native.drawlist.payload") { + nativePayload.push(rec); + } + if (rec.scope === "worker" && stage === "native.drawlist.summary") { + nativeSummaries.push(rec); + } + if (rec.scope === "worker" && stage === "native.debug.header") { + nativeHeaders.push(rec); + } + + if (rec.scope === "core" && stage === "drawlist.built") { + coreBuilt.push(rec); + } + + if (rec.scope === "core" && stage === "backend.completed") { + coreCompleted.push(rec); + } + + if (stage === "frame.submitted") { + const key = seqKey(rec); + if (key !== null && submittedFramesSeen.has(key)) { + continue; + } + if (key !== null) submittedFramesSeen.add(key); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + summary.framesSubmitted += 1; + if (Number.isFinite(rec.byteLen)) summary.bytesTotal += Number(rec.byteLen); + if (Number.isFinite(rec.cmdCount)) { + summary.cmdsTotal += Number(rec.cmdCount); + summary.cmdsSamples += 1; + } + if (rec.cmdStreamValid === false) summary.invalidCmdStreams += 1; + routeSummaries.set(route, summary); + } + + if (stage === "frame.completed") { + const key = seqKey(rec); + if (key !== null && completedFramesSeen.has(key)) { + continue; + } + if (key !== null) completedFramesSeen.add(key); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + summary.framesCompleted += 1; + routeSummaries.set(route, summary); + } +} + +let missingWorkerPayload = 0; +let hashMismatch = 0; +let missingAccepted = 0; +let missingCompleted = 0; +let firstMismatch = null; + +for (const [key, backend] of backendSubmitted.entries()) { + const worker = submitPayloadBySeq.get(key); + if (!worker) { + missingWorkerPayload++; + continue; + } + + if ( + typeof backend.hash32 === "string" && + typeof worker.hash32 === "string" && + backend.hash32 !== worker.hash32 + ) { + hashMismatch++; + if (firstMismatch === null) { + firstMismatch = { + frameKey: key, + backendHash: backend.hash32, + workerHash: worker.hash32, + backendByteLen: backend.byteLen, + workerByteLen: worker.byteLen, + }; + } + } + + if (!acceptedBySeq.has(key)) missingAccepted++; + if (!completedBySeq.has(key)) missingCompleted++; +} + +process.stdout.write(`records=${records.length}\n`); +if (pidFilter !== null) { + process.stdout.write(`pid_filter=${pidFilter}\n`); +} +if (recordsByPid.size > 0) { + process.stdout.write(`pids=${recordsByPid.size}\n`); + process.stdout.write("pid_sessions:\n"); + const sessionRows = [...recordsByPid.entries()].sort((a, b) => a[0] - b[0]); + for (const [pid, s] of sessionRows) { + const modes = [...s.modes].sort().join("|"); + process.stdout.write(` pid=${pid} records=${s.count} firstTs=${s.firstTs ?? "-"} lastTs=${s.lastTs ?? "-"} modes=${modes}\n`); + } +} +process.stdout.write(`backend_submitted=${backendSubmitted.size}\n`); +process.stdout.write(`worker_payload=${submitPayloadBySeq.size}\n`); +process.stdout.write(`worker_accepted=${acceptedBySeq.size}\n`); +process.stdout.write(`worker_completed=${completedBySeq.size}\n`); +process.stdout.write(`native_payload_records=${nativePayload.length}\n`); +process.stdout.write(`native_summary_records=${nativeSummaries.length}\n`); +process.stdout.write(`native_header_records=${nativeHeaders.length}\n`); +process.stdout.write(`missing_worker_payload=${missingWorkerPayload}\n`); +process.stdout.write(`hash_mismatch_backend_vs_worker=${hashMismatch}\n`); +process.stdout.write(`missing_worker_accepted=${missingAccepted}\n`); +process.stdout.write(`missing_worker_completed=${missingCompleted}\n`); + +if (backendSubmitted.size === 0 && (coreBuilt.length > 0 || coreCompleted.length > 0)) { + process.stdout.write( + "hint=core-only audit records detected; backend likely running a path without frame-level backend instrumentation (for example old build or inline mode without updated backend).\n", + ); +} + +if (firstMismatch) { + process.stdout.write(`first_mismatch=${JSON.stringify(firstMismatch)}\n`); +} + +const topOpcodes = [...globalOpcodeCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); +process.stdout.write("top_opcodes:\n"); +for (const [opcode, count] of topOpcodes) { + const name = OPCODE_NAMES[Number(opcode)] ?? `OP_${opcode}`; + process.stdout.write(` ${name}(${opcode}): ${count}\n`); +} + +const routes = [...routeSummaries.entries()].sort((a, b) => b[1].framesSubmitted - a[1].framesSubmitted); +process.stdout.write("route_summary:\n"); +for (const [route, summary] of routes) { + const avgBytes = summary.framesSubmitted > 0 ? summary.bytesTotal / summary.framesSubmitted : 0; + const avgCmds = summary.cmdsSamples > 0 ? summary.cmdsTotal / summary.cmdsSamples : 0; + const topRouteOps = [...summary.opcodeCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([opcode, count]) => `${OPCODE_NAMES[Number(opcode)] ?? `OP_${opcode}`}=${count}`) + .join(","); + process.stdout.write( + ` ${route}: submitted=${summary.framesSubmitted} completed=${summary.framesCompleted} avgBytes=${avgBytes.toFixed(1)} avgCmds=${avgCmds.toFixed(1)} invalidCmdStreams=${summary.invalidCmdStreams} topOps=${topRouteOps}\n`, + ); +} + +const sortedStages = [...stageCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); +process.stdout.write("top_stages:\n"); +for (const [stage, count] of sortedStages) { + process.stdout.write(` ${stage}: ${count}\n`); +} From 47375c9ea7b0b42961c3894eeca45c349bea97a5 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:16:51 +0400 Subject: [PATCH 11/20] Fix refactor regressions and sync native vendor to Zireael PR --- .github/workflows/ci.yml | 3 + AGENTS.md | 4 +- CLAUDE.md | 6 +- docs/backend/native.md | 2 +- docs/dev/build.md | 7 +- docs/dev/repo-layout.md | 9 +- package.json | 3 +- packages/core/src/app/createApp.ts | 16 +- packages/core/src/app/widgetRenderer.ts | 104 +++-- .../src/app/widgetRenderer/damageTracking.ts | 44 ++- .../__tests__/builder.alignment.test.ts | 260 +++++------- .../drawlist/__tests__/builder.golden.test.ts | 22 +- .../__tests__/builder.graphics.test.ts | 152 ++++--- .../drawlist/__tests__/builder.limits.test.ts | 41 +- .../drawlist/__tests__/builder.reset.test.ts | 70 ++-- .../__tests__/builder.round-trip.test.ts | 372 ++++++------------ .../__tests__/builder.string-cache.test.ts | 82 +--- .../__tests__/builder.string-intern.test.ts | 125 ++---- .../__tests__/builder.style-encoding.test.ts | 44 ++- .../__tests__/builder.text-run.test.ts | 132 +++---- .../__tests__/builder_style_attrs.test.ts | 25 +- .../__tests__/builder_v6_resources.test.ts | 24 ++ .../drawlist/__tests__/writers.gen.test.ts | 61 ++- .../drawlist/__tests__/writers.gen.v6.test.ts | 34 ++ packages/core/src/drawlist/builder.ts | 76 +++- packages/core/src/drawlist/builderBase.ts | 9 - packages/core/src/drawlist/writers.gen.ts | 20 +- packages/core/src/layout/kinds/overlays.ts | 4 +- .../__tests__/persistentBlobKeys.test.ts | 11 +- .../__tests__/renderer.damage.test.ts | 44 +++ .../renderToDrawlist/overflowCulling.ts | 72 ++++ .../renderer/renderToDrawlist/renderTree.ts | 17 +- .../renderToDrawlist/widgets/containers.ts | 128 ++++-- .../widgets/renderCanvasWidgets.ts | 9 +- .../commit.fastReuse.regression.test.ts | 42 +- .../src/theme/__tests__/theme.extend.test.ts | 3 +- .../theme/__tests__/theme.validation.test.ts | 40 +- packages/core/src/theme/resolve.ts | 4 +- packages/core/src/theme/validate.ts | 23 +- .../widgets/__tests__/graphics.golden.test.ts | 19 +- .../__tests__/style.inheritance.test.ts | 93 +++-- .../src/widgets/__tests__/style.merge.test.ts | 2 + .../src/widgets/__tests__/style.utils.test.ts | 13 +- .../src/widgets/__tests__/styleUtils.test.ts | 10 +- packages/core/src/widgets/ui.ts | 8 +- packages/native/README.md | 8 + packages/native/src/lib.rs | 178 +++++++++ packages/native/vendor/VENDOR_COMMIT.txt | 2 +- .../vendor/zireael/include/zr/zr_caps.h | 8 + .../vendor/zireael/include/zr/zr_config.h | 8 + .../vendor/zireael/include/zr/zr_debug.h | 8 + .../vendor/zireael/include/zr/zr_drawlist.h | 10 +- .../vendor/zireael/include/zr/zr_engine.h | 8 + .../vendor/zireael/include/zr/zr_event.h | 8 + .../vendor/zireael/include/zr/zr_metrics.h | 12 +- .../zireael/include/zr/zr_platform_types.h | 8 + .../vendor/zireael/include/zr/zr_result.h | 8 + .../zireael/include/zr/zr_terminal_caps.h | 8 + .../vendor/zireael/include/zr/zr_version.h | 13 +- .../vendor/zireael/src/core/zr_config.c | 8 +- .../vendor/zireael/src/core/zr_cursor.h | 5 +- .../vendor/zireael/src/core/zr_damage.c | 2 + .../vendor/zireael/src/core/zr_damage.h | 28 +- .../native/vendor/zireael/src/core/zr_diff.c | 138 ++++--- .../vendor/zireael/src/core/zr_drawlist.c | 116 +----- .../vendor/zireael/src/core/zr_engine.c | 197 ++-------- .../zireael/src/core/zr_engine_present.inc | 91 +++-- .../vendor/zireael/src/core/zr_event_pack.c | 14 +- .../vendor/zireael/src/core/zr_event_pack.h | 9 +- .../vendor/zireael/src/core/zr_framebuffer.c | 198 +++++++++- .../vendor/zireael/src/core/zr_metrics.c | 4 +- .../vendor/zireael/src/core/zr_placeholder.c | 3 +- .../src/platform/win32/zr_win32_conpty_test.c | 46 +-- .../src/platform/win32/zr_win32_conpty_test.h | 8 +- .../vendor/zireael/src/unicode/zr_grapheme.h | 5 +- .../zireael/src/unicode/zr_unicode_data.h | 6 +- .../zireael/src/unicode/zr_unicode_pins.h | 1 - .../vendor/zireael/src/unicode/zr_utf8.h | 1 - .../vendor/zireael/src/unicode/zr_width.h | 6 +- .../vendor/zireael/src/unicode/zr_wrap.h | 5 +- .../native/vendor/zireael/src/util/zr_arena.h | 7 +- .../native/vendor/zireael/src/util/zr_ring.h | 12 +- .../zireael/src/util/zr_string_builder.h | 7 +- .../vendor/zireael/src/util/zr_string_view.h | 1 - .../native/vendor/zireael/src/util/zr_vec.h | 8 +- .../widgets/barchart_highres.bin | Bin 2084 -> 2092 bytes .../widgets/canvas_primitives.bin | Bin 3724 -> 3740 bytes .../widgets/heatmap_plasma.bin | Bin 3236 -> 3244 bytes .../widgets/image_png_contain.bin | Bin 196 -> 204 bytes .../widgets/image_rgba_sixel_cover.bin | Bin 180 -> 188 bytes .../zrdl-v1-graphics/widgets/line_chart.bin | Bin 6960 -> 6976 bytes .../zrdl-v1-graphics/widgets/link_docs.bin | Bin 284 -> 312 bytes .../widgets/richtext_underline_ext.bin | Bin 348 -> 380 bytes .../zrdl-v1-graphics/widgets/scatter_plot.bin | Bin 7844 -> 7852 bytes .../widgets/sparkline_highres.bin | Bin 356 -> 364 bytes .../fixtures/zrdl-v1/golden/clip_nested.bin | Bin 168 -> 180 bytes .../zrdl-v1/golden/draw_text_interned.bin | Bin 236 -> 292 bytes .../fixtures/zrdl-v1/golden/fill_rect.bin | Bin 104 -> 116 bytes .../zrdl-v1/widgets/button_focus_states.bin | Bin 516 -> 612 bytes .../zrdl-v1/widgets/divider_horizontal.bin | Bin 408 -> 440 bytes .../zrdl-v1/widgets/divider_with_label.bin | Bin 400 -> 432 bytes .../fixtures/zrdl-v1/widgets/input_basic.bin | Bin 208 -> 240 bytes .../zrdl-v1/widgets/input_disabled.bin | Bin 208 -> 240 bytes .../zrdl-v1/widgets/input_focused_inverse.bin | Bin 208 -> 240 bytes .../zrdl-v1/widgets/layer_backdrop_opaque.bin | Bin 216 -> 260 bytes .../zrdl-v1/widgets/modal_backdrop_dim.bin | Bin 3512 -> 4260 bytes .../zrdl-v1/widgets/spinner_tick_0.bin | Bin 268 -> 328 bytes .../zrdl-v1/widgets/spinner_tick_1.bin | Bin 268 -> 328 bytes .../check-native-vendor-integrity.test.mjs | 136 +++++++ scripts/check-native-vendor-integrity.mjs | 187 +++++++++ scripts/generate-drawlist-writers.ts | 10 +- vendor/zireael | 2 +- 112 files changed, 2307 insertions(+), 1530 deletions(-) create mode 100644 packages/core/src/renderer/renderToDrawlist/overflowCulling.ts create mode 100644 scripts/__tests__/check-native-vendor-integrity.test.mjs create mode 100644 scripts/check-native-vendor-integrity.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5951aba..203ffc35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Check core portability run: npm run check:core-portability + - name: Check native vendor integrity + run: npm run check:native-vendor + - name: Check Unicode pins (submodule sync) run: npm run check:unicode diff --git a/AGENTS.md b/AGENTS.md index e95a3375..1e221149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Key pipeline files: - `packages/core/src/runtime/router/wheel.ts` — mouse wheel routing for scroll targets - `packages/core/src/renderer/renderToDrawlist/renderTree.ts` — stack-based DFS renderer - `packages/core/src/layout/dropdownGeometry.ts` — shared dropdown overlay geometry -- `packages/core/src/drawlist/builder_v1.ts` — ZRDL binary drawlist builder +- `packages/core/src/drawlist/builder.ts` — ZRDL binary drawlist builder (unified) ## Layout Engine Baseline (Current) @@ -129,7 +129,7 @@ When core widget APIs change, JSX must be updated in the same change set. ### Drawlist writer codegen guardrail (MUST for ZRDL command changes) -The v3/v4/v5 command writer implementation is code-generated. Never hand-edit +The command writer implementation is code-generated. Never hand-edit `packages/core/src/drawlist/writers.gen.ts`. When changing drawlist command layout/opcodes/field widths/offsets: diff --git a/CLAUDE.md b/CLAUDE.md index d68d98ee..5c8e22a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,9 +50,9 @@ packages/core/src/ renderToDrawlist/ renderTree.ts # Stack-based DFS renderer drawlist/ - builder_v1.ts # ZRDL binary drawlist builder (v1) - builder_v2.ts # Drawlist builder v2 (cursor protocol) - builder_v3.ts # Drawlist builder v3 + builder.ts # ZRDL binary drawlist builder (single unified version) + builderBase.ts # Abstract base class for drawlist builder + writers.gen.ts # Generated drawlist command writers (codegen) keybindings/ manager.ts # Modal keybinding system parser.ts # Key sequence parsing diff --git a/docs/backend/native.md b/docs/backend/native.md index 854414c6..6bbd151e 100644 --- a/docs/backend/native.md +++ b/docs/backend/native.md @@ -163,7 +163,7 @@ source. - **Node.js 18+** with npm - **Rust toolchain** (for napi-rs compilation) - **C toolchain** (gcc, clang, or MSVC) for the Zireael engine -- **Git submodules** initialized (`vendor/zireael` must be present) +- **Git metadata available** (for vendor pin checks; full submodule checkout is needed when refreshing vendored engine sources) ### Build Command diff --git a/docs/dev/build.md b/docs/dev/build.md index 8447ea8b..2586fd48 100644 --- a/docs/dev/build.md +++ b/docs/dev/build.md @@ -35,9 +35,10 @@ cd Rezi git submodule update --init --recursive ``` -The `vendor/zireael` submodule contains the Zireael C engine source. It must be -present for native addon builds, but is not required for TypeScript-only -development. +The `vendor/zireael` submodule tracks the upstream Zireael source and commit +pin metadata. Native addon builds compile from the package-local snapshot at +`packages/native/vendor/zireael`; submodule checkout is required when syncing +or auditing vendored engine updates, but not for TypeScript-only development. Install all dependencies: diff --git a/docs/dev/repo-layout.md b/docs/dev/repo-layout.md index b54a9bde..3c2d7504 100644 --- a/docs/dev/repo-layout.md +++ b/docs/dev/repo-layout.md @@ -144,6 +144,7 @@ Build, test, and CI automation scripts. | `docs.sh` | Documentation build/serve with automatic venv management. | | `guardrails.sh` | Repository hygiene checks for forbidden patterns (legacy scope/name, unresolved task markers, and synthetic-content markers). | | `check-core-portability.mjs` | Scans `@rezi-ui/core` for prohibited Node.js imports. | +| `check-native-vendor-integrity.mjs` | Verifies native vendor source wiring and `VENDOR_COMMIT.txt` pin consistency with `vendor/zireael`. | | `check-unicode-sync.mjs` | Verifies Unicode table versions are consistent. | | `check-create-rezi-templates.mjs` | Validates scaffolding templates are up to date. | | `verify-native-pack.mjs` | Checks native package contents before npm publish. | @@ -152,9 +153,11 @@ Build, test, and CI automation scripts. ## vendor/zireael The Zireael C rendering engine, pinned as a git submodule. This is the upstream -source used by `@rezi-ui/native` for compilation. The native package keeps its -own vendored snapshot at `packages/native/vendor/zireael` as the compile-time -source. +reference tree. `@rezi-ui/native` compiles from the package-local snapshot at +`packages/native/vendor/zireael` (see `packages/native/build.rs`). + +`packages/native/vendor/VENDOR_COMMIT.txt` must match the repo gitlink pointer +for `vendor/zireael`; CI enforces this via `npm run check:native-vendor`. Initialize with: diff --git a/package.json b/package.json index 5c17a608..d4ce8332 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "check:create-rezi-templates": "node scripts/check-create-rezi-templates.mjs", "check:core-portability": "node scripts/check-core-portability.mjs", "check:docs": "npm run docs:build", + "check:native-vendor": "node scripts/check-native-vendor-integrity.mjs", "check:unicode": "node scripts/check-unicode-sync.mjs", - "check": "npm run codegen:check && npm run check:create-rezi-templates && npm run check:core-portability && npm run check:docs && npm run check:unicode", + "check": "npm run codegen:check && npm run check:create-rezi-templates && npm run check:core-portability && npm run check:native-vendor && npm run check:docs && npm run check:unicode", "test": "node scripts/run-tests.mjs", "test:e2e": "node scripts/run-e2e.mjs", "test:e2e:reduced": "node scripts/run-e2e.mjs --profile reduced", diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index d80ec1b6..b034557a 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -203,19 +203,17 @@ function readBackendPositiveIntMarker( return value; } -function readBackendDrawlistVersionMarker(backend: RuntimeBackend): 1 | 2 | 3 | 4 | 5 | null { +function readBackendDrawlistVersionMarker(backend: RuntimeBackend): 1 | null { const value = (backend as RuntimeBackend & Readonly>)[ BACKEND_DRAWLIST_VERSION_MARKER ]; if (value === undefined) return null; - if ( - typeof value !== "number" || - !Number.isInteger(value) || - (value !== 1 && value !== 2 && value !== 3 && value !== 4 && value !== 5) - ) { - invalidProps(`backend marker ${BACKEND_DRAWLIST_VERSION_MARKER} must be an integer in [1..5]`); - } - return value as 1 | 2 | 3 | 4 | 5; + if (value !== 1) { + invalidProps( + `backend marker ${BACKEND_DRAWLIST_VERSION_MARKER} must be 1 (received ${String(value)})`, + ); + } + return 1; } function monotonicNowMs(): number { diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 6af1f033..33cf7589 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -23,8 +23,10 @@ import type { CursorShape } from "../abi.js"; import { + BACKEND_BEGIN_FRAME_MARKER, BACKEND_RAW_WRITE_MARKER, FRAME_ACCEPTED_ACK_MARKER, + type BackendBeginFrame, type BackendRawWrite, type RuntimeBackend, } from "../backend.js"; @@ -1401,8 +1403,13 @@ export class WidgetRenderer { } } - private cleanupUnmountedInstanceIds(unmountedInstanceIds: readonly InstanceId[]): void { + private cleanupUnmountedInstanceIds( + unmountedInstanceIds: readonly InstanceId[], + opts: Readonly<{ skipIds?: ReadonlySet }> = {}, + ): void { + const skipIds = opts.skipIds; for (const unmountedId of unmountedInstanceIds) { + if (skipIds?.has(unmountedId)) continue; this.inputCursorByInstanceId.delete(unmountedId); this.inputSelectionByInstanceId.delete(unmountedId); this.inputWorkingValueByInstanceId.delete(unmountedId); @@ -1423,12 +1430,11 @@ export class WidgetRenderer { private scheduleExitAnimations( pendingExitAnimations: readonly PendingExitAnimation[], frameNowMs: number, - prevRuntimeRoot: RuntimeInstance | null, - prevLayoutRoot: LayoutTree | null, + prevLayoutSubtreeByInstanceId: ReadonlyMap | null, ): void { if (pendingExitAnimations.length === 0) return; - if (!prevRuntimeRoot || !prevLayoutRoot) { + if (!prevLayoutSubtreeByInstanceId) { for (const pending of pendingExitAnimations) { pending.runDeferredLocalStateCleanup(); this.cleanupUnmountedInstanceIds(pending.subtreeInstanceIds); @@ -1436,15 +1442,10 @@ export class WidgetRenderer { return; } - this.collectLayoutSubtreeByInstanceId( - prevRuntimeRoot, - prevLayoutRoot, - this._pooledPrevLayoutSubtreeByInstanceId, - ); const missingLayout = scheduleExitAnimationsImpl({ pendingExitAnimations, frameNowMs, - layoutSubtreeByInstanceId: this._pooledPrevLayoutSubtreeByInstanceId, + layoutSubtreeByInstanceId: prevLayoutSubtreeByInstanceId, prevFrameOpacityByInstanceId: this._prevFrameOpacityByInstanceId, exitTransitionTrackByInstanceId: this.exitTransitionTrackByInstanceId, exitRenderNodeByInstanceId: this.exitRenderNodeByInstanceId, @@ -2685,6 +2686,15 @@ export class WidgetRenderer { const prevZoneMetaByIdBeforeSubmit = this.zoneMetaById; const prevCommittedRoot = this.committedRoot; const prevLayoutTree = this.layoutTree; + let prevLayoutSubtreeByInstanceId: ReadonlyMap | null = null; + if (doCommit && prevCommittedRoot && prevLayoutTree) { + this.collectLayoutSubtreeByInstanceId( + prevCommittedRoot, + prevLayoutTree, + this._pooledPrevLayoutSubtreeByInstanceId, + ); + prevLayoutSubtreeByInstanceId = this._pooledPrevLayoutSubtreeByInstanceId; + } const hadRoutingWidgets = this.hadRoutingWidgets; let hasRoutingWidgets = hadRoutingWidgets; let didRoutingRebuild = false; @@ -2772,12 +2782,24 @@ export class WidgetRenderer { doLayout = true; } } - this.cleanupUnmountedInstanceIds(commitRes.unmountedInstanceIds); + let deferredExitCleanupIds: Set | null = null; + if (commitRes.pendingExitAnimations.length > 0) { + deferredExitCleanupIds = new Set(); + for (const pending of commitRes.pendingExitAnimations) { + for (const id of pending.subtreeInstanceIds) { + deferredExitCleanupIds.add(id); + } + } + } + + this.cleanupUnmountedInstanceIds( + commitRes.unmountedInstanceIds, + deferredExitCleanupIds ? { skipIds: deferredExitCleanupIds } : undefined, + ); this.scheduleExitAnimations( commitRes.pendingExitAnimations, frameNowMs, - prevCommittedRoot, - prevLayoutTree, + prevLayoutSubtreeByInstanceId, ); this.cancelExitTransitionsForReappearedKeys(this.committedRoot); @@ -3939,16 +3961,43 @@ export class WidgetRenderer { } perfMarkEnd("render", renderToken); + let submittedBytes: Uint8Array = new Uint8Array(0); + let inFlight: Promise | null = null; const buildToken = perfMarkStart("drawlist_build"); - const built = this.builder.build(); - perfMarkEnd("drawlist_build", buildToken); - if (!built.ok) { - return { - ok: false, - code: "ZRUI_DRAWLIST_BUILD_ERROR", - detail: `${built.error.code}: ${built.error.detail}`, - }; + const beginFrame = ( + this.backend as RuntimeBackend & + Partial> + )[BACKEND_BEGIN_FRAME_MARKER]; + if (typeof beginFrame === "function") { + const frameWriter = beginFrame(); + if (frameWriter) { + const builtInto = this.builder.buildInto(frameWriter.buf); + if (!builtInto.ok) { + frameWriter.abort(); + perfMarkEnd("drawlist_build", buildToken); + return { + ok: false, + code: "ZRUI_DRAWLIST_BUILD_ERROR", + detail: `${builtInto.error.code}: ${builtInto.error.detail}`, + }; + } + submittedBytes = builtInto.bytes; + inFlight = frameWriter.commit(submittedBytes.byteLength); + } } + if (!inFlight) { + const built = this.builder.build(); + if (!built.ok) { + perfMarkEnd("drawlist_build", buildToken); + return { + ok: false, + code: "ZRUI_DRAWLIST_BUILD_ERROR", + detail: `${built.error.code}: ${built.error.detail}`, + }; + } + submittedBytes = built.bytes; + } + perfMarkEnd("drawlist_build", buildToken); this.clearRuntimeDirtyNodes(this.committedRoot); if (captureRuntimeBreadcrumbs) { this.updateRuntimeBreadcrumbSnapshot({ @@ -3990,7 +4039,7 @@ export class WidgetRenderer { try { const backendToken = PERF_ENABLED ? perfMarkStart("backend_request") : 0; try { - const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(built.bytes) : null; + const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(submittedBytes) : null; if (fingerprint !== null) { emitFrameAudit("widgetRenderer", "drawlist.built", { tick, @@ -4013,7 +4062,10 @@ export class WidgetRenderer { ); } } - const inFlight = this.backend.requestFrame(built.bytes); + if (!inFlight) { + inFlight = this.backend.requestFrame(submittedBytes); + } + const inflightPromise = inFlight; if (fingerprint !== null) { emitFrameAudit("widgetRenderer", "backend.request", { tick, @@ -4022,7 +4074,7 @@ export class WidgetRenderer { byteLen: fingerprint.byteLen, }); const acceptedAck = ( - inFlight as Promise & + inflightPromise as Promise & Partial>> )[FRAME_ACCEPTED_ACK_MARKER]; if (acceptedAck !== undefined) { @@ -4040,7 +4092,7 @@ export class WidgetRenderer { }), ); } - void inFlight.then( + void inflightPromise.then( () => emitFrameAudit("widgetRenderer", "backend.completed", { tick, @@ -4054,7 +4106,7 @@ export class WidgetRenderer { }), ); } - return { ok: true, inFlight }; + return { ok: true, inFlight: inflightPromise }; } finally { if (PERF_ENABLED) perfMarkEnd("backend_request", backendToken); } diff --git a/packages/core/src/app/widgetRenderer/damageTracking.ts b/packages/core/src/app/widgetRenderer/damageTracking.ts index 2c37d1ef..5ae6f7eb 100644 --- a/packages/core/src/app/widgetRenderer/damageTracking.ts +++ b/packages/core/src/app/widgetRenderer/damageTracking.ts @@ -395,7 +395,40 @@ export function computeIdentityDiffDamage( const prevNode = params.pooledPrevRuntimeStack.pop(); const nextNode = params.pooledRuntimeStack.pop(); if (!prevNode || !nextNode) continue; - if (prevNode === nextNode) continue; + + if (prevNode === nextNode) { + if (!nextNode.dirty) continue; + + const nextKind = nextNode.vnode.kind; + if (nextNode.selfDirty) { + routingRelevantChanged = + collectSubtreeDamageAndRouting( + nextNode, + params.pooledChangedRenderInstanceIds, + params.pooledDamageRuntimeStack, + ) || routingRelevantChanged; + continue; + } + + if (isRoutingRelevantKind(nextKind)) routingRelevantChanged = true; + if (nextNode.children.length === 0) { + params.pooledChangedRenderInstanceIds.push(nextNode.instanceId); + continue; + } + + let pushedDirtyChild = false; + for (let i = nextNode.children.length - 1; i >= 0; i--) { + const child = nextNode.children[i]; + if (!child || !child.dirty) continue; + pushedDirtyChild = true; + params.pooledPrevRuntimeStack.push(child); + params.pooledRuntimeStack.push(child); + } + if (!pushedDirtyChild) { + params.pooledChangedRenderInstanceIds.push(nextNode.instanceId); + } + continue; + } const prevKind = prevNode.vnode.kind; const nextKind = nextNode.vnode.kind; @@ -431,7 +464,14 @@ export function computeIdentityDiffDamage( for (let i = sharedCount - 1; i >= 0; i--) { const prevChild = prevChildren[i]; const nextChild = nextChildren[i]; - if (!prevChild || !nextChild || prevChild === nextChild) continue; + if (!prevChild || !nextChild) continue; + if (prevChild === nextChild) { + if (!nextChild.dirty) continue; + hadChildChanges = true; + params.pooledPrevRuntimeStack.push(prevChild); + params.pooledRuntimeStack.push(nextChild); + continue; + } hadChildChanges = true; params.pooledPrevRuntimeStack.push(prevChild); params.pooledRuntimeStack.push(nextChild); diff --git a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts index 3ca42bd5..cec7a558 100644 --- a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts @@ -1,4 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + OP_DEF_BLOB, + OP_DEF_STRING, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildResult } from "../types.js"; @@ -8,41 +15,30 @@ const HEADER = { CMD_BYTES: 20, CMD_COUNT: 24, STRINGS_SPAN_OFFSET: 28, - STRINGS_COUNT: 32, STRINGS_BYTES_OFFSET: 36, - STRINGS_BYTES_LEN: 40, BLOBS_SPAN_OFFSET: 44, - BLOBS_COUNT: 48, BLOBS_BYTES_OFFSET: 52, - BLOBS_BYTES_LEN: 56, SIZE: 64, } as const; -const CMD = { - SIZE: 4, - HEADER_SIZE: 8, -} as const; - -const SPAN_SIZE = 8; - type ParsedHeader = Readonly<{ totalSize: number; cmdOffset: number; cmdBytes: number; cmdCount: number; stringsSpanOffset: number; - stringsCount: number; stringsBytesOffset: number; - stringsBytesLen: number; blobsSpanOffset: number; - blobsCount: number; blobsBytesOffset: number; - blobsBytesLen: number; }>; -function align4(n: number): number { - return (n + 3) & ~3; -} +type DefString = Readonly<{ + offset: number; + size: number; + id: number; + byteLen: number; + payloadStart: number; +}>; function toView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -56,13 +52,9 @@ function parseHeader(bytes: Uint8Array): ParsedHeader { cmdBytes: dv.getUint32(HEADER.CMD_BYTES, true), cmdCount: dv.getUint32(HEADER.CMD_COUNT, true), stringsSpanOffset: dv.getUint32(HEADER.STRINGS_SPAN_OFFSET, true), - stringsCount: dv.getUint32(HEADER.STRINGS_COUNT, true), stringsBytesOffset: dv.getUint32(HEADER.STRINGS_BYTES_OFFSET, true), - stringsBytesLen: dv.getUint32(HEADER.STRINGS_BYTES_LEN, true), blobsSpanOffset: dv.getUint32(HEADER.BLOBS_SPAN_OFFSET, true), - blobsCount: dv.getUint32(HEADER.BLOBS_COUNT, true), blobsBytesOffset: dv.getUint32(HEADER.BLOBS_BYTES_OFFSET, true), - blobsBytesLen: dv.getUint32(HEADER.BLOBS_BYTES_LEN, true), }; } @@ -72,32 +64,20 @@ function expectOk(result: DrawlistBuildResult): Uint8Array { return result.bytes; } -function readStringSpan( - dv: DataView, - stringsSpanOffset: number, - index: number, -): { off: number; len: number } { - const spanOff = stringsSpanOffset + index * SPAN_SIZE; - return { - off: dv.getUint32(spanOff, true), - len: dv.getUint32(spanOff + 4, true), - }; -} - -function commandStarts(bytes: Uint8Array, h: ParsedHeader): readonly number[] { - const dv = toView(bytes); - if (h.cmdCount === 0) return []; - - let cursor = h.cmdOffset; - const starts: number[] = []; - for (let i = 0; i < h.cmdCount; i++) { - starts.push(cursor); - const size = dv.getUint32(cursor + CMD.SIZE, true); - assert.equal(size >= CMD.HEADER_SIZE, true, `command ${i} has invalid size`); - cursor += align4(size); - } - assert.equal(cursor, h.cmdOffset + h.cmdBytes); - return starts; +function readDefStrings(bytes: Uint8Array): readonly DefString[] { + const headers = parseCommandHeaders(bytes); + return headers + .filter((cmd) => cmd.opcode === OP_DEF_STRING) + .map((cmd) => { + const dv = toView(bytes); + return { + offset: cmd.offset, + size: cmd.size, + id: dv.getUint32(cmd.offset + 8, true), + byteLen: dv.getUint32(cmd.offset + 12, true), + payloadStart: cmd.offset + 16, + }; + }); } describe("DrawlistBuilder - alignment and padding", () => { @@ -116,7 +96,7 @@ describe("DrawlistBuilder - alignment and padding", () => { assert.equal(h.blobsBytesOffset, 0); }); - test("near-empty clear drawlist keeps command start and section layout aligned", () => { + test("near-empty clear drawlist keeps command section aligned", () => { const b = createDrawlistBuilder(); b.clear(); const bytes = expectOk(b.build()); @@ -126,8 +106,6 @@ describe("DrawlistBuilder - alignment and padding", () => { assert.equal((h.cmdOffset & 3) === 0, true); assert.equal((h.cmdBytes & 3) === 0, true); assert.equal(h.cmdCount, 1); - assert.equal(h.stringsCount, 0); - assert.equal(h.blobsCount, 0); }); test("all command starts are 4-byte aligned in a mixed stream", () => { @@ -137,159 +115,135 @@ describe("DrawlistBuilder - alignment and padding", () => { b.drawText(1, 1, "abc"); b.pushClip(0, 0, 3, 2); b.popClip(); - const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const starts = commandStarts(bytes, h); - assert.equal(starts.length, h.cmdCount); - for (const start of starts) { - assert.equal((start & 3) === 0, true); + const bytes = expectOk(b.build()); + const headers = parseCommandHeaders(bytes); + for (const cmd of headers) { + assert.equal((cmd.offset & 3) === 0, true); + assert.equal((cmd.size & 3) === 0, true); } }); - test("walking command sizes lands exactly on cmdOffset + cmdBytes", () => { + test("mixed text/blob frame emits DEF_STRING and DEF_BLOB before draw commands", () => { const b = createDrawlistBuilder(); - const blobIndex = b.addBlob(new Uint8Array([1, 2, 3, 4])); + const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); assert.equal(blobIndex, 0); b.clear(); b.drawText(0, 0, "x"); b.drawTextRun(2, 1, 0); - const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const starts = commandStarts(bytes, h); - assert.equal(starts.length, 3); - assert.equal(starts[0], HEADER.SIZE); - }); - - test("section offsets are aligned and ordered when strings and blobs exist", () => { - const b = createDrawlistBuilder(); - b.drawText(0, 0, "abc"); - const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); - assert.equal(blobIndex, 0); - b.drawTextRun(1, 0, 0); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); + const headers = parseCommandHeaders(bytes); + const opcodes = headers.map((cmd) => cmd.opcode); - assert.equal((h.cmdOffset & 3) === 0, true); - assert.equal((h.stringsSpanOffset & 3) === 0, true); - assert.equal((h.stringsBytesOffset & 3) === 0, true); - assert.equal((h.blobsSpanOffset & 3) === 0, true); - assert.equal((h.blobsBytesOffset & 3) === 0, true); - - assert.equal(h.stringsSpanOffset, HEADER.SIZE + h.cmdBytes); - assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + h.stringsCount * SPAN_SIZE); - assert.equal(h.blobsSpanOffset, h.stringsBytesOffset + h.stringsBytesLen); - assert.equal(h.blobsBytesOffset, h.blobsSpanOffset + h.blobsCount * SPAN_SIZE); + assert.equal(opcodes.indexOf(OP_DEF_STRING) >= 0, true); + assert.equal(opcodes.indexOf(OP_DEF_BLOB) >= 0, true); + assert.equal(opcodes.indexOf(OP_DEF_STRING) < opcodes.lastIndexOf(OP_DRAW_TEXT), true); + assert.equal(opcodes.indexOf(OP_DEF_BLOB) < opcodes.lastIndexOf(OP_DRAW_TEXT_RUN), true); }); test("odd-length text: 1-byte string gets 3 zero padding bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 1); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.id, 1); + assert.equal(def.byteLen, 1); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); test("odd-length text: 2-byte string gets 2 zero padding bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "ab"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 2); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 2); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0x62); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); test("odd-length text: 3-byte string gets 1 zero padding byte", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 3); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); - assert.equal(bytes[h.stringsBytesOffset + 2], 0x63); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 3); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0x62); + assert.equal(bytes[def.payloadStart + 2], 0x63); + assert.equal(bytes[def.payloadStart + 3], 0); }); - test("empty string still has aligned string section with zero raw bytes", () => { + test("empty string emits DEF_STRING with zero payload bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, ""); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(h.stringsCount, 1); - assert.equal((h.stringsSpanOffset & 3) === 0, true); - assert.equal((h.stringsBytesOffset & 3) === 0, true); - assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + SPAN_SIZE); - assert.equal(span.off, 0); - assert.equal(span.len, 0); - assert.equal(h.stringsBytesLen, 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 0); + assert.equal(def.size, 16); }); - test("multiple odd-length strings keep contiguous raw spans and aligned tail padding", () => { + test("multiple odd-length strings keep per-command payload and tail padding correct", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); b.drawText(0, 1, "bb"); b.drawText(0, 2, "ccc"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const s0 = readStringSpan(dv, h.stringsSpanOffset, 0); - const s1 = readStringSpan(dv, h.stringsSpanOffset, 1); - const s2 = readStringSpan(dv, h.stringsSpanOffset, 2); - - assert.equal(s0.off, 0); - assert.equal(s0.len, 1); - assert.equal(s1.off, 1); - assert.equal(s1.len, 2); - assert.equal(s2.off, 3); - assert.equal(s2.len, 3); - - assert.equal(h.stringsBytesLen, 8); - assert.equal(bytes[h.stringsBytesOffset + 6], 0); - assert.equal(bytes[h.stringsBytesOffset + 7], 0); + const defs = readDefStrings(bytes); + + assert.equal(defs.length, 3); + const d0 = defs[0]; + const d1 = defs[1]; + const d2 = defs[2]; + if (!d0 || !d1 || !d2) return; + + assert.equal(d0.byteLen, 1); + assert.equal(d0.size, 20); + assert.equal(bytes[d0.payloadStart + 1], 0); + assert.equal(bytes[d0.payloadStart + 2], 0); + assert.equal(bytes[d0.payloadStart + 3], 0); + + assert.equal(d1.byteLen, 2); + assert.equal(d1.size, 20); + assert.equal(bytes[d1.payloadStart + 2], 0); + assert.equal(bytes[d1.payloadStart + 3], 0); + + assert.equal(d2.byteLen, 3); + assert.equal(d2.size, 20); + assert.equal(bytes[d2.payloadStart + 3], 0); }); test("reuseOutputBuffer keeps odd-string padding zeroed across reset/build cycles", () => { const b = createDrawlistBuilder({ reuseOutputBuffer: true }); - b.drawText(0, 0, "abcd"); expectOk(b.build()); b.reset(); b.drawText(0, 0, "a"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 1); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.golden.test.ts b/packages/core/src/drawlist/__tests__/builder.golden.test.ts index a96f182e..73125ba9 100644 --- a/packages/core/src/drawlist/__tests__/builder.golden.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.golden.test.ts @@ -87,9 +87,9 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { assertBytesEqual(res.bytes, expected, "fill_rect.bin"); assertHeader(res.bytes, { - totalSize: 104, + totalSize: 116, cmdOffset: 64, - cmdBytes: 40, + cmdBytes: 52, cmdCount: 1, stringsSpanOffset: 0, stringsCount: 0, @@ -115,14 +115,14 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { assertBytesEqual(res.bytes, expected, "draw_text_interned.bin"); assertHeader(res.bytes, { - totalSize: 236, + totalSize: 292, cmdOffset: 64, - cmdBytes: 144, - cmdCount: 3, - stringsSpanOffset: 208, - stringsCount: 2, - stringsBytesOffset: 224, - stringsBytesLen: 12, + cmdBytes: 228, + cmdCount: 5, + stringsSpanOffset: 0, + stringsCount: 0, + stringsBytesOffset: 0, + stringsBytesLen: 0, blobsSpanOffset: 0, blobsCount: 0, blobsBytesOffset: 0, @@ -145,9 +145,9 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { assertBytesEqual(res.bytes, expected, "clip_nested.bin"); assertHeader(res.bytes, { - totalSize: 168, + totalSize: 180, cmdOffset: 64, - cmdBytes: 104, + cmdBytes: 116, cmdCount: 5, stringsSpanOffset: 0, stringsCount: 0, diff --git a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts index 9d7a232a..a9930bc7 100644 --- a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts @@ -5,6 +5,9 @@ const OP_DRAW_TEXT = 3; const OP_DRAW_TEXT_RUN = 6; const OP_DRAW_CANVAS = 8; const OP_DRAW_IMAGE = 9; +const OP_DEF_STRING = 10; +const OP_DEF_BLOB = 12; +const LINK_MAX_BYTES = 2083; function u8(bytes: Uint8Array, off: number): number { return bytes[off] ?? 0; @@ -34,14 +37,6 @@ type Header = Readonly<{ cmdOffset: number; cmdBytes: number; cmdCount: number; - stringsSpanOffset: number; - stringsCount: number; - stringsBytesOffset: number; - stringsBytesLen: number; - blobsSpanOffset: number; - blobsCount: number; - blobsBytesOffset: number; - blobsBytesLen: number; }>; type Command = Readonly<{ @@ -57,14 +52,6 @@ function readHeader(bytes: Uint8Array): Header { cmdOffset: u32(bytes, 16), cmdBytes: u32(bytes, 20), cmdCount: u32(bytes, 24), - stringsSpanOffset: u32(bytes, 28), - stringsCount: u32(bytes, 32), - stringsBytesOffset: u32(bytes, 36), - stringsBytesLen: u32(bytes, 40), - blobsSpanOffset: u32(bytes, 44), - blobsCount: u32(bytes, 48), - blobsBytesOffset: u32(bytes, 52), - blobsBytesLen: u32(bytes, 56), }; } @@ -92,13 +79,36 @@ function parseCommands(bytes: Uint8Array): readonly Command[] { return Object.freeze(out); } -function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const byteOff = u32(bytes, spanOff); - const byteLen = u32(bytes, spanOff + 4); - const start = h.stringsBytesOffset + byteOff; - const end = start + byteLen; - return new TextDecoder().decode(bytes.subarray(start, end)); +type DefTables = Readonly<{ + stringsById: ReadonlyMap; + blobsById: ReadonlyMap; +}>; + +function decodeDefs(bytes: Uint8Array): DefTables { + const stringsById = new Map(); + const blobsById = new Map(); + const decoder = new TextDecoder(); + + for (const cmd of parseCommands(bytes)) { + if (cmd.opcode === OP_DEF_STRING) { + const stringId = u32(bytes, cmd.payloadOff + 0); + const byteLen = u32(bytes, cmd.payloadOff + 4); + const data = bytes.subarray(cmd.payloadOff + 8, cmd.payloadOff + 8 + byteLen); + stringsById.set(stringId, decoder.decode(data)); + continue; + } + if (cmd.opcode === OP_DEF_BLOB) { + const blobId = u32(bytes, cmd.payloadOff + 0); + const byteLen = u32(bytes, cmd.payloadOff + 4); + const data = bytes.slice(cmd.payloadOff + 8, cmd.payloadOff + 8 + byteLen); + blobsById.set(blobId, data); + } + } + + return Object.freeze({ + stringsById, + blobsById, + }); } function assertBadParams( @@ -110,7 +120,7 @@ function assertBadParams( } describe("DrawlistBuilder graphics/link commands", () => { - test("encodes v5 header with DRAW_CANVAS and DRAW_IMAGE (links via style ext)", () => { + test("encodes v1 header with DRAW_CANVAS and DRAW_IMAGE (links via style ext)", () => { const builder = createDrawlistBuilder(); builder.setLink("https://example.com", "docs"); builder.drawText(0, 0, "Docs"); @@ -130,11 +140,11 @@ describe("DrawlistBuilder graphics/link commands", () => { if (!built.ok) throw new Error("build failed"); assert.equal(u32(built.bytes, 0), ZRDL_MAGIC); - assert.equal(u32(built.bytes, 4), 5); - assert.deepEqual( - parseCommands(built.bytes).map((cmd) => cmd.opcode), - [OP_DRAW_TEXT, OP_DRAW_CANVAS, OP_DRAW_IMAGE], - ); + assert.equal(u32(built.bytes, 4), 1); + const drawOps = parseCommands(built.bytes) + .filter((cmd) => cmd.opcode !== OP_DEF_STRING && cmd.opcode !== OP_DEF_BLOB) + .map((cmd) => cmd.opcode); + assert.deepEqual(drawOps, [OP_DRAW_TEXT, OP_DRAW_CANVAS, OP_DRAW_IMAGE]); }); test("setLink state is encoded into drawText style ext references", () => { @@ -145,8 +155,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const h = readHeader(built.bytes); - const cmd = parseCommands(built.bytes)[0]; + const defs = decodeDefs(built.bytes); + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing drawText command"); assert.equal(cmd.opcode, OP_DRAW_TEXT); assert.equal(cmd.flags, 0); @@ -158,8 +168,8 @@ describe("DrawlistBuilder graphics/link commands", () => { const linkIdRef = u32(built.bytes, cmd.payloadOff + 44); assert.equal(linkUriRef > 0, true); assert.equal(linkIdRef > 0, true); - assert.equal(decodeString(built.bytes, h, linkUriRef - 1), "https://example.com"); - assert.equal(decodeString(built.bytes, h, linkIdRef - 1), "docs"); + assert.equal(defs.stringsById.get(linkUriRef) ?? "", "https://example.com"); + assert.equal(defs.stringsById.get(linkIdRef) ?? "", "docs"); }); test("setLink(null) clears hyperlink refs for subsequent text", () => { @@ -183,6 +193,39 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u32(built.bytes, second.payloadOff + 44), 0); }); + test("setLink rejects empty URI (URI must be non-empty or null)", () => { + const builder = createDrawlistBuilder(); + builder.setLink("", "docs"); + + assertBadParams(builder.build()); + }); + + test("setLink enforces URI/ID max byte caps using UTF-8 byte length", () => { + const oversizedUtf8 = "é".repeat(1042); + assert.equal(new TextEncoder().encode(oversizedUtf8).byteLength, LINK_MAX_BYTES + 1); + + const tooLongUriBuilder = createDrawlistBuilder(); + tooLongUriBuilder.setLink(oversizedUtf8); + assertBadParams(tooLongUriBuilder.build()); + + const tooLongIdBuilder = createDrawlistBuilder(); + tooLongIdBuilder.setLink("https://example.com/docs", oversizedUtf8); + assertBadParams(tooLongIdBuilder.build()); + }); + + test("setLink accepts URI/ID exactly at native byte limits", () => { + const uri = `${"é".repeat(1041)}a`; + const id = `${"é".repeat(1041)}b`; + assert.equal(new TextEncoder().encode(uri).byteLength, LINK_MAX_BYTES); + assert.equal(new TextEncoder().encode(id).byteLength, LINK_MAX_BYTES); + + const builder = createDrawlistBuilder(); + builder.setLink(uri, id); + builder.drawText(0, 0, "x"); + const built = builder.build(); + assert.equal(built.ok, true); + }); + test("encodes DRAW_CANVAS payload fields and blob offset/length", () => { const builder = createDrawlistBuilder(); const blob0 = builder.addBlob(new Uint8Array([1, 2, 3, 4])); @@ -196,8 +239,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const h = readHeader(built.bytes); - const cmd = parseCommands(built.bytes)[0]; + const defs = decodeDefs(built.bytes); + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_CANVAS); if (!cmd) throw new Error("missing command"); assert.equal(cmd.opcode, OP_DRAW_CANVAS); @@ -209,15 +252,14 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u16(built.bytes, cmd.payloadOff + 6), 2); assert.equal(u16(built.bytes, cmd.payloadOff + 8), 6); assert.equal(u16(built.bytes, cmd.payloadOff + 10), 6); - assert.equal(u32(built.bytes, cmd.payloadOff + 12), 4); - assert.equal(u32(built.bytes, cmd.payloadOff + 16), 6 * 6 * 4); + assert.equal(u32(built.bytes, cmd.payloadOff + 12), 2); + assert.equal(u32(built.bytes, cmd.payloadOff + 16), 0); assert.equal(u8(built.bytes, cmd.payloadOff + 20), 3); assert.equal(u8(built.bytes, cmd.payloadOff + 21), 0); assert.equal(u16(built.bytes, cmd.payloadOff + 22), 0); - assert.equal(h.blobsCount, 2); - assert.equal(u32(built.bytes, h.blobsSpanOffset + 8), 4); - assert.equal(u32(built.bytes, h.blobsSpanOffset + 12), 6 * 6 * 4); + assert.equal(defs.blobsById.get(1)?.byteLength ?? 0, 4); + assert.equal(defs.blobsById.get(2)?.byteLength ?? 0, 6 * 6 * 4); }); test("encodes DRAW_IMAGE payload fields", () => { @@ -231,7 +273,7 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_IMAGE); if (!cmd) throw new Error("missing command"); assert.equal(cmd.opcode, OP_DRAW_IMAGE); @@ -243,8 +285,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u16(built.bytes, cmd.payloadOff + 6), 8); assert.equal(u16(built.bytes, cmd.payloadOff + 8), 2); assert.equal(u16(built.bytes, cmd.payloadOff + 10), 2); - assert.equal(u32(built.bytes, cmd.payloadOff + 12), 0); - assert.equal(u32(built.bytes, cmd.payloadOff + 16), 16); + assert.equal(u32(built.bytes, cmd.payloadOff + 12), 1); + assert.equal(u32(built.bytes, cmd.payloadOff + 16), 0); assert.equal(u32(built.bytes, cmd.payloadOff + 20), 42); assert.equal(u8(built.bytes, cmd.payloadOff + 24), 0); assert.equal(u8(built.bytes, cmd.payloadOff + 25), 1); @@ -280,7 +322,7 @@ describe("DrawlistBuilder graphics/link commands", () => { { const builder = createDrawlistBuilder(); - const blobIndex = builder.addBlob(new Uint8Array(8)); + const blobIndex = builder.addBlob(new Uint8Array(10)); if (blobIndex === null) throw new Error("blob index was null"); builder.drawImage(0, 0, 1, 1, blobIndex, "rgba", "auto", 0, "contain", 0); assertBadParams(builder.build()); @@ -347,22 +389,23 @@ describe("DrawlistBuilder graphics/link commands", () => { { const builder = createDrawlistBuilder(); - assert.equal(builder.addBlob(new Uint8Array([1, 2, 3])), null); + // @ts-expect-error runtime invalid param coverage + assert.equal(builder.addBlob("not-bytes"), null); assertBadParams(builder.build()); } }); - test("encodes underline style + underline RGB in drawText v3 style fields", () => { + test("encodes underline style + numeric underline RGB in drawText v3 style fields", () => { const builder = createDrawlistBuilder(); builder.drawText(0, 0, "x", { underline: true, underlineStyle: "curly", - underlineColor: "#ff0000", + underlineColor: 0xff0000, }); const built = builder.build(); assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing command"); const reserved = u32(built.bytes, cmd.payloadOff + 32); const underlineRgb = u32(built.bytes, cmd.payloadOff + 36); @@ -380,7 +423,7 @@ describe("DrawlistBuilder graphics/link commands", () => { const built = builder.build(); assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing command"); const underlineRgb = u32(built.bytes, cmd.payloadOff + 36); assert.equal(underlineRgb, 0); @@ -405,11 +448,12 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const h = readHeader(built.bytes); - const blobOffset = u32(built.bytes, h.blobsSpanOffset); - const segmentOff = h.blobsBytesOffset + blobOffset + 4; - const reserved = u32(built.bytes, segmentOff + 12); - const underlineRgb = u32(built.bytes, segmentOff + 16); + const defs = decodeDefs(built.bytes); + const blob = defs.blobsById.get(1); + if (!blob) throw new Error("missing text run blob"); + const segmentOff = 4; + const reserved = u32(blob, segmentOff + 12); + const underlineRgb = u32(blob, segmentOff + 16); assert.equal(reserved & 0x7, 5); assert.equal(underlineRgb, 0x010203); }); diff --git a/packages/core/src/drawlist/__tests__/builder.limits.test.ts b/packages/core/src/drawlist/__tests__/builder.limits.test.ts index 81aed3de..3dc3b0b0 100644 --- a/packages/core/src/drawlist/__tests__/builder.limits.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.limits.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildErrorCode, DrawlistBuildResult } from "../types.js"; @@ -18,9 +19,9 @@ const HEADER = { SIZE: 64, } as const; -const SPAN_SIZE = 8; const CMD_SIZE_CLEAR = 8; -const CMD_SIZE_DRAW_TEXT = 8 + 40; +const CMD_SIZE_DRAW_TEXT = 8 + 52; +const CMD_SIZE_DEF_STRING_BASE = 16; type ParsedHeader = Readonly<{ totalSize: number; @@ -103,8 +104,8 @@ describe("DrawlistBuilder - limits boundaries", () => { const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.stringsCount, 2); - assert.equal(h.cmdCount, 2); + assert.deepEqual(parseInternedStrings(bytes), ["a", "b"]); + assert.equal(h.cmdCount, 4); }); test("maxStrings: interned duplicates do not consume extra slots", () => { @@ -114,8 +115,8 @@ describe("DrawlistBuilder - limits boundaries", () => { const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.stringsCount, 1); - assert.equal(h.cmdCount, 2); + assert.deepEqual(parseInternedStrings(bytes), ["same"]); + assert.equal(h.cmdCount, 3); }); test("maxStrings: overflow on next unique string fails", () => { @@ -131,12 +132,12 @@ describe("DrawlistBuilder - limits boundaries", () => { b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - const dv = toView(bytes); - const spanLen = dv.getUint32(h.stringsSpanOffset + 4, true); + const drawText = parseDrawTextCommands(bytes); - assert.equal(h.stringsCount, 1); - assert.equal(spanLen, 3); - assert.equal(h.stringsBytesLen, 4); + assert.deepEqual(parseInternedStrings(bytes), ["abc"]); + assert.equal(drawText.length, 1); + assert.equal(drawText[0]?.byteLen, 3); + assert.equal(h.cmdBytes, align4(CMD_SIZE_DEF_STRING_BASE + 3) + CMD_SIZE_DRAW_TEXT); }); test("maxStringBytes: exactly-at-limit UTF-8 payload succeeds", () => { @@ -146,12 +147,11 @@ describe("DrawlistBuilder - limits boundaries", () => { b.drawText(0, 0, text); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - const dv = toView(bytes); - const spanLen = dv.getUint32(h.stringsSpanOffset + 4, true); + const drawText = parseDrawTextCommands(bytes); assert.equal(utf8Len, 3); - assert.equal(spanLen, utf8Len); - assert.equal(h.stringsBytesLen, 4); + assert.equal(drawText[0]?.byteLen, utf8Len); + assert.equal(h.cmdBytes, align4(CMD_SIZE_DEF_STRING_BASE + utf8Len) + CMD_SIZE_DRAW_TEXT); }); test("maxStringBytes: overflow fails", () => { @@ -181,20 +181,19 @@ describe("DrawlistBuilder - limits boundaries", () => { test("maxDrawlistBytes: exact text drawlist boundary succeeds", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit }); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); assert.equal(h.totalSize, exactLimit); - assert.equal(h.cmdBytes, CMD_SIZE_DRAW_TEXT); - assert.equal(h.stringsBytesLen, 4); + assert.equal(h.cmdBytes, exactLimit - HEADER.SIZE); }); test("maxDrawlistBytes: one byte below text drawlist boundary fails", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit - 1 }); b.drawText(0, 0, "abc"); @@ -230,8 +229,8 @@ describe("DrawlistBuilder - limits boundaries", () => { const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.cmdCount, 64); - assert.equal(h.stringsCount, 64); + assert.equal(h.cmdCount, 128); + assert.equal(parseInternedStrings(bytes).length, 64); assert.equal(h.totalSize <= 1_000_000, true); assert.equal((h.totalSize & 3) === 0, true); }); diff --git a/packages/core/src/drawlist/__tests__/builder.reset.test.ts b/packages/core/src/drawlist/__tests__/builder.reset.test.ts index d9ed10aa..d6f1ce9a 100644 --- a/packages/core/src/drawlist/__tests__/builder.reset.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.reset.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; @@ -8,8 +9,8 @@ const OP_CLEAR = 1; const OP_FILL_RECT = 2; const OP_DRAW_TEXT = 3; const OP_SET_CURSOR = 7; - -const decoder = new TextDecoder(); +const OP_FREE_STRING = 11; +const OP_FREE_BLOB = 13; type Header = Readonly<{ totalSize: number; @@ -49,10 +50,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -function align4(n: number): number { - return (n + 3) & ~3; -} - function readHeader(bytes: Uint8Array): Header { return { totalSize: u32(bytes, 12), @@ -91,14 +88,6 @@ function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { return out; } -function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const off = u32(bytes, spanOff); - const len = u32(bytes, spanOff + 4); - const start = h.stringsBytesOffset + off; - return decoder.decode(bytes.subarray(start, start + len)); -} - describe("DrawlistBuilder reset behavior", () => { test("v1 reset clears prior commands/strings/blobs for next frame", () => { const b = createDrawlistBuilder(); @@ -113,9 +102,8 @@ describe("DrawlistBuilder reset behavior", () => { if (!first.ok) return; const h1 = readHeader(first.bytes); - assert.equal(h1.cmdCount, 2); - assert.equal(h1.stringsCount, 3); - assert.equal(h1.blobsCount, 1); + assert.equal(h1.cmdCount, 6); + assert.deepEqual(parseInternedStrings(first.bytes), ["A", "B", "frame0"]); b.reset(); b.clear(); @@ -124,11 +112,14 @@ describe("DrawlistBuilder reset behavior", () => { if (!second.ok) return; const h2 = readHeader(second.bytes); - assert.equal(h2.cmdCount, 1); - assert.equal(h2.cmdBytes, 8); + assert.equal(h2.cmdCount, 5); + assert.equal(h2.cmdBytes, 56); assert.equal(h2.stringsCount, 0); assert.equal(h2.blobsCount, 0); - assert.equal(h2.totalSize, 72); + assert.equal(h2.totalSize, 120); + + const opcodes = parseCommands(second.bytes).map((cmd) => cmd.opcode); + assert.deepEqual(opcodes, [OP_CLEAR, OP_FREE_STRING, OP_FREE_STRING, OP_FREE_STRING, OP_FREE_BLOB]); }); test("v2 reset drops cursor and string state before next frame", () => { @@ -140,8 +131,8 @@ describe("DrawlistBuilder reset behavior", () => { if (!first.ok) return; const h1 = readHeader(first.bytes); - assert.equal(h1.cmdCount, 2); - assert.equal(h1.stringsCount, 1); + assert.equal(h1.cmdCount, 3); + assert.deepEqual(parseInternedStrings(first.bytes), ["persist"]); b.reset(); b.fillRect(0, 0, 1, 1); @@ -150,14 +141,15 @@ describe("DrawlistBuilder reset behavior", () => { if (!second.ok) return; const h2 = readHeader(second.bytes); - assert.equal(h2.cmdCount, 1); + assert.equal(h2.cmdCount, 2); assert.equal(h2.stringsCount, 0); assert.equal(h2.blobsCount, 0); const cmds = parseCommands(second.bytes); - const cmd = cmds[0]; - if (!cmd) return; - assert.equal(cmd.opcode, OP_FILL_RECT); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [OP_FILL_RECT, OP_FREE_STRING], + ); }); test("v1 reset clears sticky failure state and restores successful builds", () => { @@ -174,7 +166,8 @@ describe("DrawlistBuilder reset behavior", () => { const recovered = b.build(); assert.equal(recovered.ok, true); if (!recovered.ok) return; - assert.equal(readHeader(recovered.bytes).stringsCount, 1); + assert.equal(readHeader(recovered.bytes).cmdCount, 2); + assert.deepEqual(parseInternedStrings(recovered.bytes), ["ok"]); }); test("v2 reset clears sticky failure state and allows cursor commands again", () => { @@ -213,27 +206,26 @@ describe("DrawlistBuilder reset behavior", () => { if (!res.ok) return; const h = readHeader(res.bytes); - assert.equal(h.cmdCount, 2); - assert.equal(h.cmdBytes, 56); - assert.equal(h.stringsCount, 1); + assert.equal(h.cmdCount, 3); + assert.equal(h.cmdBytes, 88); + assert.equal(h.stringsCount, 0); assert.equal(h.blobsCount, 0); - assert.equal(h.stringsBytesLen, align4(text.length)); - assert.equal(h.totalSize, HEADER_SIZE + 56 + 8 + align4(text.length)); + assert.equal(h.totalSize, HEADER_SIZE + 88); const cmds = parseCommands(res.bytes); - const clear = cmds[0]; - const draw = cmds[1]; + const clear = cmds.find((cmd) => cmd.opcode === OP_CLEAR); + const draw = cmds.find((cmd) => cmd.opcode === OP_DRAW_TEXT); + assert.equal(clear !== undefined, true); + assert.equal(draw !== undefined, true); if (!clear || !draw) return; - assert.equal(clear.opcode, OP_CLEAR); - assert.equal(draw.opcode, OP_DRAW_TEXT); assert.equal(draw.flags, 0); - assert.equal(draw.size, 48); + assert.equal(draw.size, 60); const stringIndex = u32(res.bytes, draw.payloadOff + 8); const byteLen = u32(res.bytes, draw.payloadOff + 16); - assert.equal(stringIndex, 0); + assert.equal(stringIndex, 1); assert.equal(byteLen, text.length); - assert.equal(decodeString(res.bytes, h, 0), text); + assert.equal(parseInternedStrings(res.bytes).includes(text), true); } }); diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts index 18ce3f2e..5fbc29bb 100644 --- a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -1,19 +1,21 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_CLEAR, + OP_DEF_STRING, + OP_DRAW_TEXT, + OP_FILL_RECT, + OP_POP_CLIP, + OP_PUSH_CLIP, + OP_SET_CURSOR, + parseCommandHeaders, + parseDrawTextCommands, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; const INT32_MAX = 2147483647; -const OP_CLEAR = 1; -const OP_FILL_RECT = 2; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; -const OP_POP_CLIP = 5; -const OP_DRAW_TEXT_RUN = 6; -const OP_SET_CURSOR = 7; - -const decoder = new TextDecoder(); - type Header = Readonly<{ magic: number; version: number; @@ -41,7 +43,15 @@ type CmdHeader = Readonly<{ payloadOff: number; }>; -type PackedStyle = Readonly<{ fg: number; bg: number; attrs: number; reserved0: number }>; +type PackedStyle = Readonly<{ + fg: number; + bg: number; + attrs: number; + reserved0: number; + underlineRgb: number; + linkUriRef: number; + linkIdRef: number; +}>; function u8(bytes: Uint8Array, off: number): number { return bytes[off] ?? 0; @@ -62,10 +72,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -function align4(n: number): number { - return (n + 3) & ~3; -} - function readHeader(bytes: Uint8Array): Header { return { magic: u32(bytes, 0), @@ -87,81 +93,14 @@ function readHeader(bytes: Uint8Array): Header { }; } -function assertAligned4(label: string, value: number): void { - assert.equal(value % 4, 0, `${label} must be 4-byte aligned`); -} - -function assertHeaderLayout(bytes: Uint8Array, h: Header): void { - assert.equal(h.headerSize, HEADER_SIZE); - assert.equal(h.totalSize, bytes.byteLength); - assert.equal(h.reserved0, 0); - - let cursor = HEADER_SIZE; - - if (h.cmdCount === 0) { - assert.equal(h.cmdOffset, 0); - assert.equal(h.cmdBytes, 0); - } else { - assert.equal(h.cmdOffset, cursor); - assertAligned4("cmdOffset", h.cmdOffset); - assertAligned4("cmdBytes", h.cmdBytes); - cursor += h.cmdBytes; - } - - if (h.stringsCount === 0) { - assert.equal(h.stringsSpanOffset, 0); - assert.equal(h.stringsBytesOffset, 0); - assert.equal(h.stringsBytesLen, 0); - } else { - assert.equal(h.stringsSpanOffset, cursor); - assertAligned4("stringsSpanOffset", h.stringsSpanOffset); - cursor += h.stringsCount * 8; - - assert.equal(h.stringsBytesOffset, cursor); - assertAligned4("stringsBytesOffset", h.stringsBytesOffset); - assertAligned4("stringsBytesLen", h.stringsBytesLen); - cursor += h.stringsBytesLen; - } - - if (h.blobsCount === 0) { - assert.equal(h.blobsSpanOffset, 0); - assert.equal(h.blobsBytesOffset, 0); - assert.equal(h.blobsBytesLen, 0); - } else { - assert.equal(h.blobsSpanOffset, cursor); - assertAligned4("blobsSpanOffset", h.blobsSpanOffset); - cursor += h.blobsCount * 8; - - assert.equal(h.blobsBytesOffset, cursor); - assertAligned4("blobsBytesOffset", h.blobsBytesOffset); - assertAligned4("blobsBytesLen", h.blobsBytesLen); - cursor += h.blobsBytesLen; - } - - assert.equal(cursor, h.totalSize); -} - function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { - const h = readHeader(bytes); - if (h.cmdCount === 0) return []; - - const out: CmdHeader[] = []; - let off = h.cmdOffset; - - for (let i = 0; i < h.cmdCount; i++) { - const size = u32(bytes, off + 4); - out.push({ - off, - opcode: u16(bytes, off), - flags: u16(bytes, off + 2), - size, - payloadOff: off + 8, - }); - off += size; - } - - assert.equal(off, h.cmdOffset + h.cmdBytes); - return out; + return parseCommandHeaders(bytes).map((cmd) => ({ + off: cmd.offset, + opcode: cmd.opcode, + flags: u16(bytes, cmd.offset + 2), + size: cmd.size, + payloadOff: cmd.payloadOffset, + })); } function readStyle(bytes: Uint8Array, off: number): PackedStyle { @@ -170,25 +109,12 @@ function readStyle(bytes: Uint8Array, off: number): PackedStyle { bg: u32(bytes, off + 4), attrs: u32(bytes, off + 8), reserved0: u32(bytes, off + 12), + underlineRgb: u32(bytes, off + 16), + linkUriRef: u32(bytes, off + 20), + linkIdRef: u32(bytes, off + 24), }; } -function decodeStringSlice( - bytes: Uint8Array, - h: Header, - stringIndex: number, - byteOff: number, - byteLen: number, -): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const strOff = u32(bytes, spanOff); - const strLen = u32(bytes, spanOff + 4); - assert.equal(byteOff + byteLen <= strLen, true); - - const start = h.stringsBytesOffset + strOff + byteOff; - return decoder.decode(bytes.subarray(start, start + byteLen)); -} - function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { assert.equal(cmd.opcode, OP_SET_CURSOR); assert.equal(cmd.size, 20); @@ -203,7 +129,7 @@ function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { } describe("DrawlistBuilder round-trip binary readback", () => { - test("v1 header magic/version/counts/offsets/byte sizes are exact for mixed commands", () => { + test("header and command stream layout are exact for mixed commands", () => { const b = createDrawlistBuilder(); b.clear(); b.fillRect(1, 2, 3, 4, { @@ -223,23 +149,30 @@ describe("DrawlistBuilder round-trip binary readback", () => { const h = readHeader(res.bytes); assert.equal(h.magic, ZRDL_MAGIC); assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdOffset, 64); - assert.equal(h.cmdBytes, 128); - assert.equal(h.cmdCount, 5); - assert.equal(h.stringsSpanOffset, 192); - assert.equal(h.stringsCount, 1); - assert.equal(h.stringsBytesOffset, 200); - assert.equal(h.stringsBytesLen, 4); + assert.equal(h.headerSize, HEADER_SIZE); + assert.equal(h.totalSize, res.bytes.byteLength); + assert.equal(h.cmdOffset, HEADER_SIZE); + assert.equal(h.cmdCount, 6); + assert.equal(h.stringsSpanOffset, 0); + assert.equal(h.stringsCount, 0); + assert.equal(h.stringsBytesOffset, 0); + assert.equal(h.stringsBytesLen, 0); assert.equal(h.blobsSpanOffset, 0); assert.equal(h.blobsCount, 0); assert.equal(h.blobsBytesOffset, 0); assert.equal(h.blobsBytesLen, 0); - assert.equal(h.totalSize, 204); + assert.equal(h.reserved0, 0); - assertHeaderLayout(res.bytes, h); + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, h.cmdCount); + assert.equal(cmds.reduce((acc, cmd) => acc + cmd.size, 0), h.cmdBytes); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [OP_DEF_STRING, OP_CLEAR, OP_FILL_RECT, OP_PUSH_CLIP, OP_DRAW_TEXT, OP_POP_CLIP], + ); }); - test("v1 fillRect command readback preserves geometry and packed style", () => { + test("fillRect command readback preserves geometry and packed style", () => { const b = createDrawlistBuilder(); b.fillRect(-3, 9, 11, 13, { fg: (1 << 16) | (2 << 8) | 3, @@ -253,14 +186,12 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes)[0]; if (!cmd) return; assert.equal(cmd.opcode, OP_FILL_RECT); assert.equal(cmd.flags, 0); - assert.equal(cmd.size, 40); + assert.equal(cmd.size, 52); assert.equal(i32(res.bytes, cmd.payloadOff + 0), -3); assert.equal(i32(res.bytes, cmd.payloadOff + 4), 9); assert.equal(i32(res.bytes, cmd.payloadOff + 8), 11); @@ -271,9 +202,12 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(style.bg, 0x0004_0506); assert.equal(style.attrs, (1 << 0) | (1 << 2) | (1 << 4)); assert.equal(style.reserved0, 0); + assert.equal(style.underlineRgb, 0); + assert.equal(style.linkUriRef, 0); + assert.equal(style.linkIdRef, 0); }); - test("v1 drawText command readback resolves string span and style fields", () => { + test("drawText command readback resolves interned text and style payload", () => { const b = createDrawlistBuilder(); b.drawText(7, 9, "hello", { fg: (255 << 16) | (128 << 8) | 1, @@ -286,37 +220,35 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); + assert.equal(cmd !== undefined, true); if (!cmd) return; - assert.equal(cmd.opcode, OP_DRAW_TEXT); - assert.equal(cmd.size, 48); - - const x = i32(res.bytes, cmd.payloadOff + 0); - const y = i32(res.bytes, cmd.payloadOff + 4); - const stringIndex = u32(res.bytes, cmd.payloadOff + 8); - const byteOff = u32(res.bytes, cmd.payloadOff + 12); - const byteLen = u32(res.bytes, cmd.payloadOff + 16); - const style = readStyle(res.bytes, cmd.payloadOff + 20); - const reserved0 = u32(res.bytes, cmd.payloadOff + 36); - assert.equal(x, 7); - assert.equal(y, 9); - assert.equal(stringIndex, 0); - assert.equal(byteOff, 0); - assert.equal(byteLen, 5); + assert.equal(cmd.size, 60); + assert.equal(i32(res.bytes, cmd.payloadOff + 0), 7); + assert.equal(i32(res.bytes, cmd.payloadOff + 4), 9); + assert.equal(u32(res.bytes, cmd.payloadOff + 8), 1); + assert.equal(u32(res.bytes, cmd.payloadOff + 12), 0); + assert.equal(u32(res.bytes, cmd.payloadOff + 16), 5); + + const style = readStyle(res.bytes, cmd.payloadOff + 20); assert.equal(style.fg, 0x00ff_8001); assert.equal(style.bg, 0x0002_0304); assert.equal(style.attrs, (1 << 1) | (1 << 3)); assert.equal(style.reserved0, 0); - assert.equal(reserved0, 0); - assert.equal(decodeStringSlice(res.bytes, h, stringIndex, byteOff, byteLen), "hello"); + assert.equal(style.underlineRgb, 0); + assert.equal(style.linkUriRef, 0); + assert.equal(style.linkIdRef, 0); + assert.equal(u32(res.bytes, cmd.payloadOff + 48), 0); + + assert.deepEqual(parseInternedStrings(res.bytes), ["hello"]); + const drawText = parseDrawTextCommands(res.bytes)[0]; + assert.equal(drawText?.stringId, 1); + assert.equal(drawText?.byteLen, 5); + assert.equal(drawText?.text, "hello"); }); - test("v1 clip push/pop commands round-trip with exact payload sizes", () => { + test("clip push/pop commands round-trip with exact payload sizes", () => { const b = createDrawlistBuilder(); b.pushClip(2, 3, 4, 5); b.popClip(); @@ -342,7 +274,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(pop.size, 8); }); - test("v1 repeated text uses interned string indices deterministically", () => { + test("repeated text uses deterministic 1-based string ids", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "same"); b.drawText(0, 1, "same"); @@ -352,52 +284,15 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - assert.equal(h.stringsCount, 2); - assert.equal(h.cmdCount, 3); - assert.equal(h.cmdBytes, 144); - assert.equal(h.stringsSpanOffset, 208); - assert.equal(h.stringsBytesOffset, 224); - assert.equal(h.stringsBytesLen, 12); - assert.equal(h.totalSize, 236); - assertHeaderLayout(res.bytes, h); - - const cmds = parseCommands(res.bytes); - const c0 = cmds[0]; - const c1 = cmds[1]; - const c2 = cmds[2]; - if (!c0 || !c1 || !c2) return; - - const idx0 = u32(res.bytes, c0.payloadOff + 8); - const idx1 = u32(res.bytes, c1.payloadOff + 8); - const idx2 = u32(res.bytes, c2.payloadOff + 8); - assert.equal(idx0, 0); - assert.equal(idx1, 0); - assert.equal(idx2, 1); - assert.equal(decodeStringSlice(res.bytes, h, idx0, 0, 4), "same"); - assert.equal(decodeStringSlice(res.bytes, h, idx2, 0, 5), "other"); - }); - - test("v2 header uses version 2 and correct cmd byte/count totals", () => { - const b = createDrawlistBuilder(); - b.clear(); - b.setCursor({ x: 10, y: 5, shape: 1, visible: true, blink: false }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const h = readHeader(res.bytes); - assert.equal(h.magic, ZRDL_MAGIC); - assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdOffset, 64); - assert.equal(h.cmdBytes, 28); - assert.equal(h.cmdCount, 2); - assert.equal(h.totalSize, 92); - assertHeaderLayout(res.bytes, h); + const drawText = parseDrawTextCommands(res.bytes); + assert.deepEqual( + drawText.map((cmd) => cmd.stringId), + [1, 1, 2], + ); + assert.deepEqual(parseInternedStrings(res.bytes), ["same", "other"]); }); - test("v2 setCursor readback preserves payload fields and reserved byte", () => { + test("setCursor readback preserves payload fields and reserved byte", () => { const b = createDrawlistBuilder(); b.setCursor({ x: -1, y: 123, shape: 2, visible: false, blink: true }); @@ -405,9 +300,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes)[0]; if (!cmd) return; const cursor = readSetCursorCommand(res.bytes, cmd); @@ -419,7 +312,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(cursor.reserved0, 0); }); - test("v2 multiple cursor commands are emitted in-order", () => { + test("multiple cursor commands are emitted in order", () => { const b = createDrawlistBuilder(); b.setCursor({ x: 1, y: 2, shape: 0, visible: true, blink: true }); b.setCursor({ x: 3, y: 4, shape: 1, visible: true, blink: false }); @@ -429,19 +322,11 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - assert.equal(h.cmdCount, 3); - assert.equal(h.cmdBytes, 60); - const cmds = parseCommands(res.bytes); - const c0 = cmds[0]; - const c1 = cmds[1]; - const c2 = cmds[2]; - if (!c0 || !c1 || !c2) return; - - const s0 = readSetCursorCommand(res.bytes, c0); - const s1 = readSetCursorCommand(res.bytes, c1); - const s2 = readSetCursorCommand(res.bytes, c2); + assert.equal(cmds.length, 3); + const s0 = readSetCursorCommand(res.bytes, cmds[0] as CmdHeader); + const s1 = readSetCursorCommand(res.bytes, cmds[1] as CmdHeader); + const s2 = readSetCursorCommand(res.bytes, cmds[2] as CmdHeader); assert.equal(s0.x, 1); assert.equal(s0.y, 2); @@ -462,43 +347,27 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(s2.blink, 0); }); - test("cursor edge position (0,0) round-trips exactly", () => { - const b = createDrawlistBuilder(); - b.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const cmd = parseCommands(res.bytes)[0]; - if (!cmd) return; - const cursor = readSetCursorCommand(res.bytes, cmd); - assert.equal(cursor.x, 0); - assert.equal(cursor.y, 0); - assert.equal(cursor.shape, 0); - assert.equal(cursor.visible, 1); - assert.equal(cursor.blink, 1); - }); - - test("cursor edge position (large int32) round-trips exactly", () => { - const b = createDrawlistBuilder(); - b.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const cmd = parseCommands(res.bytes)[0]; - if (!cmd) return; - const cursor = readSetCursorCommand(res.bytes, cmd); - assert.equal(cursor.x, INT32_MAX); - assert.equal(cursor.y, INT32_MAX); - assert.equal(cursor.shape, 2); - assert.equal(cursor.visible, 1); - assert.equal(cursor.blink, 0); + test("cursor edge positions round-trip exactly", () => { + const b0 = createDrawlistBuilder(); + b0.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); + const r0 = b0.build(); + assert.equal(r0.ok, true); + if (!r0.ok) return; + const c0 = readSetCursorCommand(r0.bytes, parseCommands(r0.bytes)[0] as CmdHeader); + assert.equal(c0.x, 0); + assert.equal(c0.y, 0); + + const b1 = createDrawlistBuilder(); + b1.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); + const r1 = b1.build(); + assert.equal(r1.ok, true); + if (!r1.ok) return; + const c1 = readSetCursorCommand(r1.bytes, parseCommands(r1.bytes)[0] as CmdHeader); + assert.equal(c1.x, INT32_MAX); + assert.equal(c1.y, INT32_MAX); }); - test("v2 mixed frame keeps aligned sections and expected total byte size", () => { + test("mixed frame keeps aligned command stream and expected opcodes", () => { const b = createDrawlistBuilder(); b.clear(); b.pushClip(0, 0, 80, 24); @@ -512,19 +381,18 @@ describe("DrawlistBuilder round-trip binary readback", () => { if (!res.ok) return; const h = readHeader(res.bytes); + const cmds = parseCommands(res.bytes); assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdCount, 6); - assert.equal( - h.cmdBytes, - 8 + // clear - 24 + // push clip - 40 + // fill rect - 48 + // draw text - 20 + // set cursor - 8, // pop clip + assert.equal(h.cmdCount, 7); + assert.equal(cmds.length, 7); + assert.equal(cmds.reduce((acc, cmd) => acc + cmd.size, 0), h.cmdBytes); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [OP_DEF_STRING, OP_CLEAR, OP_PUSH_CLIP, OP_FILL_RECT, OP_DRAW_TEXT, OP_SET_CURSOR, OP_POP_CLIP], ); - assert.equal(h.stringsCount, 1); - assert.equal(h.stringsBytesLen, align4(2)); - assertHeaderLayout(res.bytes, h); + for (const cmd of cmds) { + assert.equal((cmd.off & 3) === 0, true); + assert.equal((cmd.size & 3) === 0, true); + } }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts index c784a4a8..5c3c24f0 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -1,8 +1,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; -const OP_DRAW_TEXT = 3; - type BuildResult = | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; @@ -22,56 +21,13 @@ const FACTORIES: readonly Readonly<{ create(opts?: BuilderOpts): BuilderLike; }>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -type DrawTextEntry = Readonly<{ stringIndex: number; byteLen: number }>; +type DrawTextEntry = Readonly<{ stringId: number; byteLen: number }>; function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdCount = u32(bytes, 24); - const out: DrawTextEntry[] = []; - - let off = cmdOffset; - for (let i = 0; i < cmdCount; i++) { - const opcode = u16(bytes, off + 0); - const size = u32(bytes, off + 4); - if (opcode === OP_DRAW_TEXT) { - out.push({ - stringIndex: u32(bytes, off + 16), - byteLen: u32(bytes, off + 24), - }); - } - off += size; - } - - assert.equal(off, cmdOffset + cmdBytes, "command stream should end at cmdOffset + cmdBytes"); - return out; -} - -function readInternedStrings(bytes: Uint8Array): string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const stringsBytesOffset = u32(bytes, 36); - const decoder = new TextDecoder(); - - const out: string[] = []; - for (let i = 0; i < count; i++) { - const off = u32(bytes, spanOffset + i * 8 + 0); - const len = u32(bytes, spanOffset + i * 8 + 4); - out.push( - decoder.decode(bytes.subarray(stringsBytesOffset + off, stringsBytesOffset + off + len)), - ); - } - return out; + return parseDrawTextCommands(bytes).map((cmd) => ({ + stringId: cmd.stringId, + byteLen: cmd.byteLen, + })); } function buildOk(builder: BuilderLike, label: string): Uint8Array { @@ -191,8 +147,8 @@ describe("drawlist encoded string cache", () => { assert.equal(f1Entry?.byteLen, expectedLen, `${factory.name}: frame1 byte_len`); assert.equal(f2Entry?.byteLen, expectedLen, `${factory.name}: frame2 byte_len`); - assert.deepEqual(readInternedStrings(frame1), [text], `${factory.name}: frame1 decode`); - assert.deepEqual(readInternedStrings(frame2), [text], `${factory.name}: frame2 decode`); + assert.deepEqual(parseInternedStrings(frame1), [text], `${factory.name}: frame1 decode`); + assert.deepEqual(parseInternedStrings(frame2), [text], `${factory.name}: frame2 decode`); assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode with hit`); }); } @@ -310,7 +266,7 @@ describe("drawlist encoded string cache", () => { expectedLen, `${factory.name}: byte_len`, ); - assert.deepEqual(readInternedStrings(frameA2), [a], `${factory.name}: decoded string`); + assert.deepEqual(parseInternedStrings(frameA2), [a], `${factory.name}: decoded string`); }); } }); @@ -329,13 +285,13 @@ describe("drawlist encoded string cache", () => { const frame2 = buildOk(b, `${factory.name} frame 2`); assert.equal( - readDrawTextEntries(frame1)[0]?.stringIndex, - 0, + readDrawTextEntries(frame1)[0]?.stringId, + 1, `${factory.name}: frame1 index`, ); assert.equal( - readDrawTextEntries(frame2)[0]?.stringIndex, - 0, + readDrawTextEntries(frame2)[0]?.stringId, + 1, `${factory.name}: frame2 index`, ); assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: cache hit across frames`); @@ -357,8 +313,8 @@ describe("drawlist encoded string cache", () => { b.drawText(0, 0, second); const frame2 = buildOk(b, `${factory.name} frame 2`); - assert.deepEqual(readInternedStrings(frame1), [first], `${factory.name}: frame1 strings`); - assert.deepEqual(readInternedStrings(frame2), [second], `${factory.name}: frame2 strings`); + assert.deepEqual(parseInternedStrings(frame1), [first], `${factory.name}: frame1 strings`); + assert.deepEqual(parseInternedStrings(frame2), [second], `${factory.name}: frame2 strings`); } }); @@ -373,8 +329,8 @@ describe("drawlist encoded string cache", () => { const entries = readDrawTextEntries(frame); assert.equal(entries.length, 2, `${factory.name}: two drawText commands`); - assert.equal(entries[0]?.stringIndex, 0, `${factory.name}: first index`); - assert.equal(entries[1]?.stringIndex, 0, `${factory.name}: duplicate index`); + assert.equal(entries[0]?.stringId, 1, `${factory.name}: first id`); + assert.equal(entries[1]?.stringId, 1, `${factory.name}: duplicate id`); assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode within frame`); }); } @@ -395,8 +351,8 @@ describe("drawlist encoded string cache", () => { b.drawText(0, 0, second); const frame2 = buildOk(b, `${factory.name} cap0 frame 2`); - assert.deepEqual(readInternedStrings(frame1), [first], `${factory.name}: frame1 decode`); - assert.deepEqual(readInternedStrings(frame2), [second], `${factory.name}: frame2 decode`); + assert.deepEqual(parseInternedStrings(frame1), [first], `${factory.name}: frame1 decode`); + assert.deepEqual(parseInternedStrings(frame2), [second], `${factory.name}: frame2 decode`); assert.equal(encodeCallCount(calls, first), 1, `${factory.name}: first encoded once`); assert.equal(encodeCallCount(calls, second), 1, `${factory.name}: second encoded once`); }); diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts index cfb49e37..45d1dde4 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -1,8 +1,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; -const OP_DRAW_TEXT = 3; - type BuildResult = | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; @@ -24,65 +23,13 @@ const FACTORIES: readonly Readonly<{ create(opts?: BuilderOpts): BuilderLike; }>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -type DrawTextEntry = Readonly<{ stringIndex: number; byteLen: number }>; +type DrawTextEntry = Readonly<{ stringId: number; byteLen: number }>; function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdCount = u32(bytes, 24); - const out: DrawTextEntry[] = []; - - let off = cmdOffset; - for (let i = 0; i < cmdCount; i++) { - const opcode = u16(bytes, off + 0); - const size = u32(bytes, off + 4); - if (opcode === OP_DRAW_TEXT) { - out.push({ - stringIndex: u32(bytes, off + 16), - byteLen: u32(bytes, off + 24), - }); - } - off += size; - } - - assert.equal(off, cmdOffset + cmdBytes, "command stream should end at cmdOffset + cmdBytes"); - return out; -} - -type StringSpan = Readonly<{ off: number; len: number }>; - -function readStringSpans(bytes: Uint8Array): StringSpan[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const spans: StringSpan[] = []; - for (let i = 0; i < count; i++) { - spans.push({ - off: u32(bytes, spanOffset + i * 8 + 0), - len: u32(bytes, spanOffset + i * 8 + 4), - }); - } - return spans; -} - -function readInternedStrings(bytes: Uint8Array): string[] { - const stringsBytesOffset = u32(bytes, 36); - const spans = readStringSpans(bytes); - const decoder = new TextDecoder(); - return spans.map((span) => - decoder.decode( - bytes.subarray(stringsBytesOffset + span.off, stringsBytesOffset + span.off + span.len), - ), - ); + return parseDrawTextCommands(bytes).map((cmd) => ({ + stringId: cmd.stringId, + byteLen: cmd.byteLen, + })); } function buildOk(builder: BuilderLike, label: string): Uint8Array { @@ -102,11 +49,11 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} duplicate strings`); const drawText = readDrawTextEntries(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); assert.equal(drawText.length, 2, `${factory.name}: expected 2 drawText commands`); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first string index`); - assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: duplicate string index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first string id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: duplicate string id`); assert.deepEqual(strings, ["dup"], `${factory.name}: string table should dedupe`); } }); @@ -119,10 +66,10 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} distinct strings`); const drawText = readDrawTextEntries(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: alpha index`); - assert.equal(drawText[1]?.stringIndex, 1, `${factory.name}: beta index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: alpha id`); + assert.equal(drawText[1]?.stringId, 2, `${factory.name}: beta id`); assert.deepEqual(strings, ["alpha", "beta"], `${factory.name}: expected two strings`); } }); @@ -135,10 +82,10 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} value-based interning`); const drawText = readDrawTextEntries(bytes); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first index`); - assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: second index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: second id`); assert.deepEqual( - readInternedStrings(bytes), + parseInternedStrings(bytes), ["same"], `${factory.name}: one interned string`, ); @@ -153,14 +100,11 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} empty string`); const drawText = readDrawTextEntries(bytes); - const spans = readStringSpans(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first empty index`); - assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: second empty index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first empty id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: second empty id`); assert.equal(drawText[0]?.byteLen, 0, `${factory.name}: empty byte len in command`); - assert.equal(spans[0]?.len, 0, `${factory.name}: empty span len`); - assert.equal(u32(bytes, 40), 0, `${factory.name}: aligned strings_bytes_len`); assert.deepEqual(strings, [""], `${factory.name}: one empty string in table`); } }); @@ -173,12 +117,10 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} long string`); const drawText = readDrawTextEntries(bytes); - const spans = readStringSpans(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: long string index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: long string id`); assert.equal(drawText[0]?.byteLen, longText.length, `${factory.name}: long byte len`); - assert.equal(spans[0]?.len, longText.length, `${factory.name}: long span len`); assert.equal(strings[0], longText, `${factory.name}: long round-trip text`); } }); @@ -193,7 +135,7 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} unicode round-trip`); const drawText = readDrawTextEntries(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); assert.equal(drawText[0]?.byteLen, expectedByteLen, `${factory.name}: utf8 byte len`); assert.equal(strings[0], text, `${factory.name}: unicode round-trip`); @@ -211,10 +153,10 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} unicode normalization`); const drawText = readDrawTextEntries(bytes); - const strings = readInternedStrings(bytes); + const strings = parseInternedStrings(bytes); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: nfc index`); - assert.equal(drawText[1]?.stringIndex, 1, `${factory.name}: nfd index`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: nfc id`); + assert.equal(drawText[1]?.stringId, 2, `${factory.name}: nfd id`); assert.deepEqual(strings, [nfc, nfd], `${factory.name}: both forms are preserved`); } }); @@ -232,11 +174,12 @@ describe("drawlist string interning", () => { const bytes = buildOk(b, `${factory.name} round-trip decode`); const drawText = readDrawTextEntries(bytes); - const actualIndices = drawText.map((entry) => entry.stringIndex); + const actualIds = drawText.map((entry) => entry.stringId); + const actualIndices = actualIds.map((id) => id - 1); assert.deepEqual(actualIndices, expectedIndices, `${factory.name}: index assignment`); assert.deepEqual( - readInternedStrings(bytes), + parseInternedStrings(bytes), expectedUnique, `${factory.name}: unique decode`, ); @@ -256,9 +199,9 @@ describe("drawlist string interning", () => { const drawText = readDrawTextEntries(bytes); assert.equal(drawText.length, unique.length, `${factory.name}: drawText count`); for (let i = 0; i < drawText.length; i++) { - assert.equal(drawText[i]?.stringIndex, i, `${factory.name}: index ${i}`); + assert.equal(drawText[i]?.stringId, i + 1, `${factory.name}: id ${i + 1}`); } - assert.deepEqual(readInternedStrings(bytes), unique, `${factory.name}: decoded string table`); + assert.deepEqual(parseInternedStrings(bytes), unique, `${factory.name}: decoded string table`); } }); @@ -273,13 +216,13 @@ describe("drawlist string interning", () => { b.drawText(0, 0, "second"); const frame2 = buildOk(b, `${factory.name} frame 2`); - const frame1Indices = readDrawTextEntries(frame1).map((entry) => entry.stringIndex); - const frame2Indices = readDrawTextEntries(frame2).map((entry) => entry.stringIndex); + const frame1Ids = readDrawTextEntries(frame1).map((entry) => entry.stringId); + const frame2Ids = readDrawTextEntries(frame2).map((entry) => entry.stringId); - assert.deepEqual(frame1Indices, [0, 1], `${factory.name}: frame 1 indices`); - assert.deepEqual(frame2Indices, [0], `${factory.name}: frame 2 indices restart`); + assert.deepEqual(frame1Ids, [1, 2], `${factory.name}: frame 1 ids`); + assert.deepEqual(frame2Ids, [1], `${factory.name}: frame 2 ids restart`); assert.deepEqual( - readInternedStrings(frame2), + parseInternedStrings(frame2), ["second"], `${factory.name}: no stale strings`, ); diff --git a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts index 37bfbc07..d8e4a63a 100644 --- a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts @@ -1,32 +1,48 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { type TextStyle, createDrawlistBuilder } from "../../index.js"; import { DEFAULT_BASE_STYLE, mergeTextStyle } from "../../renderer/renderToDrawlist/textStyle.js"; -import { DRAW_TEXT_SIZE } from "../writers.gen.js"; function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); } -function firstCommandOffset(bytes: Uint8Array): number { - return u32(bytes, 16); +function firstOpcodeOffset(bytes: Uint8Array, opcode: number): number { + const cmd = parseCommandHeaders(bytes).find((entry) => entry.opcode === opcode); + assert.equal(cmd !== undefined, true, `missing opcode ${String(opcode)}`); + if (!cmd) return 0; + return cmd.offset; } function drawTextFg(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 28); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 28); } function drawTextBg(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 32); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 32); } function drawTextAttrs(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 36); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 36); +} + +function firstTextRunBlob(bytes: Uint8Array): Uint8Array { + const drawTextRunOff = firstOpcodeOffset(bytes, OP_DRAW_TEXT_RUN); + const blobId = u32(bytes, drawTextRunOff + 16); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true, `missing blob id ${String(blobId)} for DRAW_TEXT_RUN`); + return blob ?? new Uint8Array(); } function textRunField(bytes: Uint8Array, segmentIndex: number, fieldOffset: number): number { - const blobsBytesOffset = u32(bytes, 52); - return u32(bytes, blobsBytesOffset + 4 + segmentIndex * 28 + fieldOffset); + const blob = firstTextRunBlob(bytes); + return u32(blob, 4 + segmentIndex * 40 + fieldOffset); } function textRunFg(bytes: Uint8Array, segmentIndex: number): number { @@ -278,14 +294,16 @@ describe("style merge stress encodes fg/bg/attrs bytes deterministically", () => assert.equal(res.ok, true); if (!res.ok) throw new Error("build failed"); - const cmdOffset = u32(res.bytes, 16); - const cmdCount = u32(res.bytes, 24); - assert.equal(cmdCount, expected.length); + const drawTextCommands = parseCommandHeaders(res.bytes).filter( + (cmd) => cmd.opcode === OP_DRAW_TEXT, + ); + assert.equal(drawTextCommands.length, expected.length); for (let i = 0; i < expected.length; i++) { const exp = expected[i]; - if (!exp) continue; - const off = cmdOffset + i * DRAW_TEXT_SIZE; + const cmd = drawTextCommands[i]; + if (!exp || !cmd) continue; + const off = cmd.offset; assert.equal(u32(res.bytes, off + 28), exp.fg, `fg mismatch at cmd #${String(i)}`); assert.equal(u32(res.bytes, off + 32), exp.bg, `bg mismatch at cmd #${String(i)}`); assert.equal(u32(res.bytes, off + 36), exp.attrs, `attrs mismatch at cmd #${String(i)}`); diff --git a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts index 37b9f08b..326d307a 100644 --- a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts @@ -1,11 +1,15 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_CLEAR, + OP_DEF_BLOB, + OP_DEF_STRING, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); @@ -17,7 +21,7 @@ function i32(bytes: Uint8Array, off: number): number { } describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { - test("emits blob span + DRAW_TEXT_RUN command referencing it", () => { + test("emits DEF_STRING/DEF_BLOB resources and DRAW_TEXT_RUN references blob id", () => { const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ @@ -36,72 +40,60 @@ describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { const bytes = res.bytes; - // Header fields (see docs-user/abi/drawlist-v1.md) assert.equal(u32(bytes, 0), ZRDL_MAGIC); assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V1); assert.equal(u32(bytes, 8), 64); - assert.equal(u32(bytes, 12), 188); - assert.equal(u32(bytes, 16), 64); // cmd_offset - assert.equal(u32(bytes, 20), 32); // cmd_bytes - assert.equal(u32(bytes, 24), 2); // cmd_count - assert.equal(u32(bytes, 28), 96); // strings_span_offset - assert.equal(u32(bytes, 32), 2); // strings_count - assert.equal(u32(bytes, 36), 112); // strings_bytes_offset - assert.equal(u32(bytes, 40), 8); // strings_bytes_len (4-byte aligned) - assert.equal(u32(bytes, 44), 120); // blobs_span_offset - assert.equal(u32(bytes, 48), 1); // blobs_count - assert.equal(u32(bytes, 52), 128); // blobs_bytes_offset - assert.equal(u32(bytes, 56), 60); // blobs_bytes_len - assert.equal(u32(bytes, 60), 0); // reserved0 - - // Command 0: CLEAR at offset 64 - assert.equal(u16(bytes, 64 + 0), 1); - assert.equal(u16(bytes, 64 + 2), 0); - assert.equal(u32(bytes, 64 + 4), 8); - - // Command 1: DRAW_TEXT_RUN at offset 72 - assert.equal(u16(bytes, 72 + 0), 6); - assert.equal(u16(bytes, 72 + 2), 0); - assert.equal(u32(bytes, 72 + 4), 24); - assert.equal(i32(bytes, 72 + 8), 1); // x - assert.equal(i32(bytes, 72 + 12), 2); // y - assert.equal(u32(bytes, 72 + 16), 0); // blob_index - assert.equal(u32(bytes, 72 + 20), 0); // reserved0 - - // String spans: two entries at offset 96 - assert.equal(u32(bytes, 96 + 0), 0); - assert.equal(u32(bytes, 96 + 4), 3); - assert.equal(u32(bytes, 104 + 0), 3); - assert.equal(u32(bytes, 104 + 4), 3); - - // String bytes: "ABCDEF" at offset 112 (padded to 4-byte alignment). - assert.equal(String.fromCharCode(...bytes.subarray(112, 118)), "ABCDEF"); - - // Blob span: single entry at offset 120 - assert.equal(u32(bytes, 120 + 0), 0); - assert.equal(u32(bytes, 120 + 4), 60); - - // Blob bytes: seg_count=2 + two segments (28 bytes each) - const blobOff = 128; - assert.equal(u32(bytes, blobOff + 0), 2); - - // Segment 0 - assert.equal(u32(bytes, blobOff + 4 + 0), 0x00ff0000); // fg - assert.equal(u32(bytes, blobOff + 4 + 4), 0); // bg - assert.equal(u32(bytes, blobOff + 4 + 8), 1); // attrs (bold) - assert.equal(u32(bytes, blobOff + 4 + 12), 0); // reserved0 - assert.equal(u32(bytes, blobOff + 4 + 16), 0); // string_index - assert.equal(u32(bytes, blobOff + 4 + 20), 0); // byte_off - assert.equal(u32(bytes, blobOff + 4 + 24), 3); // byte_len - - // Segment 1 - const seg1 = blobOff + 4 + 28; - assert.equal(u32(bytes, seg1 + 0), 0x0000ff00); // fg - assert.equal(u32(bytes, seg1 + 4), 0); // bg - assert.equal(u32(bytes, seg1 + 8), 1 << 2); // attrs (underline) - assert.equal(u32(bytes, seg1 + 12), 0); // reserved0 - assert.equal(u32(bytes, seg1 + 16), 1); // string_index - assert.equal(u32(bytes, seg1 + 20), 0); // byte_off - assert.equal(u32(bytes, seg1 + 24), 3); // byte_len + assert.equal(u32(bytes, 12), bytes.byteLength); + assert.equal(u32(bytes, 16), 64); + assert.equal(u32(bytes, 24), 5); + assert.equal(u32(bytes, 28), 0); + assert.equal(u32(bytes, 32), 0); + assert.equal(u32(bytes, 44), 0); + assert.equal(u32(bytes, 48), 0); + + const headers = parseCommandHeaders(bytes); + assert.deepEqual( + headers.map((h) => h.opcode), + [OP_DEF_STRING, OP_DEF_STRING, OP_DEF_BLOB, OP_CLEAR, OP_DRAW_TEXT_RUN], + ); + + const drawTextRun = headers.find((h) => h.opcode === OP_DRAW_TEXT_RUN); + assert.equal(drawTextRun !== undefined, true); + if (!drawTextRun) return; + assert.equal(i32(bytes, drawTextRun.offset + 8), 1); + assert.equal(i32(bytes, drawTextRun.offset + 12), 2); + assert.equal(u32(bytes, drawTextRun.offset + 16), 1); + assert.equal(u32(bytes, drawTextRun.offset + 20), 0); + + assert.deepEqual(parseInternedStrings(bytes), ["ABC", "DEF"]); + + const blob = parseBlobById(bytes, 1); + assert.equal(blob !== null, true); + if (!blob) return; + assert.equal(u32(blob, 0), 2); + + const seg0 = 4; + assert.equal(u32(blob, seg0 + 0), 0x00ff_0000); + assert.equal(u32(blob, seg0 + 4), 0); + assert.equal(u32(blob, seg0 + 8), 1); + assert.equal(u32(blob, seg0 + 12), 0); + assert.equal(u32(blob, seg0 + 16), 0); + assert.equal(u32(blob, seg0 + 20), 0); + assert.equal(u32(blob, seg0 + 24), 0); + assert.equal(u32(blob, seg0 + 28), 1); + assert.equal(u32(blob, seg0 + 32), 0); + assert.equal(u32(blob, seg0 + 36), 3); + + const seg1 = seg0 + 40; + assert.equal(u32(blob, seg1 + 0), 0x0000_ff00); + assert.equal(u32(blob, seg1 + 4), 0); + assert.equal(u32(blob, seg1 + 8), 1 << 2); + assert.equal(u32(blob, seg1 + 12), 0); + assert.equal(u32(blob, seg1 + 16), 0); + assert.equal(u32(blob, seg1 + 20), 0); + assert.equal(u32(blob, seg1 + 24), 0); + assert.equal(u32(blob, seg1 + 28), 2); + assert.equal(u32(blob, seg1 + 32), 0); + assert.equal(u32(blob, seg1 + 36), 3); }); }); diff --git a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts index 4ebf3d38..0740ff4a 100644 --- a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts @@ -1,4 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; function u32(bytes: Uint8Array, off: number): number { @@ -7,16 +13,25 @@ function u32(bytes: Uint8Array, off: number): number { } function textRunAttrs(bytes: Uint8Array, segmentIndex: number): number { - const blobsBytesOffset = u32(bytes, 52); - return u32(bytes, blobsBytesOffset + 4 + segmentIndex * 28 + 8); + const drawTextRun = parseCommandHeaders(bytes).find((cmd) => cmd.opcode === OP_DRAW_TEXT_RUN); + assert.equal(drawTextRun !== undefined, true); + if (!drawTextRun) return 0; + const blobId = u32(bytes, drawTextRun.offset + 16); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true); + if (!blob) return 0; + return u32(blob, 4 + segmentIndex * 40 + 8); } -function firstCommandOffset(bytes: Uint8Array): number { - return u32(bytes, 16); +function firstDrawTextOffset(bytes: Uint8Array): number { + const drawText = parseCommandHeaders(bytes).find((cmd) => cmd.opcode === OP_DRAW_TEXT); + assert.equal(drawText !== undefined, true); + if (!drawText) return 0; + return drawText.offset; } function drawTextAttrs(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 36); + return u32(bytes, firstDrawTextOffset(bytes) + 36); } describe("drawlist style attrs encode dim", () => { diff --git a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts index 712dd74b..8e0f899f 100644 --- a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts @@ -1,6 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { createDrawlistBuilder } from "../../index.js"; +function u32(bytes: Uint8Array, off: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(off, true); +} + function expectOk( result: ReturnType["build"]>, ): Uint8Array { @@ -22,6 +26,26 @@ describe("DrawlistBuilder resource inputs", () => { assert.equal(bytes.byteLength > 0, true); }); + test("keeps DEF_BLOB byteLen/data exact for non-4-byte blob payloads", () => { + const b = createDrawlistBuilder(); + const payload = new Uint8Array([0xde, 0xad, 0xbe]); + const blobIndex = b.addBlob(payload); + assert.equal(blobIndex, 0); + if (blobIndex === null) throw new Error("missing blob index"); + + const bytes = expectOk(b.build()); + const cmdOffset = u32(bytes, 16); + const cmdCount = u32(bytes, 24); + assert.equal(cmdCount >= 1, true); + + assert.equal(bytes[cmdOffset], 12); // DEF_BLOB + assert.equal(u32(bytes, cmdOffset + 4), 20); // 16-byte header + 3-byte payload + 1 pad + assert.equal(u32(bytes, cmdOffset + 8), 1); + assert.equal(u32(bytes, cmdOffset + 12), 3); + assert.deepEqual(Array.from(bytes.subarray(cmdOffset + 16, cmdOffset + 19)), [0xde, 0xad, 0xbe]); + assert.equal(bytes[cmdOffset + 19], 0); + }); + test("accepts text-run blobs", () => { const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ diff --git a/packages/core/src/drawlist/__tests__/writers.gen.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.test.ts index e5d9c169..009e8dc6 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.test.ts @@ -3,6 +3,8 @@ import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../.. import type { EncodedStyle } from "../types.js"; import { CLEAR_SIZE, + DEF_BLOB_BASE_SIZE, + DEF_STRING_BASE_SIZE, DRAW_CANVAS_SIZE, DRAW_IMAGE_SIZE, DRAW_TEXT_RUN_SIZE, @@ -16,6 +18,8 @@ import { writeDrawImage, writeDrawText, writeDrawTextRun, + writeDefBlob, + writeDefString, writeFillRect, writePopClip, writePushClip, @@ -298,7 +302,11 @@ function buildReferenceDrawlist(): Uint8Array { const stringBytes = new TextEncoder().encode("OK"); const blobBytes = new Uint8Array([1, 2, 3, 4]); + const defStringSize = align4(DEF_STRING_BASE_SIZE + stringBytes.byteLength); + const defBlobSize = align4(DEF_BLOB_BASE_SIZE + blobBytes.byteLength); const cmdBytes = + defStringSize + + defBlobSize + CLEAR_SIZE + FILL_RECT_SIZE + DRAW_TEXT_SIZE + @@ -308,19 +316,8 @@ function buildReferenceDrawlist(): Uint8Array { DRAW_IMAGE_SIZE + PUSH_CLIP_SIZE + POP_CLIP_SIZE; - const stringsCount = 1; - const stringsSpanBytes = stringsCount * 8; - const stringsBytesLen = align4(stringBytes.byteLength); - const blobsCount = 1; - const blobsSpanBytes = blobsCount * 8; - const blobsBytesLen = align4(blobBytes.byteLength); - const cmdOffset = HEADER_SIZE; - const stringsSpanOffset = cmdOffset + cmdBytes; - const stringsBytesOffset = stringsSpanOffset + stringsSpanBytes; - const blobsSpanOffset = stringsBytesOffset + stringsBytesLen; - const blobsBytesOffset = blobsSpanOffset + blobsSpanBytes; - const totalSize = blobsBytesOffset + blobsBytesLen; + const totalSize = cmdOffset + cmdBytes; const out = new Uint8Array(totalSize); const dv = view(out); @@ -331,24 +328,26 @@ function buildReferenceDrawlist(): Uint8Array { dv.setUint32(12, totalSize, true); dv.setUint32(16, cmdOffset, true); dv.setUint32(20, cmdBytes, true); - dv.setUint32(24, 9, true); - dv.setUint32(28, stringsSpanOffset, true); - dv.setUint32(32, stringsCount, true); - dv.setUint32(36, stringsBytesOffset, true); - dv.setUint32(40, stringsBytesLen, true); - dv.setUint32(44, blobsSpanOffset, true); - dv.setUint32(48, blobsCount, true); - dv.setUint32(52, blobsBytesOffset, true); - dv.setUint32(56, blobsBytesLen, true); + dv.setUint32(24, 11, true); + dv.setUint32(28, 0, true); + dv.setUint32(32, 0, true); + dv.setUint32(36, 0, true); + dv.setUint32(40, 0, true); + dv.setUint32(44, 0, true); + dv.setUint32(48, 0, true); + dv.setUint32(52, 0, true); + dv.setUint32(56, 0, true); dv.setUint32(60, 0, true); let pos = cmdOffset; + pos = writeDefString(out, dv, pos, 1, stringBytes.byteLength, stringBytes); + pos = writeDefBlob(out, dv, pos, 1, blobBytes.byteLength, blobBytes); pos = legacyWriteClear(out, dv, pos); pos = legacyWriteFillRect(out, dv, pos, 1, 2, 3, 4, ZERO_STYLE); - pos = legacyWriteDrawText(out, dv, pos, 5, 6, 0, 0, stringBytes.byteLength, ZERO_STYLE, 0); - pos = legacyWriteDrawTextRun(out, dv, pos, 7, 8, 0, 0); + pos = legacyWriteDrawText(out, dv, pos, 5, 6, 1, 0, stringBytes.byteLength, ZERO_STYLE, 0); + pos = legacyWriteDrawTextRun(out, dv, pos, 7, 8, 1, 0); pos = legacyWriteSetCursor(out, dv, pos, 9, 10, 2, 1, 0, 0); - pos = legacyWriteDrawCanvas(out, dv, pos, 11, 12, 1, 1, 1, 1, 0, blobBytes.byteLength, 6, 0, 0); + pos = legacyWriteDrawCanvas(out, dv, pos, 11, 12, 1, 1, 1, 1, 1, 0, 6, 0, 0); pos = legacyWriteDrawImage( out, dv, @@ -359,8 +358,8 @@ function buildReferenceDrawlist(): Uint8Array { 1, 1, 1, + 1, 0, - blobBytes.byteLength, 99, 0, 1, @@ -374,14 +373,6 @@ function buildReferenceDrawlist(): Uint8Array { pos = legacyWritePopClip(out, dv, pos); assert.equal(pos, cmdOffset + cmdBytes); - dv.setUint32(stringsSpanOffset + 0, 0, true); - dv.setUint32(stringsSpanOffset + 4, stringBytes.byteLength, true); - out.set(stringBytes, stringsBytesOffset); - - dv.setUint32(blobsSpanOffset + 0, 0, true); - dv.setUint32(blobsSpanOffset + 4, blobBytes.byteLength, true); - out.set(blobBytes, blobsBytesOffset); - return out; } @@ -648,12 +639,14 @@ describe("writers.gen - round trip integration", () => { assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V1); assert.equal(u32(bytes, 8), HEADER_SIZE); assert.equal(u32(bytes, 12), bytes.byteLength); - assert.equal(u32(bytes, 24), 9); + assert.equal(u32(bytes, 24), 11); const cmds = parseCommands(bytes); assert.deepEqual( cmds.map((c) => c.size), [ + align4(DEF_STRING_BASE_SIZE + 2), + align4(DEF_BLOB_BASE_SIZE + 4), CLEAR_SIZE, FILL_RECT_SIZE, DRAW_TEXT_SIZE, diff --git a/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts index 59fd27f5..5defba85 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts @@ -71,6 +71,22 @@ describe("writers.gen v6", () => { assert.equal(u8(bytes, 19), 0); }); + test("DEF_STRING honors declared byteLen (does not force bytes.byteLength)", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xcc); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const payload = new Uint8Array([0x41, 0x42, 0x43, 0x44, 0x45]); + const end = writeDefString(bytes, dv, 0, 9, 3, payload); + + assert.equal(end, 20); + assert.equal(u32(bytes, 4), 20); + assert.equal(u32(bytes, 12), 3); + assert.deepEqual(Array.from(bytes.subarray(16, 19)), [0x41, 0x42, 0x43]); + assert.equal(u8(bytes, 19), 0); + assert.equal(u8(bytes, 20), 0xcc); + }); + test("FREE_* write id payload only", () => { const bytes = new Uint8Array(32); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -147,4 +163,22 @@ describe("writers.gen v6", () => { assert.deepEqual(Array.from(bytes.subarray(24, 27)), [1, 2, 3]); assert.equal(u8(bytes, 27), 0); }); + + test("DEF_BLOB honors declared byteLen (does not force bytes.byteLength)", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xcc); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const payload = new Uint8Array([1, 2, 3, 4, 5]); + + const end = writeDefBlob(bytes, dv, 0, 7, 3, payload); + + assert.equal(end, 20); + assert.equal(u8(bytes, 0), 12); + assert.equal(u32(bytes, 4), 20); + assert.equal(u32(bytes, 8), 7); + assert.equal(u32(bytes, 12), 3); + assert.deepEqual(Array.from(bytes.subarray(16, 19)), [1, 2, 3]); + assert.equal(u8(bytes, 19), 0); + assert.equal(u8(bytes, 20), 0xcc); + }); }); diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index 07ce54f7..d0d49a72 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -89,6 +89,8 @@ type ResourceBuildPlan = Readonly<{ }>; const MAX_U16 = 0xffff; +const LINK_URI_MAX_BYTES = 2083; +const LINK_ID_MAX_BYTES = 2083; const BLITTER_SUBCELL_RESOLUTION: Readonly< Record, Readonly<{ subW: number; subH: number }>> @@ -255,22 +257,23 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - if (uri !== null) { - if (typeof uri !== "string") { - this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be a string or null"); - return; - } - const idx = this.internString(uri); - if (this.error || idx === null) return; - this.activeLinkUriRef = (idx + 1) >>> 0; - - if (id !== undefined) { - const idIdx = this.internString(id); - if (this.error || idIdx === null) return; - this.activeLinkIdRef = (idIdx + 1) >>> 0; - } else { - this.activeLinkIdRef = 0; - } + if (typeof uri !== "string") { + this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be a string or null"); + return; + } + if (!this.validateLinkString("uri", uri, LINK_URI_MAX_BYTES, true)) return; + if (id !== undefined && !this.validateLinkString("id", id, LINK_ID_MAX_BYTES, false)) return; + + const idx = this.internString(uri); + if (this.error || idx === null) return; + this.activeLinkUriRef = (idx + 1) >>> 0; + + if (id !== undefined) { + const idIdx = this.internString(id); + if (this.error || idIdx === null) return; + this.activeLinkIdRef = (idIdx + 1) >>> 0; + } else { + this.activeLinkIdRef = 0; } } @@ -728,6 +731,47 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return v >>> 0; } + private validateLinkString( + field: "uri" | "id", + value: string, + maxBytes: number, + requireNonEmpty: boolean, + ): boolean { + const byteLen = this.utf8ByteLength(value, `setLink: ${field}`); + if (byteLen === null) return false; + + if (requireNonEmpty && byteLen === 0) { + this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be non-empty when provided; use null to clear"); + return false; + } + if (byteLen > maxBytes) { + this.fail( + "ZRDL_BAD_PARAMS", + `setLink: ${field} UTF-8 length must be <= ${maxBytes} bytes (got ${byteLen})`, + ); + return false; + } + return true; + } + + private utf8ByteLength(text: string, context: string): number | null { + let asciiOnly = true; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 0x7f) { + asciiOnly = false; + break; + } + } + if (asciiOnly) { + return text.length; + } + if (!this.encoder) { + this.fail("ZRDL_INTERNAL", `${context}: TextEncoder is not available`); + return null; + } + return this.encoder.encode(text).byteLength; + } + private currentLinkRefs(): LinkRefs | null { if (this.activeLinkUriRef === 0) return null; return Object.freeze({ diff --git a/packages/core/src/drawlist/builderBase.ts b/packages/core/src/drawlist/builderBase.ts index cdce8283..d991f23c 100644 --- a/packages/core/src/drawlist/builderBase.ts +++ b/packages/core/src/drawlist/builderBase.ts @@ -252,10 +252,6 @@ export abstract class DrawlistBuilderBase { this.fail("ZRDL_BAD_PARAMS", "addBlob: bytes.byteLength must be a non-negative integer"); return null; } - if ((byteLen & 3) !== 0) { - this.fail("ZRDL_BAD_PARAMS", "addBlob: blob length must be 4-byte aligned"); - return null; - } const nextIndex = this.blobSpanOffs.length; if (nextIndex + 1 > this.maxBlobs) { @@ -275,11 +271,6 @@ export abstract class DrawlistBuilderBase { return null; } - if ((this.blobBytesLen & 3) !== 0) { - this.fail("ZRDL_INTERNAL", "addBlob: blob cursor is not 4-byte aligned"); - return null; - } - this.ensureBlobBytesCapacity(nextBytesLen); if (this.error) return null; diff --git a/packages/core/src/drawlist/writers.gen.ts b/packages/core/src/drawlist/writers.gen.ts index 2b2be0fe..cbbc1260 100644 --- a/packages/core/src/drawlist/writers.gen.ts +++ b/packages/core/src/drawlist/writers.gen.ts @@ -257,7 +257,7 @@ export function writeDefString( byteLen: number, bytes: Uint8Array, ): number { - const payloadBytes = bytes.byteLength >>> 0; + const payloadBytes = byteLen >>> 0; const size = align4(DEF_STRING_BASE_SIZE + payloadBytes); buf[pos + 0] = 10 & 0xff; buf[pos + 1] = 0; @@ -267,7 +267,13 @@ export function writeDefString( dv.setUint32(pos + 8, stringId >>> 0, true); dv.setUint32(pos + 12, payloadBytes >>> 0, true); const dataStart = pos + DEF_STRING_BASE_SIZE; - buf.set(bytes, dataStart); + const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0); + if (copyBytes > 0) { + buf.set(bytes.subarray(0, copyBytes), dataStart); + } + if (payloadBytes > copyBytes) { + buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes); + } const payloadEnd = dataStart + payloadBytes; const cmdEnd = pos + size; if (cmdEnd > payloadEnd) { @@ -299,7 +305,7 @@ export function writeDefBlob( byteLen: number, bytes: Uint8Array, ): number { - const payloadBytes = bytes.byteLength >>> 0; + const payloadBytes = byteLen >>> 0; const size = align4(DEF_BLOB_BASE_SIZE + payloadBytes); buf[pos + 0] = 12 & 0xff; buf[pos + 1] = 0; @@ -309,7 +315,13 @@ export function writeDefBlob( dv.setUint32(pos + 8, blobId >>> 0, true); dv.setUint32(pos + 12, payloadBytes >>> 0, true); const dataStart = pos + DEF_BLOB_BASE_SIZE; - buf.set(bytes, dataStart); + const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0); + if (copyBytes > 0) { + buf.set(bytes.subarray(0, copyBytes), dataStart); + } + if (payloadBytes > copyBytes) { + buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes); + } const payloadEnd = dataStart + payloadBytes; const cmdEnd = pos + size; if (cmdEnd > payloadEnd) { diff --git a/packages/core/src/layout/kinds/overlays.ts b/packages/core/src/layout/kinds/overlays.ts index 8e43e182..00396e51 100644 --- a/packages/core/src/layout/kinds/overlays.ts +++ b/packages/core/src/layout/kinds/overlays.ts @@ -85,9 +85,7 @@ function isFiniteNumber(v: unknown): v is number { function hasFrameBorder(raw: unknown): boolean { if (!raw || typeof raw !== "object") return false; const border = (raw as { border?: unknown }).border; - if (!border || typeof border !== "object") return false; - const rgb = border as { r?: unknown; g?: unknown; b?: unknown }; - return isFiniteNumber(rgb.r) && isFiniteNumber(rgb.g) && isFiniteNumber(rgb.b); + return isFiniteNumber(border); } export function measureOverlays( diff --git a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts index 21dbff24..bc854d9c 100644 --- a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts +++ b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts @@ -2,12 +2,13 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistBuilder, DrawlistTextRunSegment } from "../../drawlist/types.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; import { DEFAULT_BASE_STYLE } from "../renderToDrawlist/textStyle.js"; -import { renderCanvasWidgets } from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; +import { addBlobAligned, renderCanvasWidgets } from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; import { drawSegments } from "../renderToDrawlist/widgets/renderTextWidgets.js"; class CountingBuilder implements DrawlistBuilder { readonly blobCount = { value: 0 }; readonly textRunBlobCount = { value: 0 }; + readonly blobByteLens: number[] = []; private nextBlobId = 0; clear(): void {} @@ -49,6 +50,7 @@ class CountingBuilder implements DrawlistBuilder { addBlob(_bytes: Uint8Array): number | null { this.blobCount.value += 1; + this.blobByteLens.push(_bytes.byteLength); const id = this.nextBlobId; this.nextBlobId += 1; return id; @@ -73,6 +75,13 @@ class CountingBuilder implements DrawlistBuilder { } describe("renderer blob usage", () => { + test("addBlobAligned forwards exact payload length", () => { + const builder = new CountingBuilder(); + const blobId = addBlobAligned(builder, new Uint8Array([1, 2, 3])); + assert.equal(blobId, 0); + assert.deepEqual(builder.blobByteLens, [3]); + }); + test("drawSegments uses text-run blobs for multi-segment lines", () => { const segments = [ { text: "left", style: { ...DEFAULT_BASE_STYLE, bold: true } }, diff --git a/packages/core/src/renderer/__tests__/renderer.damage.test.ts b/packages/core/src/renderer/__tests__/renderer.damage.test.ts index 5ae4460c..2f998733 100644 --- a/packages/core/src/renderer/__tests__/renderer.damage.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.damage.test.ts @@ -312,6 +312,23 @@ function getRectById(scene: Scene, id: string): Rect { return rect ?? { x: 0, y: 0, w: 0, h: 0 }; } +function getRuntimeNodeById(root: RuntimeInstance, id: string): RuntimeInstance | null { + const stack: RuntimeInstance[] = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + const props = node.vnode.props as Readonly<{ id?: unknown }> | undefined; + if (props?.id === id) { + return node; + } + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) stack.push(child); + } + } + return null; +} + function renderScene( scene: Scene, focusedId: FocusId, @@ -606,6 +623,33 @@ describe("renderer damage rect behavior", () => { assertFramebuffersEqual(nextFramebuffer, baseFramebuffer); }); + test("clean clipped subtree does not emit no-op clip commands in damage pass", () => { + const scene = buildScene( + ui.row({ id: "clip-root", width: 16, height: 1, overflow: "hidden" }, [ + ui.text("stable-child", { id: "clip-leaf" }), + ]), + viewport, + ); + const rootNode = getRuntimeNodeById(scene.tree, "clip-root"); + const childNode = getRuntimeNodeById(scene.tree, "clip-leaf"); + assert.ok(rootNode, "missing runtime node for clip-root"); + assert.ok(childNode, "missing runtime node for clip-leaf"); + if (!rootNode || !childNode) return; + + // Simulate an incremental pass where this container is visited but no child is renderable. + rootNode.dirty = true; + rootNode.selfDirty = false; + childNode.dirty = false; + childNode.selfDirty = false; + + const damageRect = getRectById(scene, "clip-root"); + const ops = renderScene(scene, null, { damageRect }); + const pushCount = ops.filter((op) => op.kind === "pushClip").length; + const popCount = ops.filter((op) => op.kind === "popClip").length; + assert.equal(pushCount, 0); + assert.equal(popCount, 0); + }); + test("focus update null -> first button matches full render", () => { const vnode = ui.column({ width: 20, height: 6 }, [ ui.button("a", "Alpha"), diff --git a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts new file mode 100644 index 00000000..34e4e4d1 --- /dev/null +++ b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts @@ -0,0 +1,72 @@ +import type { RuntimeInstance } from "../../runtime/commit.js"; + +type OverflowProps = Readonly<{ + overflow?: unknown; + shadow?: unknown; +}>; + +function readShadowOffset(raw: unknown, fallback: number): number { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return fallback; + } + const value = Math.trunc(raw); + return value <= 0 ? 0 : value; +} + +function hasBoxShadowOverflow(node: RuntimeInstance): boolean { + if (node.vnode.kind !== "box") { + return false; + } + const shadow = (node.vnode.props as OverflowProps).shadow; + if (shadow === true) { + return true; + } + if (shadow === false || shadow === undefined || shadow === null) { + return false; + } + if (typeof shadow !== "object") { + return false; + } + const config = shadow as Readonly<{ offsetX?: unknown; offsetY?: unknown }>; + const offsetX = readShadowOffset(config.offsetX, 1); + const offsetY = readShadowOffset(config.offsetY, 1); + return offsetX > 0 || offsetY > 0; +} + +function hasVisibleOverflow(node: RuntimeInstance): boolean { + const kind = node.vnode.kind; + if (kind !== "row" && kind !== "column" && kind !== "grid" && kind !== "box") { + return false; + } + if (node.children.length === 0) { + return false; + } + const props = node.vnode.props as OverflowProps; + return props.overflow !== "hidden" && props.overflow !== "scroll"; +} + +function isTransparentOverflowWrapper(node: RuntimeInstance): boolean { + const kind = node.vnode.kind; + return kind === "themed" || kind === "focusZone" || kind === "focusTrap"; +} + +export function subtreeCanOverflowBounds(node: RuntimeInstance): boolean { + const stack: RuntimeInstance[] = [node]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (hasVisibleOverflow(current) || hasBoxShadowOverflow(current)) { + return true; + } + if (!isTransparentOverflowWrapper(current)) { + continue; + } + for (let i = current.children.length - 1; i >= 0; i--) { + const child = current.children[i]; + if (child) { + stack.push(child); + } + } + } + return false; +} diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index d5e4eae8..5ff5315d 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -15,6 +15,7 @@ import type { Theme } from "../../theme/theme.js"; import type { CommandItem } from "../../widgets/types.js"; import { getRuntimeNodeDamageRect } from "./damageBounds.js"; import type { IdRectIndex } from "./indices.js"; +import { subtreeCanOverflowBounds } from "./overflowCulling.js"; import type { ResolvedTextStyle } from "./textStyle.js"; import type { CodeEditorRenderCache, @@ -59,18 +60,6 @@ function rectIntersects(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } -function usesVisibleOverflow(node: RuntimeInstance): boolean { - const kind = node.vnode.kind; - if (kind !== "row" && kind !== "column" && kind !== "grid" && kind !== "box") { - return false; - } - if (node.children.length === 0) { - return false; - } - const props = node.vnode.props as { overflow?: unknown }; - return props.overflow !== "hidden" && props.overflow !== "scroll"; -} - export function renderTree( builder: DrawlistBuilder, focusState: FocusState, @@ -142,7 +131,7 @@ export function renderTree( if ( damageRect && !rectIntersects(getRuntimeNodeDamageRect(node, rect), damageRect) && - !usesVisibleOverflow(node) + !subtreeCanOverflowBounds(node) ) { continue; } @@ -175,7 +164,7 @@ export function renderTree( if ( damageRect && !rectIntersects(getRuntimeNodeDamageRect(child, childLayout.rect), damageRect) && - !usesVisibleOverflow(child) + !subtreeCanOverflowBounds(child) ) { continue; } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 682bd58f..63eb7e78 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -102,6 +102,20 @@ function clipEquals(a: ClipRect | undefined, b: ClipRect): boolean { return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; } +function pushChildClipIfNeeded( + builder: DrawlistBuilder, + nodeStack: (RuntimeInstance | null)[], + insertIndex: number, + currentClip: ClipRect | undefined, + childClip: ClipRect | undefined, + queuedChildCount: number, +): void { + if (queuedChildCount <= 0 || !childClip || clipEquals(currentClip, childClip)) return; + builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); + // Keep clip pop sentinel below queued children so pop runs after subtree render. + nodeStack.splice(insertIndex, 0, null); +} + function rectIntersects(a: Rect, b: Rect): boolean { if (a.w <= 0 || a.h <= 0 || b.w <= 0 || b.h <= 0) return false; return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; @@ -177,9 +191,9 @@ function pushChildrenWithLayout( skipCleanSubtrees: boolean, forceSubtreeRender: boolean, stackDirection: "row" | "column" | undefined = undefined, -): void { +): number { const childCount = Math.min(node.children.length, layoutNode.children.length); - if (childCount <= 0) return; + if (childCount <= 0) return 0; let rangeStart = 0; let rangeEnd = childCount - 1; @@ -201,13 +215,14 @@ function pushChildrenWithLayout( stackDirection, damageRect, ); - if (!range) return; + if (!range) return 0; rangeStart = range.start; rangeEnd = range.end; } } } + let pushedChildren = 0; for (let i = rangeEnd; i >= rangeStart; i--) { const c = node.children[i]; const lc = layoutNode.children[i]; @@ -228,8 +243,10 @@ function pushChildrenWithLayout( styleStack.push(style); layoutStack.push(lc); clipStack.push(clip); + pushedChildren++; } } + return pushedChildren; } function readShadowDensity(raw: unknown): "light" | "medium" | "dense" | undefined { @@ -578,11 +595,8 @@ export function renderContainerWidget( childClip = viewportWithScrollbars.viewportRect; } - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, style, @@ -596,6 +610,14 @@ export function renderContainerWidget( forceChildrenRender, vnode.kind === "row" || vnode.kind === "column" ? vnode.kind : undefined, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "box": { @@ -732,11 +754,8 @@ export function renderContainerWidget( childClip = viewportWithScrollbars.viewportRect; } - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, style, @@ -749,6 +768,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "modal": { @@ -795,11 +822,8 @@ export function renderContainerWidget( const ch = clampNonNegative(rect.h - 2); const childClip: ClipRect = { x: cx, y: cy, w: cw, h: ch }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(cx, cy, cw, ch); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, surfaceStyle, @@ -812,6 +836,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "focusZone": @@ -894,11 +926,8 @@ export function renderContainerWidget( h: clampNonNegative(rect.h - borderInset * 2), } : currentClip; - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, layerStyle, @@ -911,6 +940,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "splitPane": { @@ -921,13 +958,10 @@ export function renderContainerWidget( const dividerStyle = mergeTextStyle(parentStyle, { fg: theme.colors.border }); const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } + const childStackInsertIndex = nodeStack.length; // Render children (handled by layout) - pushChildrenWithLayout( + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -940,6 +974,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); // Render dividers between panels const childCount = Math.min(node.children.length, layoutNode.children.length); @@ -982,11 +1024,8 @@ export function renderContainerWidget( if (!isVisibleRect(rect)) break; const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -999,6 +1038,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "resizablePanel": { @@ -1006,11 +1053,8 @@ export function renderContainerWidget( if (!isVisibleRect(rect)) break; const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -1023,6 +1067,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } default: diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts index 1c2bbd53..bc3924e6 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts @@ -222,15 +222,8 @@ export function drawPlaceholderBox( builder.popClip(); } -function align4(value: number): number { - return (value + 3) & ~3; -} - export function addBlobAligned(builder: DrawlistBuilder, bytes: Uint8Array): number | null { - if ((bytes.byteLength & 3) === 0) return builder.addBlob(bytes); - const padded = new Uint8Array(align4(bytes.byteLength)); - padded.set(bytes); - return builder.addBlob(padded); + return builder.addBlob(bytes); } export function rgbToHex(color: ReturnType): string { diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts index cf935228..8dde3447 100644 --- a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -3,7 +3,7 @@ import { ui } from "../../index.js"; import { commitVNodeTree } from "../commit.js"; import { createInstanceIdAllocator } from "../instance.js"; -test("commit: leaf fast reuse does not ignore textOverflow changes", () => { +test("commit: leaf update applies textOverflow changes in-place", () => { const allocator = createInstanceIdAllocator(1); const v0 = ui.text("hello"); @@ -14,12 +14,14 @@ test("commit: leaf fast reuse does not ignore textOverflow changes", () => { const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); - assert.notEqual(c1.value.root, c0.value.root); + assert.equal(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { textOverflow?: unknown }; assert.equal(nextProps.textOverflow, "ellipsis"); + assert.equal(c1.value.root.selfDirty, true); + assert.equal(c1.value.root.dirty, true); }); -test("commit: leaf fast reuse does not ignore text id changes", () => { +test("commit: leaf update applies text id changes in-place", () => { const allocator = createInstanceIdAllocator(1); const v0 = ui.text("hello", { id: "a" }); @@ -30,9 +32,11 @@ test("commit: leaf fast reuse does not ignore text id changes", () => { const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); - assert.notEqual(c1.value.root, c0.value.root); + assert.equal(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { id?: unknown }; assert.equal(nextProps.id, "b"); + assert.equal(c1.value.root.selfDirty, true); + assert.equal(c1.value.root.dirty, true); }); test("commit: leaf fast reuse records reusedInstanceIds", () => { @@ -155,3 +159,33 @@ test("commit: semantically equal box transition keeps fast reuse", () => { assert.equal(c1.value.root, c0.value.root); }); + +test("commit: container fast-reuse rewrites parent committed vnode children", () => { + const allocator = createInstanceIdAllocator(1); + + const c0 = commitVNodeTree( + null, + ui.column({}, [ui.text("child", { style: { strikethrough: false } })]), + { allocator }, + ); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const c1 = commitVNodeTree( + c0.value.root, + ui.column({}, [ui.text("child", { style: { strikethrough: true } })]), + { allocator }, + ); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + assert.equal(c1.value.root, c0.value.root); + + const parentVNode = c1.value.root.vnode as { children?: readonly { props?: unknown }[] }; + const vnodeChild = parentVNode.children?.[0]; + assert.ok(vnodeChild !== undefined); + assert.equal(vnodeChild, c1.value.root.children[0]?.vnode); + + const childProps = (vnodeChild?.props ?? {}) as { + style?: { strikethrough?: unknown }; + }; + assert.equal(childProps.style?.strikethrough, true); +}); diff --git a/packages/core/src/theme/__tests__/theme.extend.test.ts b/packages/core/src/theme/__tests__/theme.extend.test.ts index 907cc2a4..f67fae1f 100644 --- a/packages/core/src/theme/__tests__/theme.extend.test.ts +++ b/packages/core/src/theme/__tests__/theme.extend.test.ts @@ -110,7 +110,8 @@ describe("theme.extend", () => { assert.notEqual(extended.colors, mutableBase.colors); assert.notEqual(extended.colors.bg, mutableBase.colors.bg); - assert.notEqual(extended.colors.bg.base, mutableBase.colors.bg.base); + // Packed Rgb24 primitives are value-equal; verify the inherited value is preserved. + assert.equal(extended.colors.bg.base, mutableBase.colors.bg.base); assert.equal(mutableBase.colors.bg.base, darkTheme.colors.bg.base); }); diff --git a/packages/core/src/theme/__tests__/theme.validation.test.ts b/packages/core/src/theme/__tests__/theme.validation.test.ts index 5d36a8da..06458347 100644 --- a/packages/core/src/theme/__tests__/theme.validation.test.ts +++ b/packages/core/src/theme/__tests__/theme.validation.test.ts @@ -174,63 +174,53 @@ describe("theme.validateTheme", () => { ); }); - test("throws when RGB channel is greater than 255", () => { + test("throws when color value exceeds 0x00FFFFFF", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "r"], 256); + setPath(theme, ["colors", "accent", "primary"], 0x01000000); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.r: channel "r" must be an integer 0..255 (received 256)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 16777216)", ); }); - test("throws when RGB channel is less than 0", () => { + test("throws when color value is negative", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "g"], -1); + setPath(theme, ["colors", "accent", "primary"], -1); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.g: channel "g" must be an integer 0..255 (received -1)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received -1)", ); }); - test("throws when RGB channel is non-integer", () => { + test("throws when color value is non-integer", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "b"], 1.5); + setPath(theme, ["colors", "accent", "primary"], 1.5); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.b: channel "b" must be an integer 0..255 (received 1.5)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 1.5)", ); }); - test("throws when RGB channel is not a number", () => { + test("throws when color value is not a number", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "r"], "255"); + setPath(theme, ["colors", "accent", "primary"], "255"); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.r: channel "r" must be an integer 0..255 (received "255")', + 'Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received "255")', ); }); - test("throws when RGB channel is missing", () => { + test("throws when color value is an object", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "g"], undefined); + setPath(theme, ["colors", "info"], { r: 0, g: 0, b: 255 }); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.g: channel "g" must be an integer 0..255 (received undefined)', - ); - }); - - test("throws when a color token is not an RGB object", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "info"], 7); - - expectValidationError( - theme, - "Theme validation failed at colors.info: expected RGB object { r, g, b } (received 7)", + "Theme validation failed at colors.info: expected packed Rgb24 integer 0..0x00FFFFFF (received [object])", ); }); diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index 16ac9f28..5d7a665a 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -68,10 +68,10 @@ export type ResolveColorResult = { ok: true; value: Rgb24 } | { ok: false; error * @example * ```typescript * const color = resolveColorToken(darkTheme, "fg.primary"); - * // { r: 230, g: 225, b: 207 } + * // 0xe6e1cf (packed Rgb24) * * const error = resolveColorToken(darkTheme, "error"); - * // { r: 240, g: 113, b: 120 } + * // 0xf07178 (packed Rgb24) * ``` */ export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb24; diff --git a/packages/core/src/theme/validate.ts b/packages/core/src/theme/validate.ts index b025a435..4dfb3a4a 100644 --- a/packages/core/src/theme/validate.ts +++ b/packages/core/src/theme/validate.ts @@ -92,25 +92,16 @@ function throwMissingPaths(theme: unknown): void { } function validateRgb(path: string, value: unknown): void { - if (!isRecord(value)) { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 0 || + value > 0x00ffffff + ) { throw new Error( - `Theme validation failed at ${path}: expected RGB object { r, g, b } (received ${formatValue(value)})`, + `Theme validation failed at ${path}: expected packed Rgb24 integer 0..0x00FFFFFF (received ${formatValue(value)})`, ); } - - for (const channel of ["r", "g", "b"] as const) { - const channelValue = value[channel]; - if ( - typeof channelValue !== "number" || - !Number.isInteger(channelValue) || - channelValue < 0 || - channelValue > 255 - ) { - throw new Error( - `Theme validation failed at ${path}.${channel}: channel "${channel}" must be an integer 0..255 (received ${formatValue(channelValue)})`, - ); - } - } } function validateSpacingValue(path: string, value: unknown): void { diff --git a/packages/core/src/widgets/__tests__/graphics.golden.test.ts b/packages/core/src/widgets/__tests__/graphics.golden.test.ts index bd133f7b..2a043e6d 100644 --- a/packages/core/src/widgets/__tests__/graphics.golden.test.ts +++ b/packages/core/src/widgets/__tests__/graphics.golden.test.ts @@ -1,4 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; +import { parseBlobById } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder, ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -332,19 +333,19 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { const payloadOff = findCommandPayload(actual, OP_DRAW_TEXT_RUN); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobIndex = u32(actual, payloadOff + 8); - const blobsSpanOffset = u32(actual, 44); - const blobsBytesOffset = u32(actual, 52); - const blobByteOff = u32(actual, blobsSpanOffset + blobIndex * 8); - const firstSegmentReserved = u32(actual, blobsBytesOffset + blobByteOff + 4 + 12); - const firstSegmentUnderlineRgb = u32(actual, blobsBytesOffset + blobByteOff + 4 + 16); - const thirdSegmentReserved = u32(actual, blobsBytesOffset + blobByteOff + 4 + 12 + 40 * 2); - const thirdSegmentUnderlineRgb = u32(actual, blobsBytesOffset + blobByteOff + 4 + 16 + 40 * 2); + const blobId = u32(actual, payloadOff + 8); + const blob = parseBlobById(actual, blobId); + assert.equal(blob !== null, true); + if (!blob) return; + const firstSegmentReserved = u32(blob, 4 + 12); + const firstSegmentUnderlineRgb = u32(blob, 4 + 16); + const thirdSegmentReserved = u32(blob, 4 + 12 + 40 * 2); + const thirdSegmentUnderlineRgb = u32(blob, 4 + 16 + 40 * 2); const firstDecoded = decodeStyleV3(firstSegmentReserved, firstSegmentUnderlineRgb); const thirdDecoded = decodeStyleV3(thirdSegmentReserved, thirdSegmentUnderlineRgb); assert.deepEqual(firstDecoded, { underlineStyle: 3, - underlineColorRgb: 0xff3366, + underlineColorRgb: 0xffffff, }); assert.deepEqual(thirdDecoded, { underlineStyle: 5, diff --git a/packages/core/src/widgets/__tests__/style.inheritance.test.ts b/packages/core/src/widgets/__tests__/style.inheritance.test.ts index f398feeb..85533af5 100644 --- a/packages/core/src/widgets/__tests__/style.inheritance.test.ts +++ b/packages/core/src/widgets/__tests__/style.inheritance.test.ts @@ -32,6 +32,25 @@ function resolveRootBoxRowText( return resolveChain([root, box, row, text]); } +function computeExpectedAttrs(style: Readonly): number { + let attrs = 0; + if (style.bold) attrs |= 1 << 0; + if (style.italic) attrs |= 1 << 1; + if (style.underline || (style.underlineStyle !== undefined && style.underlineStyle !== "none")) { + attrs |= 1 << 2; + } + if (style.inverse) attrs |= 1 << 3; + if (style.dim) attrs |= 1 << 4; + if (style.strikethrough) attrs |= 1 << 5; + if (style.overline) attrs |= 1 << 6; + if (style.blink) attrs |= 1 << 7; + return attrs >>> 0; +} + +function withAttrs(style: T): T & Readonly<{ attrs: number }> { + return { ...style, attrs: computeExpectedAttrs(style) }; +} + describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { test("inherits root fg/bg when Box, Row, and Text are unset", () => { const resolved = resolveRootBoxRowText( @@ -41,11 +60,11 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, bold: true, - }); + })); }); test("Box fg override wins while bg still inherits from Root", () => { @@ -56,10 +75,10 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: BOX_FG, bg: ROOT_BG, - }); + })); }); test("Row bg override wins while fg inherits from Box", () => { @@ -70,10 +89,10 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: BOX_FG, bg: ROW_BG, - }); + })); }); test("Text override wins over Row/Box/Root and unset fields continue inheriting", () => { @@ -84,13 +103,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { fg: TEXT_FG, bold: true }, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: TEXT_FG, bg: ROOT_BG, bold: true, italic: true, underline: true, - }); + })); }); test("Text with no local overrides inherits nearest defined values", () => { @@ -101,13 +120,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { {}, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, bold: true, dim: false, italic: true, - }); + })); }); test("Explicit false in Box overrides Root true through deeper unset descendants", () => { @@ -118,11 +137,11 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, underline: false, - }); + })); }); test("Explicit true in Text overrides Root false", () => { @@ -133,12 +152,12 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { blink: true }, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, italic: true, blink: true, - }); + })); }); test("fg/bg inheritance stays independent across levels with mixed overrides", () => { @@ -149,12 +168,12 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { italic: true }, ); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: BOX_FG, bg: ROW_BG, dim: true, italic: true, - }); + })); }); }); @@ -173,11 +192,11 @@ describe("mergeTextStyle deep inheritance chains", () => { assert.equal(boxResolved === rootResolved, true); assert.equal(rowResolved === rootResolved, true); assert.equal(textResolved === rootResolved, true); - assert.deepEqual(textResolved, { + assert.deepEqual(textResolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, inverse: true, - }); + })); }); test("8+ level chain resolves nearest ancestor values deterministically", () => { @@ -193,14 +212,14 @@ describe("mergeTextStyle deep inheritance chains", () => { { fg: TEXT_FG }, ]); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: TEXT_FG, bg: DEEP_BG, bold: false, dim: true, italic: true, underline: true, - }); + })); }); test("long undefined tail after deep chain preserves resolved object", () => { @@ -216,11 +235,11 @@ describe("mergeTextStyle deep inheritance chains", () => { } assert.equal(resolved === anchor, true); - assert.deepEqual(resolved, { + assert.deepEqual(resolved, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, overline: true, - }); + })); }); test("very deep chain (512 levels) remains deterministic without stack/logic regressions", () => { @@ -262,7 +281,7 @@ describe("mergeTextStyle deep inheritance chains", () => { }; if (expectedBlink !== undefined) expected.blink = expectedBlink; - assert.deepEqual(resolved, expected); + assert.deepEqual(resolved, withAttrs(expected)); }); }); @@ -285,11 +304,11 @@ describe("mergeTextStyle recompute after middle style unset", () => { const rowResolved = mergeTextStyle(boxUnset, { italic: true }); const after = mergeTextStyle(rowResolved, undefined); - assert.deepEqual(after, { + assert.deepEqual(after, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, italic: true, - }); + })); }); test("when Box bg is unset, descendants inherit Root bg after recompute", () => { @@ -299,10 +318,10 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: BOX_FG }, undefined, ); - assert.deepEqual(before, { + assert.deepEqual(before, withAttrs({ fg: BOX_FG, bg: BOX_BG, - }); + })); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG }, @@ -311,10 +330,10 @@ describe("mergeTextStyle recompute after middle style unset", () => { undefined, ); - assert.deepEqual(after, { + assert.deepEqual(after, withAttrs({ fg: BOX_FG, bg: ROOT_BG, - }); + })); }); test("middle unset recompute keeps Text override but re-inherits Root for unset fields", () => { @@ -324,12 +343,12 @@ describe("mergeTextStyle recompute after middle style unset", () => { { underline: true }, { fg: TEXT_FG }, ); - assert.deepEqual(before, { + assert.deepEqual(before, withAttrs({ fg: TEXT_FG, bg: BOX_BG, bold: false, underline: true, - }); + })); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -338,12 +357,12 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: TEXT_FG }, ); - assert.deepEqual(after, { + assert.deepEqual(after, withAttrs({ fg: TEXT_FG, bg: ROOT_BG, bold: true, underline: true, - }); + })); }); test("middle unset recompute restores higher-ancestor boolean when Text has no local override", () => { @@ -353,12 +372,12 @@ describe("mergeTextStyle recompute after middle style unset", () => { { italic: true }, {}, ); - assert.deepEqual(before, { + assert.deepEqual(before, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, bold: false, italic: true, - }); + })); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -367,11 +386,11 @@ describe("mergeTextStyle recompute after middle style unset", () => { {}, ); - assert.deepEqual(after, { + assert.deepEqual(after, withAttrs({ fg: ROOT_FG, bg: ROOT_BG, bold: true, italic: true, - }); + })); }); }); diff --git a/packages/core/src/widgets/__tests__/style.merge.test.ts b/packages/core/src/widgets/__tests__/style.merge.test.ts index 4d0e3a63..aec7a283 100644 --- a/packages/core/src/widgets/__tests__/style.merge.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge.test.ts @@ -151,6 +151,7 @@ describe("mergeTextStyle boolean merge correctness", () => { assert.deepEqual(merged, { fg: DEFAULT_BASE_STYLE.fg, bg: DEFAULT_BASE_STYLE.bg, + attrs: 246, bold: false, dim: true, italic: true, @@ -169,6 +170,7 @@ describe("mergeTextStyle boolean merge correctness", () => { assert.deepEqual(allFalse, { fg: DEFAULT_BASE_STYLE.fg, bg: DEFAULT_BASE_STYLE.bg, + attrs: 0, bold: false, dim: false, italic: false, diff --git a/packages/core/src/widgets/__tests__/style.utils.test.ts b/packages/core/src/widgets/__tests__/style.utils.test.ts index 2db2c8f4..c1df1f86 100644 --- a/packages/core/src/widgets/__tests__/style.utils.test.ts +++ b/packages/core/src/widgets/__tests__/style.utils.test.ts @@ -73,14 +73,15 @@ describe("style utils contracts", () => { assert.deepEqual(second, { inverse: false, blink: true }); }); - test("sanitizeRgb clamps channels and accepts numeric strings", () => { - const out = sanitizeRgb({ r: "260", g: -2, b: "127.6" }); - assert.deepEqual(out, (255 << 16) | (0 << 8) | 128); + test("sanitizeRgb clamps packed numeric RGB values", () => { + assert.equal(sanitizeRgb(-42), 0); + assert.equal(sanitizeRgb(0x0001_0203), 0x0001_0203); + assert.equal(sanitizeRgb(0x01ff_00ff), 0x00ff_ffff); }); test("sanitizeTextStyle drops invalid fields and coerces booleans", () => { const out = sanitizeTextStyle({ - fg: { r: 1.4, g: "2", b: 3.6 }, + fg: (1 << 16) | (2 << 8) | 4, bg: { r: "bad", g: 10, b: 20 }, bold: "TRUE", italic: "false", @@ -97,12 +98,12 @@ describe("style utils contracts", () => { test("mergeStyles sanitizes incoming style values", () => { const merged = mergeStyles({ fg: (0 << 16) | (0 << 8) | 0, bold: true }, { - fg: { r: 512, g: "-10", b: "3.2" }, + fg: 0x01ff_0013, bold: "false", } as unknown as TextStyle); assert.deepEqual(merged, { - fg: (255 << 16) | (0 << 8) | 3, + fg: 0x00ff_ffff, bold: false, }); }); diff --git a/packages/core/src/widgets/__tests__/styleUtils.test.ts b/packages/core/src/widgets/__tests__/styleUtils.test.ts index 2219cd24..d39d43d2 100644 --- a/packages/core/src/widgets/__tests__/styleUtils.test.ts +++ b/packages/core/src/widgets/__tests__/styleUtils.test.ts @@ -34,13 +34,9 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle preserves underlineColor rgb", () => { - assert.deepEqual( + assert.equal( sanitizeTextStyle({ underlineColor: (1 << 16) | (2 << 8) | 3 }).underlineColor, - { - r: 1, - g: 2, - b: 3, - }, + (1 << 16) | (2 << 8) | 3, ); }); @@ -56,7 +52,7 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle drops invalid underlineColor values", () => { - assert.equal(sanitizeTextStyle({ underlineColor: 42 }).underlineColor, undefined); + assert.equal(sanitizeTextStyle({ underlineColor: {} }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: null }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: "" }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: " " }).underlineColor, undefined); diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index 87021b03..5e57a997 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -355,7 +355,7 @@ function divider(props: DividerProps = {}): VNode { * @example * ```ts * ui.icon("status.check") - * ui.icon("arrow.right", { style: { fg: { r: 0, g: 255, b: 0 } } }) + * ui.icon("arrow.right", { style: { fg: rgb(0, 255, 0) } }) * ui.icon("ui.search", { fallback: true }) * ``` */ @@ -419,7 +419,7 @@ function skeleton(width: number, props: Omit = {}): VNod * @example * ```ts * ui.richText([ - * { text: "Error: ", style: { fg: { r: 255, g: 0, b: 0 }, bold: true } }, + * { text: "Error: ", style: { fg: rgb(255, 0, 0), bold: true } }, * { text: "File not found" }, * ]) * ``` @@ -1314,8 +1314,8 @@ export const ui = { * sortColumn: state.sortCol, * sortDirection: state.sortDir, * onSort: (col, dir) => app.update({ sortCol: col, sortDir: dir }), - * stripeStyle: { odd: { r: 30, g: 33, b: 41 } }, - * borderStyle: { variant: "double", color: { r: 130, g: 140, b: 150 } }, + * stripeStyle: { odd: rgb(30, 33, 41) }, + * borderStyle: { variant: "double", color: rgb(130, 140, 150) }, * }) * ``` */ diff --git a/packages/native/README.md b/packages/native/README.md index ac58a206..2699e7b1 100644 --- a/packages/native/README.md +++ b/packages/native/README.md @@ -18,11 +18,19 @@ Smoke test: npm -w @rezi-ui/native run test:native:smoke ``` +Vendoring integrity check: + +```bash +npm run check:native-vendor +``` + ## Design and constraints - Engine placement is controlled by `@rezi-ui/node` `executionMode` (`auto` | `worker` | `inline`). - `executionMode: "auto"` selects inline when `fpsCap <= 30`, worker otherwise. - All buffers across the boundary are caller-owned; binary formats are validated strictly. +- Native compilation reads `packages/native/vendor/zireael` (not `vendor/zireael`). +- `packages/native/vendor/VENDOR_COMMIT.txt` must match the `vendor/zireael` gitlink commit. See: diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index 572b4f5d..ccf946a4 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -337,6 +337,23 @@ mod ffi { pub fn zr_fb_release(fb: *mut zr_fb_t); pub fn zr_fb_cell(fb: *mut zr_fb_t, x: u32, y: u32) -> *mut zr_cell_t; pub fn zr_fb_clear(fb: *mut zr_fb_t, style: *const zr_style_t) -> ZrResultT; + pub fn zr_fb_links_clone_from(dst: *mut zr_fb_t, src: *const zr_fb_t) -> ZrResultT; + pub fn zr_fb_link_intern( + fb: *mut zr_fb_t, + uri: *const u8, + uri_len: usize, + id: *const u8, + id_len: usize, + out_link_ref: *mut u32, + ) -> ZrResultT; + pub fn zr_fb_link_lookup( + fb: *const zr_fb_t, + link_ref: u32, + out_uri: *mut *const u8, + out_uri_len: *mut usize, + out_id: *mut *const u8, + out_id_len: *mut usize, + ) -> ZrResultT; pub fn zr_fb_painter_begin( p: *mut zr_fb_painter_t, fb: *mut zr_fb_t, @@ -1662,6 +1679,20 @@ mod tests { (*cell).style = style; } } + + fn set_cell_link_ref(&mut self, x: u32, y: u32, link_ref: u32) { + let cell = unsafe { ffi::zr_fb_cell(&mut self.raw as *mut _, x, y) }; + assert!(!cell.is_null(), "zr_fb_cell({x},{y}) must return a valid pointer"); + unsafe { + (*cell).style.link_ref = link_ref; + } + } + + fn cell_link_ref(&mut self, x: u32, y: u32) -> u32 { + let cell = unsafe { ffi::zr_fb_cell(&mut self.raw as *mut _, x, y) }; + assert!(!cell.is_null(), "zr_fb_cell({x},{y}) must return a valid pointer"); + unsafe { (*cell).style.link_ref } + } } impl Drop for TestFramebuffer { @@ -1758,6 +1789,153 @@ mod tests { unsafe { ((*cell).glyph[0], (*cell).width) } } + #[test] + fn fb_links_clone_from_failure_has_no_partial_effects() { + let mut dst = TestFramebuffer::new(2, 1); + let uri = b"https://example.test/rezi"; + let mut link_ref = 0u32; + let intern_rc = unsafe { + ffi::zr_fb_link_intern( + &mut dst.raw as *mut _, + uri.as_ptr(), + uri.len(), + std::ptr::null(), + 0, + &mut link_ref as *mut _, + ) + }; + assert_eq!(intern_rc, ffi::ZR_OK, "zr_fb_link_intern must seed destination link state"); + assert_eq!(link_ref, 1u32); + + let before_links_ptr = dst.raw.links; + let before_links_len = dst.raw.links_len; + let before_links_cap = dst.raw.links_cap; + let before_link_bytes_ptr = dst.raw.link_bytes; + let before_link_bytes_len = dst.raw.link_bytes_len; + let before_link_bytes_cap = dst.raw.link_bytes_cap; + assert!(!before_links_ptr.is_null(), "seeded links pointer must be non-null"); + assert!(!before_link_bytes_ptr.is_null(), "seeded link-bytes pointer must be non-null"); + + let before_first_link = unsafe { *before_links_ptr }; + let before_link_bytes = + unsafe { std::slice::from_raw_parts(before_link_bytes_ptr, before_link_bytes_len as usize).to_vec() }; + + let invalid_src = ffi::zr_fb_t { + cols: dst.raw.cols, + rows: dst.raw.rows, + cells: dst.raw.cells, + links: std::ptr::null_mut(), + links_len: 1, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: before_link_bytes_len, + link_bytes_cap: 0, + }; + let clone_rc = unsafe { ffi::zr_fb_links_clone_from(&mut dst.raw as *mut _, &invalid_src as *const _) }; + assert_eq!(clone_rc, ffi::ZR_ERR_INVALID_ARGUMENT); + + assert_eq!(dst.raw.links, before_links_ptr); + assert_eq!(dst.raw.links_len, before_links_len); + assert_eq!(dst.raw.links_cap, before_links_cap); + assert_eq!(dst.raw.link_bytes, before_link_bytes_ptr); + assert_eq!(dst.raw.link_bytes_len, before_link_bytes_len); + assert_eq!(dst.raw.link_bytes_cap, before_link_bytes_cap); + + let after_first_link = unsafe { *dst.raw.links }; + assert_eq!(after_first_link.uri_off, before_first_link.uri_off); + assert_eq!(after_first_link.uri_len, before_first_link.uri_len); + assert_eq!(after_first_link.id_off, before_first_link.id_off); + assert_eq!(after_first_link.id_len, before_first_link.id_len); + + let after_link_bytes = + unsafe { std::slice::from_raw_parts(dst.raw.link_bytes, dst.raw.link_bytes_len as usize) }; + assert_eq!(after_link_bytes, before_link_bytes.as_slice()); + } + + #[test] + fn fb_link_intern_compacts_stale_refs_and_bounds_growth() { + const LINK_ENTRY_MAX_BYTES: u32 = 2083 + 2083; + let mut fb = TestFramebuffer::new(2, 1); + let persistent_uri = b"https://example.test/persistent"; + + let mut persistent_ref = 0u32; + let seed_rc = unsafe { + ffi::zr_fb_link_intern( + &mut fb.raw as *mut _, + persistent_uri.as_ptr(), + persistent_uri.len(), + std::ptr::null(), + 0, + &mut persistent_ref as *mut _, + ) + }; + assert_eq!(seed_rc, ffi::ZR_OK); + assert_ne!(persistent_ref, 0); + fb.set_cell_link_ref(0, 0, persistent_ref); + + let mut peak_links_len = fb.raw.links_len; + let mut peak_link_bytes_len = fb.raw.link_bytes_len; + + for i in 0..64u32 { + let uri = format!("https://example.test/ephemeral/{i}"); + let mut ref_i = 0u32; + let rc = unsafe { + ffi::zr_fb_link_intern( + &mut fb.raw as *mut _, + uri.as_ptr(), + uri.len(), + std::ptr::null(), + 0, + &mut ref_i as *mut _, + ) + }; + assert_eq!(rc, ffi::ZR_OK, "zr_fb_link_intern failed at iteration {i}"); + assert!(ref_i >= 1 && ref_i <= fb.raw.links_len); + + fb.set_cell_link_ref(1, 0, ref_i); + + let live_ref0 = fb.cell_link_ref(0, 0); + let live_ref1 = fb.cell_link_ref(1, 0); + assert!(live_ref0 >= 1 && live_ref0 <= fb.raw.links_len, "cell(0,0) link_ref must remain valid"); + assert!(live_ref1 >= 1 && live_ref1 <= fb.raw.links_len, "cell(1,0) link_ref must remain valid"); + + peak_links_len = peak_links_len.max(fb.raw.links_len); + peak_link_bytes_len = peak_link_bytes_len.max(fb.raw.link_bytes_len); + } + + assert!( + peak_links_len <= 5, + "link table must stay bounded for 2-cell framebuffer (peak={peak_links_len})", + ); + assert!( + peak_link_bytes_len <= 5 * LINK_ENTRY_MAX_BYTES, + "link byte arena must stay bounded for 2-cell framebuffer (peak={peak_link_bytes_len})", + ); + + let mut uri_ptr: *const u8 = std::ptr::null(); + let mut uri_len: usize = 0; + let mut id_ptr: *const u8 = std::ptr::null(); + let mut id_len: usize = 0; + let persistent_cell_ref = fb.cell_link_ref(0, 0); + let lookup_rc = unsafe { + ffi::zr_fb_link_lookup( + &fb.raw as *const _, + persistent_cell_ref, + &mut uri_ptr as *mut _, + &mut uri_len as *mut _, + &mut id_ptr as *mut _, + &mut id_len as *mut _, + ) + }; + assert_eq!(lookup_rc, ffi::ZR_OK); + assert_eq!(id_len, 0); + assert!(id_ptr.is_null()); + assert!(!uri_ptr.is_null()); + + let resolved_uri = unsafe { std::slice::from_raw_parts(uri_ptr, uri_len) }; + assert_eq!(resolved_uri, persistent_uri); + } + #[test] fn ffi_layout_matches_vendored_headers() { use std::mem::{align_of, size_of}; diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index 886d5b72..c3c5074d 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -3e70a0f8407cdc073432372d42af7a854e176f5c +268e21f51e1c2b32fd6975c3be185e28a3738736 diff --git a/packages/native/vendor/zireael/include/zr/zr_caps.h b/packages/native/vendor/zireael/include/zr/zr_caps.h index cb27c3f9..933870c1 100644 --- a/packages/native/vendor/zireael/include/zr/zr_caps.h +++ b/packages/native/vendor/zireael/include/zr/zr_caps.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_CAPS_H_INCLUDED #define ZR_ZR_CAPS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_result.h" #include @@ -46,4 +50,8 @@ zr_limits_t zr_limits_default(void); /* Validate limit values and relationships. */ zr_result_t zr_limits_validate(const zr_limits_t* limits); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_CAPS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_config.h b/packages/native/vendor/zireael/include/zr/zr_config.h index b751f787..4e7ddb95 100644 --- a/packages/native/vendor/zireael/include/zr/zr_config.h +++ b/packages/native/vendor/zireael/include/zr/zr_config.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_CONFIG_H_INCLUDED #define ZR_ZR_CONFIG_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_caps.h" #include "zr/zr_platform_types.h" #include "zr/zr_result.h" @@ -92,4 +96,8 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg); /* Validate runtime config for engine_set_config. */ zr_result_t zr_engine_runtime_config_validate(const zr_engine_runtime_config_t* cfg); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_CONFIG_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_debug.h b/packages/native/vendor/zireael/include/zr/zr_debug.h index 7bd7b38f..6f920b65 100644 --- a/packages/native/vendor/zireael/include/zr/zr_debug.h +++ b/packages/native/vendor/zireael/include/zr/zr_debug.h @@ -14,6 +14,10 @@ #ifndef ZR_ZR_DEBUG_H_INCLUDED #define ZR_ZR_DEBUG_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_result.h" #include @@ -220,4 +224,8 @@ typedef struct zr_debug_stats_t { */ zr_debug_config_t zr_debug_config_default(void); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_DEBUG_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_drawlist.h b/packages/native/vendor/zireael/include/zr/zr_drawlist.h index 4ceffb80..4b10cd03 100644 --- a/packages/native/vendor/zireael/include/zr/zr_drawlist.h +++ b/packages/native/vendor/zireael/include/zr/zr_drawlist.h @@ -1,5 +1,5 @@ /* - include/zr/zr_drawlist.h — Drawlist ABI structs (v1/v2). + include/zr/zr_drawlist.h — Drawlist ABI structs (v1). Why: Defines the little-endian drawlist command stream used by wrappers to drive rendering through engine_submit_drawlist(). @@ -8,6 +8,10 @@ #ifndef ZR_ZR_DRAWLIST_H_INCLUDED #define ZR_ZR_DRAWLIST_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* ABI-facing types (little-endian on-wire). */ @@ -260,4 +264,8 @@ typedef struct zr_dl_cmd_free_resource_t { uint32_t id; } zr_dl_cmd_free_resource_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_DRAWLIST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_engine.h b/packages/native/vendor/zireael/include/zr/zr_engine.h index 1af9dfb8..dfa5c20c 100644 --- a/packages/native/vendor/zireael/include/zr/zr_engine.h +++ b/packages/native/vendor/zireael/include/zr/zr_engine.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_ENGINE_H_INCLUDED #define ZR_ZR_ENGINE_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_config.h" #include "zr/zr_debug.h" #include "zr/zr_metrics.h" @@ -199,4 +203,8 @@ int32_t engine_debug_export(zr_engine_t* e, uint8_t* out_buf, size_t out_cap); /* Clear trace records while keeping tracing enabled. */ void engine_debug_reset(zr_engine_t* e); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_ENGINE_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_event.h b/packages/native/vendor/zireael/include/zr/zr_event.h index fbf3a7e5..90f2f804 100644 --- a/packages/native/vendor/zireael/include/zr/zr_event.h +++ b/packages/native/vendor/zireael/include/zr/zr_event.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_EVENT_H_INCLUDED #define ZR_ZR_EVENT_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_version.h" #include @@ -188,4 +192,8 @@ typedef struct zr_ev_user_t { uint32_t reserved1; } zr_ev_user_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_EVENT_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_metrics.h b/packages/native/vendor/zireael/include/zr/zr_metrics.h index 7b0d5407..13ca8d6a 100644 --- a/packages/native/vendor/zireael/include/zr/zr_metrics.h +++ b/packages/native/vendor/zireael/include/zr/zr_metrics.h @@ -9,6 +9,10 @@ #ifndef ZR_ZR_METRICS_H_INCLUDED #define ZR_ZR_METRICS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* @@ -64,8 +68,12 @@ typedef struct zr_metrics_t { /* --- Damage summary (last frame) --- */ uint32_t damage_rects_last_frame; uint32_t damage_cells_last_frame; - uint8_t damage_full_frame; - uint8_t _pad2[3]; + uint8_t damage_full_frame; + uint8_t _pad2[3]; } zr_metrics_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_METRICS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_platform_types.h b/packages/native/vendor/zireael/include/zr/zr_platform_types.h index cb88a2d9..939b3d86 100644 --- a/packages/native/vendor/zireael/include/zr/zr_platform_types.h +++ b/packages/native/vendor/zireael/include/zr/zr_platform_types.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_PLATFORM_TYPES_H_INCLUDED #define ZR_ZR_PLATFORM_TYPES_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* @@ -69,4 +73,8 @@ typedef struct plat_config_t { uint8_t _pad[3]; } plat_config_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_PLATFORM_TYPES_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_result.h b/packages/native/vendor/zireael/include/zr/zr_result.h index e4b40e19..fa585f70 100644 --- a/packages/native/vendor/zireael/include/zr/zr_result.h +++ b/packages/native/vendor/zireael/include/zr/zr_result.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_RESULT_H_INCLUDED #define ZR_ZR_RESULT_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + typedef int zr_result_t; /* Success. */ @@ -25,4 +29,8 @@ typedef int zr_result_t; #define ZR_ERR_FORMAT ((zr_result_t) - 5) #define ZR_ERR_PLATFORM ((zr_result_t) - 6) +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_RESULT_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h b/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h index 5355b81c..6b4aadef 100644 --- a/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h +++ b/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h @@ -9,6 +9,10 @@ #ifndef ZR_ZR_TERMINAL_CAPS_H_INCLUDED #define ZR_ZR_TERMINAL_CAPS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_platform_types.h" #include @@ -157,4 +161,8 @@ typedef struct zr_terminal_caps_t { zr_terminal_cap_flags_t cap_suppress_flags; } zr_terminal_caps_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_TERMINAL_CAPS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_version.h b/packages/native/vendor/zireael/include/zr/zr_version.h index f6261076..6891b7fd 100644 --- a/packages/native/vendor/zireael/include/zr/zr_version.h +++ b/packages/native/vendor/zireael/include/zr/zr_version.h @@ -8,13 +8,17 @@ #ifndef ZR_ZR_VERSION_H_INCLUDED #define ZR_ZR_VERSION_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + /* NOTE: These version pins are part of the determinism contract. They must not be overridden by downstream builds. */ #if defined(ZR_LIBRARY_VERSION_MAJOR) || defined(ZR_LIBRARY_VERSION_MINOR) || defined(ZR_LIBRARY_VERSION_PATCH) || \ defined(ZR_ENGINE_ABI_MAJOR) || defined(ZR_ENGINE_ABI_MINOR) || defined(ZR_ENGINE_ABI_PATCH) || \ - defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_DRAWLIST_VERSION_V2) || defined(ZR_EVENT_BATCH_VERSION_V1) + defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_EVENT_BATCH_VERSION_V1) #error "Zireael version pins are locked; do not override ZR_*_VERSION_* macros." #endif @@ -28,11 +32,14 @@ #define ZR_ENGINE_ABI_MINOR (2u) #define ZR_ENGINE_ABI_PATCH (0u) -/* Drawlist binary format versions. */ +/* Drawlist binary format version (current protocol baseline). */ #define ZR_DRAWLIST_VERSION_V1 (1u) -#define ZR_DRAWLIST_VERSION_V2 (2u) /* Packed event batch binary format versions. */ #define ZR_EVENT_BATCH_VERSION_V1 (1u) +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_VERSION_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/core/zr_config.c b/packages/native/vendor/zireael/src/core/zr_config.c index 05ad8ee3..62d8e15a 100644 --- a/packages/native/vendor/zireael/src/core/zr_config.c +++ b/packages/native/vendor/zireael/src/core/zr_config.c @@ -139,12 +139,8 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg) { return ZR_ERR_UNSUPPORTED; } - if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 && - cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V2) { - return ZR_ERR_UNSUPPORTED; - } - - if (cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { + if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 || + cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } diff --git a/packages/native/vendor/zireael/src/core/zr_cursor.h b/packages/native/vendor/zireael/src/core/zr_cursor.h index 169b62f7..267c9ac8 100644 --- a/packages/native/vendor/zireael/src/core/zr_cursor.h +++ b/packages/native/vendor/zireael/src/core/zr_cursor.h @@ -12,9 +12,9 @@ #include typedef uint8_t zr_cursor_shape_t; -#define ZR_CURSOR_SHAPE_BLOCK ((zr_cursor_shape_t)0u) +#define ZR_CURSOR_SHAPE_BLOCK ((zr_cursor_shape_t)0u) #define ZR_CURSOR_SHAPE_UNDERLINE ((zr_cursor_shape_t)1u) -#define ZR_CURSOR_SHAPE_BAR ((zr_cursor_shape_t)2u) +#define ZR_CURSOR_SHAPE_BAR ((zr_cursor_shape_t)2u) /* zr_cursor_state_t: @@ -32,4 +32,3 @@ typedef struct zr_cursor_state_t { } zr_cursor_state_t; #endif /* ZR_CORE_ZR_CURSOR_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/core/zr_damage.c b/packages/native/vendor/zireael/src/core/zr_damage.c index 58b37b99..1e83aa13 100644 --- a/packages/native/vendor/zireael/src/core/zr_damage.c +++ b/packages/native/vendor/zireael/src/core/zr_damage.c @@ -46,6 +46,7 @@ static void zr_damage_mark_full(zr_damage_t* d) { d->rects[0].y0 = 0u; d->rects[0].x1 = d->cols - 1u; d->rects[0].y1 = d->rows - 1u; + d->rects[0]._link = UINT32_MAX; d->rect_count = 1u; } @@ -97,6 +98,7 @@ void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1) { r->y0 = y; r->x1 = x1; r->y1 = y; + r->_link = UINT32_MAX; } uint32_t zr_damage_cells(const zr_damage_t* d) { diff --git a/packages/native/vendor/zireael/src/core/zr_damage.h b/packages/native/vendor/zireael/src/core/zr_damage.h index 342c4072..c819b964 100644 --- a/packages/native/vendor/zireael/src/core/zr_damage.h +++ b/packages/native/vendor/zireael/src/core/zr_damage.h @@ -16,22 +16,30 @@ typedef struct zr_damage_rect_t { uint32_t y0; uint32_t x1; uint32_t y1; + /* + Scratch link field for allocation-free damage coalescing. + + Why: The diff renderer's indexed damage-walk needs per-rectangle "next" + pointers but must not clobber the rectangle coordinates because the engine + can reuse the computed rectangles after diff emission (e.g. for fb_prev + resync on partial presents). + */ + uint32_t _link; } zr_damage_rect_t; typedef struct zr_damage_t { zr_damage_rect_t* rects; - uint32_t rect_cap; - uint32_t rect_count; - uint32_t cols; - uint32_t rows; - uint8_t full_frame; - uint8_t _pad0[3]; + uint32_t rect_cap; + uint32_t rect_count; + uint32_t cols; + uint32_t rows; + uint8_t full_frame; + uint8_t _pad0[3]; } zr_damage_t; -void zr_damage_begin_frame(zr_damage_t* d, zr_damage_rect_t* storage, uint32_t storage_cap, uint32_t cols, - uint32_t rows); -void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1); +void zr_damage_begin_frame(zr_damage_t* d, zr_damage_rect_t* storage, uint32_t storage_cap, uint32_t cols, + uint32_t rows); +void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1); uint32_t zr_damage_cells(const zr_damage_t* d); #endif /* ZR_CORE_ZR_DAMAGE_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/core/zr_diff.c b/packages/native/vendor/zireael/src/core/zr_diff.c index 59e33840..d4e35f57 100644 --- a/packages/native/vendor/zireael/src/core/zr_diff.c +++ b/packages/native/vendor/zireael/src/core/zr_diff.c @@ -311,65 +311,109 @@ static bool zr_row_eq_exact(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uin return memcmp(pa, pb, row_bytes) == 0; } -static uint64_t zr_hash_bytes_fnv1a64(const uint8_t* bytes, size_t n) { - if (!bytes && n != 0u) { - return 0u; +static bool zr_fb_links_eq_exact(const zr_fb_t* a, const zr_fb_t* b) { + if (!a || !b) { + return false; } - uint64_t h = ZR_FNV64_OFFSET_BASIS; - for (size_t i = 0u; i < n; i++) { - h ^= (uint64_t)bytes[i]; - h *= ZR_FNV64_PRIME; + if (a->links_len != b->links_len || a->link_bytes_len != b->link_bytes_len) { + return false; + } + if (a->links_len == 0u && a->link_bytes_len == 0u) { + return true; + } + const bool links_ptr_bad = (a->links_len != 0u && (!a->links || !b->links)); + const bool bytes_ptr_bad = (a->link_bytes_len != 0u && (!a->link_bytes || !b->link_bytes)); + if (links_ptr_bad || bytes_ptr_bad) { + return false; } - return h; -} -static uint64_t zr_row_hash64(const zr_fb_t* fb, uint32_t y) { - if (!fb || y >= fb->rows) { - return 0u; + if (a->links_len != 0u) { + const size_t links_bytes = (size_t)a->links_len * sizeof(zr_fb_link_t); + if (memcmp(a->links, b->links, links_bytes) != 0) { + return false; + } } - const uint8_t* row = zr_fb_row_ptr(fb, y); - const size_t row_bytes = zr_fb_row_bytes(fb); - if (!row && row_bytes != 0u) { - return 0u; + if (a->link_bytes_len != 0u) { + if (memcmp(a->link_bytes, b->link_bytes, (size_t)a->link_bytes_len) != 0) { + return false; + } } - return zr_hash_bytes_fnv1a64(row, row_bytes); + + return true; } -static bool zr_fb_links_payload_equal(const zr_fb_t* a, const zr_fb_t* b) { - if (!a || !b) { +/* + Compare hyperlink targets for corresponding cells across framebuffer domains. + + Why: row hashing and exact byte compares do not include hyperlink payloads + (URI/ID). Two rows can be byte-identical while mapping the same link_ref + indices to different targets, so row-cache decisions must validate targets. +*/ +static bool zr_row_links_targets_eq(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uint32_t by) { + if (!a || !b || a->cols != b->cols) { return false; } - if (a->links_len != b->links_len || a->link_bytes_len != b->link_bytes_len) { + if (ay >= a->rows || by >= b->rows) { return false; } - if (a->links_len == 0u && a->link_bytes_len == 0u) { + if (a->cols == 0u) { return true; } - if ((a->links_len != 0u && (!a->links || !b->links)) || - (a->link_bytes_len != 0u && (!a->link_bytes || !b->link_bytes))) { - return false; + if (a->links_len == 0u && b->links_len == 0u) { + return true; } - if (a->links_len != 0u && - memcmp(a->links, b->links, (size_t)a->links_len * sizeof(zr_fb_link_t)) != 0) { + + const zr_cell_t* arow = (const zr_cell_t*)zr_fb_row_ptr(a, ay); + const zr_cell_t* brow = (const zr_cell_t*)zr_fb_row_ptr(b, by); + if (!arow || !brow) { return false; } - if (a->link_bytes_len != 0u && memcmp(a->link_bytes, b->link_bytes, (size_t)a->link_bytes_len) != 0) { - return false; + + uint32_t last_a_ref = 0u; + uint32_t last_b_ref = 0u; + for (uint32_t x = 0u; x < a->cols; x++) { + const uint32_t a_ref = arow[x].style.link_ref; + const uint32_t b_ref = brow[x].style.link_ref; + if (a_ref == 0u && b_ref == 0u) { + last_a_ref = 0u; + last_b_ref = 0u; + continue; + } + if (a_ref == last_a_ref && b_ref == last_b_ref) { + continue; + } + if (!zr_link_targets_eq(a, a_ref, b, b_ref)) { + return false; + } + last_a_ref = a_ref; + last_b_ref = b_ref; } + return true; } -static bool zr_row_has_link_ref(const zr_fb_t* fb, uint32_t y) { - if (!fb || !fb->cells || y >= fb->rows) { - return false; +static uint64_t zr_hash_bytes_fnv1a64(const uint8_t* bytes, size_t n) { + if (!bytes && n != 0u) { + return 0u; } - for (uint32_t x = 0u; x < fb->cols; x++) { - const zr_cell_t* c = zr_fb_cell_const(fb, x, y); - if (c && c->style.link_ref != 0u) { - return true; - } + uint64_t h = ZR_FNV64_OFFSET_BASIS; + for (size_t i = 0u; i < n; i++) { + h ^= (uint64_t)bytes[i]; + h *= ZR_FNV64_PRIME; } - return false; + return h; +} + +static uint64_t zr_row_hash64(const zr_fb_t* fb, uint32_t y) { + if (!fb || y >= fb->rows) { + return 0u; + } + const uint8_t* row = zr_fb_row_ptr(fb, y); + const size_t row_bytes = zr_fb_row_bytes(fb); + if (!row && row_bytes != 0u) { + return 0u; + } + return zr_hash_bytes_fnv1a64(row, row_bytes); } /* Return display width of cell at (x,y): 0 for continuation, 2 for wide, 1 otherwise. */ @@ -1160,7 +1204,7 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr ctx->has_row_cache = true; const bool reuse_prev_hashes = (scratch->prev_hashes_valid != 0u); - const bool links_payload_changed = !zr_fb_links_payload_equal(ctx->prev, ctx->next); + const bool links_exact_equal = zr_fb_links_eq_exact(ctx->prev, ctx->next); for (uint32_t y = 0u; y < ctx->next->rows; y++) { uint64_t prev_hash = 0u; @@ -1180,12 +1224,7 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr /* Collision guard: equal hash must still pass exact row-byte compare. */ dirty = 1u; ctx->stats.collision_guard_hits++; - } else if (links_payload_changed && - (zr_row_has_link_ref(ctx->prev, y) || zr_row_has_link_ref(ctx->next, y))) { - /* - Row bytes can remain identical while OSC8 payload bytes change underneath - reused link_ref values. Force linked rows dirty on link table changes. - */ + } else if (!links_exact_equal && !zr_row_links_targets_eq(ctx->prev, y, ctx->next, y)) { dirty = 1u; } @@ -1198,7 +1237,10 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr /* Compare full framebuffer rows for scroll-shift detection (full width). */ static bool zr_row_eq(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uint32_t by) { - return zr_row_eq_exact(a, ay, b, by); + if (!zr_row_eq_exact(a, ay, b, by)) { + return false; + } + return zr_row_links_targets_eq(a, ay, b, by); } /* Deterministic preference order for competing scroll candidates. */ @@ -1756,7 +1798,7 @@ static void zr_diff_row_heads_reset(uint64_t* row_heads, uint32_t rows) { } /* - Use rect.y0 as a temporary intrusive "next" index while coalescing. + Use rect._link as a temporary intrusive "next" index while coalescing. Why: Indexed coalescing must stay allocation-free in the present hot path. Damage rectangles are frame-local scratch, so temporary link reuse is safe. @@ -1765,14 +1807,14 @@ static uint32_t zr_diff_rect_link_get(const zr_damage_rect_t* r) { if (!r) { return ZR_DIFF_RECT_INDEX_NONE; } - return r->y0; + return r->_link; } static void zr_diff_rect_link_set(zr_damage_rect_t* r, uint32_t next_idx) { if (!r) { return; } - r->y0 = next_idx; + r->_link = next_idx; } typedef struct zr_diff_active_rects_t { diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index f1637a11..d129eff7 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1,5 +1,5 @@ /* - src/core/zr_drawlist.c — Drawlist validator + executor (v1/v2). + src/core/zr_drawlist.c — Drawlist validator + executor (v1). Why: Validates wrapper-provided drawlist bytes (bounds/caps/version) and executes deterministic drawing into the framebuffer without UB. @@ -176,50 +176,6 @@ static void zr_dl_store_release(zr_dl_resource_store_t* store) { memset(store, 0, sizeof(*store)); } -static zr_result_t zr_dl_store_clone_deep(zr_dl_resource_store_t* dst, const zr_dl_resource_store_t* src) { - zr_dl_resource_store_t tmp; - if (!dst || !src) { - return ZR_ERR_INVALID_ARGUMENT; - } - - memset(&tmp, 0, sizeof(tmp)); - if (src->len != 0u) { - zr_result_t rc = zr_dl_store_ensure_cap(&tmp, src->len); - if (rc != ZR_OK) { - return rc; - } - } - - for (uint32_t i = 0u; i < src->len; i++) { - const zr_dl_resource_entry_t* e = &src->entries[i]; - uint8_t* copy = NULL; - if (e->len != 0u) { - if (!e->bytes) { - zr_dl_store_release(&tmp); - return ZR_ERR_FORMAT; - } - copy = (uint8_t*)malloc((size_t)e->len); - if (!copy) { - zr_dl_store_release(&tmp); - return ZR_ERR_OOM; - } - memcpy(copy, e->bytes, (size_t)e->len); - } - tmp.entries[i].id = e->id; - tmp.entries[i].bytes = copy; - tmp.entries[i].len = e->len; - tmp.entries[i].owned = 1u; - memset(tmp.entries[i].reserved0, 0, sizeof(tmp.entries[i].reserved0)); - } - - tmp.len = src->len; - tmp.total_bytes = src->total_bytes; - - zr_dl_store_release(dst); - *dst = tmp; - return ZR_OK; -} - static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id, const uint8_t* bytes, uint32_t byte_len) { int32_t idx = -1; @@ -234,31 +190,21 @@ static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id return ZR_ERR_LIMIT; } + if (byte_len != 0u) { + copy = (uint8_t*)malloc((size_t)byte_len); + if (!copy) { + return ZR_ERR_OOM; + } + memcpy(copy, bytes, (size_t)byte_len); + } + idx = zr_dl_store_find_index(store, id); if (idx >= 0) { - const zr_dl_resource_entry_t* existing = &store->entries[(uint32_t)idx]; if (!store->entries) { - return ZR_ERR_FORMAT; - } - old_len = existing->len; - - if (old_len == byte_len) { - if (byte_len == 0u) { - return ZR_OK; - } - if (existing->bytes && memcmp(existing->bytes, bytes, (size_t)byte_len) == 0) { - return ZR_OK; - } - } - - if (byte_len != 0u) { - copy = (uint8_t*)malloc((size_t)byte_len); - if (!copy) { - return ZR_ERR_OOM; - } - memcpy(copy, bytes, (size_t)byte_len); + free(copy); + return ZR_ERR_LIMIT; } - + old_len = store->entries[(uint32_t)idx].len; if (old_len > store->total_bytes) { free(copy); return ZR_ERR_LIMIT; @@ -279,14 +225,6 @@ static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id return ZR_OK; } - if (byte_len != 0u) { - copy = (uint8_t*)malloc((size_t)byte_len); - if (!copy) { - return ZR_ERR_OOM; - } - memcpy(copy, bytes, (size_t)byte_len); - } - if (store->total_bytes > (UINT32_MAX - byte_len)) { free(copy); return ZR_ERR_LIMIT; @@ -374,20 +312,23 @@ void zr_dl_resources_swap(zr_dl_resources_t* a, zr_dl_resources_t* b) { zr_result_t zr_dl_resources_clone(zr_dl_resources_t* dst, const zr_dl_resources_t* src) { zr_dl_resources_t tmp; + zr_result_t rc = ZR_OK; if (!dst || !src) { return ZR_ERR_INVALID_ARGUMENT; } zr_dl_resources_init(&tmp); - { - const zr_result_t rc = zr_dl_store_clone_deep(&tmp.strings, &src->strings); + for (uint32_t i = 0u; i < src->strings.len; i++) { + const zr_dl_resource_entry_t* e = &src->strings.entries[i]; + rc = zr_dl_store_define(&tmp.strings, e->id, e->bytes, e->len); if (rc != ZR_OK) { zr_dl_resources_release(&tmp); return rc; } } - { - const zr_result_t rc = zr_dl_store_clone_deep(&tmp.blobs, &src->blobs); + for (uint32_t i = 0u; i < src->blobs.len; i++) { + const zr_dl_resource_entry_t* e = &src->blobs.entries[i]; + rc = zr_dl_store_define(&tmp.blobs, e->id, e->bytes, e->len); if (rc != ZR_OK) { zr_dl_resources_release(&tmp); return rc; @@ -800,14 +741,6 @@ typedef struct zr_dl_range_t { uint32_t len; } zr_dl_range_t; -static bool zr_dl_version_supported(uint32_t version) { - return version == ZR_DRAWLIST_VERSION_V1 || version == ZR_DRAWLIST_VERSION_V2; -} - -static bool zr_dl_version_supports_blit_rect(uint32_t version) { - return version >= ZR_DRAWLIST_VERSION_V2; -} - static bool zr_dl_range_is_empty(zr_dl_range_t r) { return r.len == 0u; } @@ -866,7 +799,7 @@ static zr_result_t zr_dl_validate_header(const zr_dl_header_t* hdr, size_t bytes if (hdr->magic != ZR_DL_MAGIC) { return ZR_ERR_FORMAT; } - if (!zr_dl_version_supported(hdr->version)) { + if (hdr->version != ZR_DRAWLIST_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } if (hdr->header_size != (uint32_t)sizeof(zr_dl_header_t)) { @@ -1294,9 +1227,6 @@ static zr_result_t zr_dl_validate_cmd_payload(const zr_dl_view_t* view, const zr case ZR_DL_OP_PUSH_CLIP: return zr_dl_validate_cmd_push_clip(ch, r, lim, clip_depth); case ZR_DL_OP_BLIT_RECT: - if (!zr_dl_version_supports_blit_rect(view->hdr.version)) { - return ZR_ERR_UNSUPPORTED; - } return zr_dl_validate_cmd_blit_rect(ch, r); case ZR_DL_OP_POP_CLIP: return zr_dl_validate_cmd_pop_clip(ch, clip_depth); @@ -1804,9 +1734,6 @@ zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_ima break; } case ZR_DL_OP_BLIT_RECT: { - if (!zr_dl_version_supports_blit_rect(v->hdr.version)) { - return ZR_ERR_UNSUPPORTED; - } zr_dl_cmd_blit_rect_t cmd; rc = zr_dl_read_cmd_blit_rect(&r, &cmd); if (rc != ZR_OK) { @@ -2485,9 +2412,6 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t break; } case ZR_DL_OP_BLIT_RECT: { - if (!zr_dl_version_supports_blit_rect(view.hdr.version)) { - return ZR_ERR_UNSUPPORTED; - } rc = zr_dl_exec_blit_rect(&r, &painter); if (rc != ZR_OK) { return rc; diff --git a/packages/native/vendor/zireael/src/core/zr_engine.c b/packages/native/vendor/zireael/src/core/zr_engine.c index 04625f77..83f5b923 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine.c +++ b/packages/native/vendor/zireael/src/core/zr_engine.c @@ -33,9 +33,7 @@ #include #include #include -#include #include -#include #include #include @@ -80,6 +78,9 @@ struct zr_engine_t { zr_term_state_t term_state; zr_cursor_state_t cursor_desired; + /* True when fb_prev is a byte-identical copy of fb_next (submit rollback fast-path). */ + uint8_t fb_next_synced_to_prev; + uint8_t _pad_fb_sync0[3]; /* --- Image sideband state (DRAW_IMAGE staging + protocol cache) --- */ zr_image_frame_t image_frame_next; @@ -149,13 +150,6 @@ struct zr_engine_t { uint8_t* debug_ring_buf; uint32_t* debug_record_offsets; uint32_t* debug_record_sizes; - - /* --- Optional native audit log sink (file-backed, env-driven) --- */ - FILE* native_audit_fp; - uint8_t native_audit_enabled; - uint8_t native_audit_flush; - uint8_t native_audit_verbose; - uint8_t _pad_native_audit0; }; enum { @@ -168,10 +162,6 @@ enum { /* Forward declaration for cleanup helper. */ static void zr_engine_debug_free(zr_engine_t* e); -static uint32_t zr_engine_fnv1a32(const uint8_t* bytes, size_t len); -static void zr_engine_native_audit_write(zr_engine_t* e, const char* stage, const char* fmt, ...); -static void zr_engine_native_audit_close(zr_engine_t* e); -static void zr_engine_native_audit_init(zr_engine_t* e); static const uint8_t ZR_SYNC_BEGIN[] = "\x1b[?2026h"; static const uint8_t ZR_SYNC_END[] = "\x1b[?2026l"; @@ -514,7 +504,6 @@ static zr_result_t zr_engine_fb_copy(const zr_fb_t* src, zr_fb_t* dst) { if (n != 0u && src->cells && dst->cells) { memcpy(dst->cells, src->cells, n); } - zr_fb_links_reset(dst); return zr_fb_links_clone_from(dst, src); } @@ -628,6 +617,10 @@ static zr_result_t zr_engine_resize_framebuffers(zr_engine_t* e, uint32_t cols, e->term_state.flags &= (uint8_t) ~(ZR_TERM_STATE_STYLE_VALID | ZR_TERM_STATE_CURSOR_POS_VALID | ZR_TERM_STATE_SCREEN_VALID); + /* After resize, prev/next are newly allocated and cleared identically. */ + e->fb_next_synced_to_prev = 1u; + memset(e->_pad_fb_sync0, 0, sizeof(e->_pad_fb_sync0)); + return ZR_OK; } @@ -1291,19 +1284,9 @@ zr_result_t engine_create(zr_engine_t** out_engine, const zr_engine_config_t* cf zr_engine_runtime_from_create_cfg(e, cfg); zr_engine_metrics_init(e, cfg); - zr_engine_native_audit_init(e); - - zr_engine_native_audit_write( - e, "engine.create.begin", - "requested_drawlist_version=%u out_max_bytes_per_frame=%u target_fps=%u enable_scroll_optimizations=%u " - "wait_for_output_drain=%u", - (unsigned)e->cfg_create.requested_drawlist_version, (unsigned)e->cfg_create.limits.out_max_bytes_per_frame, - (unsigned)e->cfg_runtime.target_fps, (unsigned)e->cfg_runtime.enable_scroll_optimizations, - (unsigned)e->cfg_runtime.wait_for_output_drain); rc = zr_engine_init_runtime_state(e); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "engine.create.error", "rc=%d", (int)rc); goto cleanup; } @@ -1316,8 +1299,6 @@ zr_result_t engine_create(zr_engine_t** out_engine, const zr_engine_config_t* cf the initial size so callers can render the full framebuffer immediately. */ zr_engine_enqueue_initial_resize(e); - zr_engine_native_audit_write(e, "engine.create.ready", "cols=%u rows=%u", (unsigned)e->size.cols, - (unsigned)e->size.rows); *out_engine = e; return ZR_OK; @@ -1343,23 +1324,17 @@ static void zr_engine_release_heap_state(zr_engine_t* e) { return; } - zr_engine_native_audit_write(e, "engine.destroy.begin", "frame_index=%llu", (unsigned long long)e->metrics.frame_index); - zr_fb_release(&e->fb_prev); zr_fb_release(&e->fb_next); zr_fb_release(&e->fb_stage); - zr_engine_native_audit_write(e, "engine.destroy.step", "released=framebuffers"); zr_image_frame_release(&e->image_frame_next); zr_image_frame_release(&e->image_frame_stage); zr_image_state_init(&e->image_state); - zr_engine_native_audit_write(e, "engine.destroy.step", "released=image_state"); zr_dl_resources_release(&e->dl_resources_next); zr_dl_resources_release(&e->dl_resources_stage); - zr_engine_native_audit_write(e, "engine.destroy.step", "released=drawlist_resources"); zr_arena_release(&e->arena_frame); zr_arena_release(&e->arena_persistent); - zr_engine_native_audit_write(e, "engine.destroy.step", "released=arenas"); free(e->out_buf); e->out_buf = NULL; @@ -1391,8 +1366,6 @@ static void zr_engine_release_heap_state(zr_engine_t* e) { e->input_pending_len = 0u; zr_engine_debug_free(e); - zr_engine_native_audit_write(e, "engine.destroy.step", "released=debug_state"); - zr_engine_native_audit_close(e); } void engine_destroy(zr_engine_t* e) { @@ -1420,101 +1393,6 @@ static uint64_t zr_engine_now_us(void) { return (uint64_t)plat_now_ms() * 1000u; } -static bool zr_engine_env_truthy(const char* value) { - if (!value || value[0] == '\0') { - return false; - } - if (strcmp(value, "1") == 0 || strcmp(value, "true") == 0 || strcmp(value, "TRUE") == 0 || - strcmp(value, "yes") == 0 || strcmp(value, "YES") == 0 || strcmp(value, "on") == 0 || - strcmp(value, "ON") == 0) { - return true; - } - return false; -} - -static uint32_t zr_engine_fnv1a32(const uint8_t* bytes, size_t len) { - uint32_t h = 0x811C9DC5u; - if (!bytes || len == 0u) { - return h; - } - for (size_t i = 0u; i < len; i++) { - h ^= (uint32_t)bytes[i]; - h *= 0x01000193u; - } - return h; -} - -static void zr_engine_native_audit_write(zr_engine_t* e, const char* stage, const char* fmt, ...) { - if (!e || !stage || !e->native_audit_enabled || !e->native_audit_fp) { - return; - } - - const uint64_t ts_us = zr_engine_now_us(); - const uint64_t next_frame_id = (e->metrics.frame_index == UINT64_MAX) ? UINT64_MAX : (e->metrics.frame_index + 1u); - const uint64_t presented_frames = e->metrics.frame_index; - - (void)fprintf(e->native_audit_fp, - "REZI_NATIVE_AUDIT ts_us=%llu next_frame_id=%llu presented_frames=%llu stage=%s", - (unsigned long long)ts_us, (unsigned long long)next_frame_id, (unsigned long long)presented_frames, - stage); - - if (fmt && fmt[0] != '\0') { - va_list args; - va_start(args, fmt); - (void)fputc(' ', e->native_audit_fp); - (void)vfprintf(e->native_audit_fp, fmt, args); - va_end(args); - } - - (void)fputc('\n', e->native_audit_fp); - if (e->native_audit_flush != 0u) { - (void)fflush(e->native_audit_fp); - } -} - -static void zr_engine_native_audit_close(zr_engine_t* e) { - if (!e) { - return; - } - if (e->native_audit_fp) { - (void)fclose(e->native_audit_fp); - e->native_audit_fp = NULL; - } - e->native_audit_enabled = 0u; - e->native_audit_flush = 0u; - e->native_audit_verbose = 0u; -} - -static void zr_engine_native_audit_init(zr_engine_t* e) { - if (!e) { - return; - } - - const char* path = getenv("REZI_NATIVE_ENGINE_LOG"); - const bool enabled_flag = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_AUDIT")); - if ((!path || path[0] == '\0') && !enabled_flag) { - return; - } - if (!path || path[0] == '\0') { - path = "/tmp/rezi-native-engine.log"; - } - - FILE* fp = fopen(path, "ab"); - if (!fp) { - return; - } - (void)setvbuf(fp, NULL, _IOLBF, 0u); - - e->native_audit_fp = fp; - e->native_audit_enabled = 1u; - e->native_audit_flush = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_LOG_FLUSH")) ? 1u : 0u; - e->native_audit_verbose = zr_engine_env_truthy(getenv("REZI_NATIVE_ENGINE_LOG_VERBOSE")) ? 1u : 0u; - - zr_engine_native_audit_write( - e, "engine.audit.init", "path=%s verbose=%u flush=%u", path, (unsigned)e->native_audit_verbose, - (unsigned)e->native_audit_flush); -} - /* Compute the debug-trace frame id for the next present. @@ -1591,32 +1469,19 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt if (bytes_len < 0) { return ZR_ERR_INVALID_ARGUMENT; } - const uint64_t frame_id = zr_engine_trace_frame_id(e); - const uint32_t bytes_hash = zr_engine_fnv1a32(bytes, (size_t)bytes_len); - zr_engine_native_audit_write(e, "submit.begin", "frame_id=%llu bytes=%d hash32=0x%08x", - (unsigned long long)frame_id, bytes_len, (unsigned)bytes_hash); zr_dl_view_t v; zr_result_t rc = zr_dl_validate(bytes, (size_t)bytes_len, &e->cfg_runtime.limits, &v); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.validate.error", "frame_id=%llu rc=%d bytes=%d hash32=0x%08x", - (unsigned long long)frame_id, (int)rc, bytes_len, (unsigned)bytes_hash); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, bytes, (uint32_t)bytes_len, 0u, 0u, rc, ZR_OK); return rc; } - zr_engine_native_audit_write( - e, "submit.validate.ok", "frame_id=%llu version=%u cmd_count=%u bytes=%d hash32=0x%08x", - (unsigned long long)frame_id, (unsigned)v.hdr.version, (unsigned)v.hdr.cmd_count, bytes_len, (unsigned)bytes_hash); /* Enforce create-time drawlist version negotiation before any framebuffer staging mutation to preserve the no-partial-effects contract. */ if (v.hdr.version != e->cfg_create.requested_drawlist_version) { - zr_engine_native_audit_write(e, "submit.version_mismatch", - "frame_id=%llu requested_version=%u drawlist_version=%u cmd_count=%u", - (unsigned long long)frame_id, (unsigned)e->cfg_create.requested_drawlist_version, - (unsigned)v.hdr.version, (unsigned)v.hdr.cmd_count); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_ERR_UNSUPPORTED, ZR_OK); return ZR_ERR_UNSUPPORTED; @@ -1629,35 +1494,52 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt zr_dl_resources_release(&e->dl_resources_stage); rc = zr_dl_resources_clone(&e->dl_resources_stage, &e->dl_resources_next); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.resources.clone.error", "frame_id=%llu rc=%d", - (unsigned long long)frame_id, (int)rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); return rc; } rc = zr_dl_resources_clone_shallow(&preflight_resources, &e->dl_resources_stage); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.resources.clone_shallow.error", "frame_id=%llu rc=%d", - (unsigned long long)frame_id, (int)rc); zr_dl_resources_release(&e->dl_resources_stage); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rc); return rc; } + /* + Snapshot fb_next for rollback when fb_prev does not currently match it. + + Why: The submit path executes in-place into fb_next; to preserve the + no-partial-effects contract we need a rollback source that represents the + pre-submit fb_next contents (not necessarily fb_prev when debug overlays + or multi-submit scenarios are in play). + */ + bool have_fb_next_snapshot = false; + if (e->fb_next_synced_to_prev == 0u) { + zr_result_t snap_rc = zr_engine_fb_copy_noalloc(&e->fb_next, &e->fb_stage); + if (snap_rc == ZR_ERR_LIMIT) { + snap_rc = zr_engine_fb_copy(&e->fb_next, &e->fb_stage); + } + if (snap_rc != ZR_OK) { + zr_dl_resources_release(&preflight_resources); + zr_dl_resources_release(&e->dl_resources_stage); + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, snap_rc); + return snap_rc; + } + have_fb_next_snapshot = true; + } + zr_image_frame_reset(&e->image_frame_stage); rc = zr_dl_preflight_resources(&v, &e->fb_next, &e->image_frame_stage, &e->cfg_runtime.limits, &e->term_profile, &preflight_resources); zr_dl_resources_release(&preflight_resources); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.preflight.error", "frame_id=%llu rc=%d", - (unsigned long long)frame_id, (int)rc); - const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); + const zr_fb_t* rollback_src = have_fb_next_snapshot ? &e->fb_stage : &e->fb_prev; + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(rollback_src, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.rollback.error", "frame_id=%llu rollback_rc=%d", - (unsigned long long)frame_id, (int)rollback_rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rollback_rc); return rollback_rc; @@ -1672,14 +1554,11 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt rc = zr_dl_execute(&v, &e->fb_next, &e->cfg_runtime.limits, e->cfg_runtime.tab_width, e->cfg_runtime.width_policy, &blit_caps, &e->term_profile, &e->image_frame_stage, &e->dl_resources_stage, &cursor_stage); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.execute.error", "frame_id=%llu rc=%d", - (unsigned long long)frame_id, (int)rc); - const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); + const zr_fb_t* rollback_src = have_fb_next_snapshot ? &e->fb_stage : &e->fb_prev; + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(rollback_src, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { - zr_engine_native_audit_write(e, "submit.rollback.error", "frame_id=%llu rollback_rc=%d", - (unsigned long long)frame_id, (int)rollback_rc); zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, rollback_rc); return rollback_rc; @@ -1694,14 +1573,10 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt zr_dl_resources_swap(&e->dl_resources_next, &e->dl_resources_stage); zr_dl_resources_release(&e->dl_resources_stage); e->cursor_desired = cursor_stage; + e->fb_next_synced_to_prev = 0u; zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, ZR_OK); - zr_engine_native_audit_write( - e, "submit.success", - "frame_id=%llu cmd_count=%u version=%u bytes=%d hash32=0x%08x cursor_x=%d cursor_y=%d cursor_visible=%u", - (unsigned long long)frame_id, (unsigned)v.hdr.cmd_count, (unsigned)v.hdr.version, bytes_len, (unsigned)bytes_hash, - (int)e->cursor_desired.x, (int)e->cursor_desired.y, (unsigned)e->cursor_desired.visible); return ZR_OK; } diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index 2f4dcb09..63ee123a 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -320,6 +320,35 @@ static void zr_engine_trace_diff_telemetry(zr_engine_t* e, uint64_t frame_id, co zr_engine_now_us(), &rec, (uint32_t)sizeof(rec)); } +static bool zr_fb_links_prefix_equal(const zr_fb_t* prefix, const zr_fb_t* full) { + if (!prefix || !full) { + return false; + } + if (prefix->links_len > full->links_len || prefix->link_bytes_len > full->link_bytes_len) { + return false; + } + if (prefix->links_len != 0u && (!prefix->links || !full->links)) { + return false; + } + if (prefix->link_bytes_len != 0u && (!prefix->link_bytes || !full->link_bytes)) { + return false; + } + + if (prefix->links_len != 0u) { + const size_t links_bytes = (size_t)prefix->links_len * sizeof(zr_fb_link_t); + if (memcmp(prefix->links, full->links, links_bytes) != 0) { + return false; + } + } + if (prefix->link_bytes_len != 0u) { + if (memcmp(prefix->link_bytes, full->link_bytes, (size_t)prefix->link_bytes_len) != 0) { + return false; + } + } + + return true; +} + static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_t out_len, const zr_term_state_t* final_ts, const zr_diff_stats_t* stats, const zr_image_state_t* image_state_stage, uint32_t diff_us, uint32_t write_us) { @@ -327,12 +356,15 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ return; } bool invalidate_prev_hashes = false; + bool invalidate_screen_valid = false; + bool fb_next_synced_to_prev = (presented_stage == false); const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; + const bool links_compatible_for_damage_copy = zr_fb_links_prefix_equal(&e->fb_prev, presented_fb); const bool use_damage_rect_copy = - (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && (stats->damage_rects <= e->damage_rect_cap); - bool links_synced = false; + links_compatible_for_damage_copy && (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && + (stats->damage_rects <= e->damage_rect_cap); /* Resync fb_prev to the framebuffer that was actually presented. @@ -343,7 +375,9 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ if (!e->fb_prev.cells || !presented_fb->cells || e->fb_prev.cols != presented_fb->cols || e->fb_prev.rows != presented_fb->rows) { invalidate_prev_hashes = true; + fb_next_synced_to_prev = false; } else { + bool links_synced = false; if (!use_damage_rect_copy) { const size_t n = zr_engine_cells_bytes(presented_fb); if (n != 0u) { @@ -363,8 +397,14 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ } } - if (!links_synced && zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { - invalidate_prev_hashes = true; + if (!links_synced) { + const zr_result_t links_rc = zr_fb_links_clone_from(&e->fb_prev, presented_fb); + if (links_rc != ZR_OK) { + invalidate_prev_hashes = true; + fb_next_synced_to_prev = false; + invalidate_screen_valid = true; + (void)zr_fb_clear(&e->fb_prev, NULL); + } } } @@ -373,7 +413,13 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ e->diff_prev_hashes_valid = 0u; } e->term_state = *final_ts; + if (invalidate_screen_valid) { + e->term_state.flags &= + (uint8_t) ~(ZR_TERM_STATE_STYLE_VALID | ZR_TERM_STATE_CURSOR_POS_VALID | ZR_TERM_STATE_SCREEN_VALID); + } e->image_state = *image_state_stage; + e->fb_next_synced_to_prev = fb_next_synced_to_prev ? 1u : 0u; + memset(e->_pad_fb_sync0, 0, sizeof(e->_pad_fb_sync0)); /* --- Update public metrics snapshot --- */ e->metrics.frame_index++; @@ -403,17 +449,6 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ zr_engine_trace_diff_telemetry(e, frame_id_presented, stats); zr_debug_trace_set_frame(e->debug_trace, zr_engine_trace_frame_id(e)); } - - zr_engine_native_audit_write( - e, "present.commit", - "frame_id=%llu out_len=%zu presented_stage=%u use_damage_rect_copy=%u links_synced=%u invalidate_prev_hashes=%u " - "dirty_lines=%u dirty_cells=%u damage_rects=%u damage_cells=%u damage_full_frame=%u scroll_attempted=%u " - "scroll_hit=%u collision_guard_hits=%u diff_us=%u write_us=%u", - (unsigned long long)frame_id_presented, out_len, (unsigned)presented_stage, (unsigned)use_damage_rect_copy, - (unsigned)links_synced, (unsigned)invalidate_prev_hashes, (unsigned)stats->dirty_lines, - (unsigned)stats->dirty_cells, (unsigned)stats->damage_rects, (unsigned)stats->damage_cells, - (unsigned)stats->damage_full_frame, (unsigned)stats->scroll_opt_attempted, (unsigned)stats->scroll_opt_hit, - (unsigned)stats->collision_guard_hits, (unsigned)diff_us, (unsigned)write_us); } /* @@ -426,11 +461,6 @@ zr_result_t engine_present(zr_engine_t* e) { if (!e || !e->plat) { return ZR_ERR_INVALID_ARGUMENT; } - const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); - zr_engine_native_audit_write(e, "present.begin", - "frame_id=%llu overlay=%u wait_for_output_drain=%u diff_prev_hashes_valid=%u", - (unsigned long long)frame_id_presented, (unsigned)e->cfg_runtime.enable_debug_overlay, - (unsigned)e->cfg_runtime.wait_for_output_drain, (unsigned)e->diff_prev_hashes_valid); /* Enforced contract: the per-frame arena is reset exactly once per present. */ zr_arena_reset(&e->arena_frame); @@ -439,8 +469,6 @@ zr_result_t engine_present(zr_engine_t* e) { const int32_t timeout_ms = zr_engine_output_wait_timeout_ms(&e->cfg_runtime); zr_result_t rc = plat_wait_output_writable(e->plat, timeout_ms); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "present.wait_output.error", "frame_id=%llu rc=%d timeout_ms=%d", - (unsigned long long)frame_id_presented, (int)rc, (int)timeout_ms); return rc; } } @@ -456,31 +484,16 @@ zr_result_t engine_present(zr_engine_t* e) { zr_result_t rc = zr_engine_present_pick_fb(e, &present_fb, &presented_stage); if (rc != ZR_OK) { - zr_engine_native_audit_write(e, "present.pick_fb.error", "frame_id=%llu rc=%d", (unsigned long long)frame_id_presented, - (int)rc); return rc; } - zr_engine_native_audit_write(e, "present.pick_fb.ok", "frame_id=%llu presented_stage=%u cols=%u rows=%u", - (unsigned long long)frame_id_presented, (unsigned)presented_stage, - (unsigned)present_fb->cols, (unsigned)present_fb->rows); const uint64_t diff_start_us = zr_engine_now_us(); rc = zr_engine_present_render(e, present_fb, &out_len, &final_ts, &stats, &image_state_stage); if (rc != ZR_OK) { /* Diff scratch may have been used as transient indexed-coalescing storage. */ e->diff_prev_hashes_valid = 0u; - zr_engine_native_audit_write(e, "present.render.error", "frame_id=%llu rc=%d", - (unsigned long long)frame_id_presented, (int)rc); return rc; } - zr_engine_native_audit_write( - e, "present.render.ok", - "frame_id=%llu out_len=%zu dirty_lines=%u dirty_cells=%u damage_rects=%u damage_cells=%u damage_full_frame=%u " - "path_sweep_used=%u path_damage_used=%u scroll_attempted=%u scroll_hit=%u collision_guard_hits=%u", - (unsigned long long)frame_id_presented, out_len, (unsigned)stats.dirty_lines, (unsigned)stats.dirty_cells, - (unsigned)stats.damage_rects, (unsigned)stats.damage_cells, (unsigned)stats.damage_full_frame, - (unsigned)stats.path_sweep_used, (unsigned)stats.path_damage_used, (unsigned)stats.scroll_opt_attempted, - (unsigned)stats.scroll_opt_hit, (unsigned)stats.collision_guard_hits); { const uint64_t diff_end_us = zr_engine_now_us(); @@ -495,8 +508,6 @@ zr_result_t engine_present(zr_engine_t* e) { if (rc != ZR_OK) { /* Keep reuse conservative when present fails before prev/next commit. */ e->diff_prev_hashes_valid = 0u; - zr_engine_native_audit_write(e, "present.write.error", "frame_id=%llu rc=%d out_len=%zu", - (unsigned long long)frame_id_presented, (int)rc, out_len); return rc; } { @@ -508,7 +519,5 @@ zr_result_t engine_present(zr_engine_t* e) { } zr_engine_present_commit(e, presented_stage, out_len, &final_ts, &stats, &image_state_stage, diff_us, write_us); - zr_engine_native_audit_write(e, "present.done", "frame_id=%llu rc=0 out_len=%zu", (unsigned long long)frame_id_presented, - out_len); return ZR_OK; } diff --git a/packages/native/vendor/zireael/src/core/zr_event_pack.c b/packages/native/vendor/zireael/src/core/zr_event_pack.c index da95adce..d88d2d4c 100644 --- a/packages/native/vendor/zireael/src/core/zr_event_pack.c +++ b/packages/native/vendor/zireael/src/core/zr_event_pack.c @@ -70,9 +70,8 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ } /* Write placeholder header; patched by zr_evpack_finish(). */ - if (!zr__write_u32le(w, ZR_EV_MAGIC) || !zr__write_u32le(w, ZR_EVENT_BATCH_VERSION_V1) || - !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || - !zr__write_u32le(w, 0u)) { + if (!zr__write_u32le(w, ZR_EV_MAGIC) || !zr__write_u32le(w, ZR_EVENT_BATCH_VERSION_V1) || !zr__write_u32le(w, 0u) || + !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u)) { /* Should be unreachable due to pre-check. */ memset(w, 0, sizeof(*w)); return ZR_ERR_LIMIT; @@ -82,15 +81,14 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ return ZR_OK; } -bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* payload, size_t payload_len) { +bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* payload, size_t payload_len) { return zr_evpack_append_record2(w, type, time_ms, flags, payload, payload_len, NULL, 0u); } /* Append event record with two payload chunks; sets TRUNCATED flag if no space. */ -bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* p1, size_t n1, const void* p2, - size_t n2) { +bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* p1, size_t n1, const void* p2, size_t n2) { if (!w || !w->started) { return false; } diff --git a/packages/native/vendor/zireael/src/core/zr_event_pack.h b/packages/native/vendor/zireael/src/core/zr_event_pack.h index e662f82d..29e7be3e 100644 --- a/packages/native/vendor/zireael/src/core/zr_event_pack.h +++ b/packages/native/vendor/zireael/src/core/zr_event_pack.h @@ -50,8 +50,8 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ - zr_evpack_begin() must have succeeded. - payload may be NULL only if payload_len == 0. */ -bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* payload, size_t payload_len); +bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* payload, size_t payload_len); /* zr_evpack_append_record2: @@ -59,9 +59,8 @@ bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32 - Useful for variable-length payload records like PASTE and USER ({hdr}{bytes}). */ -bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* p1, size_t n1, const void* p2, - size_t n2); +bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* p1, size_t n1, const void* p2, size_t n2); /* zr_evpack_finish: diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.c b/packages/native/vendor/zireael/src/core/zr_framebuffer.c index 487b5e55..548cea91 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.c +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.c @@ -37,6 +37,7 @@ enum { ZR_FB_UTF8_C1_MAX_EXCL = 0xA0u, ZR_FB_LINKS_INITIAL_CAP = 8u, ZR_FB_LINK_BYTES_INITIAL_CAP = 256u, + ZR_FB_LINK_ENTRY_MAX_BYTES = ZR_FB_LINK_URI_MAX_BYTES + ZR_FB_LINK_ID_MAX_BYTES, }; static bool zr_fb_utf8_grapheme_bytes_safe_for_terminal(const uint8_t* bytes, size_t len) { @@ -183,12 +184,13 @@ zr_result_t zr_fb_links_clone_from(zr_fb_t* dst, const zr_fb_t* src) { if (!dst || !src) { return ZR_ERR_INVALID_ARGUMENT; } - zr_fb_links_reset(dst); - if (src->links_len == 0u) { + zr_fb_links_reset(dst); return ZR_OK; } - + if (!src->links || (src->link_bytes_len != 0u && !src->link_bytes)) { + return ZR_ERR_INVALID_ARGUMENT; + } zr_result_t rc = zr_fb_links_ensure_cap(dst, src->links_len); if (rc != ZR_OK) { return rc; @@ -473,6 +475,155 @@ const zr_cell_t* zr_fb_cell_const(const zr_fb_t* fb, uint32_t x, uint32_t y) { return &fb->cells[idx]; } +static uint32_t zr_fb_links_slots_limit(const zr_fb_t* fb) { + if (!fb) { + return 1u; + } + + uint32_t cell_count = 0u; + if (!zr_checked_mul_u32(fb->cols, fb->rows, &cell_count)) { + return UINT32_MAX; + } + + /* + * Allow a bounded transient window while callers replace existing links: + * max slots = (live cells * 2) + 1. + */ + if (cell_count > ((UINT32_MAX - 1u) / 2u)) { + return UINT32_MAX; + } + cell_count = (cell_count * 2u) + 1u; + return (cell_count == 0u) ? 1u : cell_count; +} + +static uint32_t zr_fb_link_bytes_limit(uint32_t slots_limit) { + uint32_t bytes_limit = 0u; + if (!zr_checked_mul_u32(slots_limit, (uint32_t)ZR_FB_LINK_ENTRY_MAX_BYTES, &bytes_limit)) { + return UINT32_MAX; + } + return bytes_limit; +} + +/* + * Reclaim stale link entries by keeping only refs currently referenced by cells. + * + * Why: draw workloads can churn many temporary link targets while mutating a + * fixed-size framebuffer. Compacting live refs prevents unbounded table growth. + */ +static zr_result_t zr_fb_links_compact_live(zr_fb_t* fb) { + if (!fb) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (fb->links_len == 0u) { + fb->link_bytes_len = 0u; + return ZR_OK; + } + if (!fb->links || (fb->link_bytes_len != 0u && !fb->link_bytes)) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (!zr_fb_has_backing(fb)) { + zr_fb_links_reset(fb); + return ZR_OK; + } + + size_t cell_count = 0u; + if (!zr_checked_mul_size((size_t)fb->cols, (size_t)fb->rows, &cell_count)) { + return ZR_ERR_LIMIT; + } + if (cell_count == 0u) { + zr_fb_links_reset(fb); + return ZR_OK; + } + + size_t remap_count = 0u; + if (!zr_checked_add_size((size_t)fb->links_len, 1u, &remap_count)) { + return ZR_ERR_LIMIT; + } + size_t remap_bytes = 0u; + if (!zr_checked_mul_size(remap_count, sizeof(uint32_t), &remap_bytes)) { + return ZR_ERR_LIMIT; + } + uint32_t* remap = (uint32_t*)malloc(remap_bytes); + if (!remap) { + return ZR_ERR_OOM; + } + memset(remap, 0, remap_bytes); + + /* Mark live refs and sanitize any out-of-range link refs in cells. */ + for (size_t i = 0u; i < cell_count; i++) { + const uint32_t ref = fb->cells[i].style.link_ref; + if (ref == 0u) { + continue; + } + if (ref <= fb->links_len) { + remap[(size_t)ref] = UINT32_MAX; + continue; + } + fb->cells[i].style.link_ref = 0u; + } + + const uint32_t old_links_len = fb->links_len; + uint32_t new_links_len = 0u; + uint32_t new_link_bytes_len = 0u; + + for (uint32_t i = 0u; i < old_links_len; i++) { + const uint32_t old_ref = i + 1u; + if (remap[(size_t)old_ref] != UINT32_MAX) { + continue; + } + + const zr_fb_link_t src = fb->links[i]; + uint32_t span_len = 0u; + if (!zr_checked_add_u32(src.uri_len, src.id_len, &span_len)) { + free(remap); + return ZR_ERR_FORMAT; + } + if ((size_t)src.uri_off + (size_t)src.uri_len > (size_t)fb->link_bytes_len || + (size_t)src.id_off + (size_t)src.id_len > (size_t)fb->link_bytes_len) { + free(remap); + return ZR_ERR_FORMAT; + } + if (src.id_off != src.uri_off + src.uri_len) { + free(remap); + return ZR_ERR_FORMAT; + } + if (span_len != 0u && src.uri_off != new_link_bytes_len) { + memmove(fb->link_bytes + new_link_bytes_len, fb->link_bytes + src.uri_off, (size_t)span_len); + } + + zr_fb_link_t dst; + dst.uri_off = new_link_bytes_len; + dst.uri_len = src.uri_len; + dst.id_off = new_link_bytes_len + src.uri_len; + dst.id_len = src.id_len; + + fb->links[new_links_len] = dst; + remap[(size_t)old_ref] = new_links_len + 1u; + new_links_len++; + if (!zr_checked_add_u32(new_link_bytes_len, span_len, &new_link_bytes_len)) { + free(remap); + return ZR_ERR_LIMIT; + } + } + + for (size_t i = 0u; i < cell_count; i++) { + const uint32_t ref = fb->cells[i].style.link_ref; + if (ref == 0u) { + continue; + } + if (ref > old_links_len) { + fb->cells[i].style.link_ref = 0u; + continue; + } + fb->cells[i].style.link_ref = remap[(size_t)ref]; + } + + fb->links_len = new_links_len; + fb->link_bytes_len = new_link_bytes_len; + free(remap); + return ZR_OK; +} + /* * Intern a framebuffer-owned hyperlink target and return a 1-based reference. * @@ -515,6 +666,28 @@ zr_result_t zr_fb_link_intern(zr_fb_t* fb, const uint8_t* uri, size_t uri_len, c return ZR_ERR_LIMIT; } + const uint32_t slots_limit = zr_fb_links_slots_limit(fb); + const uint32_t bytes_limit = zr_fb_link_bytes_limit(slots_limit); + const bool would_grow = need_links > fb->links_cap || need_bytes > fb->link_bytes_cap; + if (would_grow || need_links > slots_limit || need_bytes > bytes_limit) { + zr_result_t compact_rc = zr_fb_links_compact_live(fb); + if (compact_rc != ZR_OK) { + return compact_rc; + } + + if (!zr_checked_add_u32(fb->links_len, 1u, &need_links)) { + return ZR_ERR_LIMIT; + } + if (!zr_checked_add_u32(fb->link_bytes_len, (uint32_t)uri_len, &need_bytes) || + !zr_checked_add_u32(need_bytes, (uint32_t)id_len, &need_bytes)) { + return ZR_ERR_LIMIT; + } + } + + if (need_links > slots_limit || need_bytes > bytes_limit) { + return ZR_ERR_LIMIT; + } + zr_result_t rc = zr_fb_links_ensure_cap(fb, need_links); if (rc != ZR_OK) { return rc; @@ -1244,12 +1417,19 @@ zr_result_t zr_fb_blit_rect(zr_fb_painter_t* p, zr_rect_t dst, zr_rect_t src) { } /* - Snapshot source before write: overlapping blits can mutate source cells - (wide-pair repair), so reads must not alias framebuffer after destination - write begins. - */ - zr_cell_t snap = *c; - (void)zr_fb_put_grapheme(p, dx, dy, snap.glyph, (size_t)snap.glyph_len, snap.width, &snap.style); + * Prevent wide-glyph leads from writing outside the effective rectangle. + * + * Why: BLIT_RECT is specified as a rectangle copy. Wide glyphs must be + * kept invariant-safe, so when a wide lead does not fully fit inside the + * src/dst effective span, replace deterministically rather than touching + * a neighbor cell outside the rectangle. + */ + if (c->width == 2u && (ox + 1) >= w) { + (void)zr_fb_put_grapheme(p, dx, dy, ZR_UTF8_REPLACEMENT, ZR_UTF8_REPLACEMENT_LEN, 1u, &c->style); + continue; + } + + (void)zr_fb_put_grapheme(p, dx, dy, c->glyph, (size_t)c->glyph_len, c->width, &c->style); } } diff --git a/packages/native/vendor/zireael/src/core/zr_metrics.c b/packages/native/vendor/zireael/src/core/zr_metrics.c index 671daba2..6d585704 100644 --- a/packages/native/vendor/zireael/src/core/zr_metrics.c +++ b/packages/native/vendor/zireael/src/core/zr_metrics.c @@ -25,7 +25,9 @@ zr_metrics_t zr_metrics__default_snapshot(void) { return m; } -static size_t zr_min_size(size_t a, size_t b) { return (a < b) ? a : b; } +static size_t zr_min_size(size_t a, size_t b) { + return (a < b) ? a : b; +} /* Prefix-copy a snapshot into out_metrics without overruns (append-only ABI). */ zr_result_t zr_metrics__copy_out(zr_metrics_t* out_metrics, const zr_metrics_t* snapshot) { diff --git a/packages/native/vendor/zireael/src/core/zr_placeholder.c b/packages/native/vendor/zireael/src/core/zr_placeholder.c index 36dcb225..87a28d46 100644 --- a/packages/native/vendor/zireael/src/core/zr_placeholder.c +++ b/packages/native/vendor/zireael/src/core/zr_placeholder.c @@ -4,4 +4,5 @@ Why: Keeps the CMake scaffolding building before real engine sources land. */ -void zireael__placeholder(void) {} +void zireael__placeholder(void) { +} diff --git a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c index ef100cc5..2bf98362 100644 --- a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c +++ b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c @@ -23,7 +23,8 @@ typedef HANDLE zr_win32_hpc_t; -typedef HRESULT(WINAPI* zr_win32_create_pseudoconsole_fn)(COORD size, HANDLE h_in, HANDLE h_out, DWORD flags, zr_win32_hpc_t* out_hpc); +typedef HRESULT(WINAPI* zr_win32_create_pseudoconsole_fn)(COORD size, HANDLE h_in, HANDLE h_out, DWORD flags, + zr_win32_hpc_t* out_hpc); typedef void(WINAPI* zr_win32_close_pseudoconsole_fn)(zr_win32_hpc_t hpc); /* --- ConPTY runner limits --- */ @@ -45,8 +46,7 @@ static void zr_win32_strcpy_reason(char* dst, size_t cap, const char* s) { } static bool zr_win32_conpty_load(zr_win32_create_pseudoconsole_fn* out_create, - zr_win32_close_pseudoconsole_fn* out_close, - char* out_skip_reason, + zr_win32_close_pseudoconsole_fn* out_close, char* out_skip_reason, size_t out_skip_reason_cap) { if (!out_create || !out_close) { return false; @@ -60,10 +60,13 @@ static bool zr_win32_conpty_load(zr_win32_create_pseudoconsole_fn* out_create, return false; } - zr_win32_create_pseudoconsole_fn create_fn = (zr_win32_create_pseudoconsole_fn)(void*)GetProcAddress(k32, "CreatePseudoConsole"); - zr_win32_close_pseudoconsole_fn close_fn = (zr_win32_close_pseudoconsole_fn)(void*)GetProcAddress(k32, "ClosePseudoConsole"); + zr_win32_create_pseudoconsole_fn create_fn = + (zr_win32_create_pseudoconsole_fn)(void*)GetProcAddress(k32, "CreatePseudoConsole"); + zr_win32_close_pseudoconsole_fn close_fn = + (zr_win32_close_pseudoconsole_fn)(void*)GetProcAddress(k32, "ClosePseudoConsole"); if (!create_fn || !close_fn) { - zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, "ConPTY APIs not available (CreatePseudoConsole/ClosePseudoConsole)"); + zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, + "ConPTY APIs not available (CreatePseudoConsole/ClosePseudoConsole)"); return false; } @@ -170,12 +173,8 @@ static char* zr_win32_build_cmdline(const char* exe_path, const char* child_args return cmd; } -zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, - uint8_t* out_bytes, - size_t out_cap, - size_t* out_len, - uint32_t* out_exit_code, - char* out_skip_reason, +zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, uint8_t* out_bytes, size_t out_cap, + size_t* out_len, uint32_t* out_exit_code, char* out_skip_reason, size_t out_skip_reason_cap) { if (!out_len || !out_exit_code || !out_skip_reason || out_skip_reason_cap == 0u) { return ZR_ERR_INVALID_ARGUMENT; @@ -224,7 +223,8 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, size.Y = 25; hr = create_pc(size, conpty_in_r, conpty_out_w, 0u, &hpc); if (FAILED(hr) || !hpc) { - zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, "CreatePseudoConsole failed (ConPTY unavailable or blocked)"); + zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, + "CreatePseudoConsole failed (ConPTY unavailable or blocked)"); r = ZR_ERR_UNSUPPORTED; goto cleanup; } @@ -244,13 +244,8 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, r = ZR_ERR_PLATFORM; goto cleanup; } - if (!UpdateProcThreadAttribute(si.lpAttributeList, - 0u, - (DWORD_PTR)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - hpc, - sizeof(hpc), - NULL, - NULL)) { + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0u, (DWORD_PTR)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hpc, + sizeof(hpc), NULL, NULL)) { r = ZR_ERR_PLATFORM; goto cleanup; } @@ -272,16 +267,7 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, goto cleanup; } - ok = CreateProcessA(NULL, - cmdline, - NULL, - NULL, - FALSE, - EXTENDED_STARTUPINFO_PRESENT, - NULL, - NULL, - &si.StartupInfo, - &pi); + ok = CreateProcessA(NULL, cmdline, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, &si.StartupInfo, &pi); HeapFree(GetProcessHeap(), 0u, cmdline); cmdline = NULL; if (!ok) { diff --git a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h index af378b76..eb566434 100644 --- a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h +++ b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h @@ -20,12 +20,8 @@ - On unsupported environments, returns ZR_ERR_UNSUPPORTED and writes a stable skip reason string. */ -zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, - uint8_t* out_bytes, - size_t out_cap, - size_t* out_len, - uint32_t* out_exit_code, - char* out_skip_reason, +zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, uint8_t* out_bytes, size_t out_cap, + size_t* out_len, uint32_t* out_exit_code, char* out_skip_reason, size_t out_skip_reason_cap); #endif /* ZR_PLATFORM_WIN32_ZR_WIN32_CONPTY_TEST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/unicode/zr_grapheme.h b/packages/native/vendor/zireael/src/unicode/zr_grapheme.h index a60e1e44..67f27401 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_grapheme.h +++ b/packages/native/vendor/zireael/src/unicode/zr_grapheme.h @@ -21,8 +21,8 @@ typedef struct zr_grapheme_t { typedef struct zr_grapheme_iter_t { const uint8_t* bytes; - size_t len; - size_t off; + size_t len; + size_t off; } zr_grapheme_iter_t; /* @@ -41,4 +41,3 @@ void zr_grapheme_iter_init(zr_grapheme_iter_t* it, const uint8_t* bytes, size_t bool zr_grapheme_next(zr_grapheme_iter_t* it, zr_grapheme_t* out); #endif /* ZR_UNICODE_ZR_GRAPHEME_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h b/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h index 7b85af7b..ef74eaf9 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h +++ b/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h @@ -30,8 +30,8 @@ typedef enum zr_gcb_class_t { } zr_gcb_class_t; zr_gcb_class_t zr_unicode_gcb_class(uint32_t scalar); -bool zr_unicode_is_extended_pictographic(uint32_t scalar); -bool zr_unicode_is_emoji_presentation(uint32_t scalar); -bool zr_unicode_is_eaw_wide(uint32_t scalar); +bool zr_unicode_is_extended_pictographic(uint32_t scalar); +bool zr_unicode_is_emoji_presentation(uint32_t scalar); +bool zr_unicode_is_eaw_wide(uint32_t scalar); #endif /* ZR_UNICODE_ZR_UNICODE_DATA_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h b/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h index 98e7ddc7..45bb13c0 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h +++ b/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h @@ -37,4 +37,3 @@ static inline zr_unicode_version_t zr_unicode_version(void) { } #endif /* ZR_UNICODE_ZR_UNICODE_PINS_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_utf8.h b/packages/native/vendor/zireael/src/unicode/zr_utf8.h index 94531ad4..f169e1cc 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_utf8.h +++ b/packages/native/vendor/zireael/src/unicode/zr_utf8.h @@ -41,4 +41,3 @@ typedef struct zr_utf8_decode_result_t { zr_utf8_decode_result_t zr_utf8_decode_one(const uint8_t* s, size_t len); #endif /* ZR_UNICODE_ZR_UTF8_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_width.h b/packages/native/vendor/zireael/src/unicode/zr_width.h index c9eb8a67..432c9a33 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_width.h +++ b/packages/native/vendor/zireael/src/unicode/zr_width.h @@ -13,10 +13,7 @@ #include #include -typedef enum zr_width_policy_t { - ZR_WIDTH_EMOJI_NARROW = 0, - ZR_WIDTH_EMOJI_WIDE = 1 -} zr_width_policy_t; +typedef enum zr_width_policy_t { ZR_WIDTH_EMOJI_NARROW = 0, ZR_WIDTH_EMOJI_WIDE = 1 } zr_width_policy_t; static inline zr_width_policy_t zr_width_policy_default(void) { return (zr_width_policy_t)ZR_WIDTH_POLICY_DEFAULT; @@ -38,4 +35,3 @@ uint8_t zr_width_codepoint(uint32_t scalar); uint8_t zr_width_grapheme_utf8(const uint8_t* bytes, size_t len, zr_width_policy_t policy); #endif /* ZR_UNICODE_ZR_WIDTH_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_wrap.h b/packages/native/vendor/zireael/src/unicode/zr_wrap.h index 1fcf0d49..f41799a7 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_wrap.h +++ b/packages/native/vendor/zireael/src/unicode/zr_wrap.h @@ -41,8 +41,7 @@ zr_result_t zr_measure_utf8(const uint8_t* bytes, size_t len, zr_width_policy_t and returns ZR_OK */ zr_result_t zr_wrap_greedy_utf8(const uint8_t* bytes, size_t len, uint32_t max_cols, zr_width_policy_t policy, - uint32_t tab_stop, size_t* out_offsets, size_t out_offsets_cap, - size_t* out_count, bool* out_truncated); + uint32_t tab_stop, size_t* out_offsets, size_t out_offsets_cap, size_t* out_count, + bool* out_truncated); #endif /* ZR_UNICODE_ZR_WRAP_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_arena.h b/packages/native/vendor/zireael/src/util/zr_arena.h index bb97db64..496f43ab 100644 --- a/packages/native/vendor/zireael/src/util/zr_arena.h +++ b/packages/native/vendor/zireael/src/util/zr_arena.h @@ -33,14 +33,13 @@ typedef struct zr_arena_mark_t { - max_total_bytes == 0 is treated as 1 byte. */ zr_result_t zr_arena_init(zr_arena_t* a, size_t initial_bytes, size_t max_total_bytes); -void zr_arena_reset(zr_arena_t* a); -void zr_arena_release(zr_arena_t* a); +void zr_arena_reset(zr_arena_t* a); +void zr_arena_release(zr_arena_t* a); void* zr_arena_alloc(zr_arena_t* a, size_t size, size_t align); void* zr_arena_alloc_zeroed(zr_arena_t* a, size_t size, size_t align); zr_arena_mark_t zr_arena_mark(const zr_arena_t* a); -void zr_arena_rewind(zr_arena_t* a, zr_arena_mark_t mark); +void zr_arena_rewind(zr_arena_t* a, zr_arena_mark_t mark); #endif /* ZR_UTIL_ZR_ARENA_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_ring.h b/packages/native/vendor/zireael/src/util/zr_ring.h index 92ac5465..f58b33a2 100644 --- a/packages/native/vendor/zireael/src/util/zr_ring.h +++ b/packages/native/vendor/zireael/src/util/zr_ring.h @@ -24,14 +24,14 @@ typedef struct zr_ring_t { } zr_ring_t; zr_result_t zr_ring_init(zr_ring_t* r, void* backing_buf, size_t cap_elems, size_t elem_size); -void zr_ring_reset(zr_ring_t* r); +void zr_ring_reset(zr_ring_t* r); -size_t zr_ring_len(const zr_ring_t* r); -size_t zr_ring_cap(const zr_ring_t* r); -bool zr_ring_is_empty(const zr_ring_t* r); -bool zr_ring_is_full(const zr_ring_t* r); +size_t zr_ring_len(const zr_ring_t* r); +size_t zr_ring_cap(const zr_ring_t* r); +bool zr_ring_is_empty(const zr_ring_t* r); +bool zr_ring_is_full(const zr_ring_t* r); zr_result_t zr_ring_push(zr_ring_t* r, const void* elem); -bool zr_ring_pop(zr_ring_t* r, void* out_elem); +bool zr_ring_pop(zr_ring_t* r, void* out_elem); #endif /* ZR_UTIL_ZR_RING_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/util/zr_string_builder.h b/packages/native/vendor/zireael/src/util/zr_string_builder.h index 8223b665..a706f47e 100644 --- a/packages/native/vendor/zireael/src/util/zr_string_builder.h +++ b/packages/native/vendor/zireael/src/util/zr_string_builder.h @@ -19,10 +19,10 @@ typedef struct zr_sb_t { bool truncated; } zr_sb_t; -void zr_sb_init(zr_sb_t* sb, uint8_t* buf, size_t cap); -void zr_sb_reset(zr_sb_t* sb); +void zr_sb_init(zr_sb_t* sb, uint8_t* buf, size_t cap); +void zr_sb_reset(zr_sb_t* sb); size_t zr_sb_len(const zr_sb_t* sb); -bool zr_sb_truncated(const zr_sb_t* sb); +bool zr_sb_truncated(const zr_sb_t* sb); bool zr_sb_write_bytes(zr_sb_t* sb, const void* bytes, size_t len); bool zr_sb_write_u8(zr_sb_t* sb, uint8_t v); @@ -31,4 +31,3 @@ bool zr_sb_write_u32le(zr_sb_t* sb, uint32_t v); bool zr_sb_write_u64le(zr_sb_t* sb, uint64_t v); #endif /* ZR_UTIL_ZR_STRING_BUILDER_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_string_view.h b/packages/native/vendor/zireael/src/util/zr_string_view.h index 66b8b73b..12f5b6fe 100644 --- a/packages/native/vendor/zireael/src/util/zr_string_view.h +++ b/packages/native/vendor/zireael/src/util/zr_string_view.h @@ -42,4 +42,3 @@ static inline bool zr_sv_eq(zr_string_view_t a, zr_string_view_t b) { } #endif /* ZR_UTIL_ZR_STRING_VIEW_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_vec.h b/packages/native/vendor/zireael/src/util/zr_vec.h index 734a7946..41482206 100644 --- a/packages/native/vendor/zireael/src/util/zr_vec.h +++ b/packages/native/vendor/zireael/src/util/zr_vec.h @@ -21,12 +21,12 @@ typedef struct zr_vec_t { } zr_vec_t; zr_result_t zr_vec_init(zr_vec_t* v, void* backing_buf, size_t cap_elems, size_t elem_size); -void zr_vec_reset(zr_vec_t* v); +void zr_vec_reset(zr_vec_t* v); -size_t zr_vec_len(const zr_vec_t* v); -size_t zr_vec_cap(const zr_vec_t* v); +size_t zr_vec_len(const zr_vec_t* v); +size_t zr_vec_cap(const zr_vec_t* v); -void* zr_vec_at(zr_vec_t* v, size_t idx); +void* zr_vec_at(zr_vec_t* v, size_t idx); const void* zr_vec_at_const(const zr_vec_t* v, size_t idx); zr_result_t zr_vec_push(zr_vec_t* v, const void* elem); diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin index 655ab364cdae72b6625ea906bb0eb21e1c1f5209..16d258874f0a53771f8634791dce1d5f44d2b8bc 100644 GIT binary patch delta 63 zcmZ1?utq>KD#*o$k%5810f==tAoLq{1_qXiAJsW|fPxc%9L9}v+u0{N1n_`Vf&dc` F0{~_X3Df`p delta 70 zcmZ1@utY#ID#*o$m4Si50f<#NAao3n%{)<2m~Re{!3e}lfVhEu@+tN(ULXs|WME(d KF*bU(vjYHgmkLP$ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin index e5244fbaec88533976762391f5ecdaa65939394e..6d8fbc82847304ca182ba3bdfc86fe8523308d2c 100644 GIT binary patch delta 136 zcmeB?og$iTqh0K{|nAao3n<^b}E0bD>uB0vl>1B4xcmL+U_8S!(IJ2hq>zDO;xs)*#)(Ib0WxO~TmS$7 delta 108 zcmbOu+av1`73AW>%D}+j0K`3f5PAoY%?89jfS3`8K?DnsVgb@1J`a#+-~~xAFid{J f8vs%Z6qszqr^m=JIg`&AEC*8Q2$Iv{T1As~y1b_tN#<}b~6CDD0K!$+;$N&J< CXa{)! delta 76 zcmZ1@xkOSjD#*o$m4Si50f?9IKn< JjE$b`JOH0B2>Sp4 diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin index ae8e63d8ce5fbed784ffba2cf0f24318d321f25c..d46b6d07dd3e2a98b2e2073cbb2f7f5df6105642 100644 GIT binary patch delta 105 zcmX@Yc!tq4D#*o$k%5810f^5)=^h}>0>s1s9-tx(AZDDnRDPmEfC$Je5IAVCw3?Bb H0n7yeHIE6) delta 82 zcmX@Zc!W_hD#*o$m4Si50f>)4=@cN%JW)|tWC@VL2*hiESOSPaU}8pqD2N8}4;n13 NW@H8{0ZMqv0|5Va3ts>L diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin index a75a80de6315d7fd09ba291f11a2aa4961933783..e2d7ec91334673b39975ff6ebba01ccc11c992a7 100644 GIT binary patch literal 188 zcmazFa`9ngU|?_n;yqBh21v62F)@G#s7L~cL1u7(XeMSBRyKAH7@r9!W&*@8paEoX s0rA&&FBo{Z*kQsbG$)V;QU?M&46F=HK+{3yfdC6L6C(rDe~_^Z054?+_W%F@ delta 94 zcmdnPxP?(ND#*o$m4Si50f@Ii=@cN%JW)|tWC@VL2*hiEm;;DGU}8jo3Wx^rS(up^ W8JPYvf%qT*5@TdyW?^Mx=Kugi`3X1x diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin index 6a9804f82d9b2ebd5f316a049e1da4dc298fb09e..196afd18462e31156bcc0ecdc60c00edd08dbe90 100644 GIT binary patch delta 144 zcmdmBcEC(BD#*o$k%5810feO?Gy{-koA^;(jSDC!0mL8$96+30P^zGio0`i5WCut= z#2ut2PZWE%`G9Z&^JE7;ok@%`5UOJG24@3;0T2zc9|Tx| H7-TO1YPJ~J delta 104 zcmdnNG>1tuD#*o$m4Si50f=Q7A@mF&8zgoEh?#--0}$&>R1}}MAcc`>GApC8C{Tbw j0*X0-v?365PV|))Ny$$x*3HSx%MMDd%2aRx@)#HZAG#53 diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin index ccd1936572a0e9c3ac5e1cc14d7839990aa4ba94..bef6d42a2f25b7aa8cd67e0a0dbd46c81a1f0c11 100644 GIT binary patch literal 380 zcma)$tq#IK5QG=XZ>dBeD=|1EX%I*h5{JMd&S3foeG;Cm3Riv1mv)l_#UyjHH@iD` z*URZVGP9#tt3VIZrSu=<8d{5FWa2#pb8oEsL@akz=^it9&s%sD(ne&r7U^~G){1;? z)8QUtRMmqykk0ka_sP3}cRY`p%=pqVfE+-N?1jkgUUY0#;Cqhysj|OGjFoEgW@l-#I^u2GZ3EvVjdvA0mMKh3=e>~28clb#ODBFCLlHe zVi?c>GC<;A-@Rbq;bMmgqtGlso&=HzH;@f7j}1*6rUc|BkeMI=au*1L%mA4VqEm~C z6m;zr$`gz7fINsBL26)b0O<$u(~SQENmPKW4pj!i1Gx(XK=#1Q1F=D7{$B+opa2vO IAOMmB07+IFUH||9 diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin index 88e4bc78d4f40f47e50d88f2dd9d11dd7767599e..45f8d9905bb83496bc6d46cac7be00bd34557600 100644 GIT binary patch delta 63 zcmZ2tyT(>BD#*o$k%5810f^VgLFgPH%`$PLIwucMPyonb+&GtAZlXf~4@e~lFaa?D DY~Kk< delta 80 zcmZ2uyTn#9D#*o$m4Si50f?8#LFgDDn|Y$5Fy9;?gApVF#0+wiz2w4#Kr9BJIwmj! P#GdTP_-?Zeqlhd3^^FUC diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin index f2d451be7988cb04d29b49aef053a2754611d95b..b9ac2074132b2ba79447b734494f60e778f93857 100644 GIT binary patch delta 77 zcmaFD^oB_?D#*o$k%5810f=*e3=pjYq**2&6rOm21sQSj0Oc+KG2_I!$0s@j@PPDz I01FTU0K0n-v;Y7A delta 78 zcmaFE^n^(=D#*o$m4Si50flR2008k}4Q2oU diff --git a/packages/testkit/fixtures/zrdl-v1/golden/clip_nested.bin b/packages/testkit/fixtures/zrdl-v1/golden/clip_nested.bin index 0ad673f816685a1a835c395cb5415e8d515bd94d..87892d2003f8253593d65ecb0d69194c992ddacd 100644 GIT binary patch delta 50 ycmZ3%xP?(ND#*o$k%5810f@Ii=@KB#I#E%6q5;Q52NOnoNDm;?aZU<@z- literal 236 zcmazFa`9ngU|?_n;x|xw0+40~;tN2`1jG-3mm_`0BINq05T+j`0KkD x3_M)yFkuv$8OXB%Vvu>*oWC~%L)0L2D=W;AP(`A|SIs02Ge+^nug>0I%ezcmMzZ literal 408 zcmazFa`9ngU|?_n;u$~&h)w`vW*}Yw#2~p9K>PuSu>p`g2M{v>u?CWY03cfuh`+vj f!N9}C4wnI$YXIjV7$Eb})xu;!a*w7oj3@*E>wl_J diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin b/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin index af7a6398c2faefd0010ce5b9df11f05c57d8e2e7..8dcf92f6e9e123a6ddc0fd4138d570d5cc6e6611 100644 GIT binary patch literal 432 zcmazFa`9ngU|?_n;tfCsh%NxqEI^tVzy(zF1BgLpya3`yQyS=u6#Rn}=xim>I*@-k tfS3u0O@J5%0)PxjApZL91p^NkJ4_gbW(M+XkVHUcfdDA{@aY4o0RZHvqF4X` literal 400 zcmazFa`9ngU|?_n;t4PxTu>p`g2M{v>u?CWY03cfuh`+vj n!N9}C4wnI$YXIjV7$Eb})xu;!a*w7o&>1QC2Px3mN}zQBxFn-H diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin index 1d0485773b22c387aaa2bfdb02fc6a5d66f8e785..2013050ca500d1596e74f771e9d13daa37d1245d 100644 GIT binary patch literal 240 zcmazFa`9ngU|?_n;tx=I1CV9|VqyRnP>}=>gUnzBVvp3EoO~#s14uIgu?Y~vKmd>- v3B+IDyEYR<1-JY2IL+9nqLfV literal 208 zcmazFa`9ngU|?_n;tNo^0Z6j~@c|$P$(;aV4j{$`K=L3tCLq>8QV;-SO9JuNcP|)t wxY*$`EI^h75QFrA0LUH?n;D1=pll!o2OxQLGeD9cahRJxY>(8OoO~b+0I4JmasU7T diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin index 0e56dd6e334b4511b08d0031725796be8ccd80c9..bbc5da31c3b1097245ecd4d134d6aad1bb09a9f9 100644 GIT binary patch literal 240 zcmazFa`9ngU|?_n;tx=I1CV9|VqyRnP>}=>gUnzBVvp3EoO~#s14uIgu?Y~vKmd>- y3B+IDyEYRH#9WhG!Lc*8QV;-SO9JuNcP|)t zxY*$`EI^h75QFrA0LUH?n;D1=pll!o2O#-|h6WTfK$0MFn43UskJOx;d>{<~aefRZ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin index bd866899c3062bf9df50a4c7abeb42c962b19018..c05c69a39e1d2015a6ae6e84a038164d90a887a4 100644 GIT binary patch literal 240 zcmazFa`9ngU|?_n;tx=I1CV9|VqyRnP>}=>gUnzBVvp3EoO~#s14uIgu?Y~vKmd>- y3B+IDyEYR!^{J*u>p`c$UOj>{|s{g literal 208 zcmazFa`9ngU|?_n;tNo^0Z6j~@c|$P$(;aV4j{$`K=L3tCLq>8QV;-SO9JuNcP|)t xxY*$`EI^h75QFrA0LUH?n;D1=pll!o2OxQv86Y+cfY>lMf!H3YIXU@28UU%-4R-(l diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin b/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin index 396fceac36a0f9ea29f1829c092ff8a58cc0c0a8..5ec07ce886243aac4f5b2a805a363509260393b0 100644 GIT binary patch literal 260 zcmazFa`9ngU|?_nViq6+L>~cSRv;z@Z~+xb05Ql6kRG4J%G4q#p94rU0kH`X!$1I# vAqm7^-@Rbq;bMmgqtLkYfE3Be$)Tu)ahQP&8yE*jgWLlGAUEN&3#0}BxmOHl literal 216 zcmazFa`9ngU|?_n;u}!914y#~@d+RX$z1?q4j{$`K=L3tCLq>8QV;-SO9JuNcP|)t oxY*$`xYU6Z$jQmU)iDEE25=sN!3t!en+cNz$@wH!rWOHd04c-{RsaA1 diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin b/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin index 86a4aef873658b68827cda6b4281d5766a168653..56723e00b05342f034e0b72eea2ed5ac0b4174a0 100644 GIT binary patch literal 4260 zcmd^?yGjF55Qf*g#(2Ls`xIieML-2XMX|IKVz-c25NivCMSK7YAHnC@%1UhPoiEvu zfZ`_qGy#W9_Uy^|&Oc|**%@+qad0x0BuUHsdAWCO+x5`y2eAf&Yy#%^FfSh;{?&)| zznYJmzp(z|t}Jdw{&VkfPkhH+jC|1cSf7Y{-F(uV*vsNJp>NxKs`ArL`zp7#_fJ>- zN7s4Ysrs2?#+-dSJMNv|+3J4Ru5Rbn*fIZne@mJTh@VCrSzkBr0>=B0Wm%G@Y4`rO zuYMLzWm$*H5`)UJ4=T%ksVsY{vh1SDaz<2kF2Hh@Ro(dj+YGP^0d_IKE(KUCee!c& z39zdHb}hh?2i0@65nwk1>{fuKPAKc;Js@9^XEAF1Up0X-p4(yBBkr+bS#lkD7GsV< zb@y$o$`UhqQq@&ibjh)*uF9fIepYo=7F}|?s;jc-QWsQRl|>gSt~q79j#+Mt}k3fG;aU^ literal 3512 zcmd^>yGjE=6o$vU#(2LssV!4k1qs9=puu2iCn#71@(N;Yp|D6E!AJ0UQdvnFd(US) z>mo6nzY93*a3;H7zL{($fA;d?u2|^?)6otdDH21&rj1Q0{1hZ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin index 7cb6e6c4252e3ee2a094767e7b6065cc6d2cce8a..537471a4e338ae8e6e85ffc279d2404ff24f8237 100644 GIT binary patch literal 328 zcmazFa`9ngU|?_nVhlF+PJY4KxF(4NOfYgBi$n3B0UVzns1<(kPIUoSC u52Od=evp_65W_$KkRb`g_{?De%1gjB0BMkWLFTamF|s(!U93QHkX-<$SrGI9 literal 268 zcmazFa`9ngU|?_nVjds^L}vgoD-f>$VkRKo0mK48d;*9;YA*n>4G@C>h|dAUAaxo* z3(c(vKE&EBNFm lrex-&>*?u%WFc+>x#M~T&|ILMAajucNI$xJU}`{S0ss}X6Mp~z diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin index a38d88e5ed1afe84aaf85a70ad25083626665d64..0da30e73a164c324b5b20ff2eb7a87e0975965ff 100644 GIT binary patch literal 328 zcmazFa`9ngU|?_nVh*?t+@BsNKKnzkJ0>mIS*DDwpc(~ZXVn8km0I353klA0~y#T8N3!o7ob3g!O uA4m_#{U9+DAclbeAVU&}@tMN{l$U^M0Ma1$g3MzBVq|fcyI6tZAiDsx%@G0s literal 268 zcmazFa`9ngU|?_nVjds^L}vgoD-f>$VkRKo0mK48d;*9;YA*n>4G@C>h|dAUAaxo* z3(c(vKF*RPf19 lOv%hk*VEGj$wJ%&a>w-wpt(RhLFOU@kbZRcz|?@u1OOT^6O8}> diff --git a/scripts/__tests__/check-native-vendor-integrity.test.mjs b/scripts/__tests__/check-native-vendor-integrity.test.mjs new file mode 100644 index 00000000..14ee256c --- /dev/null +++ b/scripts/__tests__/check-native-vendor-integrity.test.mjs @@ -0,0 +1,136 @@ +/** + * Tests for check-native-vendor-integrity.mjs + */ + +import { strict as assert } from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, test } from "node:test"; +import { checkNativeVendorIntegrity } from "../check-native-vendor-integrity.mjs"; + +const COMMIT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const COMMIT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function writeUtf8(path, text) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text, "utf8"); +} + +function makeFixtureRoot() { + return mkdtempSync(join(tmpdir(), "rezi-native-vendor-")); +} + +function writeBaseFixture(root, commit = COMMIT_A) { + writeUtf8( + join(root, "packages/native/build.rs"), + [ + "fn main() {", + ' let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));', + ' let _vendor = manifest_dir.join("vendor").join("zireael");', + ' println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");', + "}", + "", + ].join("\n"), + ); + writeUtf8(join(root, "packages/native/vendor/VENDOR_COMMIT.txt"), `${commit}\n`); + mkdirSync(join(root, "packages/native/vendor/zireael/include"), { recursive: true }); + mkdirSync(join(root, "packages/native/vendor/zireael/src"), { recursive: true }); +} + +describe("check-native-vendor-integrity", () => { + test("passes when pin matches gitlink and build.rs uses native vendor path", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.equal( + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }).success, + true, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when commit pin format is invalid", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, "not-a-commit"); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }), + /VENDOR_COMMIT\.txt must contain exactly one 40-hex commit hash/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when build.rs does not use packages/native/vendor/zireael", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + writeUtf8( + join(root, "packages/native/build.rs"), + [ + "fn main() {", + ' let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));', + ' let _vendor = manifest_dir.join("..").join("vendor").join("zireael");', + ' println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");', + "}", + "", + ].join("\n"), + ); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }), + /build\.rs must compile Zireael from packages\/native\/vendor\/zireael/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when pinned commit and gitlink commit differ", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_B, + resolveSubmoduleHead: () => null, + }), + /native vendor commit pin mismatch/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when checked-out submodule HEAD differs from gitlink pointer", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => COMMIT_B, + }), + /checked-out vendor\/zireael is out of sync with repo gitlink pointer/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs new file mode 100644 index 00000000..431c36f2 --- /dev/null +++ b/scripts/check-native-vendor-integrity.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * check-native-vendor-integrity.mjs + * + * Guardrails for the dual Zireael vendor layout used by @rezi-ui/native. + * + * Invariants: + * - build.rs compiles from packages/native/vendor/zireael + * - packages/native/vendor/VENDOR_COMMIT.txt is exactly one 40-hex commit + * - the commit pin matches the repo gitlink pointer at vendor/zireael + * - if vendor/zireael is checked out locally, its HEAD matches that pointer + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +function isHex40(value) { + return /^[0-9a-fA-F]{40}$/.test(value); +} + +function runGit(cwd, args) { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (err) { + const stderr = String(err?.stderr ?? "").trim(); + const detail = stderr.length > 0 ? `: ${stderr}` : ""; + throw new Error(`git ${args.join(" ")} failed${detail}`); + } +} + +function normalizeCommitPin(rawPin) { + const lines = rawPin + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length !== 1 || !isHex40(lines[0])) { + throw new Error( + "packages/native/vendor/VENDOR_COMMIT.txt must contain exactly one 40-hex commit hash", + ); + } + return lines[0].toLowerCase(); +} + +function readGitlinkCommit(rootDir) { + const commit = runGit(rootDir, ["rev-parse", "HEAD:vendor/zireael"]).toLowerCase(); + if (!isHex40(commit)) { + throw new Error( + `gitlink pointer for vendor/zireael is not a 40-hex commit (got: ${JSON.stringify(commit)})`, + ); + } + return commit; +} + +function readSubmoduleHead(rootDir) { + const submoduleDir = join(rootDir, "vendor/zireael"); + if (!existsSync(submoduleDir)) return null; + + try { + if (!statSync(submoduleDir).isDirectory()) return null; + } catch { + return null; + } + + try { + const head = runGit(submoduleDir, ["rev-parse", "HEAD"]).toLowerCase(); + return isHex40(head) ? head : null; + } catch { + return null; + } +} + +function assertDir(path, label) { + if (!existsSync(path)) { + throw new Error(`${label} is missing: ${path}`); + } + if (!statSync(path).isDirectory()) { + throw new Error(`${label} must be a directory: ${path}`); + } +} + +function validateBuildRs(buildRsText) { + const compilePathNeedle = 'manifest_dir.join("vendor").join("zireael")'; + if (!buildRsText.includes(compilePathNeedle)) { + throw new Error( + "packages/native/build.rs must compile Zireael from packages/native/vendor/zireael", + ); + } + + const rerunCommitNeedle = 'println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");'; + if (!buildRsText.includes(rerunCommitNeedle)) { + throw new Error( + "packages/native/build.rs must include rerun-if-changed for vendor/VENDOR_COMMIT.txt", + ); + } +} + +export function checkNativeVendorIntegrity(rootDir, options = {}) { + const root = rootDir ?? join(dirname(fileURLToPath(import.meta.url)), ".."); + + const buildRsPath = join(root, "packages/native/build.rs"); + const nativeVendorRoot = join(root, "packages/native/vendor/zireael"); + const vendorCommitPath = join(root, "packages/native/vendor/VENDOR_COMMIT.txt"); + + if (!existsSync(buildRsPath)) { + throw new Error(`missing build script: ${buildRsPath}`); + } + if (!existsSync(vendorCommitPath)) { + throw new Error(`missing vendor commit pin file: ${vendorCommitPath}`); + } + + validateBuildRs(readFileSync(buildRsPath, "utf8")); + + assertDir(nativeVendorRoot, "native vendor root"); + assertDir(join(nativeVendorRoot, "include"), "native vendor include directory"); + assertDir(join(nativeVendorRoot, "src"), "native vendor source directory"); + + const pinnedCommit = normalizeCommitPin(readFileSync(vendorCommitPath, "utf8")); + + const resolveGitlinkCommit = options.resolveGitlinkCommit ?? readGitlinkCommit; + const resolveSubmoduleHead = options.resolveSubmoduleHead ?? readSubmoduleHead; + + const gitlinkCommitRaw = resolveGitlinkCommit(root); + const gitlinkCommit = String(gitlinkCommitRaw).toLowerCase(); + if (!isHex40(gitlinkCommit)) { + throw new Error(`resolved gitlink commit is invalid: ${JSON.stringify(gitlinkCommitRaw)}`); + } + + if (pinnedCommit !== gitlinkCommit) { + throw new Error( + [ + "native vendor commit pin mismatch:", + `- packages/native/vendor/VENDOR_COMMIT.txt: ${pinnedCommit}`, + `- gitlink HEAD:vendor/zireael: ${gitlinkCommit}`, + "Update the pin file (or submodule pointer) so both commits match.", + ].join("\n"), + ); + } + + const submoduleHeadRaw = resolveSubmoduleHead(root); + if (submoduleHeadRaw !== null && submoduleHeadRaw !== undefined) { + const submoduleHead = String(submoduleHeadRaw).toLowerCase(); + if (!isHex40(submoduleHead)) { + throw new Error( + `resolved checked-out vendor/zireael HEAD is invalid: ${JSON.stringify(submoduleHeadRaw)}`, + ); + } + if (submoduleHead !== gitlinkCommit) { + throw new Error( + [ + "checked-out vendor/zireael is out of sync with repo gitlink pointer:", + `- vendor/zireael HEAD: ${submoduleHead}`, + `- repo gitlink HEAD:vendor/zireael: ${gitlinkCommit}`, + "Run: git submodule update --init --recursive vendor/zireael", + ].join("\n"), + ); + } + } + + return { + success: true, + pinnedCommit, + gitlinkCommit, + }; +} + +const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : null; +const selfPath = realpathSync(fileURLToPath(import.meta.url)); +if (invokedPath && invokedPath === selfPath) { + try { + const result = checkNativeVendorIntegrity(); + process.stdout.write( + `check-native-vendor-integrity: OK (pin=${result.pinnedCommit.slice(0, 12)})\n`, + ); + process.exit(0); + } catch (err) { + process.stderr.write( + `check-native-vendor-integrity: FAIL\n${String(err?.stack ?? err)}\n`, + ); + process.exit(1); + } +} diff --git a/scripts/generate-drawlist-writers.ts b/scripts/generate-drawlist-writers.ts index 26d4a977..a1f31669 100644 --- a/scripts/generate-drawlist-writers.ts +++ b/scripts/generate-drawlist-writers.ts @@ -108,7 +108,7 @@ function emitFunction(command: DrawlistCommandSpec): Emission { const body: string[] = []; if (command.hasTrailingBytes) { - body.push("const payloadBytes = bytes.byteLength >>> 0;"); + body.push("const payloadBytes = byteLen >>> 0;"); body.push(`const size = align4(${command.name}_BASE_SIZE + payloadBytes);`); body.push(`buf[pos + 0] = ${command.opcode} & 0xff;`); body.push("buf[pos + 1] = 0;"); @@ -132,7 +132,13 @@ function emitFunction(command: DrawlistCommandSpec): Emission { if (command.hasTrailingBytes) { body.push(`const dataStart = pos + ${command.name}_BASE_SIZE;`); - body.push("buf.set(bytes, dataStart);"); + body.push("const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0);"); + body.push("if (copyBytes > 0) {"); + body.push(" buf.set(bytes.subarray(0, copyBytes), dataStart);"); + body.push("}"); + body.push("if (payloadBytes > copyBytes) {"); + body.push(" buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes);"); + body.push("}"); body.push("const payloadEnd = dataStart + payloadBytes;"); body.push("const cmdEnd = pos + size;"); body.push("if (cmdEnd > payloadEnd) {"); diff --git a/vendor/zireael b/vendor/zireael index 435d28bc..268e21f5 160000 --- a/vendor/zireael +++ b/vendor/zireael @@ -1 +1 @@ -Subproject commit 435d28bcd59dd07b78be0f28661ae159cefde753 +Subproject commit 268e21f51e1c2b32fd6975c3be185e28a3738736 From cd9c38775e8ddab9b26c926d5a275a78d7d8a628 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:24:30 +0400 Subject: [PATCH 12/20] Bump vendored Zireael to include review-thread fixes --- packages/native/vendor/VENDOR_COMMIT.txt | 2 +- packages/native/vendor/zireael/src/core/zr_diff.c | 5 ++++- packages/native/vendor/zireael/src/core/zr_drawlist.c | 6 +++--- .../native/vendor/zireael/src/core/zr_engine_present.inc | 2 +- vendor/zireael | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index c3c5074d..e42c9c37 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -268e21f51e1c2b32fd6975c3be185e28a3738736 +964e72a3f02a17e128e7a7166fd03db02e1bb128 diff --git a/packages/native/vendor/zireael/src/core/zr_diff.c b/packages/native/vendor/zireael/src/core/zr_diff.c index d4e35f57..4b44fc4d 100644 --- a/packages/native/vendor/zireael/src/core/zr_diff.c +++ b/packages/native/vendor/zireael/src/core/zr_diff.c @@ -328,7 +328,10 @@ static bool zr_fb_links_eq_exact(const zr_fb_t* a, const zr_fb_t* b) { } if (a->links_len != 0u) { - const size_t links_bytes = (size_t)a->links_len * sizeof(zr_fb_link_t); + size_t links_bytes = 0u; + if (!zr_checked_mul_size((size_t)a->links_len, sizeof(zr_fb_link_t), &links_bytes)) { + return false; + } if (memcmp(a->links, b->links, links_bytes) != 0) { return false; } diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index d129eff7..c3d58fd5 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1631,17 +1631,17 @@ static zr_result_t zr_dl_validate_blit_rect_bounds(const zr_fb_t* fb, const zr_d return ZR_ERR_INVALID_ARGUMENT; } if (cmd->w <= 0 || cmd->h <= 0 || cmd->src_x < 0 || cmd->src_y < 0 || cmd->dst_x < 0 || cmd->dst_y < 0) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } if (!zr_checked_add_u32((uint32_t)cmd->src_x, (uint32_t)cmd->w, &src_x_end) || !zr_checked_add_u32((uint32_t)cmd->src_y, (uint32_t)cmd->h, &src_y_end) || !zr_checked_add_u32((uint32_t)cmd->dst_x, (uint32_t)cmd->w, &dst_x_end) || !zr_checked_add_u32((uint32_t)cmd->dst_y, (uint32_t)cmd->h, &dst_y_end)) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } if (src_x_end > fb->cols || src_y_end > fb->rows || dst_x_end > fb->cols || dst_y_end > fb->rows) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } return ZR_OK; diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index 63ee123a..6cf1b03b 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -364,7 +364,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ const bool links_compatible_for_damage_copy = zr_fb_links_prefix_equal(&e->fb_prev, presented_fb); const bool use_damage_rect_copy = links_compatible_for_damage_copy && (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && - (stats->damage_rects <= e->damage_rect_cap); + (stats->damage_rects != 0u) && (stats->damage_rects <= e->damage_rect_cap); /* Resync fb_prev to the framebuffer that was actually presented. diff --git a/vendor/zireael b/vendor/zireael index 268e21f5..964e72a3 160000 --- a/vendor/zireael +++ b/vendor/zireael @@ -1 +1 @@ -Subproject commit 268e21f51e1c2b32fd6975c3be185e28a3738736 +Subproject commit 964e72a3f02a17e128e7a7166fd03db02e1bb128 From 29d53d19089c0a176d50f263778357d0dcb1d2b2 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:34:51 +0400 Subject: [PATCH 13/20] vendor zireael: bump to c0849ae after main merge --- packages/native/vendor/VENDOR_COMMIT.txt | 2 +- .../vendor/zireael/include/zr/zr_drawlist.h | 2 +- .../vendor/zireael/include/zr/zr_version.h | 5 ++-- .../vendor/zireael/src/core/zr_config.c | 8 +++++-- .../vendor/zireael/src/core/zr_drawlist.c | 23 ++++++++++++++++--- .../zireael/src/core/zr_engine_present.inc | 9 ++++---- vendor/zireael | 2 +- 7 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index e42c9c37..ea32f6a9 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -964e72a3f02a17e128e7a7166fd03db02e1bb128 +c0849ae29483322623d4ab564877a8940896affb diff --git a/packages/native/vendor/zireael/include/zr/zr_drawlist.h b/packages/native/vendor/zireael/include/zr/zr_drawlist.h index 4b10cd03..3860a11c 100644 --- a/packages/native/vendor/zireael/include/zr/zr_drawlist.h +++ b/packages/native/vendor/zireael/include/zr/zr_drawlist.h @@ -1,5 +1,5 @@ /* - include/zr/zr_drawlist.h — Drawlist ABI structs (v1). + include/zr/zr_drawlist.h — Drawlist ABI structs (v1/v2). Why: Defines the little-endian drawlist command stream used by wrappers to drive rendering through engine_submit_drawlist(). diff --git a/packages/native/vendor/zireael/include/zr/zr_version.h b/packages/native/vendor/zireael/include/zr/zr_version.h index 6891b7fd..fce007e1 100644 --- a/packages/native/vendor/zireael/include/zr/zr_version.h +++ b/packages/native/vendor/zireael/include/zr/zr_version.h @@ -18,7 +18,7 @@ extern "C" { */ #if defined(ZR_LIBRARY_VERSION_MAJOR) || defined(ZR_LIBRARY_VERSION_MINOR) || defined(ZR_LIBRARY_VERSION_PATCH) || \ defined(ZR_ENGINE_ABI_MAJOR) || defined(ZR_ENGINE_ABI_MINOR) || defined(ZR_ENGINE_ABI_PATCH) || \ - defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_EVENT_BATCH_VERSION_V1) + defined(ZR_DRAWLIST_VERSION_V1) || defined(ZR_DRAWLIST_VERSION_V2) || defined(ZR_EVENT_BATCH_VERSION_V1) #error "Zireael version pins are locked; do not override ZR_*_VERSION_* macros." #endif @@ -32,8 +32,9 @@ extern "C" { #define ZR_ENGINE_ABI_MINOR (2u) #define ZR_ENGINE_ABI_PATCH (0u) -/* Drawlist binary format version (current protocol baseline). */ +/* Drawlist binary format versions. */ #define ZR_DRAWLIST_VERSION_V1 (1u) +#define ZR_DRAWLIST_VERSION_V2 (2u) /* Packed event batch binary format versions. */ #define ZR_EVENT_BATCH_VERSION_V1 (1u) diff --git a/packages/native/vendor/zireael/src/core/zr_config.c b/packages/native/vendor/zireael/src/core/zr_config.c index 62d8e15a..05ad8ee3 100644 --- a/packages/native/vendor/zireael/src/core/zr_config.c +++ b/packages/native/vendor/zireael/src/core/zr_config.c @@ -139,8 +139,12 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg) { return ZR_ERR_UNSUPPORTED; } - if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 || - cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { + if (cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V1 && + cfg->requested_drawlist_version != ZR_DRAWLIST_VERSION_V2) { + return ZR_ERR_UNSUPPORTED; + } + + if (cfg->requested_event_batch_version != ZR_EVENT_BATCH_VERSION_V1) { return ZR_ERR_UNSUPPORTED; } diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index c3d58fd5..df077861 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1,5 +1,5 @@ /* - src/core/zr_drawlist.c — Drawlist validator + executor (v1). + src/core/zr_drawlist.c — Drawlist validator + executor (v1/v2). Why: Validates wrapper-provided drawlist bytes (bounds/caps/version) and executes deterministic drawing into the framebuffer without UB. @@ -202,7 +202,7 @@ static zr_result_t zr_dl_store_define(zr_dl_resource_store_t* store, uint32_t id if (idx >= 0) { if (!store->entries) { free(copy); - return ZR_ERR_LIMIT; + return ZR_ERR_FORMAT; } old_len = store->entries[(uint32_t)idx].len; if (old_len > store->total_bytes) { @@ -741,6 +741,14 @@ typedef struct zr_dl_range_t { uint32_t len; } zr_dl_range_t; +static bool zr_dl_version_supported(uint32_t version) { + return version == ZR_DRAWLIST_VERSION_V1 || version == ZR_DRAWLIST_VERSION_V2; +} + +static bool zr_dl_version_supports_blit_rect(uint32_t version) { + return version >= ZR_DRAWLIST_VERSION_V2; +} + static bool zr_dl_range_is_empty(zr_dl_range_t r) { return r.len == 0u; } @@ -799,7 +807,7 @@ static zr_result_t zr_dl_validate_header(const zr_dl_header_t* hdr, size_t bytes if (hdr->magic != ZR_DL_MAGIC) { return ZR_ERR_FORMAT; } - if (hdr->version != ZR_DRAWLIST_VERSION_V1) { + if (!zr_dl_version_supported(hdr->version)) { return ZR_ERR_UNSUPPORTED; } if (hdr->header_size != (uint32_t)sizeof(zr_dl_header_t)) { @@ -1227,6 +1235,9 @@ static zr_result_t zr_dl_validate_cmd_payload(const zr_dl_view_t* view, const zr case ZR_DL_OP_PUSH_CLIP: return zr_dl_validate_cmd_push_clip(ch, r, lim, clip_depth); case ZR_DL_OP_BLIT_RECT: + if (!zr_dl_version_supports_blit_rect(view->hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } return zr_dl_validate_cmd_blit_rect(ch, r); case ZR_DL_OP_POP_CLIP: return zr_dl_validate_cmd_pop_clip(ch, clip_depth); @@ -1734,6 +1745,9 @@ zr_result_t zr_dl_preflight_resources(const zr_dl_view_t* v, zr_fb_t* fb, zr_ima break; } case ZR_DL_OP_BLIT_RECT: { + if (!zr_dl_version_supports_blit_rect(v->hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } zr_dl_cmd_blit_rect_t cmd; rc = zr_dl_read_cmd_blit_rect(&r, &cmd); if (rc != ZR_OK) { @@ -2412,6 +2426,9 @@ zr_result_t zr_dl_execute(const zr_dl_view_t* v, zr_fb_t* dst, const zr_limits_t break; } case ZR_DL_OP_BLIT_RECT: { + if (!zr_dl_version_supports_blit_rect(view.hdr.version)) { + return ZR_ERR_UNSUPPORTED; + } rc = zr_dl_exec_blit_rect(&r, &painter); if (rc != ZR_OK) { return rc; diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index 6cf1b03b..2dcffe80 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -362,9 +362,9 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; const bool links_compatible_for_damage_copy = zr_fb_links_prefix_equal(&e->fb_prev, presented_fb); - const bool use_damage_rect_copy = - links_compatible_for_damage_copy && (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && - (stats->damage_rects != 0u) && (stats->damage_rects <= e->damage_rect_cap); + const bool use_damage_rect_copy = links_compatible_for_damage_copy && (stats->path_damage_used != 0u) && + (stats->damage_full_frame == 0u) && (stats->damage_rects != 0u) && + (stats->damage_rects <= e->damage_rect_cap); /* Resync fb_prev to the framebuffer that was actually presented. @@ -384,8 +384,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ memcpy(e->fb_prev.cells, presented_fb->cells, n); } } else { - const zr_result_t rc = - zr_fb_copy_damage_rects(&e->fb_prev, presented_fb, e->damage_rects, stats->damage_rects); + const zr_result_t rc = zr_fb_copy_damage_rects(&e->fb_prev, presented_fb, e->damage_rects, stats->damage_rects); if (rc != ZR_OK) { const size_t n = zr_engine_cells_bytes(presented_fb); if (n != 0u) { diff --git a/vendor/zireael b/vendor/zireael index 964e72a3..c0849ae2 160000 --- a/vendor/zireael +++ b/vendor/zireael @@ -1 +1 @@ -Subproject commit 964e72a3f02a17e128e7a7166fd03db02e1bb128 +Subproject commit c0849ae29483322623d4ab564877a8940896affb From 3155af7b34345b7baa320af69d3666171d1d93ae Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:44:22 +0400 Subject: [PATCH 14/20] Address PR #223 unresolved review feedback --- examples/regression-dashboard/src/main.ts | 14 ++++++- .../renderToDrawlist/overflowCulling.ts | 32 ++++++++++++++- .../templates/starship/src/main.ts | 41 +++++++++++++------ 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/examples/regression-dashboard/src/main.ts b/examples/regression-dashboard/src/main.ts index 82850c08..57596f35 100644 --- a/examples/regression-dashboard/src/main.ts +++ b/examples/regression-dashboard/src/main.ts @@ -164,15 +164,23 @@ debugLog("boot", { argv: process.argv.slice(2), }); +let terminating = false; +function terminateProcessNow(exitCode: number): void { + if (terminating) return; + terminating = true; + process.exitCode = exitCode; + exit(exitCode); +} + process.on("uncaughtException", (error) => { debugLog("uncaughtException", error); stderrLog(`Regression dashboard uncaught exception: ${describeError(error)}`); - process.exitCode = 1; + terminateProcessNow(1); }); process.on("unhandledRejection", (reason) => { debugLog("unhandledRejection", reason); stderrLog(`Regression dashboard unhandled rejection: ${describeError(reason)}`); - process.exitCode = 1; + terminateProcessNow(1); }); process.on("beforeExit", (code) => { debugLog("beforeExit", { code }); @@ -182,9 +190,11 @@ process.on("exit", (code) => { }); process.on("SIGTERM", () => { debugLog("signal", "SIGTERM"); + terminateProcessNow(143); }); process.on("SIGINT", () => { debugLog("signal", "SIGINT"); + terminateProcessNow(130); }); if (forceHeadless || !hasInteractiveTty) { diff --git a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts index 34e4e4d1..f79fa2f1 100644 --- a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts +++ b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts @@ -5,6 +5,11 @@ type OverflowProps = Readonly<{ shadow?: unknown; }>; +type LayerWrapperProps = Readonly<{ + backdrop?: unknown; + frameStyle?: unknown; +}>; + function readShadowOffset(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; @@ -45,9 +50,34 @@ function hasVisibleOverflow(node: RuntimeInstance): boolean { return props.overflow !== "hidden" && props.overflow !== "scroll"; } +function hasFiniteColor(raw: unknown): boolean { + return typeof raw === "number" && Number.isFinite(raw); +} + +function isPassThroughLayer(node: RuntimeInstance): boolean { + if (node.vnode.kind !== "layer") { + return false; + } + const props = node.vnode.props as LayerWrapperProps; + if (props.backdrop === "dim" || props.backdrop === "opaque") { + return false; + } + if (typeof props.frameStyle !== "object" || props.frameStyle === null) { + return true; + } + const frameStyle = props.frameStyle as Readonly<{ background?: unknown; border?: unknown }>; + return !hasFiniteColor(frameStyle.background) && !hasFiniteColor(frameStyle.border); +} + function isTransparentOverflowWrapper(node: RuntimeInstance): boolean { const kind = node.vnode.kind; - return kind === "themed" || kind === "focusZone" || kind === "focusTrap"; + return ( + kind === "themed" || + kind === "focusZone" || + kind === "focusTrap" || + kind === "layers" || + isPassThroughLayer(node) + ); } export function subtreeCanOverflowBounds(node: RuntimeInstance): boolean { diff --git a/packages/create-rezi/templates/starship/src/main.ts b/packages/create-rezi/templates/starship/src/main.ts index 3ac650ca..f93181c2 100644 --- a/packages/create-rezi/templates/starship/src/main.ts +++ b/packages/create-rezi/templates/starship/src/main.ts @@ -605,19 +605,36 @@ function snapshotDebugRecord(record: DebugRecord): void { async function setupDebugTrace(): Promise { if (!DEBUG_TRACE_ENABLED) return; - debugController = createDebugController({ - backend: app.backend.debug, - terminalCapsProvider: () => app.backend.getCaps(), - maxFrames: 512, - }); + try { + debugController = createDebugController({ + backend: app.backend.debug, + terminalCapsProvider: () => app.backend.getCaps(), + maxFrames: 512, + }); + + await debugController.enable({ + minSeverity: "trace", + categoryMask: categoriesToMask(["frame", "drawlist", "error"]), + captureRawEvents: false, + captureDrawlistBytes: false, + }); + await debugController.reset(); + } catch (error) { + debugSnapshot("runtime.debug.enable.error", { + message: error instanceof Error ? error.message : String(error), + route: currentRouteId(), + }); + if (debugController) { + try { + await debugController.disable(); + } catch { + // Ignore debug shutdown races. + } + debugController = null; + } + return; + } - await debugController.enable({ - minSeverity: "trace", - categoryMask: categoriesToMask(["frame", "drawlist", "error"]), - captureRawEvents: false, - captureDrawlistBytes: false, - }); - await debugController.reset(); debugLastRecordId = 0n; debugSnapshot("runtime.debug.enable", { From 4cbcb8bf5979ecf2328f047a2a374ef595095f7f Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:30:57 +0400 Subject: [PATCH 15/20] Fix drawlist text perf counters and CI lockfile sync --- docs/protocol/versioning.md | 2 ++ docs/protocol/zrdl.md | 17 +++++++++++++ package-lock.json | 25 +++++++++++++++---- .../builder.text-arena.equivalence.test.ts | 16 ++++++++++++ packages/core/src/drawlist/builder.ts | 25 ++++++++++++++----- packages/core/src/drawlist/builderBase.ts | 7 ++++++ .../src/runtime/createInkRenderer.ts | 9 +++++++ 7 files changed, 90 insertions(+), 11 deletions(-) diff --git a/docs/protocol/versioning.md b/docs/protocol/versioning.md index 079681f2..ac7da681 100644 --- a/docs/protocol/versioning.md +++ b/docs/protocol/versioning.md @@ -24,6 +24,8 @@ Pinned drawlist version: Rezi currently emits ZRDL v1, and the engine accepts v1/v2. ZRDL v2 adds opcode `14` (`BLIT_RECT`). +Style link reference fields (`linkUriRef`, `linkIdRef`) are stable across v1/v2: +`0` means unset and positive values are 1-based string resource IDs. ## Event Batch (ZREV) diff --git a/docs/protocol/zrdl.md b/docs/protocol/zrdl.md index d7975a4d..4efbe886 100644 --- a/docs/protocol/zrdl.md +++ b/docs/protocol/zrdl.md @@ -57,6 +57,23 @@ Commands are 4-byte aligned. - `FREE_*` invalidates that ID. - Draw commands referencing unknown IDs fail. +## Style Payload Link References + +Text-bearing commands carry style fields that include hyperlink references: + +- `linkUriRef` (`u32`) +- `linkIdRef` (`u32`) + +These fields use the same ID space as `DEF_STRING`/`FREE_STRING` and are +**1-based**: + +- `0` means "no active link" (sentinel unset) +- `N > 0` maps to string resource ID `N` + +Rezi encodes link references as `(internedIndex + 1)`, and Zireael resolves +them as direct string IDs. This is part of the v1/v2 wire contract and does +not introduce a new drawlist version. + ## Codegen Command layouts are defined in `scripts/drawlist-spec.ts`. diff --git a/package-lock.json b/package-lock.json index d94421d2..4d9f81a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,16 @@ "@rezi-ui/node": "0.1.0-alpha.34" } }, + "examples/regression-dashboard": { + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, "examples/release-demo": { "extraneous": true, "dependencies": { @@ -2201,6 +2211,10 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/regression-dashboard": { + "resolved": "examples/regression-dashboard", + "link": true + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -3240,9 +3254,7 @@ "license": "Apache-2.0", "dependencies": { "@rezi-ui/core": "0.1.0-alpha.34", - "@rezi-ui/node": "0.1.0-alpha.34", - "react": "^18.2.0 || ^19.0.0", - "react-reconciler": "^0.29.0 || ^0.30.0 || ^0.31.0" + "@rezi-ui/node": "0.1.0-alpha.34" }, "devDependencies": { "@types/react": "^18.2.0 || ^19.0.0", @@ -3253,7 +3265,8 @@ "node": ">=18" }, "peerDependencies": { - "react": "^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-reconciler": "^0.29.0 || ^0.30.0 || ^0.31.0" } }, "packages/ink-compat/node_modules/react-reconciler": { @@ -3261,6 +3274,7 @@ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -3275,7 +3289,8 @@ "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "packages/jsx": { "name": "@rezi-ui/jsx", diff --git a/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts b/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts index 99b80ad3..32c52ec5 100644 --- a/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts @@ -467,6 +467,22 @@ describe("drawlist text arena equivalence", () => { } }); + test("text perf counters report TextEncoder calls for unique non-ASCII strings", () => { + const b = createDrawlistBuilder(); + b.drawText(0, 0, "α0"); + b.drawText(0, 1, "α1"); + + const built = b.build(); + assert.equal(built.ok, true, "build should succeed"); + if (!built.ok) return; + + const counters = b.getTextPerfCounters?.(); + assert.ok(counters !== undefined, "counters should exist"); + if (!counters) return; + assert.equal(counters.textArenaBytes > 0, true); + assert.equal(counters.textEncoderCalls >= 2, true); + }); + test("property: random text + random slicing matches baseline framebuffer", () => { const width = 48; const height = 16; diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index 4edd8f7e..9997d4c8 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -1,10 +1,10 @@ import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; import type { TextStyle } from "../widgets/style.js"; import { - HEADER_SIZE, DrawlistBuilderBase, - align4, type DrawlistBuilderBaseOpts, + HEADER_SIZE, + align4, } from "./builderBase.js"; import type { CursorState, @@ -33,10 +33,10 @@ import { POP_CLIP_SIZE, PUSH_CLIP_SIZE, SET_CURSOR_SIZE, + writeBlitRect, writeClear, writeDefBlob, writeDefString, - writeBlitRect, writeDrawCanvas, writeDrawImage, writeDrawText, @@ -270,7 +270,17 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D } if (!this.beginCommandWrite("blitRect", BLIT_RECT_SIZE)) return; - this.cmdLen = writeBlitRect(this.cmdBuf, this.cmdDv, this.cmdLen, srcXi, srcYi, wi, hi, dstXi, dstYi); + this.cmdLen = writeBlitRect( + this.cmdBuf, + this.cmdDv, + this.cmdLen, + srcXi, + srcYi, + wi, + hi, + dstXi, + dstYi, + ); this.cmdCount += 1; this.maybeFailTooLargeAfterWrite(); } @@ -285,7 +295,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D getTextPerfCounters(): DrawlistTextPerfCounters { return Object.freeze({ - textEncoderCalls: 0, + textEncoderCalls: this.getTextEncoderCallCount(), textArenaBytes: this.stringBytesLen, textSegments: this.textPerfSegments, }); @@ -790,7 +800,10 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D if (byteLen === null) return false; if (requireNonEmpty && byteLen === 0) { - this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be non-empty when provided; use null to clear"); + this.fail( + "ZRDL_BAD_PARAMS", + "setLink: uri must be non-empty when provided; use null to clear", + ); return false; } if (byteLen > maxBytes) { diff --git a/packages/core/src/drawlist/builderBase.ts b/packages/core/src/drawlist/builderBase.ts index fabac73b..816e493f 100644 --- a/packages/core/src/drawlist/builderBase.ts +++ b/packages/core/src/drawlist/builderBase.ts @@ -106,6 +106,7 @@ export abstract class DrawlistBuilderBase { protected outBuf: Uint8Array | null = null; protected readonly encodedStringCache: Map | null; + protected textEncoderCalls = 0; protected error: DrawlistBuildError | undefined; @@ -378,6 +379,7 @@ export abstract class DrawlistBuilderBase { this.stringSpanOffs.length = 0; this.stringSpanLens.length = 0; this.stringBytesLen = 0; + this.textEncoderCalls = 0; this.blobSpanOffs.length = 0; this.blobSpanLens.length = 0; @@ -687,6 +689,10 @@ export abstract class DrawlistBuilderBase { this.error = { code, detail }; } + protected getTextEncoderCallCount(): number { + return this.textEncoderCalls; + } + protected internString(text: string): number | null { const existing = this.stringIndexByValue.get(text); if (existing !== undefined) return existing; @@ -758,6 +764,7 @@ export abstract class DrawlistBuilderBase { return out; } + this.textEncoderCalls += 1; return this.encoder ? this.encoder.encode(text) : new Uint8Array(); } diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index 7414ba51..f053875e 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -181,6 +181,15 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { hideCursor(): void {} + blitRect( + _srcX: number, + _srcY: number, + _w: number, + _h: number, + _dstX: number, + _dstY: number, + ): void {} + setLink(_uri: string | null, _id?: string): void {} drawCanvas( From 85c87d9dd35147d572a31c53f600aea1e3026567 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:34:28 +0400 Subject: [PATCH 16/20] Fix regression dashboard lint violations --- examples/regression-dashboard/src/main.ts | 57 ++++++++++------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/examples/regression-dashboard/src/main.ts b/examples/regression-dashboard/src/main.ts index 57596f35..8e8e06e7 100644 --- a/examples/regression-dashboard/src/main.ts +++ b/examples/regression-dashboard/src/main.ts @@ -16,12 +16,12 @@ const TICK_MS = 900; const DRAWLIST_HEADER_SIZE = 64; const DEBUG_HEADER_SIZE = 40; const DEBUG_QUERY_MAX_RECORDS = 64; -const ENABLE_BACKEND_DEBUG = process.env["REZI_REGRESSION_BACKEND_DEBUG"] !== "0"; +const ENABLE_BACKEND_DEBUG = process.env.REZI_REGRESSION_BACKEND_DEBUG !== "0"; const DEBUG_LOG_PATH = - process.env["REZI_REGRESSION_DEBUG_LOG"] ?? `${tmpdir()}/rezi-regression-dashboard.log`; + process.env.REZI_REGRESSION_DEBUG_LOG ?? `${tmpdir()}/rezi-regression-dashboard.log`; const initialState = createInitialState(); -const enableHsr = process.argv.includes("--hsr") || process.env["REZI_HSR"] === "1"; +const enableHsr = process.argv.includes("--hsr") || process.env.REZI_HSR === "1"; const forceHeadless = process.argv.includes("--headless"); const hasInteractiveTty = process.stdout.isTTY === true && process.stdin.isTTY === true; @@ -109,8 +109,8 @@ function summarizeDrawlistHeader(bytes: Uint8Array): DrawlistHeaderSummary | nul function summarizeDebugPayload(payload: unknown): unknown { if (!payload || typeof payload !== "object") return payload; const value = payload as Readonly>; - if (value["kind"] === "drawlistBytes") { - const bytes = value["bytes"]; + if (value.kind === "drawlistBytes") { + const bytes = value.bytes; if (bytes instanceof Uint8Array) { return { kind: "drawlistBytes", @@ -119,14 +119,11 @@ function summarizeDebugPayload(payload: unknown): unknown { }; } } - if ( - typeof value["validationResult"] === "number" && - typeof value["executionResult"] === "number" - ) { + if (typeof value.validationResult === "number" && typeof value.executionResult === "number") { return { ...value, - validationResultSigned: toSignedI32(value["validationResult"]), - executionResultSigned: toSignedI32(value["executionResult"]), + validationResultSigned: toSignedI32(value.validationResult), + executionResultSigned: toSignedI32(value.executionResult), }; } return value; @@ -156,7 +153,7 @@ function debugLog(step: string, detail?: unknown): void { debugLog("boot", { pid: process.pid, cwd: process.cwd(), - term: process.env["TERM"] ?? null, + term: process.env.TERM ?? null, stdoutTTY: process.stdout.isTTY === true, stdinTTY: process.stdin.isTTY === true, stdoutCols: process.stdout.columns ?? null, @@ -227,9 +224,7 @@ const ttyRows = ? process.stdout.rows : 0; if (ttyCols <= 0 || ttyRows <= 0) { - const message = - `Regression dashboard: terminal reports invalid size cols=${String(ttyCols)} rows=${String(ttyRows)}.` + - " Run `stty rows 24 cols 80` and retry, or run with --headless."; + const message = `Regression dashboard: terminal reports invalid size cols=${String(ttyCols)} rows=${String(ttyRows)}. Run \`stty rows 24 cols 80\` and retry, or run with --headless.`; stderrLog(message); debugLog("mode.invalid-tty-size", { ttyCols, ttyRows }); exit(1); @@ -397,7 +392,11 @@ async function dumpBackendDebug(reason: string): Promise { }); let lastDrawlistHeader: DrawlistHeaderSummary | null = null; - for (let offset = 0; offset + DEBUG_HEADER_SIZE <= queried.headers.byteLength; offset += DEBUG_HEADER_SIZE) { + for ( + let offset = 0; + offset + DEBUG_HEADER_SIZE <= queried.headers.byteLength; + offset += DEBUG_HEADER_SIZE + ) { const headerParsed = parseRecordHeader(queried.headers, offset); if (!headerParsed.ok) { debugLog("backend.debug.header.parse.error", { reason, offset, error: headerParsed.error }); @@ -424,28 +423,24 @@ async function dumpBackendDebug(reason: string): Promise { if (payloadSummary && typeof payloadSummary === "object") { const record = payloadSummary as Readonly>; - if (record["kind"] === "drawlistBytes") { - const headerSummary = record["header"]; + if (record.kind === "drawlistBytes") { + const headerSummary = record.header; if (headerSummary && typeof headerSummary === "object") { lastDrawlistHeader = headerSummary as DrawlistHeaderSummary; } } else if ( !protocolMismatchReported && - typeof record["validationResultSigned"] === "number" && - record["validationResultSigned"] === -5 && + typeof record.validationResultSigned === "number" && + record.validationResultSigned === -5 && lastDrawlistHeader !== null && (lastDrawlistHeader.stringsCount !== 0 || lastDrawlistHeader.blobsCount !== 0) ) { protocolMismatchReported = true; - const message = - "Regression dashboard: native drawlist validation failed with ZR_ERR_FORMAT. " + - `Captured header uses strings/blobs sections (stringsCount=${String(lastDrawlistHeader.stringsCount)}, blobsCount=${String(lastDrawlistHeader.blobsCount)}), ` + - "but the current native expects these header fields to be zero in drawlist v1. " + - "This indicates @rezi-ui/core and @rezi-ui/native drawlist wire formats are out of sync."; + const message = `Regression dashboard: native drawlist validation failed with ZR_ERR_FORMAT. Captured header uses strings/blobs sections (stringsCount=${String(lastDrawlistHeader.stringsCount)}, blobsCount=${String(lastDrawlistHeader.blobsCount)}), but the current native expects these header fields to be zero in drawlist v1. This indicates @rezi-ui/core and @rezi-ui/native drawlist wire formats are out of sync.`; stderrLog(message); debugLog("diagnostic.drawlist-wire-mismatch", { reason, - validationResultSigned: record["validationResultSigned"], + validationResultSigned: record.validationResultSigned, header: lastDrawlistHeader, }); } @@ -468,7 +463,9 @@ if (ENABLE_BACKEND_DEBUG) { debugLog("backend.debug.enable.ok"); } catch (error) { debugLog("backend.debug.enable.error", error); - stderrLog(`Regression dashboard: failed to enable backend debug trace: ${describeError(error)}`); + stderrLog( + `Regression dashboard: failed to enable backend debug trace: ${describeError(error)}`, + ); } } @@ -484,11 +481,7 @@ app.onEvent((event) => { (drawlistHeaderProbe.stringsCount !== 0 || drawlistHeaderProbe.blobsCount !== 0) ) { protocolMismatchReported = true; - const message = - "Regression dashboard: detected drawlist wire-format mismatch. " + - `Builder probe emits non-zero header string/blob sections (stringsCount=${String(drawlistHeaderProbe.stringsCount)}, blobsCount=${String(drawlistHeaderProbe.blobsCount)}), ` + - "while native submit is failing with ZR_ERR_FORMAT (-5). " + - "This indicates @rezi-ui/core and @rezi-ui/native are out of sync."; + const message = `Regression dashboard: detected drawlist wire-format mismatch. Builder probe emits non-zero header string/blob sections (stringsCount=${String(drawlistHeaderProbe.stringsCount)}, blobsCount=${String(drawlistHeaderProbe.blobsCount)}), while native submit is failing with ZR_ERR_FORMAT (-5). This indicates @rezi-ui/core and @rezi-ui/native are out of sync.`; stderrLog(message); debugLog("diagnostic.drawlist-wire-mismatch", { event, drawlistHeaderProbe }); } From 45836e771ded8d310e38a5895ee1b304435d1668 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:38:44 +0400 Subject: [PATCH 17/20] Fix CI lint failures and apply review nitpicks --- packages/core/src/__tests__/drawlistDecode.ts | 20 +++++++--- .../integration/integration.dashboard.test.ts | 2 +- .../integration.file-manager.test.ts | 2 +- .../integration.form-editor.test.ts | 2 +- .../integration/integration.reflow.test.ts | 18 ++++----- .../integration/integration.resize.test.ts | 2 +- packages/core/src/app/rawRenderer.ts | 2 +- packages/core/src/app/widgetRenderer.ts | 8 +--- .../renderToDrawlist/overflowCulling.ts | 11 +++++- .../templates/starship/src/main.ts | 16 +++++--- packages/node/src/backend/nodeBackend.ts | 28 ++++++++++--- .../node/src/backend/nodeBackendInline.ts | 6 ++- packages/node/src/frameAudit.ts | 17 ++++---- packages/node/src/worker/engineWorker.ts | 39 ++++++++++++------- scripts/check-native-vendor-integrity.mjs | 4 +- scripts/frame-audit-report.mjs | 20 ++++++++-- 16 files changed, 129 insertions(+), 68 deletions(-) diff --git a/packages/core/src/__tests__/drawlistDecode.ts b/packages/core/src/__tests__/drawlistDecode.ts index db28396f..88f421c4 100644 --- a/packages/core/src/__tests__/drawlistDecode.ts +++ b/packages/core/src/__tests__/drawlistDecode.ts @@ -100,7 +100,9 @@ export function parseCommandHeaders(bytes: Uint8Array): readonly DrawlistCommand throw new Error(`command size below minimum at offset ${String(off)} (size=${String(size)})`); } if ((size & 3) !== 0) { - throw new Error(`command size not 4-byte aligned at offset ${String(off)} (size=${String(size)})`); + throw new Error( + `command size not 4-byte aligned at offset ${String(off)} (size=${String(size)})`, + ); } const next = off + size; if (next > cmdEnd) { @@ -136,7 +138,9 @@ function parseResourceState(bytes: Uint8Array): ResourceState { switch (cmd.opcode) { case OP_DEF_STRING: { if (cmd.size < 16) { - throw new Error(`DEF_STRING too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + throw new Error( + `DEF_STRING too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); } const id = u32(bytes, cmd.offset + 8); const byteLen = u32(bytes, cmd.offset + 12); @@ -152,14 +156,18 @@ function parseResourceState(bytes: Uint8Array): ResourceState { } case OP_FREE_STRING: { if (cmd.size !== 12) { - throw new Error(`FREE_STRING wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + throw new Error( + `FREE_STRING wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); } strings.delete(u32(bytes, cmd.offset + 8)); break; } case OP_DEF_BLOB: { if (cmd.size < 16) { - throw new Error(`DEF_BLOB too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + throw new Error( + `DEF_BLOB too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); } const id = u32(bytes, cmd.offset + 8); const byteLen = u32(bytes, cmd.offset + 12); @@ -175,7 +183,9 @@ function parseResourceState(bytes: Uint8Array): ResourceState { } case OP_FREE_BLOB: { if (cmd.size !== 12) { - throw new Error(`FREE_BLOB wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`); + throw new Error( + `FREE_BLOB wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); } blobs.delete(u32(bytes, cmd.offset + 8)); break; diff --git a/packages/core/src/__tests__/integration/integration.dashboard.test.ts b/packages/core/src/__tests__/integration/integration.dashboard.test.ts index adbb74c9..4d49c684 100644 --- a/packages/core/src/__tests__/integration/integration.dashboard.test.ts +++ b/packages/core/src/__tests__/integration/integration.dashboard.test.ts @@ -1,5 +1,4 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -14,6 +13,7 @@ import { ZR_KEY_TAB, } from "../../keybindings/keyCodes.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type SectionId = "overview" | "files" | "settings"; diff --git a/packages/core/src/__tests__/integration/integration.file-manager.test.ts b/packages/core/src/__tests__/integration/integration.file-manager.test.ts index 7f33de1c..4dc1237b 100644 --- a/packages/core/src/__tests__/integration/integration.file-manager.test.ts +++ b/packages/core/src/__tests__/integration/integration.file-manager.test.ts @@ -1,5 +1,4 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -23,6 +22,7 @@ import { } from "../../keybindings/keyCodes.js"; import type { CommandItem, CommandSource, FileNode } from "../../widgets/types.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; diff --git a/packages/core/src/__tests__/integration/integration.form-editor.test.ts b/packages/core/src/__tests__/integration/integration.form-editor.test.ts index f7338762..160545cc 100644 --- a/packages/core/src/__tests__/integration/integration.form-editor.test.ts +++ b/packages/core/src/__tests__/integration/integration.form-editor.test.ts @@ -1,5 +1,4 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -18,6 +17,7 @@ import { ZR_MOD_SHIFT, } from "../../keybindings/keyCodes.js"; import type { Rect } from "../../layout/types.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; const VIEWPORT = Object.freeze({ cols: 96, rows: 30 }); diff --git a/packages/core/src/__tests__/integration/integration.reflow.test.ts b/packages/core/src/__tests__/integration/integration.reflow.test.ts index 01368bd1..b99de5e9 100644 --- a/packages/core/src/__tests__/integration/integration.reflow.test.ts +++ b/packages/core/src/__tests__/integration/integration.reflow.test.ts @@ -1,13 +1,4 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { - OP_CLEAR, - OP_DRAW_TEXT, - OP_FILL_RECT, - OP_PUSH_CLIP, - parseCommandHeaders, - parseDrawTextCommands, - parseInternedStrings, -} from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -17,6 +8,15 @@ import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { App } from "../../index.js"; import { ui } from "../../widgets/ui.js"; +import { + OP_CLEAR, + OP_DRAW_TEXT, + OP_FILL_RECT, + OP_PUSH_CLIP, + parseCommandHeaders, + parseDrawTextCommands, + parseInternedStrings, +} from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type Viewport = Readonly<{ cols: number; rows: number }>; diff --git a/packages/core/src/__tests__/integration/integration.resize.test.ts b/packages/core/src/__tests__/integration/integration.resize.test.ts index 98379844..5a0521c0 100644 --- a/packages/core/src/__tests__/integration/integration.resize.test.ts +++ b/packages/core/src/__tests__/integration/integration.resize.test.ts @@ -1,5 +1,4 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { parseInternedStrings } from "../drawlistDecode.js"; import { encodeZrevBatchV1, flushMicrotasks, @@ -9,6 +8,7 @@ import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { App } from "../../index.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type Viewport = Readonly<{ cols: number; rows: number }>; diff --git a/packages/core/src/app/rawRenderer.ts b/packages/core/src/app/rawRenderer.ts index 61b161c4..ae39ba66 100644 --- a/packages/core/src/app/rawRenderer.ts +++ b/packages/core/src/app/rawRenderer.ts @@ -12,8 +12,8 @@ import { FRAME_ACCEPTED_ACK_MARKER, type RuntimeBackend } from "../backend.js"; import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; -import { perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import { FRAME_AUDIT_ENABLED, drawlistFingerprint, emitFrameAudit } from "../perf/frameAudit.js"; +import { perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import type { DrawFn } from "./types.js"; /** Callbacks for render lifecycle tracking (used by app to set inRender flag). */ diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 33cf7589..cc55f8f3 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -25,9 +25,9 @@ import type { CursorShape } from "../abi.js"; import { BACKEND_BEGIN_FRAME_MARKER, BACKEND_RAW_WRITE_MARKER, - FRAME_ACCEPTED_ACK_MARKER, type BackendBeginFrame, type BackendRawWrite, + FRAME_ACCEPTED_ACK_MARKER, type RuntimeBackend, } from "../backend.js"; import { CURSOR_DEFAULTS } from "../cursor/index.js"; @@ -622,11 +622,7 @@ function summarizeRuntimeTreeForAudit( if (kind !== layoutKind) { const runtimeLabel = describeAuditVNode(node.vnode); const layoutLabel = describeAuditVNode(layout.vnode); - pushLimited( - mismatchSamples, - `${path}:${runtimeLabel}!${layoutLabel}`, - 24, - ); + pushLimited(mismatchSamples, `${path}:${runtimeLabel}!${layoutLabel}`, 24); } const rect = layout.rect; diff --git a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts index 743f2a5d..41feb264 100644 --- a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts +++ b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts @@ -32,10 +32,17 @@ function hasBoxShadowOverflow(node: RuntimeInstance): boolean { if (typeof shadow !== "object") { return false; } - const config = shadow as Readonly<{ offsetX?: unknown; offsetY?: unknown }>; + const config = shadow as Readonly<{ + offsetX?: unknown; + offsetY?: unknown; + blur?: unknown; + spread?: unknown; + }>; const offsetX = readShadowOffset(config.offsetX, 1); const offsetY = readShadowOffset(config.offsetY, 1); - return offsetX > 0 || offsetY > 0; + const blur = readShadowOffset(config.blur, 0); + const spread = readShadowOffset(config.spread, 0); + return offsetX > 0 || offsetY > 0 || blur > 0 || spread > 0; } function hasVisibleOverflow(node: RuntimeInstance): boolean { diff --git a/packages/create-rezi/templates/starship/src/main.ts b/packages/create-rezi/templates/starship/src/main.ts index f93181c2..865dac19 100644 --- a/packages/create-rezi/templates/starship/src/main.ts +++ b/packages/create-rezi/templates/starship/src/main.ts @@ -1,8 +1,8 @@ import { - categoriesToMask, - createDebugController, type DebugController, type DebugRecord, + categoriesToMask, + createDebugController, } from "@rezi-ui/core"; import { createNodeApp } from "@rezi-ui/node"; import { debugSnapshot } from "./helpers/debug.js"; @@ -58,6 +58,7 @@ let stopping = false; let tickTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; let debugTraceTimer: ReturnType | null = null; +let debugTraceInFlight = false; let debugController: DebugController | null = null; let debugLastRecordId = 0n; let stopCode = 0; @@ -527,7 +528,8 @@ function snapshotDebugRecord(record: DebugRecord): void { route: currentRouteId(), drawlistBytes: record.payload.drawlistBytes, drawlistCmds: record.payload.drawlistCmds, - diffBytesEmitted: "diffBytesEmitted" in record.payload ? record.payload.diffBytesEmitted : null, + diffBytesEmitted: + "diffBytesEmitted" in record.payload ? record.payload.diffBytesEmitted : null, dirtyLines: "dirtyLines" in record.payload ? record.payload.dirtyLines : null, dirtyCells: "dirtyCells" in record.payload ? record.payload.dirtyCells : null, damageRects: "damageRects" in record.payload ? record.payload.damageRects : null, @@ -565,7 +567,8 @@ function snapshotDebugRecord(record: DebugRecord): void { cmdCount: record.payload.cmdCount, validationResult: "validationResult" in record.payload ? record.payload.validationResult : null, - executionResult: "executionResult" in record.payload ? record.payload.executionResult : null, + executionResult: + "executionResult" in record.payload ? record.payload.executionResult : null, clipStackMaxDepth: "clipStackMaxDepth" in record.payload ? record.payload.clipStackMaxDepth : null, textRuns: "textRuns" in record.payload ? record.payload.textRuns : null, @@ -644,7 +647,8 @@ async function setupDebugTrace(): Promise { }); const pump = async () => { - if (!debugController) return; + if (!debugController || debugTraceInFlight) return; + debugTraceInFlight = true; try { const records = await debugController.query({ maxRecords: 256 }); if (records.length === 0) return; @@ -658,6 +662,8 @@ async function setupDebugTrace(): Promise { message: error instanceof Error ? error.message : String(error), route: currentRouteId(), }); + } finally { + debugTraceInFlight = false; } }; diff --git a/packages/node/src/backend/nodeBackend.ts b/packages/node/src/backend/nodeBackend.ts index 2007a4c1..eeb52eff 100644 --- a/packages/node/src/backend/nodeBackend.ts +++ b/packages/node/src/backend/nodeBackend.ts @@ -20,7 +20,6 @@ import type { TerminalCaps, TerminalProfile, } from "@rezi-ui/core"; -import type { BackendBeginFrame } from "@rezi-ui/core/backend"; import { BACKEND_BEGIN_FRAME_MARKER, BACKEND_DRAWLIST_VERSION_MARKER, @@ -40,6 +39,12 @@ import { setTextMeasureEmojiPolicy, severityToNum, } from "@rezi-ui/core"; +import type { BackendBeginFrame } from "@rezi-ui/core/backend"; +import { + createFrameAuditLogger, + drawlistFingerprint, + maybeDumpDrawlistBytes, +} from "../frameAudit.js"; import { type EngineCreateConfig, FRAME_SAB_CONTROL_CONSUMED_SEQ_WORD, @@ -63,7 +68,6 @@ import { import { applyEmojiWidthPolicy, resolveBackendEmojiWidthPolicy } from "./emojiWidthPolicy.js"; import { createNodeBackendInlineInternal } from "./nodeBackendInline.js"; import { terminalProfileFromNodeEnv } from "./terminalProfile.js"; -import { createFrameAuditLogger, drawlistFingerprint, maybeDumpDrawlistBytes } from "../frameAudit.js"; export type NodeBackendConfig = Readonly<{ /** @@ -1554,7 +1558,9 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N buf, commit: (byteLen: number) => { if (finalized) { - return Promise.reject(new Error("NodeBackend: beginFrame writer already finalized")); + return Promise.reject( + new Error("NodeBackend: beginFrame writer already finalized"), + ); } finalized = true; if (disposed) { @@ -1572,10 +1578,16 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); return Promise.reject(new Error("NodeBackend: stopped")); } - if (!Number.isInteger(byteLen) || byteLen < 0 || byteLen > sabFrameTransport.slotBytes) { + if ( + !Number.isInteger(byteLen) || + byteLen < 0 || + byteLen > sabFrameTransport.slotBytes + ) { Atomics.store(sabFrameTransport.tokens, slotIndex, 0); Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); - return Promise.reject(new Error("NodeBackend: beginFrame commit byteLen out of range")); + return Promise.reject( + new Error("NodeBackend: beginFrame commit byteLen out of range"), + ); } const frameSeq = nextFrameSeq++; @@ -1600,7 +1612,11 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N byteLen, }); } - Atomics.notify(sabFrameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 1); + Atomics.notify( + sabFrameTransport.controlHeader, + FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, + 1, + ); return framePromise; }, abort: () => { diff --git a/packages/node/src/backend/nodeBackendInline.ts b/packages/node/src/backend/nodeBackendInline.ts index 2fb88793..e7bd48c1 100644 --- a/packages/node/src/backend/nodeBackendInline.ts +++ b/packages/node/src/backend/nodeBackendInline.ts @@ -36,8 +36,12 @@ import { setTextMeasureEmojiPolicy, severityToNum, } from "@rezi-ui/core"; +import { + createFrameAuditLogger, + drawlistFingerprint, + maybeDumpDrawlistBytes, +} from "../frameAudit.js"; import { applyEmojiWidthPolicy, resolveBackendEmojiWidthPolicy } from "./emojiWidthPolicy.js"; -import { createFrameAuditLogger, drawlistFingerprint, maybeDumpDrawlistBytes } from "../frameAudit.js"; import type { NodeBackend, NodeBackendInternalOpts, diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts index c7118d96..0b98f472 100644 --- a/packages/node/src/frameAudit.ts +++ b/packages/node/src/frameAudit.ts @@ -15,8 +15,8 @@ */ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { performance } from "node:perf_hooks"; import { join } from "node:path"; +import { performance } from "node:perf_hooks"; export type DrawlistFingerprint = Readonly<{ byteLen: number; @@ -129,8 +129,7 @@ function nowUs(): number { const FRAME_AUDIT_ENABLED = envFlag("REZI_FRAME_AUDIT", false); const FRAME_AUDIT_LOG_PATH = - readEnv("REZI_FRAME_AUDIT_LOG") ?? - (FRAME_AUDIT_ENABLED ? "/tmp/rezi-frame-audit.ndjson" : null); + readEnv("REZI_FRAME_AUDIT_LOG") ?? (FRAME_AUDIT_ENABLED ? "/tmp/rezi-frame-audit.ndjson" : null); const FRAME_AUDIT_STDERR_MIRROR = envFlag("REZI_FRAME_AUDIT_STDERR_MIRROR", false); const FRAME_AUDIT_DUMP_DIR = readEnv("REZI_FRAME_AUDIT_DUMP_DIR"); const FRAME_AUDIT_DUMP_ROUTE = readEnv("REZI_FRAME_AUDIT_DUMP_ROUTE"); @@ -138,8 +137,12 @@ const FRAME_AUDIT_DUMP_MAX = envPositiveInt("REZI_FRAME_AUDIT_DUMP_MAX", 0); let frameAuditDumpCount = 0; export { FRAME_AUDIT_ENABLED }; -export const FRAME_AUDIT_NATIVE_ENABLED = FRAME_AUDIT_ENABLED && envFlag("REZI_FRAME_AUDIT_NATIVE", true); -export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt("REZI_FRAME_AUDIT_NATIVE_RING", 4 << 20); +export const FRAME_AUDIT_NATIVE_ENABLED = + FRAME_AUDIT_ENABLED && envFlag("REZI_FRAME_AUDIT_NATIVE", true); +export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( + "REZI_FRAME_AUDIT_NATIVE_RING", + 4 << 20, +); // Match zr_debug category/code values. export const ZR_DEBUG_CAT_DRAWLIST = 3; @@ -149,12 +152,12 @@ export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; function readContextRoute(): string | null { const g = globalThis as { - __reziFrameAuditContext?: () => Readonly>; + __reziFrameAuditContext?: () => Readonly<{ route?: unknown }>; }; if (typeof g.__reziFrameAuditContext !== "function") return null; try { const ctx = g.__reziFrameAuditContext(); - const route = ctx["route"]; + const route = ctx.route; return typeof route === "string" && route.length > 0 ? route : null; } catch { return null; diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index 2964accd..b07aef8c 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -8,6 +8,16 @@ import { performance } from "node:perf_hooks"; import { parentPort, workerData } from "node:worker_threads"; +import { + FRAME_AUDIT_NATIVE_ENABLED, + FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CODE_DRAWLIST_CMD, + ZR_DEBUG_CODE_DRAWLIST_EXECUTE, + ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + createFrameAuditLogger, + drawlistFingerprint, +} from "../frameAudit.js"; import { EVENT_POOL_SIZE, FRAME_SAB_CONTROL_CONSUMED_SEQ_WORD, @@ -29,16 +39,6 @@ import { type WorkerToMainMessage, } from "./protocol.js"; import { computeNextIdleDelay, computeTickTiming } from "./tickTiming.js"; -import { - FRAME_AUDIT_NATIVE_ENABLED, - FRAME_AUDIT_NATIVE_RING_BYTES, - ZR_DEBUG_CAT_DRAWLIST, - ZR_DEBUG_CODE_DRAWLIST_CMD, - ZR_DEBUG_CODE_DRAWLIST_EXECUTE, - ZR_DEBUG_CODE_DRAWLIST_VALIDATE, - createFrameAuditLogger, - drawlistFingerprint, -} from "../frameAudit.js"; /** * Perf tracking for worker-side event polling. @@ -378,7 +378,11 @@ function setFrameAuditMeta( frameAuditBySeq.set(frameSeq, next); } -function emitFrameAudit(stage: string, frameSeq: number, fields: Readonly> = {}): void { +function emitFrameAudit( + stage: string, + frameSeq: number, + fields: Readonly> = {}, +): void { if (!frameAudit.enabled) return; const meta = frameAuditBySeq.get(frameSeq); frameAudit.emit(stage, { @@ -1011,7 +1015,8 @@ function tick(): void { try { pres = native.enginePresent(engineId); } catch (err) { - if (submittedFrameSeq !== null) emitFrameAudit("frame.present.throw", submittedFrameSeq, { detail: safeDetail(err) }); + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.throw", submittedFrameSeq, { detail: safeDetail(err) }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, -1); drainNativeFrameAudit("present-throw"); fatal("enginePresent", -1, `engine_present threw: ${safeDetail(err)}`); @@ -1019,14 +1024,16 @@ function tick(): void { return; } if (pres < 0) { - if (submittedFrameSeq !== null) emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, pres); drainNativeFrameAudit("present-failed"); fatal("enginePresent", pres, "engine_present failed"); running = false; return; } - if (submittedFrameSeq !== null) emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); } if (submittedFrameSeq !== null) { @@ -1216,7 +1223,9 @@ function onMessage(msg: MainToWorkerMessage): void { // latest-wins overwrite for transfer-path fallback. if (pendingFrame !== null) { - emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { reason: "message-latest-wins" }); + emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { + reason: "message-latest-wins", + }); deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); } diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs index 431c36f2..df2c85ed 100644 --- a/scripts/check-native-vendor-integrity.mjs +++ b/scripts/check-native-vendor-integrity.mjs @@ -179,9 +179,7 @@ if (invokedPath && invokedPath === selfPath) { ); process.exit(0); } catch (err) { - process.stderr.write( - `check-native-vendor-integrity: FAIL\n${String(err?.stack ?? err)}\n`, - ); + process.stderr.write(`check-native-vendor-integrity: FAIL\n${String(err?.stack ?? err)}\n`); process.exit(1); } } diff --git a/scripts/frame-audit-report.mjs b/scripts/frame-audit-report.mjs index e3c3244c..1c13e416 100755 --- a/scripts/frame-audit-report.mjs +++ b/scripts/frame-audit-report.mjs @@ -64,7 +64,12 @@ const recordsByPid = new Map(); for (const rec of records) { if (!Number.isInteger(rec.pid)) continue; const pid = rec.pid; - const state = recordsByPid.get(pid) ?? { count: 0, firstTs: null, lastTs: null, modes: new Set() }; + const state = recordsByPid.get(pid) ?? { + count: 0, + firstTs: null, + lastTs: null, + modes: new Set(), + }; state.count += 1; if (typeof rec.ts === "string") { if (state.firstTs === null || rec.ts < state.firstTs) state.firstTs = rec.ts; @@ -170,7 +175,10 @@ for (const rec of records) { if (route !== "" && key !== null) routeBySeq.set(key, route); } - if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.submit.payload") { + if ( + (rec.scope === "worker" || rec.scope === "backend-inline") && + stage === "frame.submit.payload" + ) { const key = seqKey(rec); if (key !== null) submitPayloadBySeq.set(key, rec); addOpcodeHistogram(globalOpcodeCounts, rec.opcodeHistogram); @@ -283,7 +291,9 @@ if (recordsByPid.size > 0) { const sessionRows = [...recordsByPid.entries()].sort((a, b) => a[0] - b[0]); for (const [pid, s] of sessionRows) { const modes = [...s.modes].sort().join("|"); - process.stdout.write(` pid=${pid} records=${s.count} firstTs=${s.firstTs ?? "-"} lastTs=${s.lastTs ?? "-"} modes=${modes}\n`); + process.stdout.write( + ` pid=${pid} records=${s.count} firstTs=${s.firstTs ?? "-"} lastTs=${s.lastTs ?? "-"} modes=${modes}\n`, + ); } } process.stdout.write(`backend_submitted=${backendSubmitted.size}\n`); @@ -315,7 +325,9 @@ for (const [opcode, count] of topOpcodes) { process.stdout.write(` ${name}(${opcode}): ${count}\n`); } -const routes = [...routeSummaries.entries()].sort((a, b) => b[1].framesSubmitted - a[1].framesSubmitted); +const routes = [...routeSummaries.entries()].sort( + (a, b) => b[1].framesSubmitted - a[1].framesSubmitted, +); process.stdout.write("route_summary:\n"); for (const [route, summary] of routes) { const avgBytes = summary.framesSubmitted > 0 ? summary.bytesTotal / summary.framesSubmitted : 0; From ef53eef377d12334dbaaf67377e112faade0be2c Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:40:22 +0400 Subject: [PATCH 18/20] Apply biome formatting fixes for CI lint --- .../__tests__/builder.alignment.test.ts | 4 +- .../drawlist/__tests__/builder.limits.test.ts | 6 +- .../drawlist/__tests__/builder.reset.test.ts | 8 +- .../__tests__/builder.round-trip.test.ts | 20 +- .../__tests__/builder.string-cache.test.ts | 12 +- .../__tests__/builder.string-intern.test.ts | 6 +- .../__tests__/builder_v6_resources.test.ts | 5 +- .../drawlist/__tests__/writers.gen.test.ts | 26 +- .../__tests__/persistentBlobKeys.test.ts | 5 +- .../renderer/__tests__/renderer.text.test.ts | 3 +- packages/core/src/theme/validate.ts | 7 +- .../__tests__/style.inheritance.test.ts | 252 +++++++++++------- 12 files changed, 204 insertions(+), 150 deletions(-) diff --git a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts index cec7a558..4cab49a5 100644 --- a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts @@ -1,9 +1,9 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { - OP_DRAW_TEXT, - OP_DRAW_TEXT_RUN, OP_DEF_BLOB, OP_DEF_STRING, + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, parseCommandHeaders, } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../builder.js"; diff --git a/packages/core/src/drawlist/__tests__/builder.limits.test.ts b/packages/core/src/drawlist/__tests__/builder.limits.test.ts index 3dc3b0b0..91711345 100644 --- a/packages/core/src/drawlist/__tests__/builder.limits.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.limits.test.ts @@ -181,7 +181,8 @@ describe("DrawlistBuilder - limits boundaries", () => { test("maxDrawlistBytes: exact text drawlist boundary succeeds", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); + const exactLimit = + HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit }); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); @@ -193,7 +194,8 @@ describe("DrawlistBuilder - limits boundaries", () => { test("maxDrawlistBytes: one byte below text drawlist boundary fails", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); + const exactLimit = + HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit - 1 }); b.drawText(0, 0, "abc"); diff --git a/packages/core/src/drawlist/__tests__/builder.reset.test.ts b/packages/core/src/drawlist/__tests__/builder.reset.test.ts index d6f1ce9a..e7bddd39 100644 --- a/packages/core/src/drawlist/__tests__/builder.reset.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.reset.test.ts @@ -119,7 +119,13 @@ describe("DrawlistBuilder reset behavior", () => { assert.equal(h2.totalSize, 120); const opcodes = parseCommands(second.bytes).map((cmd) => cmd.opcode); - assert.deepEqual(opcodes, [OP_CLEAR, OP_FREE_STRING, OP_FREE_STRING, OP_FREE_STRING, OP_FREE_BLOB]); + assert.deepEqual(opcodes, [ + OP_CLEAR, + OP_FREE_STRING, + OP_FREE_STRING, + OP_FREE_STRING, + OP_FREE_BLOB, + ]); }); test("v2 reset drops cursor and string state before next frame", () => { diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts index 5fbc29bb..45c7b800 100644 --- a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -165,7 +165,10 @@ describe("DrawlistBuilder round-trip binary readback", () => { const cmds = parseCommands(res.bytes); assert.equal(cmds.length, h.cmdCount); - assert.equal(cmds.reduce((acc, cmd) => acc + cmd.size, 0), h.cmdBytes); + assert.equal( + cmds.reduce((acc, cmd) => acc + cmd.size, 0), + h.cmdBytes, + ); assert.deepEqual( cmds.map((cmd) => cmd.opcode), [OP_DEF_STRING, OP_CLEAR, OP_FILL_RECT, OP_PUSH_CLIP, OP_DRAW_TEXT, OP_POP_CLIP], @@ -385,10 +388,21 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); assert.equal(h.cmdCount, 7); assert.equal(cmds.length, 7); - assert.equal(cmds.reduce((acc, cmd) => acc + cmd.size, 0), h.cmdBytes); + assert.equal( + cmds.reduce((acc, cmd) => acc + cmd.size, 0), + h.cmdBytes, + ); assert.deepEqual( cmds.map((cmd) => cmd.opcode), - [OP_DEF_STRING, OP_CLEAR, OP_PUSH_CLIP, OP_FILL_RECT, OP_DRAW_TEXT, OP_SET_CURSOR, OP_POP_CLIP], + [ + OP_DEF_STRING, + OP_CLEAR, + OP_PUSH_CLIP, + OP_FILL_RECT, + OP_DRAW_TEXT, + OP_SET_CURSOR, + OP_POP_CLIP, + ], ); for (const cmd of cmds) { assert.equal((cmd.off & 3) === 0, true); diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts index 5c3c24f0..14bea871 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -284,16 +284,8 @@ describe("drawlist encoded string cache", () => { b.drawText(0, 0, text); const frame2 = buildOk(b, `${factory.name} frame 2`); - assert.equal( - readDrawTextEntries(frame1)[0]?.stringId, - 1, - `${factory.name}: frame1 index`, - ); - assert.equal( - readDrawTextEntries(frame2)[0]?.stringId, - 1, - `${factory.name}: frame2 index`, - ); + assert.equal(readDrawTextEntries(frame1)[0]?.stringId, 1, `${factory.name}: frame1 index`); + assert.equal(readDrawTextEntries(frame2)[0]?.stringId, 1, `${factory.name}: frame2 index`); assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: cache hit across frames`); }); } diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts index 45d1dde4..90632d40 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -201,7 +201,11 @@ describe("drawlist string interning", () => { for (let i = 0; i < drawText.length; i++) { assert.equal(drawText[i]?.stringId, i + 1, `${factory.name}: id ${i + 1}`); } - assert.deepEqual(parseInternedStrings(bytes), unique, `${factory.name}: decoded string table`); + assert.deepEqual( + parseInternedStrings(bytes), + unique, + `${factory.name}: decoded string table`, + ); } }); diff --git a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts index 8e0f899f..4a4a38bc 100644 --- a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts @@ -42,7 +42,10 @@ describe("DrawlistBuilder resource inputs", () => { assert.equal(u32(bytes, cmdOffset + 4), 20); // 16-byte header + 3-byte payload + 1 pad assert.equal(u32(bytes, cmdOffset + 8), 1); assert.equal(u32(bytes, cmdOffset + 12), 3); - assert.deepEqual(Array.from(bytes.subarray(cmdOffset + 16, cmdOffset + 19)), [0xde, 0xad, 0xbe]); + assert.deepEqual( + Array.from(bytes.subarray(cmdOffset + 16, cmdOffset + 19)), + [0xde, 0xad, 0xbe], + ); assert.equal(bytes[cmdOffset + 19], 0); }); diff --git a/packages/core/src/drawlist/__tests__/writers.gen.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.test.ts index 82328f45..6bafbfcb 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.test.ts @@ -16,12 +16,12 @@ import { SET_CURSOR_SIZE, writeBlitRect, writeClear, + writeDefBlob, + writeDefString, writeDrawCanvas, writeDrawImage, writeDrawText, writeDrawTextRun, - writeDefBlob, - writeDefString, writeFillRect, writePopClip, writePushClip, @@ -371,27 +371,7 @@ function buildReferenceDrawlist(): Uint8Array { pos = legacyWriteDrawTextRun(out, dv, pos, 7, 8, 1, 0); pos = legacyWriteSetCursor(out, dv, pos, 9, 10, 2, 1, 0, 0); pos = legacyWriteDrawCanvas(out, dv, pos, 11, 12, 1, 1, 1, 1, 1, 0, 6, 0, 0); - pos = legacyWriteDrawImage( - out, - dv, - pos, - 13, - 14, - 1, - 1, - 1, - 1, - 1, - 0, - 99, - 0, - 1, - -1, - 1, - 0, - 0, - 0, - ); + pos = legacyWriteDrawImage(out, dv, pos, 13, 14, 1, 1, 1, 1, 1, 0, 99, 0, 1, -1, 1, 0, 0, 0); pos = legacyWritePushClip(out, dv, pos, 0, 0, 20, 10); pos = legacyWritePopClip(out, dv, pos); assert.equal(pos, cmdOffset + cmdBytes); diff --git a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts index ebdb82b9..60858122 100644 --- a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts +++ b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts @@ -2,7 +2,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistBuilder, DrawlistTextRunSegment } from "../../drawlist/types.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; import { DEFAULT_BASE_STYLE } from "../renderToDrawlist/textStyle.js"; -import { addBlobAligned, renderCanvasWidgets } from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; +import { + addBlobAligned, + renderCanvasWidgets, +} from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; import { drawSegments } from "../renderToDrawlist/widgets/renderTextWidgets.js"; class CountingBuilder implements DrawlistBuilder { diff --git a/packages/core/src/renderer/__tests__/renderer.text.test.ts b/packages/core/src/renderer/__tests__/renderer.text.test.ts index 8a6bfb59..1a243dd7 100644 --- a/packages/core/src/renderer/__tests__/renderer.text.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.text.test.ts @@ -164,7 +164,8 @@ function parseFrame(bytes: Uint8Array): ParsedFrame { } if (cmd.opcode === OP_FREE_BLOB) { const blobId = u32(bytes, off + 8); - if (blobId > 0) textRunBlobsByIndex[blobId - 1] = Object.freeze({ segments: Object.freeze([]) }); + if (blobId > 0) + textRunBlobsByIndex[blobId - 1] = Object.freeze({ segments: Object.freeze([]) }); continue; } diff --git a/packages/core/src/theme/validate.ts b/packages/core/src/theme/validate.ts index 4dfb3a4a..cfe4169f 100644 --- a/packages/core/src/theme/validate.ts +++ b/packages/core/src/theme/validate.ts @@ -92,12 +92,7 @@ function throwMissingPaths(theme: unknown): void { } function validateRgb(path: string, value: unknown): void { - if ( - typeof value !== "number" || - !Number.isInteger(value) || - value < 0 || - value > 0x00ffffff - ) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0x00ffffff) { throw new Error( `Theme validation failed at ${path}: expected packed Rgb24 integer 0..0x00FFFFFF (received ${formatValue(value)})`, ); diff --git a/packages/core/src/widgets/__tests__/style.inheritance.test.ts b/packages/core/src/widgets/__tests__/style.inheritance.test.ts index 85533af5..b3cef6f3 100644 --- a/packages/core/src/widgets/__tests__/style.inheritance.test.ts +++ b/packages/core/src/widgets/__tests__/style.inheritance.test.ts @@ -60,11 +60,14 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + }), + ); }); test("Box fg override wins while bg still inherits from Root", () => { @@ -75,10 +78,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, withAttrs({ - fg: BOX_FG, - bg: ROOT_BG, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROOT_BG, + }), + ); }); test("Row bg override wins while fg inherits from Box", () => { @@ -89,10 +95,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, withAttrs({ - fg: BOX_FG, - bg: ROW_BG, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROW_BG, + }), + ); }); test("Text override wins over Row/Box/Root and unset fields continue inheriting", () => { @@ -103,13 +112,16 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { fg: TEXT_FG, bold: true }, ); - assert.deepEqual(resolved, withAttrs({ - fg: TEXT_FG, - bg: ROOT_BG, - bold: true, - italic: true, - underline: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: TEXT_FG, + bg: ROOT_BG, + bold: true, + italic: true, + underline: true, + }), + ); }); test("Text with no local overrides inherits nearest defined values", () => { @@ -120,13 +132,16 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { {}, ); - assert.deepEqual(resolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - dim: false, - italic: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + dim: false, + italic: true, + }), + ); }); test("Explicit false in Box overrides Root true through deeper unset descendants", () => { @@ -137,11 +152,14 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - underline: false, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + underline: false, + }), + ); }); test("Explicit true in Text overrides Root false", () => { @@ -152,12 +170,15 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { blink: true }, ); - assert.deepEqual(resolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - italic: true, - blink: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + italic: true, + blink: true, + }), + ); }); test("fg/bg inheritance stays independent across levels with mixed overrides", () => { @@ -168,12 +189,15 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { italic: true }, ); - assert.deepEqual(resolved, withAttrs({ - fg: BOX_FG, - bg: ROW_BG, - dim: true, - italic: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROW_BG, + dim: true, + italic: true, + }), + ); }); }); @@ -192,11 +216,14 @@ describe("mergeTextStyle deep inheritance chains", () => { assert.equal(boxResolved === rootResolved, true); assert.equal(rowResolved === rootResolved, true); assert.equal(textResolved === rootResolved, true); - assert.deepEqual(textResolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - inverse: true, - })); + assert.deepEqual( + textResolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + inverse: true, + }), + ); }); test("8+ level chain resolves nearest ancestor values deterministically", () => { @@ -212,14 +239,17 @@ describe("mergeTextStyle deep inheritance chains", () => { { fg: TEXT_FG }, ]); - assert.deepEqual(resolved, withAttrs({ - fg: TEXT_FG, - bg: DEEP_BG, - bold: false, - dim: true, - italic: true, - underline: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: TEXT_FG, + bg: DEEP_BG, + bold: false, + dim: true, + italic: true, + underline: true, + }), + ); }); test("long undefined tail after deep chain preserves resolved object", () => { @@ -235,11 +265,14 @@ describe("mergeTextStyle deep inheritance chains", () => { } assert.equal(resolved === anchor, true); - assert.deepEqual(resolved, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - overline: true, - })); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + overline: true, + }), + ); }); test("very deep chain (512 levels) remains deterministic without stack/logic regressions", () => { @@ -304,11 +337,14 @@ describe("mergeTextStyle recompute after middle style unset", () => { const rowResolved = mergeTextStyle(boxUnset, { italic: true }); const after = mergeTextStyle(rowResolved, undefined); - assert.deepEqual(after, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - italic: true, - })); + assert.deepEqual( + after, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + italic: true, + }), + ); }); test("when Box bg is unset, descendants inherit Root bg after recompute", () => { @@ -318,10 +354,13 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: BOX_FG }, undefined, ); - assert.deepEqual(before, withAttrs({ - fg: BOX_FG, - bg: BOX_BG, - })); + assert.deepEqual( + before, + withAttrs({ + fg: BOX_FG, + bg: BOX_BG, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG }, @@ -330,10 +369,13 @@ describe("mergeTextStyle recompute after middle style unset", () => { undefined, ); - assert.deepEqual(after, withAttrs({ - fg: BOX_FG, - bg: ROOT_BG, - })); + assert.deepEqual( + after, + withAttrs({ + fg: BOX_FG, + bg: ROOT_BG, + }), + ); }); test("middle unset recompute keeps Text override but re-inherits Root for unset fields", () => { @@ -343,12 +385,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { underline: true }, { fg: TEXT_FG }, ); - assert.deepEqual(before, withAttrs({ - fg: TEXT_FG, - bg: BOX_BG, - bold: false, - underline: true, - })); + assert.deepEqual( + before, + withAttrs({ + fg: TEXT_FG, + bg: BOX_BG, + bold: false, + underline: true, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -357,12 +402,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: TEXT_FG }, ); - assert.deepEqual(after, withAttrs({ - fg: TEXT_FG, - bg: ROOT_BG, - bold: true, - underline: true, - })); + assert.deepEqual( + after, + withAttrs({ + fg: TEXT_FG, + bg: ROOT_BG, + bold: true, + underline: true, + }), + ); }); test("middle unset recompute restores higher-ancestor boolean when Text has no local override", () => { @@ -372,12 +420,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { italic: true }, {}, ); - assert.deepEqual(before, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - bold: false, - italic: true, - })); + assert.deepEqual( + before, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: false, + italic: true, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -386,11 +437,14 @@ describe("mergeTextStyle recompute after middle style unset", () => { {}, ); - assert.deepEqual(after, withAttrs({ - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - italic: true, - })); + assert.deepEqual( + after, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + italic: true, + }), + ); }); }); From 697af0452a2c05e38a9719671746e70765416d6c Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:48:19 +0400 Subject: [PATCH 19/20] Fix starship template theme to use packed rgb colors --- .../templates/starship/src/theme.ts | 199 +++++++++--------- 1 file changed, 94 insertions(+), 105 deletions(-) diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index 91d1ba95..df0e53c7 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,12 +1,12 @@ import { type BadgeVariant, - type Rgb, type Rgb24, type TextStyle, type ThemeDefinition, draculaTheme, extendTheme, nordTheme, + rgb, } from "@rezi-ui/core"; import type { AlertLevel, ThemeName } from "./types.js"; @@ -42,52 +42,52 @@ const DAY_SHIFT_THEME = extendTheme(nordTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 118, g: 208, b: 255 }, + focusRingColor: rgb(118, 208, 255), }, colors: { bg: { - base: { r: 29, g: 42, b: 58 }, - elevated: { r: 40, g: 57, b: 76 }, - overlay: { r: 52, g: 72, b: 94 }, - subtle: { r: 34, g: 49, b: 67 }, + base: rgb(29, 42, 58), + elevated: rgb(40, 57, 76), + overlay: rgb(52, 72, 94), + subtle: rgb(34, 49, 67), }, fg: { - primary: { r: 236, g: 243, b: 255 }, - secondary: { r: 190, g: 217, b: 242 }, - muted: { r: 108, g: 138, b: 168 }, - inverse: { r: 20, g: 30, b: 42 }, + primary: rgb(236, 243, 255), + secondary: rgb(190, 217, 242), + muted: rgb(108, 138, 168), + inverse: rgb(20, 30, 42), }, border: { - subtle: { r: 74, g: 98, b: 126 }, - default: { r: 104, g: 136, b: 168 }, - strong: { r: 137, g: 171, b: 206 }, + subtle: rgb(74, 98, 126), + default: rgb(104, 136, 168), + strong: rgb(137, 171, 206), }, accent: { - primary: { r: 106, g: 195, b: 255 }, - secondary: { r: 129, g: 217, b: 255 }, - tertiary: { r: 180, g: 231, b: 164 }, + primary: rgb(106, 195, 255), + secondary: rgb(129, 217, 255), + tertiary: rgb(180, 231, 164), }, - info: { r: 118, g: 208, b: 255 }, - success: { r: 166, g: 228, b: 149 }, - warning: { r: 255, g: 211, b: 131 }, - error: { r: 239, g: 118, b: 132 }, + info: rgb(118, 208, 255), + success: rgb(166, 228, 149), + warning: rgb(255, 211, 131), + error: rgb(239, 118, 132), selected: { - bg: { r: 68, g: 105, b: 139 }, - fg: { r: 236, g: 243, b: 255 }, + bg: rgb(68, 105, 139), + fg: rgb(236, 243, 255), }, disabled: { - fg: { r: 95, g: 121, b: 149 }, - bg: { r: 39, g: 55, b: 72 }, + fg: rgb(95, 121, 149), + bg: rgb(39, 55, 72), }, diagnostic: { - error: { r: 239, g: 118, b: 132 }, - warning: { r: 255, g: 211, b: 131 }, - info: { r: 118, g: 208, b: 255 }, - hint: { r: 149, g: 187, b: 228 }, + error: rgb(239, 118, 132), + warning: rgb(255, 211, 131), + info: rgb(118, 208, 255), + hint: rgb(149, 187, 228), }, focus: { - ring: { r: 118, g: 208, b: 255 }, - bg: { r: 63, g: 96, b: 126 }, + ring: rgb(118, 208, 255), + bg: rgb(63, 96, 126), }, }, }); @@ -105,52 +105,52 @@ const NIGHT_SHIFT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 176, g: 133, b: 255 }, + focusRingColor: rgb(176, 133, 255), }, colors: { bg: { - base: { r: 19, g: 22, b: 33 }, - elevated: { r: 28, g: 31, b: 46 }, - overlay: { r: 37, g: 41, b: 60 }, - subtle: { r: 24, g: 27, b: 40 }, + base: rgb(19, 22, 33), + elevated: rgb(28, 31, 46), + overlay: rgb(37, 41, 60), + subtle: rgb(24, 27, 40), }, fg: { - primary: { r: 244, g: 246, b: 252 }, - secondary: { r: 202, g: 185, b: 252 }, - muted: { r: 131, g: 146, b: 186 }, - inverse: { r: 19, g: 22, b: 33 }, + primary: rgb(244, 246, 252), + secondary: rgb(202, 185, 252), + muted: rgb(131, 146, 186), + inverse: rgb(19, 22, 33), }, accent: { - primary: { r: 176, g: 133, b: 255 }, - secondary: { r: 129, g: 235, b: 255 }, - tertiary: { r: 119, g: 255, b: 196 }, + primary: rgb(176, 133, 255), + secondary: rgb(129, 235, 255), + tertiary: rgb(119, 255, 196), }, - info: { r: 129, g: 235, b: 255 }, - success: { r: 110, g: 249, b: 174 }, - warning: { r: 255, g: 207, b: 124 }, - error: { r: 255, g: 118, b: 132 }, + info: rgb(129, 235, 255), + success: rgb(110, 249, 174), + warning: rgb(255, 207, 124), + error: rgb(255, 118, 132), selected: { - bg: { r: 68, g: 76, b: 112 }, - fg: { r: 244, g: 246, b: 252 }, + bg: rgb(68, 76, 112), + fg: rgb(244, 246, 252), }, disabled: { - fg: { r: 99, g: 111, b: 146 }, - bg: { r: 28, g: 31, b: 46 }, + fg: rgb(99, 111, 146), + bg: rgb(28, 31, 46), }, diagnostic: { - error: { r: 255, g: 118, b: 132 }, - warning: { r: 255, g: 207, b: 124 }, - info: { r: 129, g: 235, b: 255 }, - hint: { r: 206, g: 158, b: 255 }, + error: rgb(255, 118, 132), + warning: rgb(255, 207, 124), + info: rgb(129, 235, 255), + hint: rgb(206, 158, 255), }, focus: { - ring: { r: 176, g: 133, b: 255 }, - bg: { r: 64, g: 57, b: 96 }, + ring: rgb(176, 133, 255), + bg: rgb(64, 57, 96), }, border: { - subtle: { r: 43, g: 49, b: 71 }, - default: { r: 72, g: 81, b: 116 }, - strong: { r: 104, g: 115, b: 156 }, + subtle: rgb(43, 49, 71), + default: rgb(72, 81, 116), + strong: rgb(104, 115, 156), }, }, }); @@ -168,52 +168,52 @@ const RED_ALERT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 255, g: 112, b: 112 }, + focusRingColor: rgb(255, 112, 112), }, colors: { bg: { - base: { r: 24, g: 12, b: 19 }, - elevated: { r: 34, g: 15, b: 24 }, - overlay: { r: 46, g: 21, b: 32 }, - subtle: { r: 29, g: 13, b: 22 }, + base: rgb(24, 12, 19), + elevated: rgb(34, 15, 24), + overlay: rgb(46, 21, 32), + subtle: rgb(29, 13, 22), }, fg: { - primary: { r: 255, g: 238, b: 242 }, - secondary: { r: 244, g: 190, b: 205 }, - muted: { r: 170, g: 122, b: 139 }, - inverse: { r: 24, g: 12, b: 19 }, + primary: rgb(255, 238, 242), + secondary: rgb(244, 190, 205), + muted: rgb(170, 122, 139), + inverse: rgb(24, 12, 19), }, accent: { - primary: { r: 255, g: 114, b: 144 }, - secondary: { r: 255, g: 182, b: 120 }, - tertiary: { r: 255, g: 220, b: 146 }, + primary: rgb(255, 114, 144), + secondary: rgb(255, 182, 120), + tertiary: rgb(255, 220, 146), }, - success: { r: 134, g: 247, b: 176 }, - warning: { r: 255, g: 181, b: 112 }, - error: { r: 255, g: 93, b: 117 }, - info: { r: 255, g: 141, b: 153 }, + success: rgb(134, 247, 176), + warning: rgb(255, 181, 112), + error: rgb(255, 93, 117), + info: rgb(255, 141, 153), selected: { - bg: { r: 82, g: 34, b: 52 }, - fg: { r: 255, g: 238, b: 242 }, + bg: rgb(82, 34, 52), + fg: rgb(255, 238, 242), }, disabled: { - fg: { r: 142, g: 96, b: 112 }, - bg: { r: 34, g: 15, b: 24 }, + fg: rgb(142, 96, 112), + bg: rgb(34, 15, 24), }, diagnostic: { - error: { r: 255, g: 93, b: 117 }, - warning: { r: 255, g: 181, b: 112 }, - info: { r: 255, g: 141, b: 153 }, - hint: { r: 255, g: 203, b: 133 }, + error: rgb(255, 93, 117), + warning: rgb(255, 181, 112), + info: rgb(255, 141, 153), + hint: rgb(255, 203, 133), }, focus: { - ring: { r: 255, g: 112, b: 112 }, - bg: { r: 76, g: 35, b: 50 }, + ring: rgb(255, 112, 112), + bg: rgb(76, 35, 50), }, border: { - subtle: { r: 86, g: 45, b: 61 }, - default: { r: 124, g: 65, b: 86 }, - strong: { r: 172, g: 86, b: 112 }, + subtle: rgb(86, 45, 61), + default: rgb(124, 65, 86), + strong: rgb(172, 86, 112), }, }, }); @@ -244,14 +244,10 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -type ColorInput = Rgb | Rgb24; +type ColorInput = Rgb24; -function packRgb(value: Rgb): Rgb24 { - return ( - ((clampChannel(value.r) & 0xff) << 16) | - ((clampChannel(value.g) & 0xff) << 8) | - (clampChannel(value.b) & 0xff) - ); +function packRgb(value: Rgb24): Rgb24 { + return (Math.round(value) >>> 0) & 0x00ff_ffff; } function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { @@ -259,17 +255,10 @@ function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { } function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { - if (typeof value === "number") { - return Object.freeze({ - r: rgbChannel(value, 16), - g: rgbChannel(value, 8), - b: rgbChannel(value, 0), - }); - } return Object.freeze({ - r: clampChannel(value.r), - g: clampChannel(value.g), - b: clampChannel(value.b), + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), }); } From d55b8a4e1b099d36a552700eca7db633e20ff38d Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:13:19 +0400 Subject: [PATCH 20/20] Fix starship rendering regressions and add PTY debug runbook --- AGENTS.md | 19 ++ CLAUDE.md | 9 + docs/dev/live-pty-debugging.md | 176 ++++++++++ docs/dev/testing.md | 12 + mkdocs.yml | 1 + .../renderToDrawlist/widgets/collections.ts | 27 ++ .../src/widgets/__tests__/pagination.test.ts | 5 +- packages/core/src/widgets/pagination.ts | 12 +- .../templates/starship/src/screens/bridge.ts | 2 +- .../templates/starship/src/screens/cargo.ts | 2 +- .../templates/starship/src/screens/comms.ts | 2 +- .../templates/starship/src/screens/crew.ts | 307 +++++++++++------- .../starship/src/screens/engineering.ts | 38 ++- .../starship/src/screens/settings.ts | 4 +- packages/node/src/frameAudit.ts | 8 + packages/node/src/worker/engineWorker.ts | 136 +++++++- 16 files changed, 627 insertions(+), 133 deletions(-) create mode 100644 docs/dev/live-pty-debugging.md diff --git a/AGENTS.md b/AGENTS.md index 1e221149..9b2fceec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,25 @@ node scripts/run-tests.mjs --filter "widget" 2. If changing runtime, layout, or renderer code, also run integration tests. 3. Run the full suite before committing. +### Mandatory Live PTY Validation for UI Regressions + +For rendering/layout/theme regressions, do not stop at unit snapshots. Run the +app in a real PTY and collect frame audit evidence yourself before asking a +human to reproduce. + +Canonical runbook: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +Minimum required checks for UI regression work: + +1. Run target app/template in PTY with deterministic viewport. +2. Exercise relevant routes/keys (for starship: `1..6`, `t`, `q`). +3. Capture `REZI_FRAME_AUDIT` logs and analyze with + `node scripts/frame-audit-report.mjs ... --latest-pid`. +4. Capture app-level debug snapshots (`REZI_STARSHIP_DEBUG=1`) when applicable. +5. Include concrete evidence in your report (hash changes, route summary, key stages). + ## Verification Protocol (Two-Agent Verification) When verifying documentation or code changes, split into two passes: diff --git a/CLAUDE.md b/CLAUDE.md index 5c8e22a5..433355bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,15 @@ result.toText(); // Render to plain text for snapshots Test runner: `node:test`. Run all tests with `node scripts/run-tests.mjs`. +For rendering regressions, add a live PTY verification pass and frame-audit +evidence (not just snapshot/unit tests). Use: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +This runbook covers deterministic viewport setup, worker-mode PTY execution, +route/theme key driving, and cross-layer log analysis (`REZI_FRAME_AUDIT`, +`REZI_STARSHIP_DEBUG`, `frame-audit-report.mjs`). + ## Skills (Repeatable Recipes) Project-level skills for both Claude Code and Codex: diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md new file mode 100644 index 00000000..bdb18d91 --- /dev/null +++ b/docs/dev/live-pty-debugging.md @@ -0,0 +1,176 @@ +# Live PTY UI Testing and Frame Audit Runbook + +This runbook documents how to validate Rezi UI behavior autonomously in a real +terminal (PTY), capture end-to-end frame telemetry, and pinpoint regressions +across core/node/native layers. + +Use this before asking a human for screenshots. + +## Why this exists + +Headless/unit tests catch many issues, but rendering regressions often involve: + +- terminal dimensions and capability negotiation +- worker transport boundaries (core -> node worker -> native) +- partial redraw/damage behavior across many frames + +The PTY + frame-audit workflow gives deterministic evidence for all of those. + +## Prerequisites + +From repo root: + +```bash +cd /home/k3nig/Rezi +npx tsc -b packages/core packages/node packages/create-rezi +``` + +## Canonical interactive run (Starship template) + +This enables: + +- app-level debug snapshots (`REZI_STARSHIP_DEBUG`) +- cross-layer frame audit (`REZI_FRAME_AUDIT`) +- worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) + +```bash +cd /home/k3nig/Rezi +: > /tmp/rezi-frame-audit.ndjson +: > /tmp/starship.log + +env -u NO_COLOR \ + REZI_STARSHIP_EXECUTION_MODE=worker \ + REZI_STARSHIP_DEBUG=1 \ + REZI_STARSHIP_DEBUG_LOG=/tmp/starship.log \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +Key controls in template: + +- `1..6`: route switch (bridge/engineering/crew/comms/cargo/settings) +- `t`: cycle theme +- `q`: quit + +## Deterministic viewport (important) + +Many regressions are viewport-threshold dependent. Always test with a known +size before comparing runs. + +For an interactive shell/PTY: + +```bash +stty rows 68 cols 300 +``` + +Then launch the app in that same PTY. + +## Autonomous PTY execution (agent workflow) + +When your agent runtime supports PTY stdin/stdout control: + +1. Start app in PTY mode (with env above). +2. Send key sequences (`2`, `3`, `t`, `q`) through stdin. +3. Wait between keys to allow frames to settle. +4. Quit and analyze logs. + +Do not rely only on static test snapshots for visual regressions. + +## Frame audit analysis + +Use the built-in analyzer: + +```bash +node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson --latest-pid +``` + +What to look for: + +- `backend_submitted`, `worker_payload`, `worker_accepted`, `worker_completed` + should stay aligned in worker mode. +- `hash_mismatch_backend_vs_worker` should be `0`. +- `top_opcodes` should reflect expected widget workload. +- `route_summary` should show submissions for every exercised route. +- `native_summary_records`/`native_header_records` confirm native debug pull + from worker path. + +If a log contains multiple app runs, always use `--latest-pid` (or `--pid=`) +to avoid mixed-session confusion. + +## Useful grep patterns + +```bash +rg "runtime.command|runtime.fatal|shell.layout|engineering.layout|engineering.render|crew.render" /tmp/starship.log +rg "\"stage\":\"table.layout\"|\"stage\":\"drawlist.built\"|\"stage\":\"frame.submitted\"|\"stage\":\"frame.completed\"" /tmp/rezi-frame-audit.ndjson +``` + +## Optional deep capture (drawlist bytes) + +Capture raw drawlist payload snapshots for diffing: + +```bash +env \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_DUMP_DIR=/tmp/rezi-drawlist-dumps \ + REZI_FRAME_AUDIT_DUMP_MAX=20 \ + REZI_FRAME_AUDIT_DUMP_ROUTE=crew \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +This writes paired `.bin` + `.json` files with hashes and metadata. + +## Native trace through frame-audit + +Native debug records are enabled by frame audit in worker mode. Controls: + +- `REZI_FRAME_AUDIT_NATIVE=1|0` (default on when frame audit is enabled) +- `REZI_FRAME_AUDIT_NATIVE_RING=` (ring size override) + +Look for stages such as: + +- `native.debug.header` +- `native.drawlist.summary` +- `native.frame.*` +- `native.perf.*` + +## Triage playbook for common regressions + +### 1) “Theme only updates animated region” + +Check: + +1. `runtime.command` contains `cycle-theme`. +2. `drawlist.built` hashes change after theme switch. +3. `frame.submitted`/`frame.completed` continue for that route. + +If hashes do not change, bug is likely in view/theme resolution. +If hashes change but screen does not, investigate native diff/damage path. + +### 2) “Table looks empty or only one row visible” + +Check `table.layout` record: + +- `bodyH` +- `visibleRows` +- `startIndex` / `endIndex` +- table rect height + +If `bodyH` is too small, inspect parent layout/flex and sibling widgets +(pagination or controls often steal height). + +### 3) “Worker mode renders differently from inline” + +Run both modes with identical viewport and compare audit summaries: + +- worker: `REZI_STARSHIP_EXECUTION_MODE=worker` +- inline: `REZI_STARSHIP_EXECUTION_MODE=inline` + +If only worker diverges, focus on backend transport and worker audit stages. + +## Guardrails + +- Keep all instrumentation opt-in via env vars. +- Never print continuous debug spam to stdout during normal app usage. +- Write logs to files (`/tmp/...`) and inspect post-run. +- Prefer deterministic viewport + scripted route/theme steps when verifying fixes. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3eab0163..f3fdd9b1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -66,6 +66,18 @@ npm run test:e2e npm run test:e2e:reduced ``` +## Live PTY Rendering Validation (for UI regressions) + +For terminal rendering/theme/layout regressions, run a live PTY session with +frame-audit instrumentation in addition to normal tests. + +Use the dedicated runbook: + +- [Live PTY UI Testing and Frame Audit Runbook](live-pty-debugging.md) + +That guide includes deterministic viewport setup, worker-mode run commands, +scripted key driving, and cross-layer telemetry analysis. + ## Test Categories ### Unit Tests diff --git a/mkdocs.yml b/mkdocs.yml index c66f6e8e..bcc9b91b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -250,6 +250,7 @@ nav: - Repo Layout: dev/repo-layout.md - Build: dev/build.md - Testing: dev/testing.md + - Live PTY Debugging: dev/live-pty-debugging.md - Code Standards: dev/code-standards.md - Ink Compat Debugging: dev/ink-compat-debugging.md - Perf Regressions: dev/perf-regressions.md diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 33ec6158..f92cd8fd 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -28,6 +28,7 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; +import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; @@ -605,6 +606,32 @@ export function renderCollectionWidget( ? Math.min(rowCount, startIndex + visibleRows + overscan) : rowCount; + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit( + "tableWidget", + "table.layout", + Object.freeze({ + tableId: props.id, + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + innerW, + innerH, + bodyY, + bodyH, + rowCount, + headerHeight, + rowHeight: safeRowHeight, + virtualized, + startIndex, + endIndex, + visibleRows, + overscan, + }), + ); + } + if (tableStore) { tableStore.set(props.id, { viewportHeight: bodyH, startIndex, endIndex }); } diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index d1ce4396..1b8ab376 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -128,7 +128,10 @@ describe("pagination ids and vnode", () => { const zoneNode = children[0]; assert.equal(zoneNode?.kind, "focusZone"); if (zoneNode?.kind !== "focusZone") return; - const ids = zoneNode.children + const controlsRow = zoneNode.children[0]; + assert.equal(controlsRow?.kind, "row"); + if (controlsRow?.kind !== "row") return; + const ids = controlsRow.children .filter((child) => child.kind === "button") .map((child) => (child.kind === "button" ? child.props.id : "")); assert.equal(ids.includes(getPaginationControlId("pages", "first")), true); diff --git a/packages/core/src/widgets/pagination.ts b/packages/core/src/widgets/pagination.ts index ea202124..6bfa73d4 100644 --- a/packages/core/src/widgets/pagination.ts +++ b/packages/core/src/widgets/pagination.ts @@ -243,6 +243,16 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ }); } + const controlsRow: VNode = { + kind: "row", + props: { + gap: 0, + wrap: true, + items: "center", + }, + children: Object.freeze(controls), + }; + const zone: VNode = { kind: "focusZone", props: { @@ -252,7 +262,7 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ columns: 1, wrapAround: false, }, - children: Object.freeze(controls), + children: Object.freeze([controlsRow]), }; return Object.freeze([zone]); diff --git a/packages/create-rezi/templates/starship/src/screens/bridge.ts b/packages/create-rezi/templates/starship/src/screens/bridge.ts index a2666219..bb4546b1 100644 --- a/packages/create-rezi/templates/starship/src/screens/bridge.ts +++ b/packages/create-rezi/templates/starship/src/screens/bridge.ts @@ -554,7 +554,7 @@ export function renderBridgeScreen( title: "Bridge Overview", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ BridgeCommandDeck({ key: "bridge-command-deck", state, dispatch: deps.dispatch }), ]), }); diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index f5735e23..7cef074d 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -30,7 +30,7 @@ export function renderCargoScreen( title: "Cargo Hold", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CargoDeck({ key: "cargo-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index 1a115088..8830a08c 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -462,7 +462,7 @@ export function renderCommsScreen( title: "Communications", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CommsDeck({ key: "comms-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 44f71ff1..0013fd90 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -7,6 +7,7 @@ import { type RouteRenderContext, type VNode, } from "@rezi-ui/core"; +import { debugSnapshot } from "../helpers/debug.js"; import { resolveLayout } from "../helpers/layout.js"; import { crewCounts, departmentLabel, rankBadge, statusBadge } from "../helpers/formatters.js"; import { selectedCrew, visibleCrew } from "../helpers/state.js"; @@ -193,7 +194,7 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ...tableSkin(tokens), }); - const detailPanel = ui.column({ gap: SPACE.sm }, [ + const detailPanel = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ maybe(selected, (member) => surfacePanel( tokens, @@ -300,8 +301,18 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ), ]); - const manifestBlock = ui.column({ gap: SPACE.sm }, [ - surfacePanel(tokens, "Crew Manifest", [table]), + const manifestBlock = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 10, + overflow: "hidden", + }, + [surfacePanel(tokens, "Crew Manifest", [table])], + ), ui.pagination({ id: ctx.id("crew-pagination"), page, @@ -313,126 +324,185 @@ const CrewDeck = defineWidget((props, ctx): VNode => { const deckLayout = layout.wide ? showDetailPane - ? ui.masterDetail({ - id: ctx.id("crew-master-detail"), - masterWidth: layout.crewMasterWidth, - master: manifestBlock, - detail: detailPanel, - }) - : manifestBlock - : showDetailPane - ? ui.column({ gap: SPACE.sm }, [manifestBlock, detailPanel]) - : manifestBlock; - - const operationsPanel = surfacePanel( - tokens, - "Crew Operations", - [ - sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), - ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ - ui.box( + ? ui.row( { - border: "none", - p: 0, + id: ctx.id("crew-master-detail"), gap: SPACE.sm, - ...(layout.wide ? { flex: 2 } : {}), + width: "100%", + height: "100%", + items: "stretch", + wrap: false, }, [ - ui.row({ gap: SPACE.sm, wrap: true }, [ - ui.badge(`Total ${counts.total}`, { variant: "info" }), - ui.badge(`Active ${counts.active}`, { variant: "success" }), - ui.badge(`Away ${counts.away}`, { variant: "warning" }), - ui.badge(`Injured ${counts.injured}`, { variant: "error" }), - ]), - ui.form([ - ui.field({ - label: "Search Crew", - hint: "Filter by name, rank, or department", - children: ui.input({ - id: ctx.id("crew-search"), - value: props.state.crewSearchQuery, - placeholder: "Type to filter", - onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), - }), - }), - ]), - ui.actions([ - ui.button({ - id: ctx.id("crew-new-assignment"), - label: "New Assignment", - intent: "primary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ui.button({ - id: ctx.id("crew-edit-selected"), - label: "Edit Selected", - intent: "secondary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ]), + ui.box( + { + border: "none", + p: 0, + width: layout.crewMasterWidth, + height: "100%", + overflow: "hidden", + }, + [manifestBlock], + ), + ui.box( + { + border: "none", + p: 0, + flex: 1, + height: "100%", + overflow: "hidden", + }, + [detailPanel], + ), ], - ), - ...(layout.wide - ? [ - ui.box( - { - border: "none", - p: 0, - flex: 1, - }, - [ - surfacePanel( - tokens, - "Crew Snapshot", + ) + : manifestBlock + : showDetailPane + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [manifestBlock], + ), + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [detailPanel], + ), + ]) + : manifestBlock; + + const operationsPanelMaxHeight = Math.max(12, Math.min(22, Math.floor(layout.height * 0.34))); + debugSnapshot("crew.render", { + viewportCols: props.state.viewportCols, + viewportRows: props.state.viewportRows, + visibleCount: visible.length, + sortedCount: sorted.length, + page, + totalPages, + pageDataCount: pageData.length, + showDetailPane, + operationsPanelMaxHeight, + editingCrew: props.state.editingCrew, + }); + const operationsPanel = ui.box( + { + border: "none", + p: 0, + width: "100%", + height: operationsPanelMaxHeight, + overflow: "scroll", + }, + [ + surfacePanel( + tokens, + "Crew Operations", + [ + sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), + ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ + ui.box( + { + border: "none", + p: 0, + gap: SPACE.sm, + ...(layout.wide ? { flex: 2 } : {}), + }, + [ + ui.row({ gap: SPACE.sm, wrap: true }, [ + ui.badge(`Total ${counts.total}`, { variant: "info" }), + ui.badge(`Active ${counts.active}`, { variant: "success" }), + ui.badge(`Away ${counts.away}`, { variant: "warning" }), + ui.badge(`Injured ${counts.injured}`, { variant: "error" }), + ]), + ui.form([ + ui.field({ + label: "Search Crew", + hint: "Filter by name, rank, or department", + children: ui.input({ + id: ctx.id("crew-search"), + value: props.state.crewSearchQuery, + placeholder: "Type to filter", + onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), + }), + }), + ]), + ui.actions([ + ui.button({ + id: ctx.id("crew-new-assignment"), + label: "New Assignment", + intent: "primary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ui.button({ + id: ctx.id("crew-edit-selected"), + label: "Edit Selected", + intent: "secondary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ]), + ], + ), + ...(layout.wide + ? [ + ui.box( + { + border: "none", + p: 0, + flex: 1, + }, [ - selected - ? ui.column({ gap: SPACE.xs }, [ - ui.text(selected.name, { variant: "label" }), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(rankBadge(selected.rank).text, { - variant: rankBadge(selected.rank).variant, + surfacePanel( + tokens, + "Crew Snapshot", + [ + selected + ? ui.column({ gap: SPACE.xs }, [ + ui.text(selected.name, { variant: "label" }), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(rankBadge(selected.rank).text, { + variant: rankBadge(selected.rank).variant, + }), + ui.badge(statusBadge(selected.status).text, { + variant: statusBadge(selected.status).variant, + }), + ui.tag(departmentLabel(selected.department), { variant: "info" }), + ]), + ]) + : ui.text("No crew selected", { + variant: "caption", + style: { fg: tokens.text.muted, dim: true }, }), - ui.badge(statusBadge(selected.status).text, { - variant: statusBadge(selected.status).variant, + ui.divider(), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(`Visible ${sorted.length}`, { variant: "info" }), + ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), + ]), + staffingError + ? ui.callout("Critical staffing below minimum.", { + title: "Guardrail", + variant: "warning", + }) + : ui.callout("Critical staffing thresholds healthy.", { + title: "Guardrail", + variant: "success", }), - ui.tag(departmentLabel(selected.department), { variant: "info" }), - ]), - ]) - : ui.text("No crew selected", { - variant: "caption", - style: { fg: tokens.text.muted, dim: true }, - }), - ui.divider(), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(`Visible ${sorted.length}`, { variant: "info" }), - ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), - ]), - staffingError - ? ui.callout("Critical staffing below minimum.", { - title: "Guardrail", - variant: "warning", - }) - : ui.callout("Critical staffing thresholds healthy.", { - title: "Guardrail", - variant: "success", - }), + ], + { + tone: "inset", + p: SPACE.sm, + gap: SPACE.sm, + }, + ), ], - { - tone: "inset", - p: SPACE.sm, - gap: SPACE.sm, - }, ), - ], - ), - ] - : []), - ]), + ] + : []), + ]), + ], + { tone: "base" }, + ), ], - { tone: "base" }, ); - return ui.column({ gap: SPACE.md, width: "100%" }, [ + return ui.column({ gap: SPACE.md, width: "100%", height: "100%" }, [ operationsPanel, show( asyncCrew.loading, @@ -443,7 +513,20 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ui.skeleton(44, { variant: "text" }), ], { tone: "inset" }), ), - show(!asyncCrew.loading, deckLayout), + show( + !asyncCrew.loading, + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 12, + overflow: "hidden", + }, + [deckLayout], + ), + ), ]); }); @@ -452,7 +535,7 @@ export function renderCrewScreen(context: RouteRenderContext, dep title: "Crew Manifest", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CrewDeck({ key: "crew-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 6247de81..016b2158 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -371,16 +371,27 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = }), ]); - const leftPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - reactorPanel, - ...(showSecondaryPanels ? [treePanel] : []), - ]); - const rightPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - powerPanel, - ...(showSecondaryPanels ? [thermalPanel, diagnosticsPanel] : []), - ]); + const leftPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12 }, [reactorPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + treePanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [reactorPanel]); + const rightPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12, overflow: "hidden" }, [ + powerPanel, + ]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10 }, [thermalPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + diagnosticsPanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [powerPanel]); - const responsiveDeckHeight = Math.max( + const responsiveDeckMinHeight = Math.max( 16, contentRows - (showControlsSummary ? 12 : 10) - (showSecondaryPanels ? 0 : 2), ); @@ -395,7 +406,8 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = border: "none", p: 0, width: "100%", - height: responsiveDeckHeight, + flex: 1, + minHeight: responsiveDeckMinHeight, overflow: "scroll", }, [responsiveDeckBody], @@ -491,7 +503,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = includeResponsiveDeck: renderMode === "full", responsiveDeckMode: useWideRow ? "row" : "column", forceStackViaEnv, - responsiveDeckHeight, + responsiveDeckMinHeight, }); if (veryCompactHeight) { @@ -502,7 +514,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = return ui.column({ gap: SPACE.sm, width: "100%" }, [controlsPanel, reactorPanel]); } - return ui.column({ gap: SPACE.sm, width: "100%" }, [ + return ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ controlsRegion, responsiveDeck, ]); @@ -516,7 +528,7 @@ export function renderEngineeringScreen( title: "Engineering Deck", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ EngineeringDeck({ key: "engineering-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index 9b9aac93..e18b31da 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -209,7 +209,7 @@ function settingsRightRail(state: StarshipState, deps: RouteDeps): VNode { subtitle: activeTheme.label, actions: [ui.badge("Preview", { variant: "info" })], }), - body: ui.column({ gap: SPACE.xs }, [ + body: ui.column({ gap: SPACE.xs, width: "100%", height: "100%" }, [ ui.breadcrumb({ items: [{ label: "Bridge" }, { label: "Settings" }, { label: "Theme Preview" }], }), @@ -277,7 +277,7 @@ export function renderSettingsScreen( title: "Ship Settings", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ SettingsDeck({ key: "settings-deck", state: context.state, diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts index 0b98f472..63953f08 100644 --- a/packages/node/src/frameAudit.ts +++ b/packages/node/src/frameAudit.ts @@ -145,10 +145,18 @@ export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( ); // Match zr_debug category/code values. +export const ZR_DEBUG_CAT_FRAME = 1; export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CAT_PERF = 6; +export const ZR_DEBUG_CODE_FRAME_BEGIN = 0x0100; +export const ZR_DEBUG_CODE_FRAME_SUBMIT = 0x0101; +export const ZR_DEBUG_CODE_FRAME_PRESENT = 0x0102; +export const ZR_DEBUG_CODE_FRAME_RESIZE = 0x0103; export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; +export const ZR_DEBUG_CODE_PERF_TIMING = 0x0600; +export const ZR_DEBUG_CODE_PERF_DIFF_PATH = 0x0601; function readContextRoute(): string | null { const g = globalThis as { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index b07aef8c..4d62620d 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -11,10 +11,18 @@ import { parentPort, workerData } from "node:worker_threads"; import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_PERF, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_SUBMIT, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_PERF_DIFF_PATH, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -318,6 +326,26 @@ const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; +const DEBUG_FRAME_RECORD_BYTES = 56; +const DEBUG_PERF_RECORD_BYTES = 24; +const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +const NATIVE_FRAME_AUDIT_CATEGORY_MASK = + (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); + +function nativeFrameCodeName(code: number): string { + if (code === ZR_DEBUG_CODE_FRAME_BEGIN) return "frame.begin"; + if (code === ZR_DEBUG_CODE_FRAME_SUBMIT) return "frame.submit"; + if (code === ZR_DEBUG_CODE_FRAME_PRESENT) return "frame.present"; + if (code === ZR_DEBUG_CODE_FRAME_RESIZE) return "frame.resize"; + return "frame.unknown"; +} + +function nativePerfPhaseName(phase: number): string { + if (phase === 0) return "poll"; + if (phase === 1) return "submit"; + if (phase === 2) return "present"; + return "unknown"; +} type FrameAuditMeta = { frameSeq: number; @@ -438,7 +466,7 @@ function drainNativeFrameAudit(reason: string): void { engineId, { minRecordId: nativeFrameAuditNextRecordId, - categoryMask: 1 << ZR_DEBUG_CAT_DRAWLIST, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, minSeverity: 0, maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), }, @@ -552,6 +580,112 @@ function drainNativeFrameAudit(reason: string): void { fillRects: dvPayload.getUint32(36, true), }); } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_FRAME_BEGIN || + code === ZR_DEBUG_CODE_FRAME_SUBMIT || + code === ZR_DEBUG_CODE_FRAME_PRESENT || + code === ZR_DEBUG_CODE_FRAME_RESIZE) && + payloadSize >= DEBUG_FRAME_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_FRAME_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_PERF_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); + } } } if (!advanced) return;