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 }