lib/gui/src/frame.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const flex_layout = @import("arrange");
4 const model = @import("model.zig");
5 const widget_query = @import("widget.zig");
6
7 const Allocator = std.mem.Allocator;
8 const RootFrame = model.RootFrame;
9 const UiNode = model.UiNode;
10 const UiSurfaceTree = model.UiSurfaceTree;
11 const UiScroll = model.UiScroll;
12 const UiPoint = model.UiPoint;
13 const UiTextSelection = model.UiTextSelection;
14 const UiFrame = model.UiFrame;
15 const WidgetFrame = model.WidgetFrame;
16 const nonNegativeFinite = model.nonNegativeFinite;
17
18 pub const max_depth = flex_layout.max_depth;
19
20 /// Semantic and ancestry data paired with one externally solved rectangle.
21 pub const SolvedBinding = struct {
22 node: *const UiNode,
23 /// Preorder span including this binding and all of its descendants.
24 subtree_size: usize,
25 };
26
27 /// A caller-owned preorder subtree solved in viewport-local coordinates.
28 /// All four spans have equal nonzero length. Clips are already effective.
29 /// Offsets translate rectangles only, leaving viewport-local clips anchored.
30 pub const SolvedSubtree = struct {
31 viewport_widget_id: u64,
32 bindings: []const SolvedBinding,
33 rectangles: []const flex_layout.Rect,
34 clips: []const flex_layout.Rect,
35 offsets: []const Offset,
36 };
37
38 pub const SurfaceFrameRoot = struct {
39 surface: *const UiSurfaceTree,
40 root_id: u64 = 1,
41 revision: u64 = 1,
42 hovered_widget: ?u64 = null,
43 hovered_point: ?UiPoint = null,
44 captured_widget: ?u64 = null,
45 focused_widget: ?u64 = null,
46 solved_subtrees: []const SolvedSubtree = &.{},
47 };
48
49 pub const SurfaceFrameOptions = struct {
50 root_id: u64 = 1,
51 revision: u64 = 1,
52 hovered_widget: ?u64 = null,
53 hovered_point: ?UiPoint = null,
54 captured_widget: ?u64 = null,
55 focused_widget: ?u64 = null,
56 resolvers: FrameResolvers = .{},
57 solved_subtrees: []const SolvedSubtree = &.{},
58 };
59
60 pub const FrameResolvers = struct {
61 context: ?*anyopaque = null,
62 scroll: ?*const fn (?*anyopaque, u64, *const UiNode, flex_layout.Size, flex_layout.Rect) UiScroll = null,
63 text_selection: ?*const fn (?*anyopaque, u64, *const UiNode) UiTextSelection = null,
64 text_size: ?*const fn (?*anyopaque, *const UiNode) anyerror!?flex_layout.Size = null,
65 };
66
67 pub const SurfaceFacts = struct {
68 nodes: usize = 0,
69 layout_nodes: usize = 0,
70 children: usize = 0,
71
72 pub fn add(self: SurfaceFacts, other: SurfaceFacts) SurfaceFacts {
73 return .{
74 .nodes = self.nodes + other.nodes,
75 .layout_nodes = self.layout_nodes + other.layout_nodes,
76 .children = self.children + other.children,
77 };
78 }
79 };
80
81 pub fn surveySurface(surface: *const UiSurfaceTree) error{SurfaceTooDeep}!SurfaceFacts {
82 var facts = SurfaceFacts{};
83 try surveyNode(&surface.root, &facts, 0);
84 return facts;
85 }
86
87 fn surveyNode(node: *const UiNode, facts: *SurfaceFacts, depth: usize) error{SurfaceTooDeep}!void {
88 if (depth >= max_depth) return error.SurfaceTooDeep;
89 facts.nodes += 1;
90 facts.layout_nodes += 1;
91 facts.children += node.children.len;
92 for (node.children) |*child| {
93 try surveyNode(child, facts, depth + 1);
94 }
95 }
96
97 /// Validates solved spans and derives exact shell-layout and frame demand.
98 pub fn surveyFrameRoot(root: SurfaceFrameRoot) !SurfaceFacts {
99 var facts = try surveySurface(root.surface);
100 try validateSolvedSubtrees(root.surface, root.solved_subtrees);
101 for (root.solved_subtrees) |subtree| {
102 facts.nodes = std.math.add(usize, facts.nodes, subtree.bindings.len) catch
103 return error.SurfaceTooLarge;
104 }
105 return facts;
106 }
107
108 fn validateSolvedSubtrees(surface: *const UiSurfaceTree, subtrees: []const SolvedSubtree) !void {
109 for (subtrees, 0..) |subtree, subtree_index| {
110 for (subtrees[0..subtree_index]) |earlier| {
111 if (earlier.viewport_widget_id == subtree.viewport_widget_id) {
112 return error.DuplicateSolvedViewport;
113 }
114 }
115 const viewport_depth = try opaqueViewportDepth(&surface.root, subtree.viewport_widget_id, 0);
116 const subtree_depth = try validateSolvedSubtree(subtree);
117 if (viewport_depth + 1 + subtree_depth >= max_depth) return error.SurfaceTooDeep;
118 }
119 }
120
121 fn opaqueViewportDepth(node: *const UiNode, widget_id: u64, depth: usize) !usize {
122 var match_depth: ?usize = null;
123 try findViewport(node, widget_id, depth, &match_depth);
124 return match_depth orelse error.SolvedViewportNotFound;
125 }
126
127 fn findViewport(node: *const UiNode, widget_id: u64, depth: usize, match_depth: *?usize) !void {
128 if (node.widget_id == widget_id) {
129 if (match_depth.* != null) return error.AmbiguousSolvedViewport;
130 if (node.children.len != 0) return error.SolvedViewportNotOpaque;
131 match_depth.* = depth;
132 }
133 for (node.children) |*child| try findViewport(child, widget_id, depth + 1, match_depth);
134 }
135
136 fn validateSolvedSubtree(subtree: SolvedSubtree) !usize {
137 const count = subtree.bindings.len;
138 if (count == 0 or
139 subtree.rectangles.len != count or
140 subtree.clips.len != count or
141 subtree.offsets.len != count)
142 {
143 return error.InvalidSolvedSpans;
144 }
145 if (subtree.bindings[0].subtree_size != count) return error.InvalidSolvedBindings;
146
147 var ancestor_ends: [max_depth]usize = undefined;
148 var ancestor_count: usize = 0;
149 var maximum_depth: usize = 0;
150 for (subtree.bindings, 0..) |binding, index| {
151 while (ancestor_count > 0 and index == ancestor_ends[ancestor_count - 1]) {
152 ancestor_count -= 1;
153 }
154 if (index != 0 and ancestor_count == 0) return error.InvalidSolvedBindings;
155 const subtree_end = std.math.add(usize, index, binding.subtree_size) catch
156 return error.InvalidSolvedBindings;
157 const parent_end = if (ancestor_count == 0) count else ancestor_ends[ancestor_count - 1];
158 if (binding.subtree_size == 0 or subtree_end > parent_end) return error.InvalidSolvedBindings;
159 maximum_depth = @max(maximum_depth, ancestor_count);
160 if (binding.subtree_size > 1) {
161 if (ancestor_count >= max_depth) return error.SurfaceTooDeep;
162 ancestor_ends[ancestor_count] = subtree_end;
163 ancestor_count += 1;
164 }
165 if (!validSolvedRect(subtree.rectangles[index]) or
166 !validSolvedRect(subtree.clips[index]) or
167 !validSolvedOffset(subtree.offsets[index]))
168 {
169 return error.InvalidSolvedGeometry;
170 }
171 }
172 while (ancestor_count > 0 and count == ancestor_ends[ancestor_count - 1]) {
173 ancestor_count -= 1;
174 }
175 if (ancestor_count != 0) return error.InvalidSolvedBindings;
176 return maximum_depth;
177 }
178
179 fn validSolvedRect(rect: flex_layout.Rect) bool {
180 return std.math.isFinite(rect.x) and
181 std.math.isFinite(rect.y) and
182 std.math.isFinite(rect.width) and
183 std.math.isFinite(rect.height) and
184 rect.width >= 0 and
185 rect.height >= 0;
186 }
187
188 fn validSolvedOffset(offset: Offset) bool {
189 return std.math.isFinite(offset.x) and std.math.isFinite(offset.y);
190 }
191
192 pub const FrameStorage = struct {
193 pub const claim: alloc_phase.capacity.Declaration = .{
194 .source = .{
195 .id = "gui.frame_storage",
196 .kind = .phase_static,
197 .limit_source = .caller,
198 .storage = .{
199 .covered = &.{
200 .{
201 .id = "root_frame_slots_at_the_admitted_root_quota",
202 .lifetime = .steady,
203 .detail = "root frame slots at the admitted root quota",
204 },
205 .{
206 .id = "widget_frame_slots_at_the_surveyed_shell_and_solved_node_count",
207 .lifetime = .steady,
208 .detail = "widget frame slots at the surveyed shell and solved node count",
209 },
210 .{
211 .id = "flex_node_spans_at_the_surveyed_child_count",
212 .lifetime = .steady,
213 .detail = "flex node spans at the surveyed child count",
214 },
215 .{
216 .id = "layout_node_state_spans_at_the_surveyed_shell_node_count",
217 .lifetime = .steady,
218 .detail = "layout node-state spans at the surveyed shell node count",
219 },
220 .{
221 .id = "layout_child_index_result_metric_and_line_spans",
222 .lifetime = .steady,
223 .detail = "layout child-index, result, metric, and line spans",
224 },
225 },
226 .excluded = &.{
227 "the caller-owned UiSurfaceTree, solved spans, and strings",
228 "resolver callbacks and any storage they touch",
229 "sdfii semantics byte ownership and root-id ordering scratch",
230 "paint command, atlas, and executor storage",
231 "epoch replacement allocation and release outside one admitted demand epoch",
232 },
233 },
234 .capacity = .{
235 .inputs = &.{
236 alloc_phase.capacity.bindInput(Limits, "roots", "roots"),
237 alloc_phase.capacity.bindInput(Limits, "nodes", "nodes"),
238 alloc_phase.capacity.bindInput(Limits, "children", "children"),
239 alloc_phase.capacity.bindInput(Limits, "layout_nodes", "layout_nodes"),
240 },
241 .type_selectors = &.{
242 alloc_phase.capacity.bindType(RootFrame, "root"),
243 alloc_phase.capacity.bindType(WidgetFrame, "widget"),
244 alloc_phase.capacity.bindType(flex_layout.NodeState, "state"),
245 alloc_phase.capacity.bindType(flex_layout.Node, "node"),
246 alloc_phase.capacity.bindType(flex_layout.LayoutResult, "result"),
247 alloc_phase.capacity.bindType(flex_layout.ChildIndex, "index"),
248 alloc_phase.capacity.bindType(flex_layout.ChildMetrics, "metric"),
249 alloc_phase.capacity.bindType(flex_layout.Line, "line"),
250 },
251 .nodes = &.{
252 .{ .input = 0 },
253 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } },
254 .{ .input = 1 },
255 .{ .constant = 1 },
256 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 1 } } },
257 .{ .product = .{ .left = 2, .right = 4 } },
258 .{ .input = 3 },
259 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 2 } } },
260 .{ .product = .{ .left = 6, .right = 7 } },
261 .{ .input = 2 },
262 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 3 } } },
263 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 4 } } },
264 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 5 } } },
265 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 6 } } },
266 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 7 } } },
267 .{ .add = .{ .left = 10, .right = 11 } },
268 .{ .add = .{ .left = 15, .right = 12 } },
269 .{ .add = .{ .left = 16, .right = 13 } },
270 .{ .add = .{ .left = 17, .right = 14 } },
271 .{ .product = .{ .left = 9, .right = 18 } },
272 .{ .add = .{ .left = 1, .right = 5 } },
273 .{ .add = .{ .left = 20, .right = 8 } },
274 .{ .add = .{ .left = 21, .right = 19 } },
275 },
276 .assertions = &.{.{
277 .scope = .closure_total,
278 .measure = .retained,
279 .relation = .exact,
280 .expression = 22,
281 }},
282 },
283 .overload = .{
284 .kind = .reject_before_seal,
285 .detail = "surveyFrameRoot rejects invalid or over-deep shell and solved trees before acquisition; demand past the admitted epoch re-derives storage at the new high water before building; carve past admitted capacity is a capacity-model bug caught by assertion",
286 },
287 .risks = .{
288 .transitive = .{
289 .status = .open,
290 .detail = "the build crosses layout, widget query, and model helpers without a machine-checked call-graph closure certificate",
291 },
292 .foreign = .{
293 .status = .open,
294 .detail = "resolver callbacks (text measurement, scroll, selection) run host code outside the claim during builds",
295 },
296 },
297 .obligations = &.{
298 .{ .key = "gui_frame_capacity", .role = .capacity_model },
299 .{ .key = "gui_frame_acquisition", .role = .custom },
300 .{ .key = "gui_frame_survey", .role = .overload },
301 .{ .key = "gui_frame_highwater", .role = .overload },
302 .{ .key = "gui_frame_steady_transitive_risk", .role = .transitive_risk },
303 .{ .key = "gui_frame_steady_foreign_risk", .role = .foreign_risk },
304 .{ .key = "gui_frame_oom", .role = .custom },
305 .{ .key = "gui_frame_session", .role = .custom },
306 .{ .key = "gui_frame_session_steady", .role = .transitive_risk },
307 },
308 },
309 .bindings = .{
310 .owner = @This(),
311 .seal = .{
312 .family = alloc_phase.capacity.selector(@This().activate),
313 .premise = .{
314 .class = .checked_semantic_fact,
315 .authority = .checker,
316 },
317 },
318 .teardown = .{
319 .family = alloc_phase.capacity.selector(@This().deinit),
320 .premise = .{
321 .class = .checked_semantic_fact,
322 .authority = .checker,
323 },
324 },
325 },
326 };
327 pub const Limits = struct {
328 nodes: usize,
329 layout_nodes: usize,
330 children: usize,
331 roots: usize,
332 };
333
334 pub const Capacity = struct {
335 roots: usize,
336 widgets: usize,
337 flex_nodes: usize,
338 layout_results: usize,
339 layout_metrics: usize,
340 layout_lines: usize,
341 layout_states: usize,
342 layout_child_indices: usize,
343 total_bytes: usize,
344
345 pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity {
346 if (limits.roots == 0) return error.CapacityOverflow;
347 if (limits.nodes == 0) return error.CapacityOverflow;
348 if (limits.layout_nodes == 0 or limits.layout_nodes > limits.nodes) return error.CapacityOverflow;
349 const root_bytes = std.math.mul(
350 usize,
351 limits.roots,
352 @sizeOf(RootFrame),
353 ) catch return error.CapacityOverflow;
354 const widget_bytes = std.math.mul(
355 usize,
356 limits.nodes,
357 @sizeOf(WidgetFrame),
358 ) catch return error.CapacityOverflow;
359 const state_bytes = std.math.mul(
360 usize,
361 limits.layout_nodes,
362 @sizeOf(flex_layout.NodeState),
363 ) catch return error.CapacityOverflow;
364 const per_child = @sizeOf(flex_layout.Node) +
365 @sizeOf(flex_layout.LayoutResult) +
366 @sizeOf(flex_layout.ChildIndex) +
367 @sizeOf(flex_layout.ChildMetrics) +
368 @sizeOf(flex_layout.Line);
369 const child_bytes = std.math.mul(
370 usize,
371 limits.children,
372 per_child,
373 ) catch return error.CapacityOverflow;
374 const frame_bytes = std.math.add(
375 usize,
376 root_bytes,
377 widget_bytes,
378 ) catch return error.CapacityOverflow;
379 const owned_node_bytes = std.math.add(
380 usize,
381 frame_bytes,
382 state_bytes,
383 ) catch return error.CapacityOverflow;
384 const total_bytes = std.math.add(
385 usize,
386 owned_node_bytes,
387 child_bytes,
388 ) catch return error.CapacityOverflow;
389 return .{
390 .roots = limits.roots,
391 .widgets = limits.nodes,
392 .flex_nodes = limits.children,
393 .layout_results = limits.children,
394 .layout_metrics = limits.children,
395 .layout_lines = limits.children,
396 .layout_states = limits.layout_nodes,
397 .layout_child_indices = limits.children,
398 .total_bytes = total_bytes,
399 };
400 }
401 };
402
403 phase: alloc_phase.capacity.Phase,
404 capacity: Capacity,
405 limits: Limits,
406 roots: []RootFrame,
407 roots_used: usize,
408 widgets: []WidgetFrame,
409 widgets_used: usize,
410 flex_nodes: []flex_layout.Node,
411 flex_used: usize,
412 layout_scratch: flex_layout.Scratch,
413
414 pub fn init(allocator: Allocator, limits: Limits) !FrameStorage {
415 const capacity = try Capacity.derive(limits);
416 const roots = try allocator.alloc(RootFrame, capacity.roots);
417 errdefer allocator.free(roots);
418 const widgets = try allocator.alloc(WidgetFrame, capacity.widgets);
419 errdefer allocator.free(widgets);
420 const flex_nodes = try allocator.alloc(flex_layout.Node, capacity.flex_nodes);
421 errdefer allocator.free(flex_nodes);
422 const results = try allocator.alloc(flex_layout.LayoutResult, capacity.layout_results);
423 errdefer allocator.free(results);
424 const metrics = try allocator.alloc(flex_layout.ChildMetrics, capacity.layout_metrics);
425 errdefer allocator.free(metrics);
426 const lines = try allocator.alloc(flex_layout.Line, capacity.layout_lines);
427 errdefer allocator.free(lines);
428 const states = try allocator.alloc(flex_layout.NodeState, capacity.layout_states);
429 errdefer allocator.free(states);
430 const child_indices = try allocator.alloc(
431 flex_layout.ChildIndex,
432 capacity.layout_child_indices,
433 );
434 return .{
435 .phase = .initialization,
436 .capacity = capacity,
437 .limits = limits,
438 .roots = roots,
439 .roots_used = 0,
440 .widgets = widgets,
441 .widgets_used = 0,
442 .flex_nodes = flex_nodes,
443 .flex_used = 0,
444 .layout_scratch = .{
445 .results = results,
446 .metrics = metrics,
447 .lines = lines,
448 .states = states,
449 .child_indices = child_indices,
450 },
451 };
452 }
453
454 pub fn activate(self: *FrameStorage) void {
455 std.debug.assert(self.phase == .initialization);
456 self.assertStorage();
457 self.phase = .steady;
458 }
459
460 pub fn deinit(self: *FrameStorage, allocator: Allocator) void {
461 std.debug.assert(self.phase != .teardown);
462 self.assertStorage();
463 self.phase = .teardown;
464 allocator.free(self.roots);
465 allocator.free(self.widgets);
466 allocator.free(self.flex_nodes);
467 allocator.free(self.layout_scratch.results);
468 allocator.free(self.layout_scratch.metrics);
469 allocator.free(self.layout_scratch.lines);
470 allocator.free(self.layout_scratch.states);
471 allocator.free(self.layout_scratch.child_indices);
472 }
473
474 pub fn admits(self: *const FrameStorage, facts: SurfaceFacts, roots: usize) bool {
475 std.debug.assert(self.phase == .steady);
476 if (facts.nodes > self.capacity.widgets) return false;
477 if (facts.layout_nodes > self.capacity.layout_states) return false;
478 if (facts.children > self.capacity.flex_nodes) return false;
479 return roots <= self.capacity.roots;
480 }
481
482 pub fn reset(self: *FrameStorage) void {
483 std.debug.assert(self.phase == .steady);
484 self.roots_used = 0;
485 self.widgets_used = 0;
486 self.flex_used = 0;
487 self.layout_scratch.reset();
488 }
489
490 fn carveFlexNodes(self: *FrameStorage, count: usize) []flex_layout.Node {
491 std.debug.assert(self.flex_used + count <= self.flex_nodes.len);
492 const span = self.flex_nodes[self.flex_used..][0..count];
493 self.flex_used += count;
494 return span;
495 }
496
497 fn assertStorage(self: *const FrameStorage) void {
498 const expected = Capacity.derive(self.limits) catch unreachable;
499 std.debug.assert(std.meta.eql(expected, self.capacity));
500 std.debug.assert(self.roots.len == self.capacity.roots);
501 std.debug.assert(self.widgets.len == self.capacity.widgets);
502 std.debug.assert(self.flex_nodes.len == self.capacity.flex_nodes);
503 std.debug.assert(self.layout_scratch.results.len == self.capacity.layout_results);
504 std.debug.assert(self.layout_scratch.metrics.len == self.capacity.layout_metrics);
505 std.debug.assert(self.layout_scratch.lines.len == self.capacity.layout_lines);
506 std.debug.assert(self.layout_scratch.states.len == self.capacity.layout_states);
507 std.debug.assert(
508 self.layout_scratch.child_indices.len == self.capacity.layout_child_indices,
509 );
510 }
511 };
512
513 comptime {
514 alloc_phase.capacity.requireAllocatorExactOwnerShape(FrameStorage);
515 }
516
517 fn modelFrameCapacity(limits: FrameStorage.Limits) error{CapacityOverflow}!FrameStorage.Capacity {
518 if (limits.roots == 0) return error.CapacityOverflow;
519 if (limits.nodes == 0) return error.CapacityOverflow;
520 if (limits.layout_nodes == 0 or limits.layout_nodes > limits.nodes) return error.CapacityOverflow;
521 const per_child: u128 = @sizeOf(flex_layout.Node) +
522 @sizeOf(flex_layout.LayoutResult) +
523 @sizeOf(flex_layout.ChildIndex) +
524 @sizeOf(flex_layout.ChildMetrics) +
525 @sizeOf(flex_layout.Line);
526 const total = @as(u128, limits.roots) * @sizeOf(RootFrame) +
527 @as(u128, limits.nodes) * @sizeOf(WidgetFrame) +
528 @as(u128, limits.layout_nodes) * @sizeOf(flex_layout.NodeState) +
529 @as(u128, limits.children) * per_child;
530 if (total > std.math.maxInt(usize)) return error.CapacityOverflow;
531 return .{
532 .roots = limits.roots,
533 .widgets = limits.nodes,
534 .flex_nodes = limits.children,
535 .layout_results = limits.children,
536 .layout_metrics = limits.children,
537 .layout_lines = limits.children,
538 .layout_states = limits.layout_nodes,
539 .layout_child_indices = limits.children,
540 .total_bytes = @intCast(total),
541 };
542 }
543
544 comptime {
545 const worst = FrameStorage.Limits{
546 .nodes = std.math.maxInt(u32),
547 .layout_nodes = std.math.maxInt(u32),
548 .children = std.math.maxInt(u32),
549 .roots = std.math.maxInt(u16),
550 };
551 _ = modelFrameCapacity(worst) catch @compileError("frame capacity exceeds the target word size");
552 }
553
554 pub const Workspace = struct {
555 allocator: Allocator,
556 storage: ?FrameStorage = null,
557
558 pub fn init(allocator: Allocator) Workspace {
559 return .{ .allocator = allocator };
560 }
561
562 pub fn deinit(self: *Workspace) void {
563 if (self.storage) |*storage| storage.deinit(self.allocator);
564 self.* = undefined;
565 }
566
567 pub fn buildSurface(self: *Workspace, surface: *const UiSurfaceTree, options: SurfaceFrameOptions) !UiFrame {
568 const root = SurfaceFrameRoot{
569 .surface = surface,
570 .root_id = options.root_id,
571 .revision = options.revision,
572 .hovered_widget = options.hovered_widget,
573 .hovered_point = options.hovered_point,
574 .captured_widget = options.captured_widget,
575 .focused_widget = options.focused_widget,
576 .solved_subtrees = options.solved_subtrees,
577 };
578 const facts = try surveyFrameRoot(root);
579 const storage = try self.ensureStorage(facts, 1);
580 storage.reset();
581
582 appendSurfaceFrame(
583 storage,
584 root,
585 options.resolvers,
586 ) catch |err| {
587 storage.reset();
588 return err;
589 };
590
591 return .{
592 .revision = options.revision,
593 .root_count = storage.roots_used,
594 .roots = storage.roots[0..storage.roots_used],
595 .widget_count = storage.widgets_used,
596 .widgets = storage.widgets[0..storage.widgets_used],
597 };
598 }
599
600 fn ensureStorage(self: *Workspace, facts: SurfaceFacts, roots: usize) !*FrameStorage {
601 if (self.storage) |*storage| {
602 if (storage.admits(facts, roots)) return storage;
603 }
604 const grown = FrameStorage.Limits{
605 .nodes = @max(facts.nodes, if (self.storage) |s| s.capacity.widgets else 0),
606 .layout_nodes = @max(facts.layout_nodes, if (self.storage) |s| s.capacity.layout_states else 0),
607 .children = @max(facts.children, if (self.storage) |s| s.capacity.flex_nodes else 0),
608 .roots = @max(roots, if (self.storage) |s| s.capacity.roots else 0),
609 };
610 var next = try FrameStorage.init(self.allocator, grown);
611 next.activate();
612 if (self.storage) |*storage| storage.deinit(self.allocator);
613 self.storage = next;
614 return &self.storage.?;
615 }
616 };
617
618 pub const Session = struct {
619 allocator: Allocator,
620 front: ?FrameStorage = null,
621 back: ?FrameStorage = null,
622
623 pub fn init(allocator: Allocator) Session {
624 return .{ .allocator = allocator };
625 }
626
627 pub fn deinit(self: *Session) void {
628 if (self.front) |*storage| storage.deinit(self.allocator);
629 if (self.back) |*storage| storage.deinit(self.allocator);
630 self.* = undefined;
631 }
632
633 pub fn begin(self: *Session, facts: SurfaceFacts, roots: usize) !*FrameStorage {
634 const admitted_roots = @max(roots, 1);
635 if (self.back) |*storage| {
636 if (storage.admits(facts, admitted_roots)) {
637 storage.reset();
638 return storage;
639 }
640 }
641 const grown = FrameStorage.Limits{
642 .nodes = @max(facts.nodes, if (self.back) |s| s.capacity.widgets else 0),
643 .layout_nodes = @max(facts.layout_nodes, if (self.back) |s| s.capacity.layout_states else 0),
644 .children = @max(facts.children, if (self.back) |s| s.capacity.flex_nodes else 0),
645 .roots = @max(admitted_roots, if (self.back) |s| s.capacity.roots else 0),
646 };
647 var next = try FrameStorage.init(self.allocator, grown);
648 next.activate();
649 if (self.back) |*storage| storage.deinit(self.allocator);
650 self.back = next;
651 return &self.back.?;
652 }
653
654 pub fn append(self: *Session, root: SurfaceFrameRoot, resolvers: FrameResolvers) !void {
655 std.debug.assert(self.back != null);
656 try appendSurfaceFrame(&self.back.?, root, resolvers);
657 }
658
659 pub fn stagedWidgets(self: *Session) []WidgetFrame {
660 std.debug.assert(self.back != null);
661 const storage = &self.back.?;
662 return storage.widgets[0..storage.widgets_used];
663 }
664
665 pub fn publish(self: *Session, revision: u64) UiFrame {
666 std.debug.assert(self.back != null);
667 std.mem.swap(?FrameStorage, &self.front, &self.back);
668 const storage = &self.front.?;
669 return .{
670 .revision = revision,
671 .root_count = storage.roots_used,
672 .roots = storage.roots[0..storage.roots_used],
673 .widget_count = storage.widgets_used,
674 .widgets = storage.widgets[0..storage.widgets_used],
675 };
676 }
677 };
678
679 pub fn appendSurfaceFrame(
680 storage: *FrameStorage,
681 root: SurfaceFrameRoot,
682 resolvers: FrameResolvers,
683 ) !void {
684 std.debug.assert(storage.phase == .steady);
685 const facts = try surveyFrameRoot(root);
686 if (storage.roots_used >= storage.roots.len or
687 facts.nodes > storage.widgets.len - storage.widgets_used or
688 facts.children > storage.flex_nodes.len - storage.flex_used or
689 facts.layout_nodes > storage.layout_scratch.states.len or
690 facts.children > storage.layout_scratch.results.len or
691 facts.children > storage.layout_scratch.metrics.len or
692 facts.children > storage.layout_scratch.lines.len or
693 facts.children > storage.layout_scratch.child_indices.len)
694 {
695 return error.FrameStorageCapacity;
696 }
697 const widget_start = storage.widgets_used;
698 const flex_start = storage.flex_used;
699 const root_start = storage.roots_used;
700 const scratch_marks = storage.layout_scratch;
701 errdefer {
702 storage.widgets_used = widget_start;
703 storage.flex_used = flex_start;
704 storage.roots_used = root_start;
705 storage.layout_scratch.results_used = scratch_marks.results_used;
706 storage.layout_scratch.metrics_used = scratch_marks.metrics_used;
707 storage.layout_scratch.lines_used = scratch_marks.lines_used;
708 }
709
710 const flex_root = try buildFlexNodeWithResolvers(storage, &root.surface.root, resolvers, 0);
711 const layout = flex_layout.computeLayout(&storage.layout_scratch, flex_root, root.surface.available_size);
712 std.debug.assert(storage.roots_used < storage.roots.len);
713 const root_index = storage.roots_used;
714 storage.roots[root_index] = .{
715 .root_id = root.root_id,
716 .surface_revision = root.revision,
717 .rect = snapRect(layout.rect),
718 .widget_start = widget_start,
719 .widget_count = 0,
720 };
721 storage.roots_used += 1;
722 appendWidgetFrames(
723 storage,
724 root,
725 resolvers,
726 &root.surface.root,
727 &layout,
728 .{},
729 null,
730 0,
731 0,
732 );
733 if (root.hovered_widget == null) markHoveredPoint(storage.widgets[widget_start..storage.widgets_used], root.root_id, root.hovered_point);
734 storage.roots[root_index].widget_count = storage.widgets_used - widget_start;
735 }
736
737 fn markHoveredPoint(widgets: []WidgetFrame, root_id: u64, hovered_point: ?UiPoint) void {
738 const point = hovered_point orelse return;
739 const hovered = widget_query.hitTestActivatable(widgets, root_id, point.x, point.y) orelse
740 widget_query.hitTest(widgets, root_id, point.x, point.y) orelse
741 return;
742 const index = widget_query.findIndexById(widgets, root_id, hovered.widget_id) orelse return;
743 widgets[index].hovered = true;
744 }
745
746 fn buildFlexNodeWithResolvers(
747 storage: *FrameStorage,
748 node: *const UiNode,
749 resolvers: FrameResolvers,
750 depth: usize,
751 ) !flex_layout.Node {
752 std.debug.assert(depth < max_depth);
753 const children = storage.carveFlexNodes(node.children.len);
754 for (node.children, 0..) |*child, index| {
755 children[index] = try buildFlexNodeWithResolvers(storage, child, resolvers, depth + 1);
756 }
757 const intrinsic_size = if (resolvers.text_size) |resolver| blk: {
758 const measured = try resolver(resolvers.context, node);
759 break :blk measured orelse flex_layout.Size{};
760 } else flex_layout.Size{};
761 return .{
762 .id = std.math.cast(usize, node.widget_id) orelse 0,
763 .style = node.style,
764 .size = node.size,
765 .intrinsic_size = intrinsic_size,
766 .clip_x = node.scroll.overflow_x != .visible,
767 .clip_y = node.scroll.overflow_y != .visible,
768 .children = children,
769 };
770 }
771
772 fn appendWidgetFrames(
773 storage: *FrameStorage,
774 root: SurfaceFrameRoot,
775 resolvers: FrameResolvers,
776 node: *const UiNode,
777 layout: *const flex_layout.LayoutResult,
778 offset: Offset,
779 active_clip: ?flex_layout.Rect,
780 parent_layer: u8,
781 depth: usize,
782 ) void {
783 std.debug.assert(depth < max_depth);
784 const layer = @max(parent_layer, node.layer);
785 const frame_rect = snapRect(offsetRect(layout.rect, offset.x, offset.y));
786 const visible_rect = if (active_clip) |clip| intersectRects(frame_rect, clip) else frame_rect;
787 const content_size_value = contentSize(layout);
788 const scroll = resolveScroll(resolvers, root.root_id, node, content_size_value, frame_rect);
789 const text_selection = resolveTextSelection(resolvers, root.root_id, node);
790 const frame_text = node.text;
791 const has_text = if (frame_text) |text| text.content.len != 0 else false;
792 const text_ref = if (has_text) makeTextRef(root.root_id, node.widget_id) else 0;
793 const widget_index = storage.widgets_used;
794 std.debug.assert(widget_index < storage.widgets.len);
795
796 storage.widgets[widget_index] = .{
797 .root_id = root.root_id,
798 .widget_id = node.widget_id,
799 .kind = node.kind,
800 .rect = frame_rect,
801 .visible_rect = visible_rect,
802 .paint = node.paint,
803 .scroll = scroll,
804 .constraints = node.style.constraints,
805 .content_size = content_size_value,
806 .focusable = (node.focusable or node.kind == .button or node.kind == .text_input) and !widget_query.isDisabledNode(node),
807 .layer = layer,
808 .hovered = root.hovered_widget == node.widget_id,
809 .captured = root.captured_widget == node.widget_id,
810 .focused = root.focused_widget == node.widget_id,
811 .has_text = has_text,
812 .action = node.action,
813 .role = node.role,
814 .state_flags = node.state_flags,
815 .text_ref = text_ref,
816 .text = frame_text,
817 .text_selection = text_selection,
818 };
819 storage.widgets_used += 1;
820
821 const child_offset = Offset{
822 .x = offset.x - scroll.scroll_x,
823 .y = offset.y - scroll.scroll_y,
824 };
825 const next_clip = childClip(active_clip, frame_rect, scroll);
826 if (solvedSubtreeForViewport(root.solved_subtrees, node.widget_id)) |subtree| {
827 _ = appendSolvedNode(
828 storage,
829 root,
830 resolvers,
831 subtree,
832 0,
833 frame_rect,
834 visible_rect,
835 layer,
836 );
837 }
838 for (node.children, 0..) |*child_node, index| {
839 appendWidgetFrames(
840 storage,
841 root,
842 resolvers,
843 child_node,
844 &layout.children[index],
845 child_offset,
846 next_clip,
847 layer,
848 depth + 1,
849 );
850 }
851 storage.widgets[widget_index].subtree_size = storage.widgets_used - widget_index;
852 }
853
854 fn solvedSubtreeForViewport(subtrees: []const SolvedSubtree, widget_id: u64) ?*const SolvedSubtree {
855 for (subtrees) |*subtree| {
856 if (subtree.viewport_widget_id == widget_id) return subtree;
857 }
858 return null;
859 }
860
861 fn appendSolvedNode(
862 storage: *FrameStorage,
863 root: SurfaceFrameRoot,
864 resolvers: FrameResolvers,
865 subtree: *const SolvedSubtree,
866 binding_index: usize,
867 viewport_rect: flex_layout.Rect,
868 viewport_clip: flex_layout.Rect,
869 parent_layer: u8,
870 ) usize {
871 const binding = subtree.bindings[binding_index];
872 const node = binding.node;
873 const layer = @max(parent_layer, node.layer);
874 const local_rect = subtree.rectangles[binding_index];
875 const node_offset = subtree.offsets[binding_index];
876 const frame_rect = snapRect(offsetRect(
877 local_rect,
878 viewport_rect.x + node_offset.x,
879 viewport_rect.y + node_offset.y,
880 ));
881 const solved_clip = offsetRect(
882 subtree.clips[binding_index],
883 viewport_rect.x,
884 viewport_rect.y,
885 );
886 const visible_rect = intersectRects(frame_rect, intersectRects(viewport_clip, solved_clip));
887 const content_size_value = solvedContentSize(subtree, binding_index);
888 const scroll = resolveScroll(resolvers, root.root_id, node, content_size_value, frame_rect);
889 const text_selection = resolveTextSelection(resolvers, root.root_id, node);
890 const frame_text = node.text;
891 const has_text = if (frame_text) |value| value.content.len != 0 else false;
892 const text_ref = if (has_text) makeTextRef(root.root_id, node.widget_id) else 0;
893 const widget_index = storage.widgets_used;
894 std.debug.assert(widget_index < storage.widgets.len);
895 storage.widgets[widget_index] = .{
896 .root_id = root.root_id,
897 .widget_id = node.widget_id,
898 .kind = node.kind,
899 .rect = frame_rect,
900 .visible_rect = visible_rect,
901 .paint = node.paint,
902 .scroll = scroll,
903 .constraints = node.style.constraints,
904 .content_size = content_size_value,
905 .focusable = (node.focusable or node.kind == .button or node.kind == .text_input) and !widget_query.isDisabledNode(node),
906 .layer = layer,
907 .hovered = root.hovered_widget == node.widget_id,
908 .captured = root.captured_widget == node.widget_id,
909 .focused = root.focused_widget == node.widget_id,
910 .has_text = has_text,
911 .action = node.action,
912 .role = node.role,
913 .state_flags = node.state_flags,
914 .text_ref = text_ref,
915 .text = frame_text,
916 .text_selection = text_selection,
917 .subtree_size = binding.subtree_size,
918 };
919 storage.widgets_used += 1;
920
921 var child_index = binding_index + 1;
922 const subtree_end = binding_index + binding.subtree_size;
923 while (child_index < subtree_end) {
924 child_index = appendSolvedNode(
925 storage,
926 root,
927 resolvers,
928 subtree,
929 child_index,
930 viewport_rect,
931 viewport_clip,
932 layer,
933 );
934 }
935 return subtree_end;
936 }
937
938 fn solvedContentSize(subtree: *const SolvedSubtree, binding_index: usize) flex_layout.Size {
939 const rect = subtree.rectangles[binding_index];
940 var right = rect.x + rect.width;
941 var bottom = rect.y + rect.height;
942 const subtree_end = binding_index + subtree.bindings[binding_index].subtree_size;
943 for (subtree.rectangles[binding_index + 1 .. subtree_end]) |descendant| {
944 right = @max(right, descendant.x + descendant.width);
945 bottom = @max(bottom, descendant.y + descendant.height);
946 }
947 return .{
948 .width = @max(right - rect.x, rect.width),
949 .height = @max(bottom - rect.y, rect.height),
950 };
951 }
952
953 fn resolveScroll(
954 resolvers: FrameResolvers,
955 root_id: u64,
956 node: *const UiNode,
957 node_content_size: flex_layout.Size,
958 rect: flex_layout.Rect,
959 ) UiScroll {
960 if (resolvers.scroll) |resolver| return resolver(resolvers.context, root_id, node, node_content_size, rect);
961 return clampScroll(node.scroll, node_content_size, rect);
962 }
963
964 fn resolveTextSelection(
965 resolvers: FrameResolvers,
966 root_id: u64,
967 node: *const UiNode,
968 ) UiTextSelection {
969 const selection = if (resolvers.text_selection) |resolver|
970 resolver(resolvers.context, root_id, node)
971 else
972 node.text_selection;
973 const text_len = if (node.text) |text| text.content.len else 0;
974 return normalizeTextSelection(selection, text_len);
975 }
976
977 pub const pointInRect = model.pointInRect;
978
979 pub fn offsetRect(rect: flex_layout.Rect, x: f32, y: f32) flex_layout.Rect {
980 return .{
981 .x = rect.x + x,
982 .y = rect.y + y,
983 .width = rect.width,
984 .height = rect.height,
985 };
986 }
987
988 pub fn snapRect(rect: flex_layout.Rect) flex_layout.Rect {
989 const left = @round(rect.x);
990 const top = @round(rect.y);
991 const right = @round(rect.x + rect.width);
992 const bottom = @round(rect.y + rect.height);
993 return .{
994 .x = left,
995 .y = top,
996 .width = right - left,
997 .height = bottom - top,
998 };
999 }
1000
1001 pub fn contentSize(layout: *const flex_layout.LayoutResult) flex_layout.Size {
1002 var right = layout.rect.x + layout.rect.width;
1003 var bottom = layout.rect.y + layout.rect.height;
1004 for (layout.children) |child| {
1005 right = @max(right, child.rect.x + child.rect.width);
1006 bottom = @max(bottom, child.rect.y + child.rect.height);
1007 }
1008 return .{
1009 .width = @max(right - layout.rect.x, layout.rect.width),
1010 .height = @max(bottom - layout.rect.y, layout.rect.height),
1011 };
1012 }
1013
1014 pub const Offset = struct {
1015 x: f32 = 0,
1016 y: f32 = 0,
1017 };
1018
1019 pub fn childClip(current: ?flex_layout.Rect, rect: flex_layout.Rect, scroll: UiScroll) ?flex_layout.Rect {
1020 if (scroll.overflow_x == .visible and scroll.overflow_y == .visible) return current;
1021 const next = flex_layout.Rect{
1022 .x = if (scroll.overflow_x == .clip) rect.x else -clip_extent,
1023 .y = if (scroll.overflow_y == .clip) rect.y else -clip_extent,
1024 .width = if (scroll.overflow_x == .clip) rect.width else clip_extent * 2,
1025 .height = if (scroll.overflow_y == .clip) rect.height else clip_extent * 2,
1026 };
1027 return if (current) |clip| intersectRects(clip, next) else next;
1028 }
1029
1030 pub fn intersectRects(a: flex_layout.Rect, b: flex_layout.Rect) flex_layout.Rect {
1031 const left = @max(a.x, b.x);
1032 const top = @max(a.y, b.y);
1033 const right = @min(a.x + a.width, b.x + b.width);
1034 const bottom = @min(a.y + a.height, b.y + b.height);
1035 return .{
1036 .x = left,
1037 .y = top,
1038 .width = @max(right - left, 0),
1039 .height = @max(bottom - top, 0),
1040 };
1041 }
1042
1043 pub fn clampScroll(scroll: UiScroll, content_size: flex_layout.Size, rect: flex_layout.Rect) UiScroll {
1044 var result = scroll;
1045 result.scroll_x = nonNegativeFinite(result.scroll_x);
1046 result.scroll_y = nonNegativeFinite(result.scroll_y);
1047 if (result.overflow_x == .clip) result.scroll_x = std.math.clamp(result.scroll_x, 0, maxScrollX(content_size, rect));
1048 if (result.overflow_y == .clip) result.scroll_y = std.math.clamp(result.scroll_y, 0, maxScrollY(content_size, rect));
1049 return result;
1050 }
1051
1052 pub fn scrollForVisibleTarget(widget: *const WidgetFrame, current: UiScroll, target_rect: flex_layout.Rect) ?UiScroll {
1053 var next = current;
1054 const viewport = widget.visible_rect;
1055 if (current.overflow_x == .clip and viewport.width > 0) {
1056 next.scroll_x = scrollOffsetForVisibleRange(current.scroll_x, viewport.x, viewport.width, target_rect.x, target_rect.width);
1057 }
1058 if (current.overflow_y == .clip and viewport.height > 0) {
1059 next.scroll_y = scrollOffsetForVisibleRange(current.scroll_y, viewport.y, viewport.height, target_rect.y, target_rect.height);
1060 }
1061 next = clampScroll(next, widget.content_size, widget.rect);
1062 if (next.scroll_x == current.scroll_x and next.scroll_y == current.scroll_y) return null;
1063 return next;
1064 }
1065
1066 fn scrollOffsetForVisibleRange(current_scroll: f32, viewport_start: f32, viewport_size: f32, target_start: f32, target_size: f32) f32 {
1067 const viewport_end = viewport_start + viewport_size;
1068 const target_end = target_start + target_size;
1069 if (target_start <= viewport_start and viewport_end <= target_end) return current_scroll;
1070 if (viewport_start <= target_start and target_end <= viewport_end) return current_scroll;
1071 if (target_start < viewport_start) return current_scroll - (viewport_start - target_start);
1072 if (target_end > viewport_end) return current_scroll + (target_end - viewport_end);
1073 return current_scroll;
1074 }
1075
1076 pub fn maxScrollX(content_size: flex_layout.Size, rect: flex_layout.Rect) f32 {
1077 return @max(content_size.width - rect.width, 0);
1078 }
1079
1080 pub fn maxScrollY(content_size: flex_layout.Size, rect: flex_layout.Rect) f32 {
1081 return @max(content_size.height - rect.height, 0);
1082 }
1083
1084 pub fn makeTextRef(root_id: u64, widget_id: u64) u64 {
1085 var pair = [2]u64{ root_id, widget_id };
1086 const hashed = std.hash.Wyhash.hash(0, std.mem.asBytes(&pair)) & std.math.maxInt(i64);
1087 return if (hashed == 0) 1 else hashed;
1088 }
1089
1090 pub fn normalizeTextSelection(selection: UiTextSelection, text_len: usize) UiTextSelection {
1091 var result = selection;
1092 result.cursor_byte_offset = @min(result.cursor_byte_offset, text_len);
1093 result.selection_anchor_byte_offset = @min(result.selection_anchor_byte_offset, text_len);
1094 result.selection_focus_byte_offset = @min(result.selection_focus_byte_offset, text_len);
1095 if (result.selection_anchor_byte_offset == result.selection_focus_byte_offset) result.selection_active = false;
1096 return result;
1097 }
1098
1099 const clip_extent: f32 = 1_000_000;
1100
1101 test "layers propagate from a subtree root to its descendants" {
1102 const allocator = std.testing.allocator;
1103 const overlay_children = [_]UiNode{
1104 .{
1105 .widget_id = 4,
1106 .size = .{ .width = 40, .height = 10 },
1107 .text = .{ .content = "modal" },
1108 },
1109 };
1110 const children = [_]UiNode{
1111 .{
1112 .widget_id = 2,
1113 .size = .{ .width = 80, .height = 24 },
1114 .text = .{ .content = "base" },
1115 },
1116 .{
1117 .widget_id = 3,
1118 .layer = 1,
1119 .style = .{ .position = .absolute, .inset = .{ .left = 10, .top = 5 } },
1120 .size = .{ .width = 60, .height = 20 },
1121 .children = overlay_children[0..],
1122 },
1123 };
1124 const surface = UiSurfaceTree{
1125 .available_size = .{ .width = 200, .height = 40 },
1126 .root = .{
1127 .widget_id = 1,
1128 .children = children[0..],
1129 },
1130 };
1131
1132 var workspace = Workspace.init(allocator);
1133 defer workspace.deinit();
1134 const frame = try workspace.buildSurface(&surface, .{ .root_id = 42, .revision = 1 });
1135
1136 const base = widget_query.findById(frame.widgets, 42, 2) orelse return error.MissingBaseWidget;
1137 try std.testing.expectEqual(@as(u8, 0), base.layer);
1138 const panel = widget_query.findById(frame.widgets, 42, 3) orelse return error.MissingPanelWidget;
1139 try std.testing.expectEqual(@as(u8, 1), panel.layer);
1140 const descendant = widget_query.findById(frame.widgets, 42, 4) orelse return error.MissingDescendantWidget;
1141 try std.testing.expectEqual(@as(u8, 1), descendant.layer);
1142 }
1143
1144 test "surface frame facts preserve roles actions and hit geometry" {
1145 const allocator = std.testing.allocator;
1146 const children = [_]UiNode{
1147 .{
1148 .widget_id = 2,
1149 .kind = .button,
1150 .size = .{ .width = 80, .height = 24 },
1151 .text = .{ .content = "Run" },
1152 .action = "demo.run",
1153 .role = "demo.command",
1154 },
1155 .{
1156 .widget_id = 3,
1157 .kind = .button,
1158 .size = .{ .width = 80, .height = 24 },
1159 .text = .{ .content = "Stop" },
1160 .action = "demo.stop",
1161 .role = "demo.command",
1162 .state_flags = model.ui_state_disabled,
1163 },
1164 };
1165 const surface = UiSurfaceTree{
1166 .available_size = .{ .width = 200, .height = 40 },
1167 .root = .{
1168 .widget_id = 1,
1169 .style = .{
1170 .flex_direction = .row,
1171 .gap = 8,
1172 },
1173 .children = children[0..],
1174 },
1175 };
1176
1177 var workspace = Workspace.init(allocator);
1178 defer workspace.deinit();
1179 const frame = try workspace.buildSurface(&surface, .{
1180 .root_id = 42,
1181 .revision = 7,
1182 .hovered_widget = 2,
1183 .focused_widget = 2,
1184 });
1185 try std.testing.expectEqual(@as(u64, 7), frame.revision);
1186 try std.testing.expectEqual(@as(usize, 1), frame.root_count);
1187 try std.testing.expectEqual(@as(usize, 3), frame.widget_count);
1188
1189 const run = widget_query.findById(frame.widgets, 42, 2) orelse return error.MissingRunWidget;
1190 try std.testing.expect(run.focusable);
1191 try std.testing.expect(run.hovered);
1192 try std.testing.expect(run.focused);
1193 try std.testing.expect(run.has_text);
1194 try std.testing.expect(run.text_ref != 0);
1195 try std.testing.expectEqualStrings("demo.run", run.action);
1196
1197 const hit = widget_query.hitTest(frame.widgets, 42, run.rect.x + 1, run.rect.y + 1) orelse return error.MissingHit;
1198 try std.testing.expectEqual(@as(u64, 2), hit.widget_id);
1199
1200 const stop = widget_query.findById(frame.widgets, 42, 3) orelse return error.MissingStopWidget;
1201 try std.testing.expect(!stop.focusable);
1202 const disabled_hit = widget_query.hitTest(frame.widgets, 42, stop.rect.x + 1, stop.rect.y + 1);
1203 if (disabled_hit) |hit_widget| try std.testing.expect(hit_widget.widget_id != 3);
1204 }
1205
1206 test "surface frame appends an externally solved subtree beneath an opaque viewport" {
1207 const viewport_children = [_]UiNode{.{
1208 .widget_id = 2,
1209 .style = .{
1210 .position = .absolute,
1211 .inset = .{ .left = 20, .top = 10 },
1212 },
1213 .size = .{ .width = 100, .height = 60 },
1214 }};
1215 const surface = UiSurfaceTree{
1216 .available_size = .{ .width = 160, .height = 100 },
1217 .root = .{ .widget_id = 1, .children = &viewport_children },
1218 };
1219 const external_nodes = [_]UiNode{
1220 .{ .widget_id = 10, .role = "document", .layer = 1 },
1221 .{ .widget_id = 11, .kind = .button, .action = "open", .role = "command" },
1222 .{ .widget_id = 12, .kind = .label, .text = .{ .content = "status" }, .layer = 2 },
1223 };
1224 const bindings = [_]SolvedBinding{
1225 .{ .node = &external_nodes[0], .subtree_size = 3 },
1226 .{ .node = &external_nodes[1], .subtree_size = 1 },
1227 .{ .node = &external_nodes[2], .subtree_size = 1 },
1228 };
1229 const rectangles = [_]flex_layout.Rect{
1230 .{ .width = 100, .height = 60 },
1231 .{ .x = 5, .y = 8, .width = 30, .height = 10 },
1232 .{ .x = 5, .y = 30, .width = 80, .height = 20 },
1233 };
1234 const clips = [_]flex_layout.Rect{
1235 .{ .width = 100, .height = 60 },
1236 .{ .width = 20, .height = 15 },
1237 .{ .width = 100, .height = 50 },
1238 };
1239 const offsets = [_]Offset{
1240 .{},
1241 .{ .x = 2, .y = 3 },
1242 .{ .y = -4 },
1243 };
1244 const solved = SolvedSubtree{
1245 .viewport_widget_id = 2,
1246 .bindings = &bindings,
1247 .rectangles = &rectangles,
1248 .clips = &clips,
1249 .offsets = &offsets,
1250 };
1251 const root = SurfaceFrameRoot{
1252 .surface = &surface,
1253 .root_id = 7,
1254 .revision = 9,
1255 .solved_subtrees = &.{solved},
1256 };
1257
1258 const facts = try surveyFrameRoot(root);
1259 try std.testing.expectEqual(@as(usize, 5), facts.nodes);
1260 try std.testing.expectEqual(@as(usize, 2), facts.layout_nodes);
1261 try std.testing.expectEqual(@as(usize, 1), facts.children);
1262
1263 var workspace = Workspace.init(std.testing.allocator);
1264 defer workspace.deinit();
1265 const frame = try workspace.buildSurface(&surface, .{
1266 .root_id = root.root_id,
1267 .revision = root.revision,
1268 .solved_subtrees = root.solved_subtrees,
1269 });
1270 try std.testing.expectEqual(@as(usize, 5), frame.widget_count);
1271 try std.testing.expectEqual(@as(usize, 5), frame.widgets[0].subtree_size);
1272 try std.testing.expectEqual(@as(usize, 4), frame.widgets[1].subtree_size);
1273 try std.testing.expectEqual(@as(u64, 10), frame.widgets[2].widget_id);
1274 try std.testing.expectEqual(@as(usize, 3), frame.widgets[2].subtree_size);
1275
1276 const command = widget_query.findById(frame.widgets, 7, 11) orelse
1277 return error.MissingSolvedCommand;
1278 try std.testing.expectEqualStrings("open", command.action);
1279 try std.testing.expectEqual(@as(u8, 1), command.layer);
1280 try std.testing.expectEqual(@as(f32, 27), command.rect.x);
1281 try std.testing.expectEqual(@as(f32, 21), command.rect.y);
1282 try std.testing.expectEqual(@as(f32, 13), command.visible_rect.width);
1283 try std.testing.expectEqual(@as(f32, 4), command.visible_rect.height);
1284
1285 const status = widget_query.findById(frame.widgets, 7, 12) orelse
1286 return error.MissingSolvedStatus;
1287 try std.testing.expectEqual(@as(u8, 2), status.layer);
1288 try std.testing.expectEqual(@as(f32, 36), status.rect.y);
1289 try std.testing.expect(status.has_text);
1290 try std.testing.expectEqual(@as(usize, 2), workspace.storage.?.layout_scratch.states.len);
1291 }
1292
1293 test "surface frame rejects invalid solved spans before replacing the accepted frame" {
1294 const viewport_children = [_]UiNode{.{
1295 .widget_id = 2,
1296 .size = .{ .width = 16, .height = 16 },
1297 }};
1298 const surface = UiSurfaceTree{
1299 .available_size = .{ .width = 32, .height = 32 },
1300 .root = .{ .widget_id = 1, .children = &viewport_children },
1301 };
1302 const external = UiNode{ .widget_id = 10 };
1303 const bindings = [_]SolvedBinding{.{ .node = &external, .subtree_size = 1 }};
1304 const rectangles = [_]flex_layout.Rect{.{ .width = 8, .height = 8 }};
1305 const clips = [_]flex_layout.Rect{.{ .width = 8, .height = 8 }};
1306 const offsets = [_]Offset{.{}};
1307 const invalid = SolvedSubtree{
1308 .viewport_widget_id = 2,
1309 .bindings = &bindings,
1310 .rectangles = &rectangles,
1311 .clips = &clips,
1312 .offsets = &.{},
1313 };
1314 const valid = SolvedSubtree{
1315 .viewport_widget_id = 2,
1316 .bindings = &bindings,
1317 .rectangles = &rectangles,
1318 .clips = &clips,
1319 .offsets = &offsets,
1320 };
1321 const invalid_bindings = [_]SolvedBinding{.{ .node = &external, .subtree_size = 2 }};
1322 const invalid_geometry = [_]flex_layout.Rect{.{
1323 .x = std.math.nan(f32),
1324 .width = 8,
1325 .height = 8,
1326 }};
1327 try std.testing.expectError(
1328 error.InvalidSolvedBindings,
1329 surveyFrameRoot(.{
1330 .surface = &surface,
1331 .solved_subtrees = &.{.{
1332 .viewport_widget_id = 2,
1333 .bindings = &invalid_bindings,
1334 .rectangles = &rectangles,
1335 .clips = &clips,
1336 .offsets = &offsets,
1337 }},
1338 }),
1339 );
1340 try std.testing.expectError(
1341 error.InvalidSolvedGeometry,
1342 surveyFrameRoot(.{
1343 .surface = &surface,
1344 .solved_subtrees = &.{.{
1345 .viewport_widget_id = 2,
1346 .bindings = &bindings,
1347 .rectangles = &invalid_geometry,
1348 .clips = &clips,
1349 .offsets = &offsets,
1350 }},
1351 }),
1352 );
1353 try std.testing.expectError(
1354 error.SolvedViewportNotFound,
1355 surveyFrameRoot(.{
1356 .surface = &surface,
1357 .solved_subtrees = &.{.{
1358 .viewport_widget_id = 3,
1359 .bindings = &bindings,
1360 .rectangles = &rectangles,
1361 .clips = &clips,
1362 .offsets = &offsets,
1363 }},
1364 }),
1365 );
1366 try std.testing.expectError(
1367 error.DuplicateSolvedViewport,
1368 surveyFrameRoot(.{
1369 .surface = &surface,
1370 .solved_subtrees = &.{ valid, valid },
1371 }),
1372 );
1373
1374 var workspace = Workspace.init(std.testing.allocator);
1375 defer workspace.deinit();
1376 const accepted = try workspace.buildSurface(&surface, .{ .revision = 1 });
1377 const accepted_widgets = accepted.widgets.ptr;
1378 try std.testing.expectError(
1379 error.InvalidSolvedSpans,
1380 workspace.buildSurface(&surface, .{
1381 .revision = 2,
1382 .solved_subtrees = &.{invalid},
1383 }),
1384 );
1385 try std.testing.expectEqual(accepted_widgets, accepted.widgets.ptr);
1386 try std.testing.expectEqual(@as(u64, 1), accepted.revision);
1387 try std.testing.expectEqual(@as(u64, 2), accepted.widgets[1].widget_id);
1388 }
1389
1390 test "surface frame reuses admitted solved-subtree storage" {
1391 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1392 const viewport_children = [_]UiNode{.{
1393 .widget_id = 2,
1394 .size = .{ .width = 16, .height = 16 },
1395 }};
1396 const surface = UiSurfaceTree{
1397 .available_size = .{ .width = 32, .height = 32 },
1398 .root = .{ .widget_id = 1, .children = &viewport_children },
1399 };
1400 const external_nodes = [_]UiNode{
1401 .{ .widget_id = 10 },
1402 .{ .widget_id = 11, .kind = .label, .text = .{ .content = "ready" } },
1403 };
1404 const bindings = [_]SolvedBinding{
1405 .{ .node = &external_nodes[0], .subtree_size = 2 },
1406 .{ .node = &external_nodes[1], .subtree_size = 1 },
1407 };
1408 const rectangles = [_]flex_layout.Rect{
1409 .{ .width = 16, .height = 16 },
1410 .{ .width = 12, .height = 8 },
1411 };
1412 const clips = [_]flex_layout.Rect{
1413 .{ .width = 16, .height = 16 },
1414 .{ .width = 16, .height = 16 },
1415 };
1416 const offsets = [_]Offset{ .{}, .{} };
1417 const solved = SolvedSubtree{
1418 .viewport_widget_id = 2,
1419 .bindings = &bindings,
1420 .rectangles = &rectangles,
1421 .clips = &clips,
1422 .offsets = &offsets,
1423 };
1424 var workspace = Workspace.init(failing.allocator());
1425 defer workspace.deinit();
1426
1427 for (0..2) |_| {
1428 const frame = try workspace.buildSurface(&surface, .{ .solved_subtrees = &.{solved} });
1429 try std.testing.expectEqual(@as(usize, 4), frame.widget_count);
1430 }
1431 failing.fail_index = failing.alloc_index;
1432 failing.resize_fail_index = failing.resize_index;
1433 for (0..8) |_| {
1434 const frame = try workspace.buildSurface(&surface, .{ .solved_subtrees = &.{solved} });
1435 try std.testing.expectEqual(@as(usize, 4), frame.widget_count);
1436 }
1437 try std.testing.expect(!failing.has_induced_failure);
1438 }
1439
1440 test "surface frame applies text intrinsic measurement to flex layout" {
1441 const allocator = std.testing.allocator;
1442 const children = [_]UiNode{
1443 .{
1444 .widget_id = 2,
1445 .kind = .label,
1446 .text = .{ .content = "abc" },
1447 .size = .{ .height = 12 },
1448 },
1449 .{
1450 .widget_id = 3,
1451 .kind = .label,
1452 .text = .{ .content = "z" },
1453 .size = .{ .height = 12 },
1454 },
1455 };
1456 const surface = UiSurfaceTree{
1457 .available_size = .{ .width = 100, .height = 20 },
1458 .root = .{
1459 .widget_id = 1,
1460 .style = .{ .flex_direction = .row, .gap = 5, .align_items = .flex_start },
1461 .children = children[0..],
1462 },
1463 };
1464
1465 var workspace = Workspace.init(allocator);
1466 defer workspace.deinit();
1467 const frame = try workspace.buildSurface(&surface, .{
1468 .resolvers = .{ .text_size = measureTextForFrameTest },
1469 });
1470 const abc = widget_query.findById(frame.widgets, 1, 2) orelse return error.MissingAbc;
1471 const z = widget_query.findById(frame.widgets, 1, 3) orelse return error.MissingZ;
1472 try std.testing.expectEqual(@as(f32, 21), abc.rect.width);
1473 try std.testing.expectEqual(@as(f32, 26), z.rect.x);
1474 try std.testing.expectEqual(@as(f32, 7), z.rect.width);
1475 }
1476
1477 fn measureTextForFrameTest(_: ?*anyopaque, node: *const UiNode) anyerror!?flex_layout.Size {
1478 const text = node.text orelse return null;
1479 return .{
1480 .width = @as(f32, @floatFromInt(text.content.len)) * 7,
1481 .height = 11,
1482 };
1483 }
1484
1485 test "frame rects snap to whole pixels and adjacent children share edges" {
1486 const allocator = std.testing.allocator;
1487 const children = [_]UiNode{
1488 .{
1489 .widget_id = 2,
1490 .size = .{ .width = 10.5, .height = 15 },
1491 },
1492 .{
1493 .widget_id = 3,
1494 .size = .{ .width = 10.5, .height = 15 },
1495 },
1496 };
1497 const surface = UiSurfaceTree{
1498 .available_size = .{ .width = 40, .height = 20 },
1499 .root = .{
1500 .widget_id = 1,
1501 .style = .{ .flex_direction = .row, .align_items = .center },
1502 .children = children[0..],
1503 },
1504 };
1505
1506 var workspace = Workspace.init(allocator);
1507 defer workspace.deinit();
1508 const frame = try workspace.buildSurface(&surface, .{});
1509
1510 const first = widget_query.findById(frame.widgets, 1, 2) orelse return error.MissingFirst;
1511 const second = widget_query.findById(frame.widgets, 1, 3) orelse return error.MissingSecond;
1512
1513 for ([_]flex_layout.Rect{ first.rect, second.rect }) |rect| {
1514 try std.testing.expectEqual(rect.x, @round(rect.x));
1515 try std.testing.expectEqual(rect.y, @round(rect.y));
1516 try std.testing.expectEqual(rect.width, @round(rect.width));
1517 try std.testing.expectEqual(rect.height, @round(rect.height));
1518 }
1519 try std.testing.expectEqual(@as(f32, 3), first.rect.y);
1520 try std.testing.expectEqual(@as(f32, 15), first.rect.height);
1521 try std.testing.expectEqual(@as(f32, 11), first.rect.width);
1522 try std.testing.expectEqual(first.rect.x + first.rect.width, second.rect.x);
1523 try std.testing.expectEqual(@as(f32, 10), second.rect.width);
1524 }
1525
1526 test "surface frame facts derive hovered widget from point" {
1527 const allocator = std.testing.allocator;
1528 const children = [_]UiNode{
1529 .{
1530 .widget_id = 2,
1531 .kind = .button,
1532 .size = .{ .width = 80, .height = 24 },
1533 .text = .{ .content = "Run" },
1534 .action = "demo.run",
1535 },
1536 .{
1537 .widget_id = 3,
1538 .kind = .button,
1539 .size = .{ .width = 80, .height = 24 },
1540 .text = .{ .content = "Stop" },
1541 .action = "demo.stop",
1542 },
1543 };
1544 const surface = UiSurfaceTree{
1545 .available_size = .{ .width = 200, .height = 40 },
1546 .root = .{
1547 .widget_id = 1,
1548 .style = .{
1549 .flex_direction = .row,
1550 .gap = 8,
1551 },
1552 .children = children[0..],
1553 },
1554 };
1555
1556 var workspace = Workspace.init(allocator);
1557 defer workspace.deinit();
1558 const frame = try workspace.buildSurface(&surface, .{
1559 .root_id = 43,
1560 .hovered_point = .{ .x = 92, .y = 12 },
1561 });
1562 const run = widget_query.findById(frame.widgets, 43, 2) orelse return error.MissingRunWidget;
1563 const stop = widget_query.findById(frame.widgets, 43, 3) orelse return error.MissingStopWidget;
1564 try std.testing.expect(!run.hovered);
1565 try std.testing.expect(stop.hovered);
1566 }
1567
1568 test "Workspace retains frame storage at the high-water mark" {
1569 comptime {
1570 @stardustClaim(
1571 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_highwater"),
1572 null,
1573 null,
1574 null,
1575 null,
1576 null,
1577 null,
1578 );
1579 }
1580
1581 var children: [16]UiNode = undefined;
1582 for (&children, 0..) |*child, index| {
1583 child.* = .{
1584 .widget_id = index + 2,
1585 .size = .{ .width = 4, .height = 4 },
1586 };
1587 }
1588 var surface = UiSurfaceTree{
1589 .available_size = .{ .width = 64, .height = 16 },
1590 .root = .{
1591 .widget_id = 1,
1592 .style = .{ .flex_direction = .row },
1593 .children = children[0..],
1594 },
1595 };
1596 var workspace = Workspace.init(std.testing.allocator);
1597 defer workspace.deinit();
1598
1599 const large = try workspace.buildSurface(&surface, .{});
1600 const root_storage = large.roots.ptr;
1601 const widget_storage = large.widgets.ptr;
1602 surface.root.children = children[0..2];
1603 const small = try workspace.buildSurface(&surface, .{});
1604 try std.testing.expectEqual(@as(usize, 3), small.widgets.len);
1605 try std.testing.expectEqual(root_storage, small.roots.ptr);
1606 try std.testing.expectEqual(widget_storage, small.widgets.ptr);
1607 surface.root.children = children[0..];
1608 const regrown = try workspace.buildSurface(&surface, .{});
1609 try std.testing.expectEqual(@as(usize, 17), regrown.widgets.len);
1610 try std.testing.expectEqual(root_storage, regrown.roots.ptr);
1611 try std.testing.expectEqual(widget_storage, regrown.widgets.ptr);
1612 }
1613
1614 test "Workspace warmed builds need no backing allocation" {
1615 comptime {
1616 @stardustClaim(
1617 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_steady_transitive_risk"),
1618 null,
1619 null,
1620 null,
1621 null,
1622 null,
1623 null,
1624 );
1625 }
1626 comptime {
1627 @stardustClaim(
1628 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_steady_foreign_risk"),
1629 null,
1630 null,
1631 null,
1632 null,
1633 null,
1634 null,
1635 );
1636 }
1637
1638 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1639 const child = [_]UiNode{.{
1640 .widget_id = 2,
1641 .size = .{ .width = 8, .height = 8 },
1642 }};
1643 const surface = UiSurfaceTree{
1644 .available_size = .{ .width = 16, .height = 16 },
1645 .root = .{ .widget_id = 1, .children = child[0..] },
1646 };
1647 var workspace = Workspace.init(failing.allocator());
1648 defer workspace.deinit();
1649
1650 for (0..3) |_| _ = try workspace.buildSurface(&surface, .{});
1651 failing.fail_index = failing.alloc_index;
1652 failing.resize_fail_index = failing.resize_index;
1653 for (0..8) |_| {
1654 const frame = try workspace.buildSurface(&surface, .{});
1655 try std.testing.expectEqual(@as(usize, 2), frame.widgets.len);
1656 }
1657 try std.testing.expect(!failing.has_induced_failure);
1658 }
1659
1660 test "Workspace remains reusable after allocation failure" {
1661 comptime {
1662 @stardustClaim(
1663 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_oom"),
1664 null,
1665 null,
1666 null,
1667 null,
1668 null,
1669 null,
1670 );
1671 }
1672
1673 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
1674 const child = [_]UiNode{.{
1675 .widget_id = 2,
1676 .size = .{ .width = 8, .height = 8 },
1677 }};
1678 const surface = UiSurfaceTree{
1679 .available_size = .{ .width = 16, .height = 16 },
1680 .root = .{ .widget_id = 1, .children = child[0..] },
1681 };
1682 var workspace = Workspace.init(failing.allocator());
1683 defer workspace.deinit();
1684
1685 try std.testing.expectError(error.OutOfMemory, workspace.buildSurface(&surface, .{}));
1686 try std.testing.expect(workspace.storage == null);
1687 failing.fail_index = std.math.maxInt(usize);
1688 const frame = try workspace.buildSurface(&surface, .{});
1689 try std.testing.expectEqual(@as(usize, 2), frame.widgets.len);
1690 }
1691
1692 test "FrameStorage capacity matches an independent typed-byte model" {
1693 comptime {
1694 @stardustClaim(
1695 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_capacity"),
1696 null,
1697 null,
1698 null,
1699 null,
1700 null,
1701 null,
1702 );
1703 }
1704
1705 const cases = [_]FrameStorage.Limits{
1706 .{ .nodes = 1, .layout_nodes = 1, .children = 0, .roots = 1 },
1707 .{ .nodes = 17, .layout_nodes = 9, .children = 8, .roots = 1 },
1708 .{ .nodes = 4_096, .layout_nodes = 2_048, .children = 2_047, .roots = 8 },
1709 .{ .nodes = std.math.maxInt(u32), .layout_nodes = std.math.maxInt(u32), .children = std.math.maxInt(u32), .roots = 64 },
1710 };
1711 for (cases) |limits| {
1712 const derived = try FrameStorage.Capacity.derive(limits);
1713 const modeled = try modelFrameCapacity(limits);
1714 try std.testing.expectEqual(modeled, derived);
1715 }
1716 try std.testing.expectError(
1717 error.CapacityOverflow,
1718 FrameStorage.Capacity.derive(.{ .nodes = 0, .layout_nodes = 0, .children = 0, .roots = 1 }),
1719 );
1720 try std.testing.expectError(
1721 error.CapacityOverflow,
1722 FrameStorage.Capacity.derive(.{ .nodes = 1, .layout_nodes = 1, .children = 0, .roots = 0 }),
1723 );
1724 }
1725
1726 test "FrameStorage acquires the exact surveyed equation and seals" {
1727 comptime {
1728 @stardustClaim(
1729 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_acquisition"),
1730 null,
1731 null,
1732 null,
1733 null,
1734 null,
1735 null,
1736 );
1737 }
1738
1739 var storage = try FrameStorage.init(std.testing.allocator, .{
1740 .nodes = 5,
1741 .layout_nodes = 3,
1742 .children = 4,
1743 .roots = 2,
1744 });
1745 defer storage.deinit(std.testing.allocator);
1746 storage.activate();
1747
1748 try std.testing.expectEqual(@as(usize, 2), storage.roots.len);
1749 try std.testing.expectEqual(@as(usize, 5), storage.widgets.len);
1750 try std.testing.expectEqual(@as(usize, 4), storage.flex_nodes.len);
1751 try std.testing.expectEqual(@as(usize, 4), storage.layout_scratch.results.len);
1752 try std.testing.expectEqual(@as(usize, 4), storage.layout_scratch.metrics.len);
1753 try std.testing.expectEqual(@as(usize, 4), storage.layout_scratch.lines.len);
1754 try std.testing.expectEqual(@as(usize, 3), storage.layout_scratch.states.len);
1755 try std.testing.expectEqual(@as(usize, 4), storage.layout_scratch.child_indices.len);
1756
1757 try std.testing.expect(storage.admits(.{ .nodes = 5, .layout_nodes = 3, .children = 4 }, 2));
1758 try std.testing.expect(!storage.admits(.{ .nodes = 6, .layout_nodes = 3, .children = 4 }, 2));
1759 try std.testing.expect(!storage.admits(.{ .nodes = 5, .layout_nodes = 4, .children = 4 }, 2));
1760 try std.testing.expect(!storage.admits(.{ .nodes = 5, .layout_nodes = 3, .children = 5 }, 2));
1761 try std.testing.expect(!storage.admits(.{ .nodes = 5, .layout_nodes = 3, .children = 4 }, 3));
1762 }
1763
1764 test "surveySurface counts nodes and children exactly and rejects over-deep trees" {
1765 comptime {
1766 @stardustClaim(
1767 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_survey"),
1768 null,
1769 null,
1770 null,
1771 null,
1772 null,
1773 null,
1774 );
1775 }
1776
1777 const grandchildren = [_]UiNode{
1778 .{ .widget_id = 4 },
1779 .{ .widget_id = 5 },
1780 };
1781 const children = [_]UiNode{
1782 .{ .widget_id = 2, .children = grandchildren[0..] },
1783 .{ .widget_id = 3 },
1784 };
1785 const surface = UiSurfaceTree{
1786 .available_size = .{ .width = 64, .height = 64 },
1787 .root = .{ .widget_id = 1, .children = children[0..] },
1788 };
1789
1790 const facts = try surveySurface(&surface);
1791 try std.testing.expectEqual(@as(usize, 5), facts.nodes);
1792 try std.testing.expectEqual(@as(usize, 5), facts.layout_nodes);
1793 try std.testing.expectEqual(@as(usize, 4), facts.children);
1794
1795 var spine: [max_depth + 1]UiNode = undefined;
1796 spine[max_depth] = .{ .widget_id = max_depth };
1797 var level: usize = max_depth;
1798 while (level > 0) {
1799 level -= 1;
1800 spine[level] = .{
1801 .widget_id = level,
1802 .children = spine[level + 1 ..][0..1],
1803 };
1804 }
1805 const deep = UiSurfaceTree{
1806 .available_size = .{ .width = 8, .height = 8 },
1807 .root = spine[0],
1808 };
1809 try std.testing.expectError(error.SurfaceTooDeep, surveySurface(&deep));
1810 }
1811
1812 test "Session publishes double-buffered multi-root frames without reallocation" {
1813 comptime {
1814 @stardustClaim(
1815 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_session"),
1816 null,
1817 null,
1818 null,
1819 null,
1820 null,
1821 null,
1822 );
1823 }
1824
1825 var session = Session.init(std.testing.allocator);
1826 defer session.deinit();
1827
1828 const first_children = [_]UiNode{
1829 .{ .widget_id = 2, .size = .{ .width = 8, .height = 8 } },
1830 };
1831 const first = UiSurfaceTree{
1832 .available_size = .{ .width = 32, .height = 16 },
1833 .root = .{ .widget_id = 1, .children = first_children[0..] },
1834 };
1835 const second = UiSurfaceTree{
1836 .available_size = .{ .width = 16, .height = 16 },
1837 .root = .{ .widget_id = 10 },
1838 };
1839
1840 const facts = (try surveySurface(&first)).add(try surveySurface(&second));
1841 _ = try session.begin(facts, 2);
1842 try session.append(.{ .surface = &first, .root_id = 1, .revision = 7 }, .{});
1843 try session.append(.{ .surface = &second, .root_id = 2, .revision = 7 }, .{});
1844 const frame = session.publish(7);
1845
1846 try std.testing.expectEqual(@as(usize, 2), frame.root_count);
1847 try std.testing.expectEqual(@as(usize, 3), frame.widget_count);
1848 try std.testing.expectEqual(@as(u64, 1), frame.roots[0].root_id);
1849 try std.testing.expectEqual(@as(u64, 2), frame.roots[1].root_id);
1850
1851 const published_widgets = frame.widgets.ptr;
1852
1853 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
1854 _ = failing.allocator();
1855
1856 _ = try session.begin(facts, 2);
1857 try session.append(.{ .surface = &first, .root_id = 1, .revision = 8 }, .{});
1858 try session.append(.{ .surface = &second, .root_id = 2, .revision = 8 }, .{});
1859
1860 try std.testing.expectEqual(@as(u64, 1), frame.roots[0].root_id);
1861 try std.testing.expectEqual(published_widgets, frame.widgets.ptr);
1862 try std.testing.expect(!failing.has_induced_failure);
1863
1864 const next = session.publish(8);
1865 try std.testing.expectEqual(@as(usize, 3), next.widget_count);
1866 try std.testing.expect(next.widgets.ptr != published_widgets);
1867 }
1868
1869 test "Session steady builds at admitted demand make no allocator calls" {
1870 comptime {
1871 @stardustClaim(
1872 @import("alloc_phase").capacity.witness(FrameStorage, "gui_frame_session_steady"),
1873 null,
1874 null,
1875 null,
1876 null,
1877 null,
1878 null,
1879 );
1880 }
1881
1882 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1883 var session = Session.init(failing.allocator());
1884 defer session.deinit();
1885
1886 const children = [_]UiNode{
1887 .{ .widget_id = 2, .size = .{ .width = 8, .height = 8 } },
1888 .{ .widget_id = 3, .size = .{ .width = 8, .height = 8 } },
1889 };
1890 const surface = UiSurfaceTree{
1891 .available_size = .{ .width = 32, .height = 16 },
1892 .root = .{ .widget_id = 1, .children = children[0..] },
1893 };
1894 const facts = try surveySurface(&surface);
1895
1896 for (0..2) |_| {
1897 _ = try session.begin(facts, 1);
1898 try session.append(.{ .surface = &surface, .root_id = 1, .revision = 1 }, .{});
1899 _ = session.publish(1);
1900 }
1901
1902 failing.fail_index = failing.alloc_index;
1903 failing.resize_fail_index = failing.resize_index;
1904 for (0..8) |iteration| {
1905 _ = try session.begin(facts, 1);
1906 try session.append(.{ .surface = &surface, .root_id = 1, .revision = iteration }, .{});
1907 const frame = session.publish(iteration);
1908 try std.testing.expectEqual(@as(usize, 3), frame.widget_count);
1909 }
1910 try std.testing.expect(!failing.has_induced_failure);
1911 }