From 2b88c10b900e556f01813c4eb8fa6400d029b1ad Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 7 Oct 2026 00:37:38 -0500 Subject: [PATCH 1/2] Move retained paint geometry into TypeScript - Own retained damage geometry and explicit font measurement requests in the compiled portable framework. - Preserve native float semantics, complete invalidations, capacity prefixes, and copied ABI ownership. --- build.zig | 2 + build/app.zig | 1 + packages/core/src/runtime_policy.ts | 1 + packages/core/src/view_frontend.ts | 1 + packages/core/src/widget_paint.ts | 270 ++++++++++++++++++ packages/core/test/view_frontend.test.ts | 2 +- packages/core/test/widget_paint.test.ts | 45 +++ src/primitives/canvas/root.zig | 1 + src/primitives/canvas/tokens.zig | 2 + src/primitives/canvas/widget_invalidation.zig | 68 +++-- src/primitives/canvas/widget_paint_policy.zig | 165 +++++++++++ src/primitives/canvas/widget_runtime.zig | 3 +- src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 3 + src/runtime/view_widget_tree.zig | 1 + tests/ts-core/typography_e2e_tests.zig | 2 + tests/ts-core/widget_changes_e2e_tests.zig | 133 +++++++++ 17 files changed, 676 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/widget_paint.ts create mode 100644 packages/core/test/widget_paint.test.ts create mode 100644 src/primitives/canvas/widget_paint_policy.zig diff --git a/build.zig b/build.zig index 912d8a891..52c534742 100644 --- a/build.zig +++ b/build.zig @@ -1323,6 +1323,7 @@ pub fn build(b: *std.Build) void { native_api_tests.addFileArg(b.path("packages/core/test/widget_audits.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/widget_routing.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/widget_changes.test.ts")); + native_api_tests.addFileArg(b.path("packages/core/test/widget_paint.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/component_composition.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/view_frontend.test.ts")); native_api_tests.has_side_effects = true; @@ -5389,6 +5390,7 @@ fn externalCoreFixtureModule( stage_run.addFileInput(b.path("packages/core/src/widget_audits.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_routing.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_changes.ts")); + stage_run.addFileInput(b.path("packages/core/src/widget_paint.ts")); stage_run.addFileInput(b.path("packages/core/src/stream_policy.ts")); stage_run.addArg("--src"); stage_run.addDirectoryArg(spec.src_dir); diff --git a/build/app.zig b/build/app.zig index cdc33331f..1eebc07ce 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1251,6 +1251,7 @@ fn tsCoreStage( stage_run.addFileInput(dep.path("packages/core/src/widget_audits.ts")); stage_run.addFileInput(dep.path("packages/core/src/widget_routing.ts")); stage_run.addFileInput(dep.path("packages/core/src/widget_changes.ts")); + stage_run.addFileInput(dep.path("packages/core/src/widget_paint.ts")); stage_run.addFileInput(dep.path("packages/core/src/stream_policy.ts")); if (typescript_view) { stage_run.addArg("--view-markup"); diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index 8fa531e0a..12da95c92 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -218,6 +218,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 23) return nscvWidgetAudits(request); if (request[0] === 24) return nscvWidgetRouting(request); if (request[0] === 25) return nscvWidgetChanges(request); + if (request[0] === 26) return nscvWidgetPaint(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { diff --git a/packages/core/src/view_frontend.ts b/packages/core/src/view_frontend.ts index 899b61d28..4864e4d10 100644 --- a/packages/core/src/view_frontend.ts +++ b/packages/core/src/view_frontend.ts @@ -919,4 +919,5 @@ const viewPrelude = "\n// Portable Native components compiled beside the committ readFileSync(new URL("./widget_audits.ts", import.meta.url), "utf8") + readFileSync(new URL("./widget_routing.ts", import.meta.url), "utf8") + readFileSync(new URL("./widget_changes.ts", import.meta.url), "utf8") + + readFileSync(new URL("./widget_paint.ts", import.meta.url), "utf8") + readFileSync(new URL("./stream_policy.ts", import.meta.url), "utf8"); diff --git a/packages/core/src/widget_paint.ts b/packages/core/src/widget_paint.ts new file mode 100644 index 000000000..27aec1dc3 --- /dev/null +++ b/packages/core/src/widget_paint.ts @@ -0,0 +1,270 @@ +/** Retained paint geometry over copied widget/token facts. Font measurement + * is an explicit two-pass capability; no native damage verdict crosses here. + * Every intermediate follows the reference's f32 evaluation order. */ +interface NscPaintNode { + readonly paint_kind: number; readonly paint_size: number; readonly paint_variant: number; + readonly paint_parent: number; readonly paint_depth: number; readonly paint_depth_high: number; readonly paint_flags: number; + readonly paint_state: number; readonly paint_blur_token: number; readonly paint_frame: NscSurfaceRect; + readonly paint_authored_frame: NscSurfaceRect; readonly paint_transform: NscRenderAffine; + readonly paint_value: number; readonly paint_stroke: number; readonly paint_border: readonly number[]; + readonly paint_blur: number; readonly paint_text_width: number; +} +interface NscPaintContext { + readonly paint_numeric: number; readonly paint_tokens: readonly number[]; + readonly paint_strokes: readonly (number | null)[]; +} +function nscvPaintMax(c: NscPaintContext, a: number, b: number): number { return nscvRenderExtreme(a, b, c.paint_numeric, true); } +function nscvPaintMin(c: NscPaintContext, a: number, b: number): number { return nscvRenderExtreme(a, b, c.paint_numeric, false); } +function nscvPaintPositive(c: NscPaintContext, v: number): number { return nscvPaintMax(c, 0, v); } +function nscvPaintUnion(c: NscPaintContext, a: NscSurfaceRect | null, b: NscSurfaceRect | null): NscSurfaceRect | null { + return a === null ? b === null ? null : nscvSurfaceNormalize(b) : b === null ? nscvSurfaceNormalize(a) : nscvRenderUnion(a, b, c.paint_numeric); +} +function nscvPaintInflate(r: NscSurfaceRect, amount: number): NscSurfaceRect { + const f = Math.fround; + return { x: f(r.x - amount), y: f(r.y - amount), width: f(f(r.width + amount) + amount), height: f(f(r.height + amount) + amount) }; +} +function nscvPaintStroke(c: NscPaintContext, r: NscSurfaceRect, width: number): NscSurfaceRect { return nscvPaintInflate(nscvSurfaceNormalize(r), Math.fround(nscvPaintPositive(c, width) * 0.5)); } +function nscvPaintDensity(c: NscPaintContext): number { return c.paint_tokens[4] === 0 ? 0.875 : c.paint_tokens[4] === 2 ? 1.125 : 1; } +function nscvPaintSized(c: NscPaintContext, n: NscPaintNode, value: number): number { + return Math.fround(Math.fround(value * nscvPaintDensity(c)) * (n.paint_size === 0 ? 0.875 : n.paint_size === 2 ? 1.125 : 1)); +} +function nscvPaintTextSize(c: NscPaintContext, n: NscPaintNode): number { + const f = Math.fround, base = n.paint_kind === 46 && c.paint_tokens[27] === 1 ? nscvPaintMax(c, 8, f(c.paint_tokens[7]! + c.paint_tokens[8]!)) : c.paint_tokens[7]!; + return n.paint_size === 0 ? nscvPaintMax(c, 8, f(base - 1)) : n.paint_size === 2 ? f(base + 1) : base; +} +function nscvPaintSnap(c: NscPaintContext, raw: NscSurfaceRect): NscSurfaceRect { + const scale = c.paint_tokens[6]!; + if (c.paint_tokens[5] === 0 || !Number.isFinite(scale) || scale <= 0) return raw; + const r = nscvSurfaceNormalize(raw), f = Math.fround; + // Zig round uses ties away from zero; JS round uses ties toward +infinity. + const snap = (v: number): number => { const x = f(v * scale); return f((x < 0 || 1 / x < 0 ? -Math.floor(-x + 0.5) : Math.floor(x + 0.5)) / scale); }; + const x = snap(r.x), y = snap(r.y); + return { x, y, width: nscvPaintPositive(c, f(snap(nscvSurfaceRight(r)) - x)), height: nscvPaintPositive(c, f(snap(nscvSurfaceBottom(r)) - y)) }; +} +function nscvPaintChrome(c: NscPaintContext, n: NscPaintNode): NscSurfaceRect { + const r = n.paint_authored_frame, f = Math.fround, kind = n.paint_kind; + if (kind === 47 || kind === 48) { + const size = nscvPaintMin(c, nscvPaintMax(c, nscvPaintSized(c, n, 16), f(r.height * 0.55)), nscvPaintSized(c, n, 20)); + return nscvPaintSnap(c, { x: r.x, y: f(r.y + f(f(r.height - size) * 0.5)), width: size, height: size }); + } + if (kind === 49) { + const width = nscvPaintMin(c, r.width, nscvPaintMax(c, nscvPaintSized(c, n, 44), f(r.height * 1.75))), height = nscvPaintMin(c, r.height, nscvPaintSized(c, n, 24)); + return nscvPaintSnap(c, { x: r.x, y: f(r.y + f(f(r.height - height) * 0.5)), width, height }); + } + if (kind === 51) { + const width = nscvPaintMin(c, r.width, nscvPaintSized(c, n, c.paint_tokens[14]!)), height = nscvPaintMin(c, r.height, nscvPaintSized(c, n, c.paint_tokens[15]!)); + const clamp = (v: number, lo: number, hi: number): number => nscvPaintMax(c, lo, nscvPaintMin(c, v, hi)); + const value = clamp(n.paint_value, 0, 1), x = clamp(f(f(r.x + f(r.width * value)) - f(width * 0.5)), r.x, f(r.x + nscvPaintPositive(c, f(r.width - width)))); + return nscvPaintSnap(c, { x, y: f(r.y + f(f(r.height - height) * 0.5)), width, height }); + } + return r; +} +function nscvPaintWidth(c: NscPaintContext, n: NscPaintNode): number { + const kind = n.paint_kind, surface = [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25].includes(kind), button = [31, 32, 33, 50].includes(kind); + const input = [35, 36, 37, 38, 39, 60].includes(kind), select = kind === 34, selection = [46, 47, 48, 49, 51].includes(kind), cell = kind === 44; + const component = kind === 29 || kind === 30; + if (kind === 41 || kind === 42) return (n.paint_state & 4) !== 0 ? c.paint_tokens[0]! : 0; + if (!surface && !button && !input && !select && !selection && !cell && !component) return 0; + if (!surface && !component && (n.paint_state & 4) !== 0) return c.paint_tokens[0]!; + const family = button ? 0 : input ? 1 : selection ? 2 : surface ? 3 : component ? 4 : cell ? 5 : 6; + const header = new Uint8Array(8); + header.set([16, 0, family, kind, n.paint_variant, n.paint_size === 0 ? 1 : n.paint_size === 1 ? 0 : n.paint_size, 0, ((n.paint_flags & 256) !== 0 ? 1 : 0) | (c.paint_tokens[26] === 1 ? 2 : 0)]); + const tables = nscvControlAppearance(header), primary = tables[0]!, fallback = tables[1]!; + const table = primary === 255 ? null : c.paint_strokes[primary] ?? null, inherited = tables[2] === 1 && fallback !== 255 ? c.paint_strokes[fallback] ?? null : null; + const authored = (n.paint_flags & 128) !== 0 ? n.paint_stroke : null, visual = table ?? inherited; + if (authored !== null || visual !== null) return nscvPaintPositive(c, authored ?? visual ?? 0); + if (button && ((n.paint_flags & 256) !== 0 && c.paint_tokens[26] === 1 || n.paint_variant === 4 || n.paint_variant === 5)) return 0; + const fallbackWidth = surface || cell || component ? c.paint_tokens[3]! : c.paint_tokens[2]!; + return button ? fallbackWidth : nscvPaintPositive(c, fallbackWidth); +} +function nscvPaintFocus(c: NscPaintContext, n: NscPaintNode): NscSurfaceRect | null { + if ((n.paint_state & 4) === 0 || ![31, 32, 33, 34, 35, 36, 37, 38, 39, 60, 41, 42, 44, 46, 47, 48, 49, 50, 51, 62].includes(n.paint_kind) || c.paint_tokens[0]! <= 0) return null; + return nscvPaintStroke(c, nscvPaintInflate(nscvSurfaceNormalize(nscvPaintChrome(c, n)), nscvPaintPositive(c, c.paint_tokens[1]!)), c.paint_tokens[0]!); +} +function nscvPaintFrameStroke(c: NscPaintContext, n: NscPaintNode): NscSurfaceRect | null { + const width = nscvPaintWidth(c, n); + return width <= 0 ? null : nscvPaintStroke(c, nscvPaintChrome(c, n), width); +} +function nscvPaintBlur(c: NscPaintContext, n: NscPaintNode): NscSurfaceRect | null { + const explicit = nscvPaintPositive(c, n.paint_blur), radius = explicit > 0 ? explicit : n.paint_blur_token === 4294967295 ? 0 : nscvPaintPositive(c, c.paint_tokens[22 + n.paint_blur_token]!); + return radius <= 0 ? null : nscvPaintInflate(nscvSurfaceNormalize(n.paint_authored_frame), radius); +} +function nscvPaintFramed(n: NscPaintNode, frame: NscSurfaceRect, state: number): NscPaintNode { return { ...n, paint_authored_frame: frame, paint_state: state }; } +function nscvPaintFull(c: NscPaintContext, n: NscPaintNode, transform: NscRenderAffine): NscSurfaceRect { + let bounds = nscvSurfaceNormalize(n.paint_frame); + const stroke = nscvPaintFrameStroke(c, n); if (stroke !== null) bounds = nscvRenderUnion(bounds, nscvSurfaceNormalize(stroke), c.paint_numeric); + const shadow = [14, 15, 16, 22, 40].includes(n.paint_kind) ? 16 : [19, 20, 21, 23, 24, 25].includes(n.paint_kind) ? 19 : -1; + if (shadow >= 0) { + const y = c.paint_tokens[shadow]!, blur = c.paint_tokens[shadow + 1]!, spread = c.paint_tokens[shadow + 2]!; + if (y !== 0 || blur !== 0 || spread !== 0) { + const r = nscvSurfaceNormalize(n.paint_authored_frame), rect = { ...r, x: Math.fround(r.x + 0), y: Math.fround(r.y + y) }; + bounds = nscvRenderUnion(bounds, nscvSurfaceNormalize(nscvPaintInflate(rect, Math.fround(nscvPaintPositive(c, Math.abs(spread)) + nscvPaintPositive(c, blur)))), c.paint_numeric); + } + } + const backdrop = nscvPaintBlur(c, n); if (backdrop !== null) bounds = nscvRenderUnion(bounds, nscvSurfaceNormalize(backdrop), c.paint_numeric); + const r = nscvSurfaceNormalize(n.paint_frame), f = Math.fround; + if (n.paint_kind === 15 && (n.paint_flags & 16) !== 0 && !nscvRenderEmpty(r)) { + const height = f(f(nscvPaintTextSize(c, n) * 1.25) + 4), width = nscvPaintMax(c, height, f(Math.ceil(n.paint_text_width) + 12)); + // Alignment is a per-node fact; the low two high flag bits carry it. + const align = (n.paint_flags >>> 9) & 3; + const x = align === 0 ? f(r.x + 12) : align === 1 ? f(r.x + f(f(r.width - width) * 0.5)) : f(f(nscvSurfaceRight(r) - 12) - width); + bounds = nscvRenderUnion(bounds, nscvSurfaceNormalize(nscvPaintInflate({ x, y: f(nscvSurfaceBottom(r) - f(height * 0.25)), width, height }, 3)), c.paint_numeric); + } + if (n.paint_kind === 46 && c.paint_tokens[27] === 1 && !nscvRenderEmpty(r)) { + const thickness = nscvPaintMin(c, r.height, nscvPaintSized(c, n, c.paint_tokens[13]!)), textSize = nscvPaintTextSize(c, n); + const icon = (n.paint_flags & 32) !== 0 ? f(f(textSize + c.paint_tokens[11]!) + ((n.paint_flags & 16) !== 0 ? f(c.paint_tokens[12]! * nscvPaintDensity(c)) : 0)) : 0; + const content = f(icon + n.paint_text_width), base = nscvPaintPositive(c, c.paint_tokens[9]!), step = c.paint_tokens[10]!; + const inset = f((n.paint_size === 0 ? nscvPaintPositive(c, f(base - step)) : n.paint_size === 2 ? f(base + step) : base) * nscvPaintDensity(c)); + const width = content > 0 ? nscvPaintMin(c, r.width, f(content + f(inset * 2))) : r.width; + const bar = nscvPaintSnap(c, { x: f(r.x + f(f(r.width - width) * 0.5)), y: f(nscvSurfaceBottom(r) - thickness), width, height: thickness }); + bounds = nscvRenderUnion(bounds, nscvSurfaceNormalize(bar), c.paint_numeric); + } + return nscvSurfaceNormalize(nscvRenderTransform(transform, bounds, c.paint_numeric)); +} +function nscvPaintHidden(nodes: readonly NscPaintNode[], slot: number): boolean { + let n = slot; + for (let guard = 0; n !== 4294967295 && guard <= nodes.length; guard++) { if (n >= nodes.length) return false; if ((nodes[n]!.paint_flags & 1) !== 0) return true; n = nodes[n]!.paint_parent; } + if (n !== 4294967295) throw new Error("cyclic paint ancestry"); + return false; +} +function nscvPaintClipped(c: NscPaintContext, nodes: readonly NscPaintNode[], slot: number, raw: NscSurfaceRect | null): NscSurfaceRect | null { + if (slot >= nodes.length || nscvPaintHidden(nodes, slot) || raw === null) return null; + let bounds = nscvSurfaceNormalize(raw), current = slot; + for (let guard = 0; guard <= nodes.length; guard++) { + const n = nodes[current]!; + if ((n.paint_flags & 4) !== 0 || [19, 20, 21].includes(n.paint_kind)) return bounds; + if (n.paint_parent === 4294967295) return bounds; + if (n.paint_parent >= nodes.length) return null; + const parent = nodes[n.paint_parent]!; + if (parent.paint_kind === 6 || (parent.paint_flags & 2) !== 0) { bounds = nscvRenderIntersection(bounds, nscvSurfaceNormalize(parent.paint_frame), c.paint_numeric); if (nscvRenderEmpty(bounds)) return null; } + current = n.paint_parent; + } + throw new Error("cyclic paint clipping"); +} +function nscvPaintAccumulated(nodes: readonly NscPaintNode[], slot: number): NscRenderAffine { + const slots: number[] = []; let current = slot; + while (current !== 4294967295 && current < nodes.length && slots.length < 32) { slots.push(current); current = nodes[current]!.paint_parent; } + let t: NscRenderAffine = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }; + for (let i = slots.length - 1; i >= 0; i--) t = nscvRenderMultiply(t, nodes[slots[i]!]!.paint_transform); + return t; +} +function nscvPaintDepthAbove(n: NscPaintNode, root: NscPaintNode): boolean { return n.paint_depth_high > root.paint_depth_high || n.paint_depth_high === root.paint_depth_high && n.paint_depth > root.paint_depth; } +function nscvPaintSubtree(c: NscPaintContext, nodes: readonly NscPaintNode[], slot: number): NscSurfaceRect | null { + if (slot >= nodes.length) return null; + const root = nodes[slot]!; let hidden: NscPaintNode | null = null, bounds: NscSurfaceRect | null = null; + for (let i = slot; i < nodes.length; i++) { + const n = nodes[i]!; + if (i !== slot && !nscvPaintDepthAbove(n, root)) break; + if (hidden !== null) { if (nscvPaintDepthAbove(n, hidden)) continue; hidden = null; } + if ((n.paint_flags & 1) !== 0) { hidden = n; continue; } + bounds = nscvPaintUnion(c, bounds, nscvPaintClipped(c, nodes, i, nscvPaintFull(c, n, nscvPaintAccumulated(nodes, i)))); + } + return bounds; +} +function nscvPaintModal(c: NscPaintContext, n: NscPaintNode, root: NscSurfaceRect | null): NscSurfaceRect | null { + return (n.paint_flags & 8) !== 0 && [19, 20, 21].includes(n.paint_kind) && (c.paint_tokens[25]! > 0 || nscvPaintPositive(c, c.paint_tokens[28]!) > 0) ? root : null; +} +function nscvPaintStrokeChanged(c: NscPaintContext, a: NscPaintNode, b: NscPaintNode): boolean { + if (nscvPaintWidth(c, a) !== nscvPaintWidth(c, b) || (a.paint_flags & 64) !== (b.paint_flags & 64)) return true; + if ((a.paint_flags & 64) !== 0) for (let i = 0; i < 4; i++) if (a.paint_border[i] !== b.paint_border[i]) return true; + return false; +} +function nscvPaintChanged(c: NscPaintContext, a: NscPaintNode, b: NscPaintNode, render: boolean): NscSurfaceRect | null { + let bounds: NscSurfaceRect | null = render ? null : nscvPaintUnion(c, a.paint_authored_frame, b.paint_authored_frame); + if (nscvPaintStrokeChanged(c, a, b)) { bounds = nscvPaintUnion(c, bounds, nscvPaintFrameStroke(c, a)); bounds = nscvPaintUnion(c, bounds, nscvPaintFrameStroke(c, b)); } + if (!render || (a.paint_state & 4) !== (b.paint_state & 4)) { bounds = nscvPaintUnion(c, bounds, nscvPaintFocus(c, a)); bounds = nscvPaintUnion(c, bounds, nscvPaintFocus(c, b)); } + if (!render) { bounds = nscvPaintUnion(c, bounds, nscvPaintBlur(c, a)); bounds = nscvPaintUnion(c, bounds, nscvPaintBlur(c, b)); } + else if ((a.paint_state & 3) !== (b.paint_state & 3)) { bounds = nscvPaintUnion(c, bounds, nscvPaintChrome(c, a)); bounds = nscvPaintUnion(c, bounds, nscvPaintChrome(c, b)); } + return bounds; +} +function nscvWidgetPaint(request: Uint8Array): Uint8Array { + if (request.length < 544 || request[0] !== 26 || request[1]! > 2 || request[2] !== 1 || request[3]! > 15) throw new Error("invalid widget paint header"); + const w = new DataView(request.buffer, request.byteOffset, request.byteLength), mode = request[1]!, beforeCount = w.getUint32(4, true), afterCount = w.getUint32(8, true), payloadSize = w.getUint32(12, true), total = beforeCount + afterCount; + if (request.length !== 544 + total * 128 + payloadSize || w.getUint32(16, true) < 1 || w.getUint32(16, true) > 2 || w.getUint32(20, true) !== 0 || w.getUint32(24, true) !== 0 || w.getUint32(28, true) > 3) throw new Error("invalid widget paint shape"); + const tokens: number[] = [], strokes: (number | null)[] = [], nodes: NscPaintNode[] = []; + for (let i = 0; i < 32; i++) tokens.push(w.getFloat32(64 + i * 4, true)); + if (![0, 1, 2].includes(tokens[4]!) || ![0, 1].includes(tokens[5]!) || ![0, 1].includes(tokens[26]!) || ![0, 1].includes(tokens[27]!) || tokens[29] !== 0 || tokens[30] !== 0 || tokens[31] !== 0) throw new Error("invalid widget paint tokens"); + for (let i = 0; i < 43; i++) { const at = 192 + i * 8, presence = w.getUint32(at, true); if (presence > 1) throw new Error("invalid paint stroke presence"); strokes.push(presence === 0 ? null : w.getFloat32(at + 4, true)); } + for (let i = 536; i < 544; i++) if (request[i] !== 0) throw new Error("invalid widget paint padding"); + for (let i = 0; i < total; i++) { + const at = 544 + i * 128, kind = w.getUint32(at, true), size = w.getUint32(at + 4, true), variant = w.getUint32(at + 8, true), flags = w.getUint32(at + 20, true), state = w.getUint32(at + 24, true), blurToken = w.getUint32(at + 28, true); + if (kind > 62 || size > 5 || variant > 5 || flags > 2047 || ((flags >>> 9) & 3) > 2 || state > 1023 || blurToken !== 4294967295 && blurToken > 2 || w.getUint32(at + 124, true) !== 0) throw new Error("invalid widget paint node"); + const border: number[] = []; for (let j = 0; j < 4; j++) border.push(w.getFloat32(at + 96 + j * 4, true)); + nodes.push({ paint_kind: kind, paint_size: size, paint_variant: variant, paint_parent: w.getUint32(at + 12, true), paint_depth: w.getUint32(at + 16, true), paint_depth_high: w.getUint32(at + 120, true), paint_flags: flags, paint_state: state, paint_blur_token: blurToken, paint_frame: nscvRenderRect(w, at + 32), paint_authored_frame: nscvRenderRect(w, at + 48), paint_transform: nscvRenderAffine(w, at + 64), paint_value: w.getFloat32(at + 88, true), paint_stroke: w.getFloat32(at + 92, true), paint_border: border, paint_blur: w.getFloat32(at + 112, true), paint_text_width: w.getFloat32(at + 116, true) }); + } + const c: NscPaintContext = { paint_numeric: request[3]!, paint_tokens: tokens, paint_strokes: strokes }; + if (mode === 0) { + const selected: boolean[] = [], order: number[] = []; for (let i = 0; i < total; i++) selected.push(false); + const planAt = 544 + total * 128, planMode = w.getUint32(16, true); + if (payloadSize < (planMode === 1 ? 16 : 32) || request[planAt] !== 1 || request[planAt + 1] !== (planMode === 1 ? 0 : 1)) throw new Error("invalid paint measurement payload"); + const length = w.getUint32(planAt + 4, true); + const mark = (slot: number, offset: number, limit: number, subtree: boolean): void => { + if (slot >= limit) throw new Error("invalid paint measurement reference"); + const include = (i: number): void => { if (!selected[i]) { selected[i] = true; order.push(i); } }; + if (!subtree) { include(offset + slot); return; } + const root = nodes[offset + slot]!; let hidden: NscPaintNode | null = null; + for (let i = slot; i < limit; i++) { const n = nodes[offset + i]!; if (i !== slot && !nscvPaintDepthAbove(n, root)) break; if (hidden !== null) { if (nscvPaintDepthAbove(n, hidden)) continue; hidden = null; } if ((n.paint_flags & 1) !== 0) { hidden = n; continue; } include(offset + i); } + }; + if (planMode === 1) { + if (payloadSize !== 16 + length * 16) throw new Error("invalid paint measurement diff"); + for (let i = 0; i < length; i++) { const p = planAt + 16 + i * 16, kind = w.getUint32(p, true), a = w.getUint32(p + 4, true), b = w.getUint32(p + 8, true), flags = w.getUint32(p + 12, true); if (kind > 2 || flags > 127) throw new Error("invalid paint measurement entry"); if (kind === 0) mark(a, 0, beforeCount, false); else if (kind === 2) mark(b, beforeCount, afterCount, false); else if ((flags & 121) !== 0) { mark(a, 0, beforeCount, (flags & 48) !== 0); mark(b, beforeCount, afterCount, (flags & 48) !== 0); } } + } else { + if (afterCount !== 0 || payloadSize !== 32 + length * 16 + (w.getUint32(planAt + 8, true) + w.getUint32(planAt + 12, true)) * 4) throw new Error("invalid paint measurement render"); + for (let i = 0; i < length; i++) { const p = planAt + 32 + i * 16; if ((w.getUint32(p + 4, true) & 2) !== 0) mark(w.getUint32(p, true), 0, beforeCount, false); } + } + const result = new Uint8Array(8 + total * 8), out = new DataView(result.buffer); result[0] = 1; + let count = 0; + for (let position = 0; position < order.length; position++) { + const i = order[position]!; + const n = nodes[i]!; + if (selected[i] && (n.paint_kind === 15 && (n.paint_flags & 16) !== 0 || n.paint_kind === 46 && tokens[27] === 1) && !nscvRenderEmpty(nscvSurfaceNormalize(n.paint_frame))) { out.setUint32(8 + count * 8, i, true); out.setFloat32(12 + count * 8, nscvPaintTextSize(c, n), true); count++; } + } + out.setUint32(4, count, true); return result.subarray(0, 8 + count * 8); + } + if (mode !== w.getUint32(16, true)) throw new Error("inconsistent paint plan mode"); + const at = 544 + total * 128, presence = w.getUint32(28, true), previousRoot = (presence & 1) !== 0 ? nscvRenderRect(w, 32) : null, nextRoot = (presence & 2) !== 0 ? nscvRenderRect(w, 48) : null; + const before = nodes.slice(0, beforeCount), after = nodes.slice(beforeCount); + if (payloadSize < 16 || w.getUint8(at) !== 1 || w.getUint8(at + 1) !== (mode === 1 ? 0 : 1)) throw new Error("invalid paint plan payload"); + const length = w.getUint32(at + 4, true), result = new Uint8Array(8 + (mode === 1 ? length : 1) * 20), out = new DataView(result.buffer); result[0] = 1; result[1] = mode; + out.setUint32(4, mode === 1 ? length : 1, true); + if (mode === 1) { + if (payloadSize !== 16 + length * 16) throw new Error("invalid paint diff payload"); + for (let i = 0; i < length; i++) { + const p = at + 16 + i * 16, kind = w.getUint32(p, true), a = w.getUint32(p + 4, true), b = w.getUint32(p + 8, true), flags = w.getUint32(p + 12, true); + if (kind > 2 || flags > 127 || kind !== 2 && a >= before.length || kind !== 0 && b >= after.length) throw new Error("invalid paint diff entry"); + let bounds: NscSurfaceRect | null = null; + if (kind === 0) { const n = before[a]!; bounds = nscvPaintClipped(c, before, a, nscvPaintUnion(c, nscvPaintFull(c, n, n.paint_transform), nscvPaintModal(c, n, previousRoot))); } + else if (kind === 2) { const n = after[b]!; bounds = nscvPaintClipped(c, after, b, nscvPaintUnion(c, nscvPaintFull(c, n, n.paint_transform), nscvPaintModal(c, n, nextRoot))); } + else { + const x = before[a]!, y = after[b]!; + if ((flags & 48) !== 0) bounds = nscvPaintUnion(c, nscvPaintUnion(c, nscvPaintSubtree(c, before, a), nscvPaintModal(c, x, previousRoot)), nscvPaintUnion(c, nscvPaintSubtree(c, after, b), nscvPaintModal(c, y, nextRoot))); + else { + const full = (): NscSurfaceRect | null => nscvPaintUnion(c, nscvPaintFull(c, x, x.paint_transform), nscvPaintFull(c, y, y.paint_transform)); + const raw = (flags & 8) !== 0 ? nscvPaintUnion(c, full(), nscvPaintUnion(c, previousRoot, nextRoot)) : (flags & 81) !== 0 ? full() : (flags & 2) !== 0 ? nscvPaintChanged(c, x, y, false) : null; + bounds = nscvPaintUnion(c, nscvPaintClipped(c, before, a, raw), nscvPaintClipped(c, after, b, raw)); + } + } + nscvRenderPutOptional(out, 8 + i * 20, bounds); + } + } else { + if (afterCount !== 0 || payloadSize < 32) throw new Error("invalid paint render shape"); + const g0 = w.getUint32(at + 8, true), g1 = w.getUint32(at + 12, true); + if (payloadSize !== 32 + length * 16 + (g0 + g1) * 4) throw new Error("invalid paint render payload"); + let bounds: NscSurfaceRect | null = null; + for (let i = 0; i < length; i++) { + const p = at + 32 + i * 16, slot = w.getUint32(p, true), flags = w.getUint32(p + 4, true), a = w.getUint32(p + 8, true), b = w.getUint32(p + 12, true); + if (slot >= before.length || flags > 3 || a > 1023 || b > 1023) throw new Error("invalid paint render entry"); + const n = before[slot]!; + if ((flags & 2) !== 0) bounds = nscvPaintUnion(c, bounds, nscvPaintClipped(c, before, slot, nscvPaintFull(c, n, nscvPaintAccumulated(before, slot)))); + if ((flags & 1) !== 0) bounds = nscvPaintUnion(c, bounds, nscvPaintClipped(c, before, slot, nscvPaintChanged(c, nscvPaintFramed(n, n.paint_frame, a), nscvPaintFramed(n, n.paint_frame, b), true))); + } + const groups = [g0, g1]; let position = at + 32 + length * 16; + for (let g = 0; g < 2; g++) { + let group: NscSurfaceRect | null = null; + for (let i = 0; i < groups[g]!; i++) { const slot = w.getUint32(position, true); position += 4; if (slot >= before.length || before[slot]!.paint_kind !== 60) throw new Error("invalid paint focus group"); const n = before[slot]!; group = nscvPaintUnion(c, group, nscvPaintClipped(c, before, slot, nscvPaintFocus(c, nscvPaintFramed(n, n.paint_frame, n.paint_state | 4)))); } + bounds = nscvPaintUnion(c, bounds, group); + } + nscvRenderPutOptional(out, 8, bounds); + } + return result; +} diff --git a/packages/core/test/view_frontend.test.ts b/packages/core/test/view_frontend.test.ts index 70a925b43..0b93e0c42 100644 --- a/packages/core/test/view_frontend.test.ts +++ b/packages/core/test/view_frontend.test.ts @@ -194,7 +194,7 @@ test("the view frontend and compiled portable component bundle typecheck", () => }; // Production joins these policies in one module before compiling the view. const bundlePath = new URL("../src/view_components_typecheck.ts", import.meta.url).pathname; - const bundle = ["view_components.ts", "runtime_policy.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "stream_policy.ts"] + const bundle = ["view_components.ts", "runtime_policy.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "widget_paint.ts", "stream_policy.ts"] .map(name => readFileSync(new URL(`../src/${name}`, import.meta.url), "utf8")).join("\n"); const host = ts.createCompilerHost(options); const readSource = host.getSourceFile.bind(host); diff --git a/packages/core/test/widget_paint.test.ts b/packages/core/test/widget_paint.test.ts new file mode 100644 index 000000000..7a1204f25 --- /dev/null +++ b/packages/core/test/widget_paint.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +const source = ["runtime_policy.ts", "control_appearance.ts", "widget_paint.ts"].map(p => readFileSync(new URL(`../src/${p}`, import.meta.url), "utf8")).join("\n"); +const { nscvWidgetPaint: paint } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport { nscvWidgetPaint };").toString("base64")}`); +const missing = 0xffffffff; +interface Node { kind?: number; parent?: number; depth?: number; flags?: number; size?: number; frame?: number[]; transform?: number[]; textWidth?: number; } +function packet(nodes: Node[], entries: number[][], options: { mode?: number; numeric?: number; tokens?: Record; before?: number } = {}) { + const mode = options.mode ?? 1, before = options.before ?? 0, payload = 16 + entries.length * 16; + const b = new Uint8Array(544 + nodes.length * 128 + payload), w = new DataView(b.buffer); + b.set([26, mode, 1, options.numeric ?? 0]); w.setUint32(4, before, true); w.setUint32(8, nodes.length - before, true); w.setUint32(12, payload, true); w.setUint32(16, 1, true); + const tokens = { 0: 2, 1: 2, 2: 1, 3: 1, 4: 1, 6: 1, 7: 14, 10: 2, 11: 2, 12: 4, 13: 2, 14: 12, 15: 12, ...options.tokens }; + for (const [slot, value] of Object.entries(tokens)) w.setFloat32(64 + Number(slot) * 4, value, true); + nodes.forEach((n, i) => { const at = 544 + i * 128; + w.setUint32(at, n.kind ?? 26, true); w.setUint32(at + 4, n.size ?? 1, true); w.setUint32(at + 12, n.parent ?? missing, true); w.setUint32(at + 16, n.depth ?? i, true); w.setUint32(at + 20, n.flags ?? 0, true); w.setUint32(at + 28, missing, true); + (n.frame ?? [0, 0, 100, 100]).forEach((v, j) => { w.setFloat32(at + 32 + j * 4, v, true); w.setFloat32(at + 48 + j * 4, v, true); }); + (n.transform ?? [1, 0, 0, 1, 0, 0]).forEach((v, j) => w.setFloat32(at + 64 + j * 4, v, true)); w.setFloat32(at + 116, n.textWidth ?? 0, true); + }); + const at = 544 + nodes.length * 128; b[at] = 1; w.setUint32(at + 4, entries.length, true); entries.forEach((e, i) => e.forEach((v, j) => w.setUint32(at + 16 + i * 16 + j * 4, v, true))); + return b; +} +function bounds(result: Uint8Array) { const w = new DataView(result.buffer, result.byteOffset, result.byteLength); return Array.from({ length: w.getUint32(4, true) }, (_, i) => w.getUint32(8 + i * 20, true) === 0 ? null : Array.from({ length: 4 }, (_, j) => w.getFloat32(12 + i * 20 + j * 4, true))); } +test("damage obeys ancestor clipping, hoisted surfaces, and hidden ancestry", () => { + const nodes = [{ frame: [0, 0, 50, 50], flags: 2 }, { parent: 0, frame: [40, 40, 30, 30] }], entry = [[2, missing, 1, 7]]; + assert.deepEqual(bounds(paint(packet(nodes, entry))), [[40, 40, 10, 10]]); + nodes[1]!.flags = 4; assert.deepEqual(bounds(paint(packet(nodes, entry))), [[40, 40, 30, 30]]); + nodes[0]!.flags |= 1; assert.deepEqual(bounds(paint(packet(nodes, entry))), [null]); +}); +test("bubble reactions reserve the complete out-of-frame ring", () => { + assert.deepEqual(bounds(paint(packet([{ kind: 15, flags: 16 | (2 << 9), textWidth: 10 }], [[2, missing, 0, 7]]))), [[-0.5, -0.5, 101, 119.625]]); +}); +test("measurement requests follow plan encounter order and skip unchanged paint", () => { + const nodes = [{ kind: 15, flags: 16, size: 0 }, { kind: 15, flags: 16, size: 2 }, { kind: 15, flags: 16 }, { kind: 15, flags: 16 }]; + const request = packet(nodes, [[1, 0, 0, 7], [1, 1, 1, 7]], { before: 2, mode: 0 }), result = paint(request), w = new DataView(result.buffer, result.byteOffset, result.byteLength); + assert.equal(w.getUint32(4, true), 4); + assert.deepEqual(Array.from({ length: 4 }, (_, i) => [w.getUint32(8 + i * 8, true), w.getFloat32(12 + i * 8, true)]), [[0, 13], [2, 14], [1, 15], [3, 14]]); + assert.equal(new DataView(paint(packet(nodes, [[1, 0, 0, 2]], { before: 2, mode: 0 })).buffer).getUint32(4, true), 0); +}); +test("malformed geometry wires fail before yielding partial output", () => { + const valid = packet([{}], [[2, missing, 0, 7]]); + for (const at of [2, 20, 536, 544 + 124]) { const bad = valid.slice(); bad[at] = 255; assert.throws(() => paint(bad), /invalid/); } + assert.throws(() => paint(valid.subarray(0, valid.length - 1)), /invalid/); + assert.throws(() => paint(packet([{ parent: 0 }], [[2, missing, 0, 7]])), /cyclic/); +}); diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 0faa15314..40b77c10e 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -901,6 +901,7 @@ pub const pseudo_locale_text_expansion = @import("layout_audit.zig").pseudo_loca /// below) is adopted by the example suites like the layout audit's. pub const widget_routing_policy = @import("widget_routing_policy.zig"); pub const widget_change_policy = @import("widget_change_policy.zig"); +pub const widget_paint_policy = @import("widget_paint_policy.zig"); pub const widget_audit_policy = @import("widget_audit_policy.zig"); pub const a11y = @import("a11y_audit.zig"); pub const expectA11yAuditSweepClean = a11y.expectA11yAuditSweepClean; diff --git a/src/primitives/canvas/tokens.zig b/src/primitives/canvas/tokens.zig index 460cc0bde..fef775575 100644 --- a/src/primitives/canvas/tokens.zig +++ b/src/primitives/canvas/tokens.zig @@ -1918,6 +1918,8 @@ pub const DesignTokens = struct { widget_routing_policy: ?*const fn ([]const u8, []u8) usize = null, /// Portable retained widget matching and change classification. widget_change_policy: ?*const fn ([]const u8, []u8) usize = null, + /// Portable retained damage geometry; native supplies font measurements. + widget_paint_policy: ?*const fn ([]const u8, []u8) usize = null, code_content_policy: ?*const fn ([]const u8, []u8) usize = null, pub fn theme(options: ThemeOptions) DesignTokens { diff --git a/src/primitives/canvas/widget_invalidation.zig b/src/primitives/canvas/widget_invalidation.zig index 1ab50c4e6..f4b9cbf87 100644 --- a/src/primitives/canvas/widget_invalidation.zig +++ b/src/primitives/canvas/widget_invalidation.zig @@ -15,6 +15,7 @@ const widget_render_controls = @import("widget_render_controls.zig"); const textSpansEqual = @import("text_spans.zig").textSpansEqual; const chartDataEqual = @import("chart.zig").chartDataEqual; const change_policy = @import("widget_change_policy.zig"); +const paint_policy = @import("widget_paint_policy.zig"); const Error = canvas.Error; const ObjectId = canvas.ObjectId; @@ -96,7 +97,7 @@ fn widgetWithRenderState(widget: Widget, state: WidgetRenderState) Widget { pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTokens, output: []WidgetInvalidation) Error![]const WidgetInvalidation { const previous_root_bounds = widget_render.widgetLayoutRootBounds(previous); const next_root_bounds = widget_render.widgetLayoutRootBounds(next); - if (tokens.widget_change_policy orelse change_policy.owner(next) orelse change_policy.owner(previous)) |policy| + if (tokens.widget_change_policy orelse tokens.widget_paint_policy orelse change_policy.owner(next) orelse change_policy.owner(previous) orelse paint_policy.owner(next) orelse paint_policy.owner(previous)) |policy| return diffWithPolicy(previous, next, tokens, output, policy, previous_root_bounds, next_root_bounds); const root_bounds_dirty = !optionalRectsEqual(previous_root_bounds, next_root_bounds); // Id lookups ride the probe-table index whenever the trees are big @@ -201,15 +202,29 @@ pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTok return output[0..len]; } -/// Renderer geometry remains native-owned. The selected portable owner sends -/// complete change flags and ordered source references, never a native verdict. +/// Change and paint owners exchange complete copied plans. The independent +/// native geometry remains the reference for layouts without paint ownership. fn diffWithPolicy(previous: anytype, next: anytype, tokens: DesignTokens, output: []WidgetInvalidation, policy: change_policy.Policy, previous_root: ?geometry.RectF, next_root: ?geometry.RectF) Error![]const WidgetInvalidation { const plan = change_policy.Plan.init(std.heap.page_allocator, policy, previous, next, previous_root, next_root, output.len) catch @panic("widget change allocation"); defer plan.deinit(); + const geometry_owner = tokens.widget_paint_policy orelse paint_policy.owner(next) orelse paint_policy.owner(previous); + // The reference evaluates the overflowing entry's geometry before its + // append fails. Keep those font capability calls, but never write that + // entry into the caller's complete capacity-error prefix. + const overflow: ?change_policy.Plan = if (geometry_owner != null and plan.status == 2) + change_policy.Plan.init(std.heap.page_allocator, policy, previous, next, previous_root, next_root, output.len + 1) catch @panic("widget paint overflow allocation") + else + null; + defer if (overflow) |extra| extra.deinit(); + if (overflow) |extra| { + if (extra.length != plan.length + 1 or !std.mem.eql(u8, plan.result[16 .. 16 + plan.length * 16], extra.result[16 .. 16 + plan.length * 16])) @panic("inconsistent compiled paint overflow prefix"); + } + const damage: ?paint_policy.Plan = if (geometry_owner) |owner| paint_policy.Plan.diff(std.heap.page_allocator, owner, previous, next, tokens, previous_root, next_root, overflow orelse plan) catch @panic("widget paint allocation") else null; + defer if (damage) |owned| owned.deinit(); for (0..plan.length) |i| { const entry = plan.entry(i); const flags = entry.flags; - const bounds: ?geometry.RectF = switch (entry.kind) { + const bounds: ?geometry.RectF = if (damage) |owned| owned.bounds(i) else switch (entry.kind) { .removed => blk: { const p = entry.previous.?; break :blk widgetClippedDirtyBounds(previous, p, unionOptionalBounds(widgetFullPaintBounds(previous.nodes[p], tokens), widgetModalScrimBounds(previous, previous.nodes[p].widget, tokens))); @@ -417,7 +432,7 @@ fn widgetChange( } pub fn widgetRenderStateDirtyBounds(layout: anytype, previous: WidgetRenderState, next: WidgetRenderState, tokens: DesignTokens) ?geometry.RectF { - if (tokens.widget_change_policy orelse change_policy.owner(layout)) |policy| + if (tokens.widget_change_policy orelse tokens.widget_paint_policy orelse change_policy.owner(layout) orelse paint_policy.owner(layout)) |policy| return renderStateWithPolicy(layout, previous, next, tokens, policy); var ids: [8]?ObjectId = [_]?ObjectId{null} ** 8; var id_len: usize = 0; @@ -525,27 +540,32 @@ fn widgetWithProjectedState(base: Widget, bits: u32) Widget { fn renderStateWithPolicy(layout: anytype, previous: WidgetRenderState, next: WidgetRenderState, tokens: DesignTokens, policy: change_policy.Policy) ?geometry.RectF { const plan = change_policy.RenderPlan.init(std.heap.page_allocator, policy, layout, previous, next) catch @panic("render change allocation"); defer plan.deinit(); - var bounds: ?geometry.RectF = null; - for (0..plan.length) |i| { - const item = plan.entry(i); - const index = item.node_index; - const node = layout.nodes[index]; - if (item.terminal) bounds = unionOptionalBounds(bounds, widgetClippedDirtyBounds(layout, index, widgetFullPaintBoundsWithTransform(node, widgetAccumulatedTransform(layout, index), tokens))); - if (item.state) { - const base = widgetWithFrame(node.widget, node.frame); - bounds = unionOptionalBounds(bounds, widgetClippedDirtyBounds(layout, index, widgetRenderStatePaintChangeBounds(widgetWithProjectedState(base, item.previous), widgetWithProjectedState(base, item.next), tokens))); - } - } - for (0..2) |g| { - var group_bounds: ?geometry.RectF = null; - for (0..plan.group_lengths[g]) |i| { - const index = plan.groupIndex(g, i); + const geometry_owner = tokens.widget_paint_policy orelse paint_policy.owner(layout); + const damage: ?paint_policy.Plan = if (geometry_owner) |owner| paint_policy.Plan.render(std.heap.page_allocator, owner, layout, tokens, plan) catch @panic("render paint allocation") else null; + defer if (damage) |owned| owned.deinit(); + var bounds: ?geometry.RectF = if (damage) |owned| owned.bounds(0) else null; + if (damage == null) { + for (0..plan.length) |i| { + const item = plan.entry(i); + const index = item.node_index; const node = layout.nodes[index]; - var focused = widgetWithFrame(node.widget, node.frame); - focused.state.focused = true; - group_bounds = unionOptionalBounds(group_bounds, widgetClippedDirtyBounds(layout, index, widgetFocusPaintBounds(focused, tokens))); + if (item.terminal) bounds = unionOptionalBounds(bounds, widgetClippedDirtyBounds(layout, index, widgetFullPaintBoundsWithTransform(node, widgetAccumulatedTransform(layout, index), tokens))); + if (item.state) { + const base = widgetWithFrame(node.widget, node.frame); + bounds = unionOptionalBounds(bounds, widgetClippedDirtyBounds(layout, index, widgetRenderStatePaintChangeBounds(widgetWithProjectedState(base, item.previous), widgetWithProjectedState(base, item.next), tokens))); + } + } + for (0..2) |g| { + var group_bounds: ?geometry.RectF = null; + for (0..plan.group_lengths[g]) |i| { + const index = plan.groupIndex(g, i); + const node = layout.nodes[index]; + var focused = widgetWithFrame(node.widget, node.frame); + focused.state.focused = true; + group_bounds = unionOptionalBounds(group_bounds, widgetClippedDirtyBounds(layout, index, widgetFocusPaintBounds(focused, tokens))); + } + bounds = unionOptionalBounds(bounds, group_bounds); } - bounds = unionOptionalBounds(bounds, group_bounds); } if (plan.chart) { bounds = unionOptionalBounds(bounds, widget_render.chartHoverDetailDirtyBounds(layout, previous, tokens)); diff --git a/src/primitives/canvas/widget_paint_policy.zig b/src/primitives/canvas/widget_paint_policy.zig new file mode 100644 index 000000000..93639e665 --- /dev/null +++ b/src/primitives/canvas/widget_paint_policy.zig @@ -0,0 +1,165 @@ +//! Copied paint boundary. Portable code owns all damage geometry; native code +//! supplies requested font measurements and caller-owned result storage. +const std = @import("std"); +const geometry = @import("geometry"); +const widgets = @import("widgets.zig"); +const tokens_model = @import("tokens.zig"); +const events = @import("events.zig"); +const changes = @import("widget_change_policy.zig"); +const text_metrics = @import("text_metrics.zig"); +pub const Policy = changes.Policy; +const missing = std.math.maxInt(u32); +const header_size = 544; +const node_size = 128; +const tables = .{ "button_default", "button_primary", "button_secondary", "button_outline", "button_ghost", "button_destructive", "toggle_button", "accordion", "alert", "bubble", "card", "dialog", "drawer", "sheet", "select", "input", "text_field", "search_field", "combobox", "textarea", "list_item", "menu_item", "data_cell", "tabs", "segmented_control", "button_group", "checkbox", "radio", "switch_control", "slider", "progress", "scrollbar", "panel", "resizable", "popover", "menu_surface", "dropdown_menu", "tooltip", "avatar", "badge", "separator", "skeleton", "spinner" }; +const sizes = [_]widgets.WidgetSize{ .sm, .default, .lg, .icon, .heading, .display }; +pub fn owner(layout: anytype) ?Policy { + const T = switch (@typeInfo(@TypeOf(layout))) { + .pointer => |p| p.child, + else => @TypeOf(layout), + }; + if (comptime @hasField(T, "paint_policy")) return layout.paint_policy; + return null; +} +fn word(bytes: []u8, at: usize, value: usize) void { + std.mem.writeInt(u32, bytes[at..][0..4], std.math.cast(u32, value) orelse @panic("paint wire capacity"), .little); +} +fn read(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} +fn float(bytes: []u8, at: usize, value: f32) void { + word(bytes, at, @as(u32, @bitCast(value))); +} +fn readFloat(bytes: []const u8, at: usize) f32 { + return @bitCast(read(bytes, at)); +} +fn rect(bytes: []u8, at: usize, value: geometry.RectF) void { + inline for (.{ "x", "y", "width", "height" }, 0..) |name, i| float(bytes, at + i * 4, @field(value, name)); +} +fn optional(bytes: []const u8, at: usize) ?geometry.RectF { + if (read(bytes, at) == 0) return null; + return .init(readFloat(bytes, at + 4), readFloat(bytes, at + 8), readFloat(bytes, at + 12), readFloat(bytes, at + 16)); +} +fn sizeCode(size: widgets.WidgetSize) usize { + for (sizes, 0..) |candidate, i| if (size == candidate) return i; + unreachable; +} +fn writeNode(bytes: []u8, node: events.WidgetLayoutNode) void { + const w = node.widget; + word(bytes, 0, widgets.widgetKindCode(w.kind)); + word(bytes, 4, sizeCode(w.size)); + word(bytes, 8, @intFromEnum(w.variant)); + word(bytes, 12, if (node.parent_index) |p| std.math.cast(u32, p) orelse missing else missing); + word(bytes, 16, @as(u32, @truncate(node.depth))); + word(bytes, 120, @as(u64, node.depth) >> 32); + const alignment: u32 = switch (w.text_alignment) { + .start => 0, + .center => 1, + .end => 2, + }; + const flags = @as(u32, @intFromBool(w.semantics.hidden)) | (@as(u32, @intFromBool(w.layout.clip_content)) << 1) | + (@as(u32, @intFromBool(w.layout.anchor != null)) << 2) | (@as(u32, @intFromBool(w.scrim)) << 3) | + (@as(u32, @intFromBool(w.text.len > 0)) << 4) | (@as(u32, @intFromBool(w.icon.len > 0)) << 5) | + (@as(u32, @intFromBool(w.style.border != null)) << 6) | (@as(u32, @intFromBool(w.style.stroke_width != null)) << 7) | + (@as(u32, @intFromBool(w.group_segment != .none)) << 8) | (alignment << 9); + word(bytes, 20, flags); + word(bytes, 24, changes.stateBits(w.state)); + word(bytes, 28, if (w.backdrop_blur_token) |t| @intFromEnum(t) else missing); + rect(bytes, 32, node.frame); + rect(bytes, 48, w.frame); + inline for (.{ "a", "b", "c", "d", "tx", "ty" }, 0..) |name, i| float(bytes, 64 + i * 4, @field(w.transform, name)); + float(bytes, 88, w.value); + float(bytes, 92, w.style.stroke_width orelse 0); + if (w.style.border) |border| inline for (.{ "r", "g", "b", "a" }, 0..) |name, i| float(bytes, 96 + i * 4, @field(border, name)); + float(bytes, 112, w.backdrop_blur); +} +fn writeTokens(bytes: []u8, tokens: tokens_model.DesignTokens) void { + const values = [_]f32{ + tokens.stroke.focus, tokens.stroke.focus_offset, tokens.stroke.regular, tokens.stroke.hairline, + @floatFromInt(@intFromEnum(tokens.density)), @floatFromInt(@intFromBool(tokens.pixel_snap.geometry)), tokens.pixel_snap.scale, tokens.typography.label_size, + tokens.metrics.tabs_label_size_step, tokens.metrics.tabs_trigger_inset, tokens.metrics.size_inset_step, tokens.metrics.icon_text_step, + tokens.metrics.button_icon_gap, tokens.metrics.tabs_indicator_thickness, tokens.metrics.slider_thumb_width, tokens.metrics.slider_thumb_height, + tokens.shadow.sm.y, tokens.shadow.sm.blur, tokens.shadow.sm.spread, tokens.shadow.md.y, + tokens.shadow.md.blur, tokens.shadow.md.spread, tokens.blur.none, tokens.blur.sm, + tokens.blur.md, tokens.colors.scrim.a, @floatFromInt(@intFromBool(tokens.controls.button_group_style == .detached)), @floatFromInt(@intFromBool(tokens.controls.tabs_indicator == .underline)), + tokens.blur.scrim, 0, 0, 0, + }; + for (values, 0..) |v, i| float(bytes, 64 + i * 4, v); + inline for (tables, 0..) |name, i| { + const value = @field(tokens.controls, name).stroke_width; + word(bytes, 192 + i * 8, @intFromBool(value != null)); + float(bytes, 196 + i * 8, value orelse 0); + } +} +pub const Plan = struct { + allocator: std.mem.Allocator, + request: []u8, + result: []u8, + length: usize, + pub fn diff(allocator: std.mem.Allocator, policy: Policy, previous: anytype, next: anytype, tokens: tokens_model.DesignTokens, previous_root: ?geometry.RectF, next_root: ?geometry.RectF, change: changes.Plan) std.mem.Allocator.Error!Plan { + return init(allocator, policy, previous.nodes, next.nodes, tokens, previous_root, next_root, 1, change.result[0 .. 16 + change.length * 16], change.length); + } + pub fn render(allocator: std.mem.Allocator, policy: Policy, layout: anytype, tokens: tokens_model.DesignTokens, change: changes.RenderPlan) std.mem.Allocator.Error!Plan { + return init(allocator, policy, layout.nodes, &.{}, tokens, null, null, 2, change.result[0 .. 32 + change.length * 16 + (change.group_lengths[0] + change.group_lengths[1]) * 4], 1); + } + fn init(allocator: std.mem.Allocator, policy: Policy, previous: []const events.WidgetLayoutNode, next: []const events.WidgetLayoutNode, tokens: tokens_model.DesignTokens, previous_root: ?geometry.RectF, next_root: ?geometry.RectF, mode: u8, payload: []const u8, length: usize) std.mem.Allocator.Error!Plan { + const total = std.math.add(usize, previous.len, next.len) catch @panic("paint node capacity"); + const base_size = std.math.add(usize, header_size, std.math.mul(usize, total, node_size) catch @panic("paint byte capacity")) catch @panic("paint byte capacity"); + const request = try allocator.alloc(u8, std.math.add(usize, base_size, payload.len) catch @panic("paint payload capacity")); + errdefer allocator.free(request); + @memset(request, 0); + request[0..4].* = .{ 26, 0, 1, @import("render_plan_policy.zig").numericFlags() }; + word(request, 4, previous.len); + word(request, 8, next.len); + word(request, 12, payload.len); + word(request, 16, mode); + word(request, 28, @as(u32, @intFromBool(previous_root != null)) | (@as(u32, @intFromBool(next_root != null)) << 1)); + if (previous_root) |r| rect(request, 32, r); + if (next_root) |r| rect(request, 48, r); + writeTokens(request, tokens); + for (previous, 0..) |n, i| writeNode(request[header_size + i * node_size ..][0..node_size], n); + for (next, 0..) |n, i| writeNode(request[header_size + (previous.len + i) * node_size ..][0..node_size], n); + const measurements = try allocator.alloc(u8, std.math.add(usize, 8, std.math.mul(usize, total, 8) catch @panic("paint measurement capacity")) catch @panic("paint measurement capacity")); + defer allocator.free(measurements); + @memset(measurements, 0xa5); + @memcpy(request[base_size..], payload); + const measured = policy(request, measurements); + if (measured < 8 or measured > measurements.len or !std.mem.eql(u8, measurements[0..4], &.{ 1, 0, 0, 0 })) @panic("invalid compiled paint measurement header"); + const measure_count = read(measurements, 4); + if (measure_count > total or measured != 8 + @as(usize, measure_count) * 8) @panic("invalid compiled paint measurement count"); + // Validate the entire request list before calling the native capability. + for (0..measure_count) |i| { + const slot = read(measurements, 8 + i * 8); + if (slot >= total) @panic("invalid compiled paint measurement slot"); + for (0..i) |earlier| if (read(measurements, 8 + earlier * 8) == slot) @panic("duplicate compiled paint measurement slot"); + } + for (0..measure_count) |i| { + const slot = read(measurements, 8 + i * 8); + const node = if (slot < previous.len) previous[slot] else next[slot - previous.len]; + const size = readFloat(measurements, 12 + i * 8); + const width = text_metrics.measureTextWidthForFont(tokens.text_measure, tokens.typography.font_id, node.widget.text, size); + float(request, header_size + @as(usize, slot) * node_size + 116, width); + } + request[1] = mode; + word(request, 12, payload.len); + @memcpy(request[base_size..], payload); + const result = try allocator.alloc(u8, std.math.add(usize, 8, std.math.mul(usize, length, 20) catch @panic("paint result capacity")) catch @panic("paint result capacity")); + errdefer allocator.free(result); + @memset(result, 0xa5); + const written = policy(request, result); + if (written != result.len or !std.mem.eql(u8, result[0..4], &.{ 1, mode, 0, 0 }) or read(result, 4) != length) @panic("invalid compiled paint result header"); + for (0..length) |i| { + const at = 8 + i * 20; + if (read(result, at) > 1) @panic("invalid compiled paint result presence"); + if (read(result, at) == 0 and !std.mem.allEqual(u8, result[at + 4 ..][0..16], 0)) @panic("invalid compiled absent paint bounds"); + } + return .{ .allocator = allocator, .request = request, .result = result, .length = length }; + } + pub fn bounds(self: Plan, slot: usize) ?geometry.RectF { + return optional(self.result, 8 + slot * 20); + } + pub fn deinit(self: Plan) void { + self.allocator.free(self.request); + self.allocator.free(self.result); + } +}; diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index 104161063..3b3c94547 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -73,6 +73,7 @@ pub const WidgetLayoutTree = struct { routing_policy: ?@import("widget_routing_policy.zig").Policy = null, /// Explicit retained-change owner, independent of input routing. change_policy: ?@import("widget_change_policy.zig").Policy = null, + paint_policy: ?@import("widget_paint_policy.zig").Policy = null, pub fn nodeCount(self: WidgetLayoutTree) usize { return self.nodes.len; @@ -226,7 +227,7 @@ pub fn layoutWidgetTreeWithTokens(widget: Widget, bounds: geometry.RectF, tokens const root_bounds = bounds.normalized(); var len: usize = 0; _ = try widget_layout.layoutWidgetDepth(widget, root_bounds, null, 0, output, &len, tokens); - return .{ .nodes = output[0..len], .root_bounds = root_bounds, .semantic_policy = tokens.intrinsic_layout_policy, .audit_policy = tokens.widget_audit_policy, .routing_policy = tokens.widget_routing_policy, .change_policy = tokens.widget_change_policy }; + return .{ .nodes = output[0..len], .root_bounds = root_bounds, .semantic_policy = tokens.intrinsic_layout_policy, .audit_policy = tokens.widget_audit_policy, .routing_policy = tokens.widget_routing_policy, .change_policy = tokens.widget_change_policy, .paint_policy = tokens.widget_paint_policy }; } pub fn intrinsicWidgetSize(widget: Widget, tokens: DesignTokens) geometry.SizeF { diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 9411fd6ad..30b2204d4 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -468,6 +468,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF stamped.widget_routing_policy = core.nativeWindowPolicy; if (options.widget_change_policy != null) @panic("TsUiApp owns widget_change_policy - remove custom change wiring"); stamped.widget_change_policy = core.nativeWindowPolicy; + if (options.widget_paint_policy != null) @panic("TsUiApp owns widget_paint_policy - remove custom paint wiring"); + stamped.widget_paint_policy = core.nativeWindowPolicy; } if (comptime @hasDecl(core, "nativeEffectPolicy")) { if (options.app_dispatch_policy != null) @panic("TsUiApp owns app_dispatch_policy - remove custom dispatch wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 0e64d36c5..feff83045 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -529,6 +529,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe widget_audit_policy: ?*const fn ([]const u8, []u8) usize = null, widget_routing_policy: ?*const fn ([]const u8, []u8) usize = null, widget_change_policy: ?*const fn ([]const u8, []u8) usize = null, + widget_paint_policy: ?*const fn ([]const u8, []u8) usize = null, /// Which built-in theme pack the stock tokens resolve when /// the app claims neither `tokens` nor `tokens_fn`: the /// pack composes with the live system appearance (scheme, @@ -2022,6 +2023,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.widget_audit_policy = self.options.widget_audit_policy; tokens.widget_routing_policy = self.options.widget_routing_policy; tokens.widget_change_policy = self.options.widget_change_policy; + tokens.widget_paint_policy = self.options.widget_paint_policy; tokens.code_content_policy = self.options.code_content_policy; return tokens; } @@ -2076,6 +2078,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.widget_audit_policy = self.options.widget_audit_policy; tokens.widget_routing_policy = self.options.widget_routing_policy; tokens.widget_change_policy = self.options.widget_change_policy; + tokens.widget_paint_policy = self.options.widget_paint_policy; tokens.code_content_policy = self.options.code_content_policy; return tokens; } diff --git a/src/runtime/view_widget_tree.zig b/src/runtime/view_widget_tree.zig index f0853bb58..9f26bbcdf 100644 --- a/src/runtime/view_widget_tree.zig +++ b/src/runtime/view_widget_tree.zig @@ -103,6 +103,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type { .audit_policy = self.widget_tokens.widget_audit_policy, .routing_policy = self.widget_tokens.widget_routing_policy, .change_policy = self.widget_tokens.widget_change_policy, + .paint_policy = self.widget_tokens.widget_paint_policy, }; } diff --git a/tests/ts-core/typography_e2e_tests.zig b/tests/ts-core/typography_e2e_tests.zig index b3de469fb..66ee9f698 100644 --- a/tests/ts-core/typography_e2e_tests.zig +++ b/tests/ts-core/typography_e2e_tests.zig @@ -35,6 +35,7 @@ test "compiled model themes copy the complete register across frame and commit o reference.widget_audit_policy = core.nativeWindowPolicy; reference.widget_routing_policy = core.nativeWindowPolicy; reference.widget_change_policy = core.nativeWindowPolicy; + reference.widget_paint_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; try std.testing.expectEqualDeep(reference, snapshot); const large = app.model.large; @@ -89,6 +90,7 @@ fn restamp(reference: *canvas.DesignTokens, actual: canvas.DesignTokens) void { reference.widget_audit_policy = core.nativeWindowPolicy; reference.widget_routing_policy = core.nativeWindowPolicy; reference.widget_change_policy = core.nativeWindowPolicy; + reference.widget_paint_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; } test "compiled themes retain forced scheme, accessibility and complete-register precedence" { diff --git a/tests/ts-core/widget_changes_e2e_tests.zig b/tests/ts-core/widget_changes_e2e_tests.zig index 5e852e666..2955baafa 100644 --- a/tests/ts-core/widget_changes_e2e_tests.zig +++ b/tests/ts-core/widget_changes_e2e_tests.zig @@ -12,6 +12,7 @@ fn node(widget: canvas.Widget, frame: sdk.geometry.RectF, parent: ?usize, depth: fn owned(reference: Tree) Tree { var result = reference; result.change_policy = core.nativeWindowPolicy; + result.paint_policy = core.nativeWindowPolicy; return result; } fn compare(previous: Tree, next: Tree, tokens: canvas.DesignTokens, capacity: usize) !void { @@ -194,6 +195,12 @@ test "compiled widget changes preserve full view budgets reorder keyed zero iden for ([_]usize{ 0, 1, 31, 32, 33, 512, 1024 }) |capacity| try compare(.{ .nodes = &before }, .{ .nodes = &after }, .{}, capacity); var plan = try canvas.widget_change_policy.Plan.init(std.testing.allocator, core.nativeWindowPolicy, Tree{ .nodes = &before }, Tree{ .nodes = &after }, null, null, 2048); defer plan.deinit(); + const paint = try canvas.widget_paint_policy.Plan.diff(std.testing.allocator, core.nativeWindowPolicy, Tree{ .nodes = &before }, Tree{ .nodes = &after }, .{}, null, null, plan); + defer paint.deinit(); + const paint_request = try std.testing.allocator.dupe(u8, paint.request); + defer std.testing.allocator.free(paint_request); + const paint_result = try std.testing.allocator.dupe(u8, paint.result); + defer std.testing.allocator.free(paint_result); const request = try std.testing.allocator.dupe(u8, plan.request); defer std.testing.allocator.free(request); const result = try std.testing.allocator.dupe(u8, plan.result); @@ -203,6 +210,8 @@ test "compiled widget changes preserve full view budgets reorder keyed zero iden core.rt.frameReset(); try std.testing.expectEqualSlices(u8, request, plan.request); try std.testing.expectEqualSlices(u8, result, plan.result); + try std.testing.expectEqualSlices(u8, paint_request, paint.request); + try std.testing.expectEqualSlices(u8, paint_result, paint.result); } } @@ -237,3 +246,127 @@ test "compiled widget changes preserve complete render state damage with focus g core.rt.frameReset(); } } + +test "compiled widget changes paint geometry preserves all chrome sizes densities token overrides and snapping" { + _ = core.initialModel(); + for (std.enums.values(canvas.WidgetKind)) |kind| for (std.enums.values(canvas.WidgetSize)) |size| for (std.enums.values(canvas.Density)) |density| for (0..8) |flags| { + var tokens: canvas.DesignTokens = .{ .density = density }; + tokens.pixel_snap = .{ .geometry = flags & 1 != 0, .scale = if (flags & 2 != 0) 1.5 else 2.25 }; + tokens.controls.tabs_indicator = if (flags & 4 != 0) .underline else .pill; + tokens.controls.button_group_style = if (flags & 4 != 0) .detached else .segmented; + tokens.controls.panel.stroke_width = 3.125; + tokens.controls.button_outline.stroke_width = 0.625; + tokens.controls.toggle_button.stroke_width = 4.25; + tokens.controls.checkbox.stroke_width = -0.0; + tokens.stroke.focus = 2.75; + tokens.stroke.focus_offset = 1.375; + tokens.metrics.size_inset_step = 3.625; + tokens.metrics.tabs_trigger_inset = 7.75; + tokens.metrics.tabs_label_size_step = 2.125; + tokens.shadow.sm = .{ .y = -2.625, .blur = 3.375, .spread = -1.125 }; + tokens.shadow.md = .{ .y = 4.125, .blur = 6.625, .spread = -2.875 }; + var before = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .row, .id = 1, .layout = .{ .clip_content = true } }, .init(-20.5, -30.25, 260.75, 230.5), null, 0), + node(.{ .kind = kind, .size = size, .id = 0xfedcba9876543210, .text = "raw\xff\x00label", .icon = "check", .frame = .init(-4.75, -6.5, 73.375, 34.125), .variant = .outline, .group_segment = .first, .backdrop_blur_token = .sm, .state = .{ .focused = true } }, .init(-3.625, -5.25, 74.125, 33.375), 0, 1), + node(.{ .kind = .bubble, .id = 3, .text = "reaction", .text_alignment = .center }, .init(5.125, 23.625, 60.75, 25.5), 1, 2), + }; + var after = before; + after[1].frame = .init(-7.25, 10.75, 90.375, 43.125); + after[1].widget.transform = .{ .a = 0.875, .b = 0.25, .c = -0.125, .d = 1.125, .tx = -0.0, .ty = 5.375 }; + after[1].widget.style.stroke_width = if (flags & 2 != 0) -1 else 4.125; + after[1].widget.text_alignment = if (flags & 2 != 0) .start else .end; + errdefer std.debug.print("paint geometry kind {t} size {t} density {t} flags {d}\n", .{ kind, size, density, flags }); + try compare(.{ .nodes = &before }, .{ .nodes = &after }, tokens, 4); + const layout: Tree = .{ .nodes = &before }; + try exact(layout.renderStateDirtyBoundsWithTokens(.{}, .{ .hovered_id = before[1].widget.id, .focus_visible_id = before[1].widget.id }, tokens), owned(layout).renderStateDirtyBoundsWithTokens(.{}, .{ .hovered_id = before[1].widget.id, .focus_visible_id = before[1].widget.id }, tokens)); + core.rt.frameReset(); + }; +} + +test "compiled widget changes paint measurement requests preserve raw text font sizes and callback order" { + _ = core.initialModel(); + const Provider = struct { + const Call = struct { font: canvas.FontId, size: f32, text: [32]u8, length: usize }; + calls: std.ArrayList(Call) = .empty, + fn width(context: ?*anyopaque, font: canvas.FontId, size: f32, text: []const u8) f32 { + const self: *@This() = @ptrCast(@alignCast(context.?)); + var bytes: [32]u8 = @splat(0); + @memcpy(bytes[0..text.len], text); + self.calls.append(std.testing.allocator, .{ .font = font, .size = size, .text = bytes, .length = text.len }) catch @panic("measurement log allocation"); + return @as(f32, @floatFromInt(text.len)) * size * 0.713; + } + }; + var state = Provider{}; + defer state.calls.deinit(std.testing.allocator); + const provider = canvas.TextMeasureProvider{ .context = &state, .measure_fn = Provider.width }; + var tokens: canvas.DesignTokens = .{ .text_measure = &provider }; + tokens.controls.tabs_indicator = .underline; + const before = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .bubble, .id = 1, .text = "before\xff\x00", .size = .sm, .text_alignment = .end }, .init(0, 0, 30, 20), null, 0), + node(.{ .kind = .segmented_control, .id = 2, .text = "tab-before", .icon = "check", .size = .lg }, .init(0, 50, 70, 30), null, 0), + }; + var after = before; + after[0].widget.text = "after\x00\xff"; + after[0].frame.height = 31.75; + after[1].widget.text = "tab-after"; + after[1].frame.width = 100.25; + for (0..4) |capacity| { + var a: [4]canvas.WidgetInvalidation = undefined; + var b: [4]canvas.WidgetInvalidation = undefined; + state.calls.clearRetainingCapacity(); + canvas.bumpTextMeasureGeneration(); + const expected = Tree.diffWithTokens(.{ .nodes = &before }, .{ .nodes = &after }, tokens, a[0..capacity]); + const calls = try std.testing.allocator.dupe(Provider.Call, state.calls.items); + defer std.testing.allocator.free(calls); + state.calls.clearRetainingCapacity(); + canvas.bumpTextMeasureGeneration(); + const actual = Tree.diffWithTokens(owned(.{ .nodes = &before }), owned(.{ .nodes = &after }), tokens, b[0..capacity]); + if (expected) |v| try exact(v, try actual) else |err| try std.testing.expectError(err, actual); + try exact(@as([]const Provider.Call, calls), state.calls.items); + core.rt.frameReset(); + } +} + +test "compiled widget changes paint geometry preserves bounded transforms hidden ancestors and escaped clips" { + _ = core.initialModel(); + var before: [40]canvas.WidgetLayoutNode = undefined; + for (&before, 0..) |*n, i| n.* = node(.{ .kind = .panel, .id = i + 1, .frame = .init(0, 0, 100, 100), .transform = .{ .a = 0.9375, .d = 1.0625, .tx = 1.125, .ty = -0.375 }, .layout = .{ .clip_content = i % 3 == 0 } }, .init(@floatFromInt(i), 0, 100, 100), if (i == 0) null else i - 1, i); + for (0..8) |flags| { + var after = before; + after[0].widget.opacity = 0.5; + after[33].widget.semantics.hidden = flags & 1 != 0; + after[35].widget.layout.anchor = if (flags & 2 != 0) .{} else null; + after[36].widget.kind = if (flags & 4 != 0) .dialog else .panel; + try compare(.{ .nodes = &before }, .{ .nodes = &after }, .{}, 40); + } +} + +test "compiled widget changes paint geometry preserves exceptional scalar words and signed zero extrema" { + _ = core.initialModel(); + const words = [_]u32{ 0, 0x80000000, 0x3f400000, 0xbf400000, 0x7f800000, 0xff800000, 0x7fc00037 }; + for ([_]canvas.WidgetKind{ .button, .checkbox, .slider, .panel, .bubble }) |kind| for (words) |a| for (words) |b| { + var before = [_]canvas.WidgetLayoutNode{node(.{ .kind = kind, .id = 1, .frame = .init(-0.0, 0, 100, 32), .value = @bitCast(a), .style = .{ .stroke_width = @bitCast(a) } }, .init(-0.0, 0, 100, 32), null, 0)}; + var after = before; + after[0].widget.state.focused = true; + after[0].widget.value = @bitCast(b); + after[0].widget.style.stroke_width = @bitCast(b); + var tokens: canvas.DesignTokens = .{}; + tokens.stroke.focus = @bitCast(a); + tokens.stroke.focus_offset = @bitCast(b); + errdefer std.debug.print("paint scalar words {t} {x}/{x}\n", .{ kind, a, b }); + try compare(.{ .nodes = &before }, .{ .nodes = &after }, tokens, 2); + }; +} + +test "compiled widget changes paint subtree comparisons preserve full width depth words" { + _ = core.initialModel(); + var before = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .panel, .id = 1 }, .init(0, 0, 100, 100), null, std.math.maxInt(usize) - 2), + node(.{ .kind = .bubble, .id = 2, .text = "wide" }, .init(5, 5, 30, 20), 0, std.math.maxInt(usize) - 1), + node(.{ .kind = .button, .id = 3 }, .init(5, 30, 20, 20), 0, std.math.maxInt(usize)), + }; + var after = before; + after[0].widget.opacity = 0.5; + after[1].widget.semantics.hidden = true; + try compare(.{ .nodes = &before }, .{ .nodes = &after }, .{}, 4); +} From c52a44036f65eb0cf3222403d614b801571de1d4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 7 Oct 2026 01:06:51 -0500 Subject: [PATCH 2/2] Preserve full-width paint ancestry - Distinguish absent parents from present invalid indices across the paint ABI. - Cover wide parent words and hoisted escape against the native reference. --- packages/core/src/widget_paint.ts | 22 ++++++++++--------- packages/core/test/widget_paint.test.ts | 4 ++-- src/primitives/canvas/widget_paint_policy.zig | 7 +++--- tests/ts-core/widget_changes_e2e_tests.zig | 16 ++++++++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/core/src/widget_paint.ts b/packages/core/src/widget_paint.ts index 27aec1dc3..c714c1f58 100644 --- a/packages/core/src/widget_paint.ts +++ b/packages/core/src/widget_paint.ts @@ -3,7 +3,7 @@ * Every intermediate follows the reference's f32 evaluation order. */ interface NscPaintNode { readonly paint_kind: number; readonly paint_size: number; readonly paint_variant: number; - readonly paint_parent: number; readonly paint_depth: number; readonly paint_depth_high: number; readonly paint_flags: number; + readonly paint_parent: number; readonly paint_parent_high: number; readonly paint_depth: number; readonly paint_depth_high: number; readonly paint_flags: number; readonly paint_state: number; readonly paint_blur_token: number; readonly paint_frame: NscSurfaceRect; readonly paint_authored_frame: NscSurfaceRect; readonly paint_transform: NscRenderAffine; readonly paint_value: number; readonly paint_stroke: number; readonly paint_border: readonly number[]; @@ -121,9 +121,10 @@ function nscvPaintFull(c: NscPaintContext, n: NscPaintNode, transform: NscRender } return nscvSurfaceNormalize(nscvRenderTransform(transform, bounds, c.paint_numeric)); } +function nscvPaintParent(n: NscPaintNode, count: number): number { return (n.paint_flags & 2048) === 0 ? 4294967295 : n.paint_parent_high !== 0 || n.paint_parent >= count ? count : n.paint_parent; } function nscvPaintHidden(nodes: readonly NscPaintNode[], slot: number): boolean { let n = slot; - for (let guard = 0; n !== 4294967295 && guard <= nodes.length; guard++) { if (n >= nodes.length) return false; if ((nodes[n]!.paint_flags & 1) !== 0) return true; n = nodes[n]!.paint_parent; } + for (let guard = 0; n !== 4294967295 && guard <= nodes.length; guard++) { if (n >= nodes.length) return false; if ((nodes[n]!.paint_flags & 1) !== 0) return true; n = nscvPaintParent(nodes[n]!, nodes.length); } if (n !== 4294967295) throw new Error("cyclic paint ancestry"); return false; } @@ -133,17 +134,18 @@ function nscvPaintClipped(c: NscPaintContext, nodes: readonly NscPaintNode[], sl for (let guard = 0; guard <= nodes.length; guard++) { const n = nodes[current]!; if ((n.paint_flags & 4) !== 0 || [19, 20, 21].includes(n.paint_kind)) return bounds; - if (n.paint_parent === 4294967295) return bounds; - if (n.paint_parent >= nodes.length) return null; - const parent = nodes[n.paint_parent]!; + const parentSlot = nscvPaintParent(n, nodes.length); + if (parentSlot === 4294967295) return bounds; + if (parentSlot >= nodes.length) return null; + const parent = nodes[parentSlot]!; if (parent.paint_kind === 6 || (parent.paint_flags & 2) !== 0) { bounds = nscvRenderIntersection(bounds, nscvSurfaceNormalize(parent.paint_frame), c.paint_numeric); if (nscvRenderEmpty(bounds)) return null; } - current = n.paint_parent; + current = parentSlot; } throw new Error("cyclic paint clipping"); } function nscvPaintAccumulated(nodes: readonly NscPaintNode[], slot: number): NscRenderAffine { const slots: number[] = []; let current = slot; - while (current !== 4294967295 && current < nodes.length && slots.length < 32) { slots.push(current); current = nodes[current]!.paint_parent; } + while (current !== 4294967295 && current < nodes.length && slots.length < 32) { slots.push(current); current = nscvPaintParent(nodes[current]!, nodes.length); } let t: NscRenderAffine = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }; for (let i = slots.length - 1; i >= 0; i--) t = nscvRenderMultiply(t, nodes[slots[i]!]!.paint_transform); return t; @@ -178,7 +180,7 @@ function nscvPaintChanged(c: NscPaintContext, a: NscPaintNode, b: NscPaintNode, return bounds; } function nscvWidgetPaint(request: Uint8Array): Uint8Array { - if (request.length < 544 || request[0] !== 26 || request[1]! > 2 || request[2] !== 1 || request[3]! > 15) throw new Error("invalid widget paint header"); + if (request.length < 544 || request[0] !== 26 || request[1]! > 2 || request[2] !== 2 || request[3]! > 15) throw new Error("invalid widget paint header"); const w = new DataView(request.buffer, request.byteOffset, request.byteLength), mode = request[1]!, beforeCount = w.getUint32(4, true), afterCount = w.getUint32(8, true), payloadSize = w.getUint32(12, true), total = beforeCount + afterCount; if (request.length !== 544 + total * 128 + payloadSize || w.getUint32(16, true) < 1 || w.getUint32(16, true) > 2 || w.getUint32(20, true) !== 0 || w.getUint32(24, true) !== 0 || w.getUint32(28, true) > 3) throw new Error("invalid widget paint shape"); const tokens: number[] = [], strokes: (number | null)[] = [], nodes: NscPaintNode[] = []; @@ -188,9 +190,9 @@ function nscvWidgetPaint(request: Uint8Array): Uint8Array { for (let i = 536; i < 544; i++) if (request[i] !== 0) throw new Error("invalid widget paint padding"); for (let i = 0; i < total; i++) { const at = 544 + i * 128, kind = w.getUint32(at, true), size = w.getUint32(at + 4, true), variant = w.getUint32(at + 8, true), flags = w.getUint32(at + 20, true), state = w.getUint32(at + 24, true), blurToken = w.getUint32(at + 28, true); - if (kind > 62 || size > 5 || variant > 5 || flags > 2047 || ((flags >>> 9) & 3) > 2 || state > 1023 || blurToken !== 4294967295 && blurToken > 2 || w.getUint32(at + 124, true) !== 0) throw new Error("invalid widget paint node"); + if (kind > 62 || size > 5 || variant > 5 || flags > 4095 || ((flags >>> 9) & 3) > 2 || state > 1023 || blurToken !== 4294967295 && blurToken > 2 || (flags & 2048) === 0 && (w.getUint32(at + 12, true) !== 4294967295 || w.getUint32(at + 124, true) !== 0)) throw new Error("invalid widget paint node"); const border: number[] = []; for (let j = 0; j < 4; j++) border.push(w.getFloat32(at + 96 + j * 4, true)); - nodes.push({ paint_kind: kind, paint_size: size, paint_variant: variant, paint_parent: w.getUint32(at + 12, true), paint_depth: w.getUint32(at + 16, true), paint_depth_high: w.getUint32(at + 120, true), paint_flags: flags, paint_state: state, paint_blur_token: blurToken, paint_frame: nscvRenderRect(w, at + 32), paint_authored_frame: nscvRenderRect(w, at + 48), paint_transform: nscvRenderAffine(w, at + 64), paint_value: w.getFloat32(at + 88, true), paint_stroke: w.getFloat32(at + 92, true), paint_border: border, paint_blur: w.getFloat32(at + 112, true), paint_text_width: w.getFloat32(at + 116, true) }); + nodes.push({ paint_kind: kind, paint_size: size, paint_variant: variant, paint_parent: w.getUint32(at + 12, true), paint_parent_high: w.getUint32(at + 124, true), paint_depth: w.getUint32(at + 16, true), paint_depth_high: w.getUint32(at + 120, true), paint_flags: flags, paint_state: state, paint_blur_token: blurToken, paint_frame: nscvRenderRect(w, at + 32), paint_authored_frame: nscvRenderRect(w, at + 48), paint_transform: nscvRenderAffine(w, at + 64), paint_value: w.getFloat32(at + 88, true), paint_stroke: w.getFloat32(at + 92, true), paint_border: border, paint_blur: w.getFloat32(at + 112, true), paint_text_width: w.getFloat32(at + 116, true) }); } const c: NscPaintContext = { paint_numeric: request[3]!, paint_tokens: tokens, paint_strokes: strokes }; if (mode === 0) { diff --git a/packages/core/test/widget_paint.test.ts b/packages/core/test/widget_paint.test.ts index 7a1204f25..c91f573fc 100644 --- a/packages/core/test/widget_paint.test.ts +++ b/packages/core/test/widget_paint.test.ts @@ -9,11 +9,11 @@ interface Node { kind?: number; parent?: number; depth?: number; flags?: number; function packet(nodes: Node[], entries: number[][], options: { mode?: number; numeric?: number; tokens?: Record; before?: number } = {}) { const mode = options.mode ?? 1, before = options.before ?? 0, payload = 16 + entries.length * 16; const b = new Uint8Array(544 + nodes.length * 128 + payload), w = new DataView(b.buffer); - b.set([26, mode, 1, options.numeric ?? 0]); w.setUint32(4, before, true); w.setUint32(8, nodes.length - before, true); w.setUint32(12, payload, true); w.setUint32(16, 1, true); + b.set([26, mode, 2, options.numeric ?? 0]); w.setUint32(4, before, true); w.setUint32(8, nodes.length - before, true); w.setUint32(12, payload, true); w.setUint32(16, 1, true); const tokens = { 0: 2, 1: 2, 2: 1, 3: 1, 4: 1, 6: 1, 7: 14, 10: 2, 11: 2, 12: 4, 13: 2, 14: 12, 15: 12, ...options.tokens }; for (const [slot, value] of Object.entries(tokens)) w.setFloat32(64 + Number(slot) * 4, value, true); nodes.forEach((n, i) => { const at = 544 + i * 128; - w.setUint32(at, n.kind ?? 26, true); w.setUint32(at + 4, n.size ?? 1, true); w.setUint32(at + 12, n.parent ?? missing, true); w.setUint32(at + 16, n.depth ?? i, true); w.setUint32(at + 20, n.flags ?? 0, true); w.setUint32(at + 28, missing, true); + w.setUint32(at, n.kind ?? 26, true); w.setUint32(at + 4, n.size ?? 1, true); w.setUint32(at + 12, n.parent ?? missing, true); w.setUint32(at + 16, n.depth ?? i, true); w.setUint32(at + 20, (n.flags ?? 0) | (n.parent !== undefined && n.parent !== missing ? 2048 : 0), true); w.setUint32(at + 28, missing, true); (n.frame ?? [0, 0, 100, 100]).forEach((v, j) => { w.setFloat32(at + 32 + j * 4, v, true); w.setFloat32(at + 48 + j * 4, v, true); }); (n.transform ?? [1, 0, 0, 1, 0, 0]).forEach((v, j) => w.setFloat32(at + 64 + j * 4, v, true)); w.setFloat32(at + 116, n.textWidth ?? 0, true); }); diff --git a/src/primitives/canvas/widget_paint_policy.zig b/src/primitives/canvas/widget_paint_policy.zig index 93639e665..10b4debcc 100644 --- a/src/primitives/canvas/widget_paint_policy.zig +++ b/src/primitives/canvas/widget_paint_policy.zig @@ -49,7 +49,8 @@ fn writeNode(bytes: []u8, node: events.WidgetLayoutNode) void { word(bytes, 0, widgets.widgetKindCode(w.kind)); word(bytes, 4, sizeCode(w.size)); word(bytes, 8, @intFromEnum(w.variant)); - word(bytes, 12, if (node.parent_index) |p| std.math.cast(u32, p) orelse missing else missing); + word(bytes, 12, if (node.parent_index) |p| @as(u32, @truncate(p)) else missing); + word(bytes, 124, if (node.parent_index) |p| @as(u64, p) >> 32 else 0); word(bytes, 16, @as(u32, @truncate(node.depth))); word(bytes, 120, @as(u64, node.depth) >> 32); const alignment: u32 = switch (w.text_alignment) { @@ -61,7 +62,7 @@ fn writeNode(bytes: []u8, node: events.WidgetLayoutNode) void { (@as(u32, @intFromBool(w.layout.anchor != null)) << 2) | (@as(u32, @intFromBool(w.scrim)) << 3) | (@as(u32, @intFromBool(w.text.len > 0)) << 4) | (@as(u32, @intFromBool(w.icon.len > 0)) << 5) | (@as(u32, @intFromBool(w.style.border != null)) << 6) | (@as(u32, @intFromBool(w.style.stroke_width != null)) << 7) | - (@as(u32, @intFromBool(w.group_segment != .none)) << 8) | (alignment << 9); + (@as(u32, @intFromBool(w.group_segment != .none)) << 8) | (alignment << 9) | (@as(u32, @intFromBool(node.parent_index != null)) << 11); word(bytes, 20, flags); word(bytes, 24, changes.stateBits(w.state)); word(bytes, 28, if (w.backdrop_blur_token) |t| @intFromEnum(t) else missing); @@ -108,7 +109,7 @@ pub const Plan = struct { const request = try allocator.alloc(u8, std.math.add(usize, base_size, payload.len) catch @panic("paint payload capacity")); errdefer allocator.free(request); @memset(request, 0); - request[0..4].* = .{ 26, 0, 1, @import("render_plan_policy.zig").numericFlags() }; + request[0..4].* = .{ 26, 0, 2, @import("render_plan_policy.zig").numericFlags() }; word(request, 4, previous.len); word(request, 8, next.len); word(request, 12, payload.len); diff --git a/tests/ts-core/widget_changes_e2e_tests.zig b/tests/ts-core/widget_changes_e2e_tests.zig index 2955baafa..aa72fd3ef 100644 --- a/tests/ts-core/widget_changes_e2e_tests.zig +++ b/tests/ts-core/widget_changes_e2e_tests.zig @@ -370,3 +370,19 @@ test "compiled widget changes paint subtree comparisons preserve full width dept after[1].widget.semantics.hidden = true; try compare(.{ .nodes = &before }, .{ .nodes = &after }, .{}, 4); } + +test "compiled widget changes paint ancestry preserves present invalid parent words and hoisted escape" { + _ = core.initialModel(); + const parents = [_]usize{ std.math.maxInt(u32), if (@bitSizeOf(usize) > 32) 0x1_00000000 else std.math.maxInt(usize), std.math.maxInt(usize) }; + for (parents) |parent| for (0..4) |flags| { + var before = [_]canvas.WidgetLayoutNode{ + node(.{ .kind = .row, .id = 1 }, .init(0, 0, 100, 100), null, 0), + node(.{ .kind = if (flags & 1 != 0) .dialog else .button, .id = 2, .text = "invalid-parent", .layout = .{ .anchor = if (flags & 2 != 0) .{} else null } }, .init(5, 5, 40, 30), parent, 1), + }; + var after = before; + after[1].widget.opacity = 0.5; + try compare(.{ .nodes = &before }, .{ .nodes = &after }, .{}, 4); + try compare(.{ .nodes = &before }, .{}, .{}, 4); + try compare(.{}, .{ .nodes = &after }, .{}, 4); + }; +}