Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/view_frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
272 changes: 272 additions & 0 deletions packages/core/src/widget_paint.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/test/view_frontend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/widget_paint.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, number>; 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, 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) | (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);
});
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/);
});
1 change: 1 addition & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/primitives/canvas/tokens.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
68 changes: 44 additions & 24 deletions src/primitives/canvas/widget_invalidation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading