Fix anchored surface correctness (#363)

* fix(canvas): dismiss anchored surfaces without focus

* fix(canvas): relayout anchors after scroll restore

* fix(canvas): place modal surfaces against root

* fix(canvas): address anchored surface review issues

* fix(canvas): unify window surface behavior
This commit is contained in:
Chris Tate
2026-08-15 22:26:04 -05:00
committed by GitHub
parent ee63266095
commit d575734635
49 changed files with 1378 additions and 326 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
# Dialog
A modal dialog surface rendered in place: the title comes from the `text` attribute, and visibility is model-owned — wrap the dialog in an `if` on an open flag. `on-dismiss` dispatches when Escape or a click outside dismisses the surface, so `update` clears the flag; the engine hides the surface immediately as an optimistic echo, and the source tree wins on the next rebuild. The title is drawn by the surface chrome and children stack over the full content box, so lead the body column with a fixed-height spacer that clears the title line. For edge-anchored surfaces with the same contract, see [drawer](/docs/components/drawer) and [sheet](/docs/components/sheet).
A modal dialog surface centered against the root view, regardless of which container declares it. The title comes from the `text` attribute, and visibility is model-owned — wrap the dialog in an `if` on an open flag. `on-dismiss` dispatches when Escape or a click outside dismisses the surface, so `update` clears the flag; the engine hides the surface immediately as an optimistic echo, and the source tree wins on the next rebuild. The title is drawn by the surface chrome and children stack over the full content box, so lead the body column with a fixed-height spacer that clears the title line. For root-edge surfaces with the same contract, see [drawer](/docs/components/drawer) and [sheet](/docs/components/sheet).
<ComponentPreview name="dialog" alt="A modal dialog rendered by the engine" caption="title chrome, body content, and a trailing action row" />
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Drawer
A side-anchored surface with the same contract as [dialog](/docs/components/dialog): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. As with the dialog, the title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer. For the bottom-edge variant, see [sheet](/docs/components/sheet).
A bottom-edge surface with the same contract as [dialog](/docs/components/dialog): it spans the root view width and pins to the root bottom, regardless of which container declares it. The title comes from the `text` attribute, visibility is model-owned behind an `if`, and `on-dismiss` dispatches on Escape or click-outside so `update` clears the open flag. As with the dialog, the title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer. For the right-edge variant, see [sheet](/docs/components/sheet).
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a side drawer with a title and stacked controls" />
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a bottom drawer with a title and stacked controls" />
## Markup
@@ -13,7 +13,7 @@ A side-anchored surface with the same contract as [dialog](/docs/components/dial
<column padding="24">
<button variant="outline" icon="menu" on-press="open_filters">Filters</button>
<if test="{filters_open}">
<drawer text="Filters" width="260" padding="24" on-dismiss="close_filters">
<drawer text="Filters" height="260" padding="24" on-dismiss="close_filters">
<column gap="12">
<spacer height="34"></spacer>
<checkbox checked="{only_unread}" on-toggle="toggle_unread">Only unread</checkbox>
@@ -31,7 +31,7 @@ In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically
```zig
// Rendered only while model.filters_open is true; on_dismiss clears it in update.
ui.el(.drawer, .{ .text = "Filters", .width = 260, .padding = 24, .on_dismiss = .close_filters }, .{
ui.el(.drawer, .{ .text = "Filters", .height = 260, .padding = 24, .on_dismiss = .close_filters }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.checkbox(.{ .text = "Only unread", .checked = model.only_unread, .on_toggle = .toggle_unread }),
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Sheet
A bottom-anchored surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/components/drawer): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. The sheet rises from the bottom edge; size it with `height`. The title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer.
A right-edge surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/components/drawer): it spans the root view height and pins to the root right, regardless of which container declares it. Visibility is model-owned behind an `if`, and `on-dismiss` dispatches on Escape or click-outside so `update` clears the open flag. Size the sheet with `width`. The title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer.
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a bottom sheet with a title, body text, and an action row" />
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a right-edge sheet with a title, body text, and an action row" />
## Markup
@@ -13,7 +13,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
<column padding="24">
<button variant="outline" icon="external-link" on-press="open_share">Share</button>
<if test="{share_open}">
<sheet text="Share" height="190" padding="24" on-dismiss="close_share">
<sheet text="Share" width="320" padding="24" on-dismiss="close_share">
<column gap="12">
<spacer height="34"></spacer>
<text wrap="true" foreground="text_muted">Anyone with the link can view this board.</text>
@@ -32,7 +32,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.sheet, .{ .text = "Share", .height = 190, .padding = 24, .on_dismiss = .close_share }, .{
ui.el(.sheet, .{ .text = "Share", .width = 320, .padding = 24, .on_dismiss = .close_share }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Anyone with the link can view this board."),
+1 -1
View File
@@ -48,7 +48,7 @@ The runtime owns the rest: install, presentation, resize, typed event dispatch,
| `dropdown-menu` | menu surface | vertical; children are `menu-item`s. `anchor="below\|above"` floats it against its parent's frame: a late z-pass above the whole tree, window-clipped (never cropped by a scroll pane), auto-flipping at the window edges, zero flow space — pair with `on-dismiss` so Escape/click-outside close model-side |
| `accordion` | accordion | header via the `text` attribute; children show while `selected`, dispatch `on-toggle` |
| `alert`, `bubble` | surfaces | `alert` title via the `text` attribute; children stack inside. `bubble` hugs its message up to 80% of the thread (`ghost` exempt; explicit `width` wins) and takes one `<reactions>` child — the reaction pill straddling its bottom edge, one text run, dock via `text-alignment` (default `end`); `text=` on bubble itself is a teaching error (that channel belongs to the pill) |
| `dialog`, `drawer`, `sheet` | modal surfaces | rendered in place — title via `text`, wrap in `<if>` to show conditionally |
| `dialog`, `drawer`, `sheet` | modal surfaces | root-relative and unaffected by ancestor scroll or clipping: dialogs center in the viewport, drawers span its width and dock to the bottom, and sheets span its height and dock to the right — title via `text`, wrap in `<if>` to show conditionally |
| `resizable` | resizable panel | engine-managed drag handle; `width` sets the initial width |
| `split` | two-pane splitter | exactly two element children (nest splits for more panes); the engine synthesizes the draggable divider between them. `value` binds the model-owned first-pane fraction, `on-resize` names an f32 Msg variant dispatched with each applied fraction (echo it back through `value`), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness; the focused divider takes Left/Right (Shift for bigger steps) and Home/End. `resize-duration` (milliseconds, split only) animates model-driven value moves — the runtime eases the rendered fraction to the new value one presented frame at a time instead of snapping, and reduced-motion appearances snap automatically; `resize-easing` (`linear`/`standard`/`emphasized`/`spring`) shapes the ramp and needs a nonzero duration beside it |
| `tree` | disclosure tree | vertical container whose descendant rows with `role="treeitem"` form one roving keyboard focus set: Up/Down walk visible rows (selection follows focus via `on-change` when bound, otherwise `on-press`), Left collapses or moves to the parent row, Right expands or moves to the first child row, Home/End jump to the edges, Enter/Space activate; nested rows derive hierarchy structurally, while flat `<for>` rows declare their one-based `tree-level`; expandable rows bind `expanded` and `on-toggle`, and the model owns both states |
+3 -3
View File
@@ -158,15 +158,15 @@
},
{
"name": "dialog",
"doc": "Modal dialog surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal centered in the viewport and unaffected by ancestor scroll or clipping; title via text, wrap in an if to show conditionally."
},
{
"name": "drawer",
"doc": "Drawer surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport width and docked to its bottom; title via text, wrap in an if to show conditionally."
},
{
"name": "sheet",
"doc": "Sheet surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport height and docked to its right edge; title via text, wrap in an if to show conditionally."
},
{
"name": "resizable",
+2 -2
View File
@@ -200,7 +200,7 @@
<column>
<button variant="outline" icon="menu" on-press="open_drawer">Open filters</button>
<if test="{drawerOpen}">
<drawer text="Filters" width="280" padding="24" on-dismiss="close_drawer">
<drawer text="Filters" height="280" padding="24" on-dismiss="close_drawer">
<column gap="12">
<spacer height="34" />
<checkbox checked="{onlyUnreadChecked}" on-toggle="toggle_only_unread" text="Only unread" />
@@ -304,7 +304,7 @@
<column>
<button variant="outline" icon="external-link" on-press="open_sheet">Open share sheet</button>
<if test="{sheetOpen}">
<sheet text="Share" height="210" padding="24" on-dismiss="close_sheet">
<sheet text="Share" width="320" padding="24" on-dismiss="close_sheet">
<column gap="12">
<spacer height="34" />
<text wrap="true" foreground="text_muted">Anyone with the link can view this component gallery.</text>
+1 -1
View File
@@ -201,7 +201,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
| `dropdown-menu` | dropdown_menu | vertical menu surface; children are `menu-item`s. `anchor="below\|above"` floats it against its PARENT's frame (see Pickers): late z-pass above the whole tree, window-clipped, auto-flipping at the window edges, zero flow space. Pair with `on-dismiss` |
| `accordion` | accordion | header via `text` attr; children show while `selected`, dispatch `on-toggle` |
| `alert`, `bubble` | surfaces | `alert` title via `text` attr; children stack inside. `bubble` hugs its message up to 80% of the thread (`ghost` exempt; explicit `width` wins) and takes one `<reactions>` child — the reaction pill straddling its bottom edge, one text run, dock via `text-alignment` (default `end`); `text=` on bubble itself is a teaching error (that channel belongs to the pill). Grouped runs are spacing, not vocabulary: 8 gap within a sender's run, 32 between turns |
| `dialog`, `drawer`, `sheet` | modal surfaces | rendered in place — title via `text` attr, wrap in `<if>` to show conditionally |
| `dialog`, `drawer`, `sheet` | modal surfaces | root-relative and unaffected by ancestor scroll or clipping: dialogs center in the viewport, drawers span its width and dock to the bottom, and sheets span its height and dock to the right — title via `text` attr, wrap in `<if>` to show conditionally |
| `resizable` | resizable | engine-managed drag handle; `width` sets the initial width |
| `split` | split | two-pane horizontal splitter: exactly two element children (nest splits for more panes), the engine synthesizes the draggable divider between them. `value` binds the model-owned first-pane fraction (0 lays out at 0.5), `on-resize` names an f32 Msg variant dispatched with every applied fraction (echo it back through `value` — see Splitters), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness. The divider is focusable: Left/Right (Shift for bigger steps) adjust, Home/End jump to the clamp edges |
| `tree` | tree | disclosure-tree container (vertical flow): descendant rows carrying `role="treeitem"` — at ANY nesting depth — form one roving keyboard focus set with the ARIA tree keymap. Up/Down walk visible rows (selection follows focus through each row's `on-press`), Left collapses an expanded row or moves to the parent row, Right expands a collapsed row or moves to the first child row, Home/End jump to the edges, Enter/Space activate. Expandable rows bind `expanded` and `on-toggle`; the model owns selection and expansion (collapsed children are simply not rendered) |
+3 -3
View File
@@ -242,9 +242,9 @@ fn auditFocusReachable(layout: WidgetLayoutTree, node_index: usize, sink: *Findi
var scrolls_vertically = false;
var scrolls_horizontally = false;
while (true) {
// Anchored floating widgets escape every ancestor clip (the
// routing layer keeps focus targets inside open overlays live).
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) return;
// Window-level surfaces escape every ancestor clip (the routing
// layer keeps focus targets inside them live).
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[current].widget)) return;
const parent_index = layout.nodes[current].parent_index orelse return;
const parent = layout.nodes[parent_index];
if (widgetClipsForAudit(parent.widget)) {
+6 -6
View File
@@ -380,7 +380,7 @@ fn nodeIsOverlapCandidate(layout: WidgetLayoutTree, node_index: usize, parent_in
const node = layout.nodes[node_index];
if (node.parent_index != parent_index) return false;
const widget = node.widget;
if (widget.layout.anchor != null) return false;
if (widget_tree.widgetEscapesAncestorClips(widget)) return false;
if (widget.semantics.hidden) return false;
if (widget.opacity <= 0) return false;
if (!equality_model.affinesEqual(widget.transform, canvas.Affine.identity())) return false;
@@ -411,15 +411,15 @@ fn scopeScrollsHorizontally(widget: Widget) bool {
}
/// Nearest ancestor whose clip bounds this node: the first clipping
/// ancestor, or the window (node 0's scope) when none clips. Anchored
/// floating widgets hoist out of every ancestor clip and are clipped by
/// the window alone, so the walk stops at an anchor boundary.
/// ancestor, or the window (node 0's scope) when none clips. Window-level
/// surfaces hoist out of every ancestor clip and are clipped by the window
/// alone, so the walk stops at that boundary.
fn clipScopeIndex(layout: WidgetLayoutTree, node_index: usize) ?usize {
if (layout.nodes[node_index].widget.layout.anchor != null) return null;
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[node_index].widget)) return null;
var current = layout.nodes[node_index].parent_index;
while (current) |index| {
if (widgetClipsForAudit(layout.nodes[index].widget)) return index;
if (layout.nodes[index].widget.layout.anchor != null) return null;
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[index].widget)) return null;
current = layout.nodes[index].parent_index;
}
return null;
+11
View File
@@ -780,6 +780,11 @@ pub const widgetWindowDragTargetIndexFromNode = @import("widget_routing.zig").wi
/// stack-container list all derive from).
pub const widgetKindStacksChildren = @import("widget_layout.zig").widgetKindStacksChildren;
pub const widgetIsAnchored = @import("widget_tree.zig").widgetIsAnchored;
pub const widgetIsRootRelativeModal = @import("widget_tree.zig").widgetIsRootRelativeModal;
pub const widgetEscapesAncestorClips = @import("widget_tree.zig").widgetEscapesAncestorClips;
pub const WidgetPaintOrder = @import("widget_tree.zig").WidgetPaintOrder;
pub const widgetLayoutWindowSurfaceOrder = @import("widget_tree.zig").widgetLayoutWindowSurfaceOrder;
pub const widgetPaintOrderLess = @import("widget_tree.zig").widgetPaintOrderLess;
/// The runtime-scrolled virtual list predicate (widget_tree.zig): a
/// virtualized scroll_view with a DECLARED total item count, whose
/// scroll offset the runtime owns (engine scrolling + native drivers)
@@ -798,6 +803,12 @@ pub const disclosureSettledOpen = @import("widget_tree.zig").disclosureSettledOp
pub const disclosureContentBottom = @import("widget_tree.zig").disclosureContentBottom;
pub const isWidgetConcealedByDisclosure = @import("widget_tree.zig").isWidgetConcealedByDisclosure;
pub const anchoredWidgetFrame = @import("widget_layout.zig").anchoredWidgetFrame;
pub const relayoutAnchoredChildren = @import("widget_layout.zig").relayoutAnchoredChildren;
pub const relayoutAnchoredChildrenWithRootBounds = @import("widget_layout.zig").relayoutAnchoredChildrenWithRootBounds;
pub const relayoutAnchoredChildrenAtDepth = @import("widget_layout.zig").relayoutAnchoredChildrenAtDepth;
pub const anchoredNestingDepth = @import("widget_layout.zig").anchoredNestingDepth;
pub const maxAnchoredNestingDepth = @import("widget_layout.zig").maxAnchoredNestingDepth;
pub const widgetLayoutRootBounds = @import("widget_render.zig").widgetLayoutRootBounds;
/// Window-control reservation trigger (widget_layout.zig): true when a
/// laid-out tree left drag-header CONTENT under the OS window-control
/// cluster, so runtimes know to stamp `DesignTokens.window_controls`
@@ -182,6 +182,44 @@ test "leaf trigger kinds lay out their anchored children" {
try std.testing.expect(layout.findById(4) != null);
}
test "anchored relayout recomputes placement from a translated trigger frame" {
const menu_items = [_]Widget{.{ .id = 5, .kind = .menu_item, .frame = geometry.RectF.init(0, 0, 0, 24), .text = "One" }};
const dropdown = Widget{
.id = 4,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 140, 50),
.layout = .{ .anchor = .{} },
.children = &menu_items,
};
const wrap_children = [_]Widget{
.{ .id = 3, .kind = .text, .text = "Files" },
dropdown,
};
const scroll_children = [_]Widget{
.{ .id = 2, .kind = .stack, .frame = geometry.RectF.init(0, 140, 160, 28), .children = &wrap_children },
.{ .id = 6, .kind = .panel, .frame = geometry.RectF.init(0, 320, 160, 20) },
};
const scroll = Widget{ .id = 1, .kind = .scroll_view, .children = &scroll_children };
var nodes: [6]WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(scroll, geometry.RectF.init(0, 0, 180, 100), &nodes);
// Model the post-layout scroll restore: content moved, while the old
// anchored frame merely rode the translation and retained its stale
// pre-scroll clamp.
for (nodes[1..layout.nodes.len]) |*node| {
node.frame.y -= 130;
node.widget.frame = node.frame;
}
try canvas.relayoutAnchoredChildren(nodes[0..layout.nodes.len], .{});
const relaid = canvas.WidgetLayoutTree{ .nodes = nodes[0..layout.nodes.len] };
const trigger = relaid.findById(2).?.frame;
const surface = relaid.findById(4).?.frame;
try std.testing.expectEqual(@as(f32, 10), trigger.y);
try std.testing.expectEqual(trigger.maxY() + 4, surface.y);
try std.testing.expectEqual(@as(f32, 50), surface.height);
}
/// The shared fixture for z-order/clip tests: a scroll pane whose content
/// holds the trigger + anchored dropdown, and a LATER sibling panel the
/// dropdown overlaps. In-tree paint order would put the panel above the
@@ -217,6 +255,74 @@ fn firstCommandIndexForWidget(list: canvas.DisplayList, widget_id: canvas.Object
return null;
}
test "window-level modals paint and route above later anchored overlays" {
const dialog = Widget{
.id = 2,
.kind = .dialog,
.frame = geometry.RectF.init(0, 0, 200, 100),
};
const dropdown = Widget{
.id = 3,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 200, 100),
.layout = .{ .anchor = .{ .offset = 0, .point = geometry.PointF.init(100, 100) } },
};
// The dropdown mounts later and overlaps the centered dialog exactly.
// Modal token order, not raw node order, owns the window-level stack.
const root = Widget{ .id = 1, .kind = .stack, .children = &.{ dialog, dropdown } };
var nodes: [4]WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(root, window, &nodes);
var commands: [64]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try layout.emitDisplayList(&builder, .{});
const list = builder.displayList();
const dialog_index = firstCommandIndexForWidget(list, 2) orelse return error.TestUnexpectedResult;
const dropdown_index = firstCommandIndexForWidget(list, 3) orelse return error.TestUnexpectedResult;
try std.testing.expect(dialog_index > dropdown_index);
try std.testing.expectEqual(@as(canvas.ObjectId, 2), layout.hitTest(geometry.PointF.init(150, 150)).?.id);
// An explicit layer remains the author-controlled escape hatch.
const elevated_dropdown = Widget{
.id = 3,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 200, 100),
.layer = 400,
.layout = .{ .anchor = .{ .offset = 0, .point = geometry.PointF.init(100, 100) } },
};
const elevated_root = Widget{ .id = 1, .kind = .stack, .children = &.{ dialog, elevated_dropdown } };
var elevated_nodes: [4]WidgetLayoutNode = undefined;
const elevated = try canvas.layoutWidgetTree(elevated_root, window, &elevated_nodes);
try std.testing.expectEqual(@as(canvas.ObjectId, 3), elevated.hitTest(geometry.PointF.init(150, 150)).?.id);
}
test "default anchored surfaces inside a modal stay above their modal" {
const dropdown = Widget{
.id = 3,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 100, 50),
.layout = .{ .anchor = .{ .offset = 0, .point = geometry.PointF.init(120, 120) } },
};
const dialog = Widget{
.id = 2,
.kind = .dialog,
.frame = geometry.RectF.init(0, 0, 200, 100),
.children = &.{dropdown},
};
const root = Widget{ .id = 1, .kind = .stack, .children = &.{dialog} };
var nodes: [4]WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(root, window, &nodes);
var commands: [64]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try layout.emitDisplayList(&builder, .{});
const list = builder.displayList();
const dialog_index = firstCommandIndexForWidget(list, 2) orelse return error.TestUnexpectedResult;
const dropdown_index = firstCommandIndexForWidget(list, 3) orelse return error.TestUnexpectedResult;
try std.testing.expect(dropdown_index > dialog_index);
try std.testing.expectEqual(@as(canvas.ObjectId, 3), layout.hitTest(geometry.PointF.init(140, 140)).?.id);
}
test "anchored surfaces render in a late z-pass above later siblings and outside ancestor clips" {
var nodes: [8]WidgetLayoutNode = undefined;
const layout = try buildOverlapFixture(&nodes);
@@ -2758,10 +2758,13 @@ test "built-in modal surfaces render house chrome and semantics" {
const root = Widget{ .kind = .stack, .children = &.{ backdrop, dialog, drawer, sheet } };
var nodes: [5]WidgetLayoutNode = undefined;
const layout = try layoutWidgetTree(root, geometry.RectF.init(0, 0, 920, 240), &nodes);
try std.testing.expectEqual(WidgetKind.panel, layout.hitTest(geometry.PointF.init(300, 220)).?.kind);
try std.testing.expectEqual(WidgetKind.dialog, layout.hitTest(geometry.PointF.init(12, 12)).?.kind);
try std.testing.expectEqual(WidgetKind.drawer, layout.hitTest(geometry.PointF.init(352, 12)).?.kind);
try std.testing.expectEqual(WidgetKind.sheet, layout.hitTest(geometry.PointF.init(652, 12)).?.kind);
// Modal placement is root-relative through the real layout pass:
// dialog centered, drawer full-width at the bottom, sheet full-height
// at the right. All three use the modal layer above the backdrop.
try std.testing.expectEqual(WidgetKind.drawer, layout.hitTest(geometry.PointF.init(300, 220)).?.kind);
try std.testing.expectEqual(WidgetKind.dialog, layout.hitTest(geometry.PointF.init(310, 50)).?.kind);
try std.testing.expectEqual(WidgetKind.drawer, layout.hitTest(geometry.PointF.init(12, 100)).?.kind);
try std.testing.expectEqual(WidgetKind.sheet, layout.hitTest(geometry.PointF.init(800, 12)).?.kind);
var semantics_buffer: [5]WidgetSemanticsNode = undefined;
const semantics = try layout.collectSemantics(&semantics_buffer);
+33 -6
View File
@@ -93,6 +93,9 @@ fn widgetWithRenderState(widget: Widget, state: WidgetRenderState) Widget {
}
pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTokens, output: []WidgetInvalidation) Error![]const WidgetInvalidation {
const previous_root_bounds = widget_render.widgetLayoutRootBounds(previous);
const next_root_bounds = widget_render.widgetLayoutRootBounds(next);
const root_bounds_dirty = !optionalRectsEqual(previous_root_bounds, next_root_bounds);
// Id lookups ride the probe-table index whenever the trees are big
// enough to be worth a table reset and fit its half-full bound;
// otherwise the linear scans run as before. Same invalidations
@@ -132,7 +135,16 @@ pub fn diffWidgetLayoutTrees(previous: anytype, next: anytype, tokens: DesignTok
continue;
};
var change = widgetChange(previous_node, next_ref.node, previous_index, next_ref.index, tokens);
var change = widgetChange(
previous_node,
next_ref.node,
previous_index,
next_ref.index,
tokens,
root_bounds_dirty and previous_node.parent_index == null and next_ref.node.parent_index == null,
previous_root_bounds,
next_root_bounds,
);
if (previous_node.widget.semantics.hidden != next_ref.node.widget.semantics.hidden) {
change.dirty_bounds = unionOptionalBounds(
unionOptionalBounds(
@@ -266,13 +278,23 @@ fn terminalBindingClean(previous: widget_model.TerminalBinding, next: widget_mod
return previous.grid == null and next.grid == null;
}
fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_index: usize, next_index: usize, tokens: DesignTokens) WidgetInvalidation {
fn widgetChange(
previous: WidgetLayoutNode,
next: WidgetLayoutNode,
previous_index: usize,
next_index: usize,
tokens: DesignTokens,
root_bounds_dirty: bool,
previous_root_bounds: ?geometry.RectF,
next_root_bounds: ?geometry.RectF,
) WidgetInvalidation {
const previous_diff_lines = previous.widget.codeDiffLines();
const next_diff_lines = next.widget.codeDiffLines();
const layout_dirty =
previous.widget.kind != next.widget.kind or
previous.depth != next.depth or
previous.parent_index != next.parent_index or
root_bounds_dirty or
!rectsEqual(previous.frame, next.frame) or
previous.widget.code_line_number_digits != next.widget.code_line_number_digits or
!widgetLayoutStylesEqual(previous.widget.layout, next.widget.layout);
@@ -321,7 +343,12 @@ fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_ind
!widgetSemanticsEqual(previous.widget.semantics, next.widget.semantics);
const paint_dirty = layout_dirty or content_dirty or visual_dirty or state_dirty or visibility_dirty or layer_dirty;
const dirty_bounds = if (layout_dirty or visibility_dirty or layer_dirty)
const dirty_bounds = if (root_bounds_dirty)
unionOptionalBounds(
unionOptionalBounds(widgetFullPaintBounds(previous, tokens), widgetFullPaintBounds(next, tokens)),
unionOptionalBounds(previous_root_bounds, next_root_bounds),
)
else if (layout_dirty or visibility_dirty or layer_dirty)
unionOptionalBounds(widgetFullPaintBounds(previous, tokens), widgetFullPaintBounds(next, tokens))
else if (paint_dirty)
widgetPaintChangeBounds(previous.widget, next.widget, tokens)
@@ -609,9 +636,9 @@ fn widgetClippedDirtyBounds(layout: anytype, node_index: usize, bounds: ?geometr
var clipped = (bounds orelse return null).normalized();
var current: usize = node_index;
while (true) {
// Anchored floating widgets escape ancestor clips (they render in
// the hoisted window-level pass), so their dirty bounds do too.
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) break;
// Window-level surfaces render in the hoisted pass, so their dirty
// bounds escape ancestor clips too.
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[current].widget)) break;
const parent_index = layout.nodes[current].parent_index orelse break;
if (parent_index >= layout.nodes.len) return null;
const parent = layout.nodes[parent_index];
+305 -112
View File
@@ -58,7 +58,7 @@ pub const max_widget_depth: usize = 32;
pub fn layoutWidgetDepth(
widget: Widget,
frame: geometry.RectF,
proposed_frame: geometry.RectF,
parent_index: ?usize,
depth: usize,
output: []WidgetLayoutNode,
@@ -69,9 +69,14 @@ pub fn layoutWidgetDepth(
if (len.* >= output.len) return error.WidgetLayoutListFull;
const index = len.*;
const root_bounds = if (index == 0) proposed_frame else output[0].frame;
const frame = rootRelativeModalFrame(widget, proposed_frame, root_bounds, tokens, depth);
output[index] = .{
.widget = widgetWithFrame(widget, frame),
.frame = frame,
// During the root's recursive layout its node frame carries the
// viewport so nested modals and anchors can resolve in window
// space. Restore the root's own resolved frame before returning.
.frame = if (index == 0) root_bounds else frame,
.depth = depth,
.parent_index = parent_index,
};
@@ -129,7 +134,7 @@ pub fn layoutWidgetDepth(
// (plus whatever stacks below it) moves.
const child_content = accordionContentFrame(widget, content, tokens, depth);
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(child_content, child, tokens), index, depth + 1, output, len, tokens);
}
},
@@ -139,13 +144,23 @@ pub fn layoutWidgetDepth(
// title line — the standard callout grid.
const child_content = alertContentFrame(widget, content, tokens);
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(child_content, child, tokens), index, depth + 1, output, len, tokens);
}
},
.stack, .bubble, .card, .dialog, .drawer, .sheet, .resizable, .panel, .popover => {
.stack, .bubble, .card, .resizable, .panel, .popover => {
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(content, child, tokens), index, depth + 1, output, len, tokens);
}
},
// Modal surfaces own root-relative geometry: the dialog centers,
// the drawer pins to the bottom edge, and the sheet pins right.
// Their content still uses the stacking-surface contract inside
// that resolved frame.
.dialog, .drawer, .sheet => {
for (widget.children) |child| {
if (!widgetTakesFlowSlot(child)) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(content, child, tokens), index, depth + 1, output, len, tokens);
}
},
@@ -160,9 +175,10 @@ pub fn layoutWidgetDepth(
.icon, .image, .avatar, .badge, .button, .toggle_button, .icon_button, .select, .input, .text_field, .search_field, .combobox, .textarea, .tooltip, .menu_item, .status_bar, .segmented_control, .checkbox, .radio, .switch_control, .toggle, .slider, .progress, .separator, .skeleton, .spinner, .chart, .split_divider, .media_surface, .terminal => {},
}
// Anchored floating children are excluded from every flow above (they
// consume no parent space) and positioned here instead, against this
// widget's resolved frame and the window (the layout root's frame).
// Root-relative modals and anchored floating children are excluded from
// every flow above (they consume no parent space) and positioned here
// instead. Modal roots resolve against the window first; anchored roots
// then resolve against this widget's frame and the window.
// Leaf trigger kinds (select, button, ...) never lay out flow
// children, but their anchored children float all the same.
// A drag header's anchor base is trimmed clear of the stamped
@@ -170,11 +186,66 @@ pub fn layoutWidgetDepth(
// collision scan counts anchored descendants too, so the remedy must
// move them or the one retry is paid for nothing. Non-drag widgets
// pass through untouched (menus and popovers anchor everywhere).
try layoutAnchoredChildren(widget.children, windowControlsClearedContent(frame, widget, tokens), index, depth, output, len, tokens);
try layoutRootRelativeModalChildren(widget.children, index, depth, output, len, root_bounds, tokens);
try layoutAnchoredChildren(widget.children, windowControlsClearedContent(frame, widget, tokens), index, depth, output, len, root_bounds, tokens);
if (index == 0) output[index].frame = frame;
return index;
}
fn rootRelativeModalFrame(widget: Widget, proposed: geometry.RectF, root: geometry.RectF, tokens: DesignTokens, depth: usize) geometry.RectF {
const kind: widget_model.BuiltinComponentKind = switch (widget.kind) {
.dialog => .dialog,
.drawer => .drawer,
.sheet => .sheet,
else => return proposed,
};
const intrinsic = intrinsicWidgetSizeDepth(widget, tokens, depth);
const preferred = geometry.SizeF.init(
clampIntrinsicAxis(if (widget.frame.width > 0) widget.frame.width else intrinsic.width, widget.layout.min_size.width, widget.layout.max_size.width),
clampIntrinsicAxis(if (widget.frame.height > 0) widget.frame.height else intrinsic.height, widget.layout.min_size.height, widget.layout.max_size.height),
);
return widget_model.builtinSurfaceFrame(kind, .{
.bounds = root,
.preferred_size = preferred,
}) orelse proposed;
}
fn widgetTakesFlowSlot(widget: Widget) bool {
return !widget_tree.widgetEscapesAncestorClips(widget);
}
fn widgetFlowChildCount(children: []const Widget) usize {
var count: usize = 0;
for (children) |child| {
if (widgetTakesFlowSlot(child)) count += 1;
}
return count;
}
fn firstWidgetFlowChild(children: []const Widget) ?Widget {
for (children) |child| {
if (widgetTakesFlowSlot(child)) return child;
}
return null;
}
fn layoutRootRelativeModalChildren(
children: []const Widget,
parent_index: usize,
depth: usize,
output: []WidgetLayoutNode,
len: *usize,
root_bounds: geometry.RectF,
tokens: DesignTokens,
) Error!void {
for (children) |child| {
if (!widget_tree.widgetIsRootRelativeModal(child)) continue;
_ = try layoutWidgetDepth(child, root_bounds, parent_index, depth + 1, output, len, tokens);
}
}
/// Lay a drag header's content out clear of the OS window-control
/// cluster. On hidden-titlebar windows the content extends under the
/// system's control band, and the cluster sits ON the content at one
@@ -317,15 +388,113 @@ fn layoutAnchoredChildren(
depth: usize,
output: []WidgetLayoutNode,
len: *usize,
root_bounds: geometry.RectF,
tokens: DesignTokens,
) Error!void {
for (children) |child| {
if (widget_tree.widgetIsRootRelativeModal(child)) continue;
const anchor = child.layout.anchor orelse continue;
const child_frame = anchoredWidgetFrame(child, anchor, anchor_rect, output[0].frame, tokens);
const child_frame = anchoredWidgetFrame(child, anchor, anchor_rect, root_bounds, tokens);
_ = try layoutWidgetDepth(child, child_frame, parent_index, depth + 1, output, len, tokens);
}
}
/// Re-run every anchored-child pass IN PLACE after a runtime geometry
/// reconciliation moved laid frames (retained scroll restoration, an
/// engine clamp, or another post-layout adjustment). Each laid parent still
/// carries its source children until adoption, so this recovers the authored
/// surface size and re-applies flip/window clamping against the parent's
/// FINAL frame instead of translating a surface that was clamped against a
/// stale pre-reconcile anchor. The original layout already charged the node
/// and anchored-surface budgets; this pass rewrites the same pre-order slots
/// and allocates none.
pub fn anchoredNestingDepth(output: []const WidgetLayoutNode, node_index: usize) usize {
if (node_index >= output.len) return 0;
var count: usize = 0;
var current: ?usize = node_index;
while (current) |index| {
if (index >= output.len) break;
if (widget_tree.widgetIsAnchored(output[index].widget)) count += 1;
current = output[index].parent_index;
}
return count;
}
pub fn maxAnchoredNestingDepth(output: []const WidgetLayoutNode) usize {
var max_depth: usize = 0;
for (output, 0..) |node, index| {
if (!widget_tree.widgetIsAnchored(node.widget)) continue;
max_depth = @max(max_depth, anchoredNestingDepth(output, index));
}
return max_depth;
}
/// Re-layout only the anchored roots whose authored parent lives in the
/// requested anchored-surface stratum. Runtime reconciliation uses this
/// top-down: finalize the outer geometry, replay its direct surfaces, then
/// restore state inside those surfaces before replaying their nested ones.
pub fn relayoutAnchoredChildrenAtDepth(output: []WidgetLayoutNode, root_bounds: geometry.RectF, tokens: DesignTokens, parent_anchored_depth: usize) Error!void {
if (output.len == 0) return;
// Partial re-layout enters below the already-resolved root. Mirror the
// full layout's construction-time root frame so recursive modal and
// nested-anchor layout still reads the viewport, then restore the live
// root surface frame before returning.
const root_frame = output[0].frame;
output[0].frame = root_bounds;
defer output[0].frame = root_frame;
// The layout sequence puts a parent's anchored children after every
// in-flow descendant. Walk directly to those anchored roots instead of
// searching every parent's subtree: rebuilds with no open surface stay
// O(nodes), and an open surface pays only for the bounded anchored
// subtrees that are actually replayed.
var anchored_start: usize = 1;
while (anchored_start < output.len) {
const anchored = output[anchored_start];
if (anchored.widget.layout.anchor == null) {
anchored_start += 1;
continue;
}
const parent_index = anchored.parent_index orelse {
anchored_start += 1;
continue;
};
if (anchoredNestingDepth(output, parent_index) != parent_anchored_depth) {
anchored_start += 1;
continue;
}
const parent = output[parent_index];
var len = anchored_start;
try layoutAnchoredChildren(
parent.widget.children,
windowControlsClearedContent(parent.frame, parent.widget, tokens),
parent_index,
parent.depth,
output,
&len,
root_bounds,
tokens,
);
// Replaying one anchored root lays every anchored sibling belonging
// to that parent, plus any nested anchored descendants. Resume at
// the first untouched node. A malformed retained tree whose source
// has no matching anchored child still makes forward progress.
anchored_start = if (len > anchored_start) len else anchored_start + 1;
}
}
pub fn relayoutAnchoredChildren(output: []WidgetLayoutNode, tokens: DesignTokens) Error!void {
if (output.len == 0) return;
try relayoutAnchoredChildrenWithRootBounds(output, output[0].frame, tokens);
}
pub fn relayoutAnchoredChildrenWithRootBounds(output: []WidgetLayoutNode, root_bounds: geometry.RectF, tokens: DesignTokens) Error!void {
const max_depth = maxAnchoredNestingDepth(output);
var parent_depth: usize = 0;
while (parent_depth < max_depth) : (parent_depth += 1) {
try relayoutAnchoredChildrenAtDepth(output, root_bounds, tokens, parent_depth);
}
}
/// Resolved frame of an anchored floating widget: sized from its explicit
/// frame or intrinsic content (`stretch` widens to at least the anchor's
/// width), placed on the preferred side of the anchor rect — flipping to
@@ -538,12 +707,12 @@ fn layoutAxisChildrenMode(
comptime fill_primary_tabs: bool,
comptime stretch_tab_triggers: bool,
) Error!void {
// Anchored floating children take no flow slot: they are skipped in
// every pass here (measurement, gap counting, placement) and laid out
// by `layoutAnchoredChildren` against the parent's frame instead.
// Window-level surfaces take no flow slot: they are skipped in every
// pass here (measurement, gap counting, placement) and laid out by the
// window-surface passes against the parent/window instead.
var flow_count: usize = 0;
for (children) |child| {
if (child.layout.anchor == null) flow_count += 1;
if (widgetTakesFlowSlot(child)) flow_count += 1;
}
if (flow_count == 0) return;
@@ -561,7 +730,7 @@ fn layoutAxisChildrenMode(
var grow_total: f32 = 0;
var fill_width_count: usize = 0;
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const implicitly_fills = if (comptime fill_primary_tabs) axisChildImplicitlyFillsWidth(child) else false;
if (implicitly_fills) {
fill_width_count += 1;
@@ -588,7 +757,7 @@ fn layoutAxisChildrenMode(
if (comptime fill_primary_tabs) {
if (fill_width_count > 0) {
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
if (!axisChildImplicitlyFillsWidth(child)) continue;
fill_width_extent += axisChildImplicitFillExtent(child, axis, fill_width_share, tokens);
}
@@ -611,7 +780,7 @@ fn layoutAxisChildrenMode(
} + mainAxisAlignmentOffset(style.main_alignment, free_extent);
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const grow = nonNegative(child.layout.grow);
const implicitly_fills = if (comptime fill_primary_tabs) axisChildImplicitlyFillsWidth(child) else false;
const main_extent = if (implicitly_fills)
@@ -666,16 +835,15 @@ pub var test_axis_overflow_diagnostics: usize = 0;
/// operating mode — the scroll exists precisely to reveal it — so the
/// vertical overflow diagnostic must stay quiet there. The walk is the
/// same rule the layout audit applies (`scopeScrollsVertically`), which
/// is why the audit was already clean on these shapes. Anchored
/// floating subtrees hoist out of every ancestor scope (window-clipped,
/// not parent-clipped), so the walk stops at an anchor boundary just
/// like the audit's clip-scope walk.
/// is why the audit was already clean on these shapes. Window-level
/// surfaces hoist out of every ancestor scope, so the walk stops at that
/// boundary just like the audit's clip-scope walk.
fn widgetInsideVerticalScrollScope(output: []const WidgetLayoutNode, parent_index: usize) bool {
var current: ?usize = parent_index;
while (current) |index| {
const widget = output[index].widget;
if ((widget.kind == .scroll_view and widget.scroll_axes.scrollsVertically()) or widget.layout.virtualized) return true;
if (widget.layout.anchor != null) return false;
if (widget_tree.widgetEscapesAncestorClips(widget)) return false;
current = output[index].parent_index;
}
return false;
@@ -690,7 +858,7 @@ fn widgetInsideHorizontalScrollScope(output: []const WidgetLayoutNode, parent_in
while (current) |index| {
const widget = output[index].widget;
if (widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized) return true;
if (widget.layout.anchor != null) return false;
if (widget_tree.widgetEscapesAncestorClips(widget)) return false;
current = output[index].parent_index;
}
return false;
@@ -838,7 +1006,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
else if (widget.children.len > 0) blk: {
var max_height: f32 = 0;
for (widget.children, 0..) |child, index| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
max_height = @max(max_height, wrappedVerticalExtentForWidth(
child,
rowChildWidth(widget, inner_width, index, tokens),
@@ -853,7 +1021,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
var sum: f32 = 0;
var flow_count: usize = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
// A column-direct bubble hugs up to the thread fraction
// (the cross-extent seam), so its wrapped height must
// measure at that same capped width.
@@ -874,7 +1042,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
.stack, .panel, .card, .bubble, .resizable, .popover => blk: {
var max_height: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const child_width = if (child.frame.width > 0) child.frame.width else inner_width;
max_height = @max(max_height, wrappedVerticalExtentForWidth(child, child_width, tokens, depth + 1));
}
@@ -891,7 +1059,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
const indent = if (widget.text.len > 0) icon_size + text_gap else 0;
var max_height: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const child_width = if (child.frame.width > 0) child.frame.width else @max(0, inner_width - indent);
max_height = @max(max_height, wrappedVerticalExtentForWidth(child, child_width, tokens, depth + 1));
}
@@ -913,7 +1081,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
if (!accordionChildrenVisible(widget)) break :blk header_height;
var max_height: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const child_width = if (child.frame.width > 0) child.frame.width else inner_width;
max_height = @max(max_height, wrappedVerticalExtentForWidth(child, child_width, tokens, depth + 1));
}
@@ -923,7 +1091,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
.row, .data_row, .breadcrumb, .button_group, .pagination, .radio_group, .tabs, .toggle_group => blk: {
var max_height: f32 = 0;
for (widget.children, 0..) |child, index| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
max_height = @max(max_height, wrappedVerticalExtentForWidth(
child,
rowChildWidth(widget, inner_width, index, tokens),
@@ -957,7 +1125,7 @@ fn rowChildWidthMode(row: Widget, available_width: f32, index: usize, tokens: De
var grow_total: f32 = 0;
var fill_width_count: usize = 0;
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
flow_count += 1;
const implicitly_fills = if (comptime fill_primary_tabs) axisChildImplicitlyFillsWidth(child) else false;
if (implicitly_fills) {
@@ -990,7 +1158,7 @@ fn rowChildWidthMode(row: Widget, available_width: f32, index: usize, tokens: De
if (comptime fill_primary_tabs) {
if (fill_width_count > 0) {
for (children) |candidate| {
if (candidate.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(candidate)) continue;
if (!axisChildImplicitlyFillsWidth(candidate)) continue;
fill_width_extent += axisChildImplicitFillExtent(candidate, .horizontal, fill_width_share, tokens);
}
@@ -1056,7 +1224,7 @@ fn layoutTextSpanLinkChildren(
if (child_index >= widget.children.len) break;
const child = widget.children[child_index];
child_index += 1;
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const frame = if (text_spans_model.textSpanBounds(layout, span_index)) |bounds|
geometry.RectF.init(content.x + bounds.x, content.y + bounds.y, bounds.width, bounds.height)
else
@@ -1064,7 +1232,7 @@ fn layoutTextSpanLinkChildren(
_ = try layoutWidgetDepth(child, frame, parent_index, depth + 1, output, len, tokens);
}
while (child_index < widget.children.len) : (child_index += 1) {
if (widget.children[child_index].layout.anchor != null) continue;
if (!widgetTakesFlowSlot(widget.children[child_index])) continue;
_ = try layoutWidgetDepth(widget.children[child_index], geometry.RectF.init(content.x, content.y, 0, 0), parent_index, depth + 1, output, len, tokens);
}
}
@@ -1073,7 +1241,7 @@ fn assignedAxisChildrenExtent(children: []const Widget, axis: LayoutAxis, fixed_
if (grow_total <= 0) return fixed_extent;
var assigned = fixed_extent;
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const grow = nonNegative(child.layout.grow);
if (grow <= 0) continue;
assigned += clampMainExtent(child, axis, remaining * grow / grow_total);
@@ -1128,21 +1296,23 @@ fn layoutGridChildren(
requested_columns: usize,
tokens: DesignTokens,
) Error!void {
if (children.len == 0) return;
const flow_count = widgetFlowChildCount(children);
if (flow_count == 0) return;
const columns = gridColumnCount(children.len, requested_columns);
const rows = gridRowCount(children.len, columns);
const columns = gridColumnCount(flow_count, requested_columns);
const rows = gridRowCount(flow_count, columns);
const clamped_gap = nonNegative(gap);
const total_column_gap = clamped_gap * @as(f32, @floatFromInt(columns - 1));
const total_row_gap = clamped_gap * @as(f32, @floatFromInt(rows - 1));
const cell_width = if (columns > 0) @max(0, content.width - total_column_gap) / @as(f32, @floatFromInt(columns)) else 0;
const fallback_cell_height = if (rows > 0) @max(0, content.height - total_row_gap) / @as(f32, @floatFromInt(rows)) else 0;
for (children, 0..) |child, child_index| {
// Anchored floating children keep their grid slot empty.
if (child.layout.anchor != null) continue;
const column = child_index % columns;
const row = child_index / columns;
var flow_index: usize = 0;
for (children) |child| {
if (!widgetTakesFlowSlot(child)) continue;
const column = flow_index % columns;
const row = flow_index / columns;
flow_index += 1;
const x = content.x + @as(f32, @floatFromInt(column)) * (cell_width + clamped_gap);
const y = content.y + @as(f32, @floatFromInt(row)) * (fallback_cell_height + clamped_gap);
const width = gridChildWidth(child, cell_width, tokens);
@@ -1168,10 +1338,11 @@ fn layoutVirtualGridChildren(
style: WidgetLayoutStyle,
tokens: DesignTokens,
) Error!void {
if (children.len == 0) return;
const flow_count = widgetFlowChildCount(children);
if (flow_count == 0) return;
const columns = gridColumnCount(children.len, style.columns);
const rows = gridRowCount(children.len, columns);
const columns = gridColumnCount(flow_count, style.columns);
const rows = gridRowCount(flow_count, columns);
if (columns == 0 or rows == 0) return;
const clamped_gap = nonNegative(style.gap);
@@ -1194,29 +1365,28 @@ fn layoutVirtualGridChildren(
if (range.isEmpty()) return;
const stride = range.item_extent + range.item_gap;
var row = range.start_index;
while (row < range.end_index) : (row += 1) {
var column: usize = 0;
while (column < columns) : (column += 1) {
const child_index = row * columns + column;
if (child_index >= children.len) break;
if (children[child_index].layout.anchor != null) continue;
var flow_index: usize = 0;
for (children) |source| {
if (!widgetTakesFlowSlot(source)) continue;
defer flow_index += 1;
const row = flow_index / columns;
if (row < range.start_index or row >= range.end_index) continue;
const column = flow_index % columns;
var child = children[child_index];
child.semantics.list_item_index = saturatingU32(child_index);
child.semantics.list_item_count = saturatingU32(children.len);
const x = content.x + @as(f32, @floatFromInt(column)) * (cell_width + clamped_gap);
const y = content.y + @as(f32, @floatFromInt(row)) * stride - range.layout_offset + child.frame.y;
const width = gridChildWidth(child, cell_width, tokens);
const height = clampIntrinsicAxis(if (child.frame.height > 0) child.frame.height else range.item_extent, child.layout.min_size.height, child.layout.max_size.height);
const child_frame = geometry.RectF.init(
x + child.frame.x,
y,
width,
height,
);
_ = try layoutWidgetDepth(child, child_frame, parent_index, depth + 1, output, len, tokens);
}
var child = source;
child.semantics.list_item_index = saturatingU32(flow_index);
child.semantics.list_item_count = saturatingU32(flow_count);
const x = content.x + @as(f32, @floatFromInt(column)) * (cell_width + clamped_gap);
const y = content.y + @as(f32, @floatFromInt(row)) * stride - range.layout_offset + child.frame.y;
const width = gridChildWidth(child, cell_width, tokens);
const height = clampIntrinsicAxis(if (child.frame.height > 0) child.frame.height else range.item_extent, child.layout.min_size.height, child.layout.max_size.height);
const child_frame = geometry.RectF.init(
x + child.frame.x,
y,
width,
height,
);
_ = try layoutWidgetDepth(child, child_frame, parent_index, depth + 1, output, len, tokens);
}
}
@@ -1234,9 +1404,12 @@ fn gridChildWidth(child: Widget, cell_width: f32, tokens: DesignTokens) f32 {
fn preferredGridRowExtent(children: []const Widget, columns: usize, tokens: DesignTokens) f32 {
if (children.len == 0 or columns == 0) return 0;
var max_height: f32 = 0;
var index: usize = 0;
while (index < children.len and index < columns) : (index += 1) {
max_height = @max(max_height, preferredMainExtent(children[index], .vertical, tokens));
var flow_index: usize = 0;
for (children) |child| {
if (!widgetTakesFlowSlot(child)) continue;
if (flow_index >= columns) break;
max_height = @max(max_height, preferredMainExtent(child, .vertical, tokens));
flow_index += 1;
}
return max_height;
}
@@ -1265,7 +1438,7 @@ fn layoutScrollChildren(
) Error!void {
const scrolled_content = content.translate(geometry.OffsetF.init(-scroll_offset.dx, -scroll_offset.dy));
for (children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
var child_frame = stackChildFrame(scrolled_content, child, tokens);
// A `both` region must retain the same intrinsic width as a
// horizontal-only shelf; otherwise its horizontal axis has no
@@ -1289,7 +1462,8 @@ fn layoutVirtualVerticalChildren(
style: WidgetLayoutStyle,
tokens: DesignTokens,
) Error!void {
if (children.len == 0) return;
const flow_count = widgetFlowChildCount(children);
if (flow_count == 0) return;
// A VARIABLE-extent windowed virtual list (`virtual_total_extent >
// 0`): the window's offset table already priced everything outside
@@ -1312,13 +1486,13 @@ fn layoutVirtualVerticalChildren(
// declared count: children are the full item set) is byte-identical.
const first_index = style.virtual_first_index;
const item_count = if (style.virtual_item_count > 0)
@max(style.virtual_item_count, first_index + children.len)
@max(style.virtual_item_count, first_index + flow_count)
else
children.len;
flow_count;
const item_extent = if (style.virtual_item_extent > 0)
style.virtual_item_extent
else
preferredMainExtent(children[0], .vertical, tokens);
preferredMainExtent(firstWidgetFlowChild(children).?, .vertical, tokens);
const range = virtualListRange(.{
.item_count = item_count,
.item_extent = item_extent,
@@ -1332,11 +1506,13 @@ fn layoutVirtualVerticalChildren(
if (range.isEmpty()) return;
const stride = range.item_extent + range.item_gap;
var index = @max(range.start_index, first_index);
const end_index = @min(range.end_index, first_index + children.len);
while (index < end_index) : (index += 1) {
if (children[index - first_index].layout.anchor != null) continue;
var child = children[index - first_index];
var flow_offset: usize = 0;
for (children) |source| {
if (!widgetTakesFlowSlot(source)) continue;
defer flow_offset += 1;
const index = first_index + flow_offset;
if (index < range.start_index or index >= range.end_index) continue;
var child = source;
child.semantics.list_item_index = saturatingU32(index);
child.semantics.list_item_count = saturatingU32(item_count);
const y = content.y + @as(f32, @floatFromInt(index)) * stride - range.layout_offset + child.frame.y;
@@ -1376,7 +1552,9 @@ fn layoutVariableVirtualChildren(
tokens: DesignTokens,
) Error!void {
const first_index = style.virtual_first_index;
const item_count = @max(style.virtual_item_count, first_index + children.len);
const flow_count = widgetFlowChildCount(children);
if (flow_count == 0) return;
const item_count = @max(style.virtual_item_count, first_index + flow_count);
const gap = nonNegative(style.gap);
const total_extent = @max(0, style.virtual_total_extent);
const max_offset = @max(0, total_extent - content.height);
@@ -1385,7 +1563,7 @@ fn layoutVariableVirtualChildren(
output[parent_index].widget.semantics.list_item_count = saturatingU32(item_count);
const anchor_child = std.math.clamp(style.virtual_anchor_index -| first_index, 0, children.len - 1);
const anchor_child = std.math.clamp(style.virtual_anchor_index -| first_index, 0, flow_count - 1);
const anchor_y = content.y + @max(0, style.virtual_anchor_extent) - layout_offset;
// Pre-pass: back the start edge out of the anchor position by the
@@ -1393,15 +1571,20 @@ fn layoutVariableVirtualChildren(
// emit every row in window order so layout-node order — which
// semantics and hit routing walk — matches the source order.
var y = anchor_y;
for (children[0..anchor_child]) |child| {
var flow_offset: usize = 0;
for (children) |child| {
if (!widgetTakesFlowSlot(child)) continue;
if (flow_offset >= anchor_child) break;
y -= variableVirtualChildExtent(child, content.width, tokens, depth) + gap;
flow_offset += 1;
}
for (children, 0..) |child, offset| {
flow_offset = 0;
for (children) |child| {
if (!widgetTakesFlowSlot(child)) continue;
const height = variableVirtualChildExtent(child, content.width, tokens, depth);
if (child.layout.anchor == null) {
try layoutVariableVirtualChild(child, content, y, height, first_index + offset, item_count, parent_index, depth, output, len, tokens);
}
try layoutVariableVirtualChild(child, content, y, height, first_index + flow_offset, item_count, parent_index, depth, output, len, tokens);
y += height + gap;
flow_offset += 1;
}
}
@@ -1454,7 +1637,7 @@ fn variableVirtualRowExtent(child: Widget, width: f32, tokens: DesignTokens, dep
const inner_width = @max(0, width - padding.left - padding.right);
var max_height: f32 = 0;
for (child.children, 0..) |grand, index| {
if (grand.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(grand)) continue;
max_height = @max(max_height, wrappedVerticalExtentForWidth(
grand,
rowChildWidth(child, inner_width, index, tokens),
@@ -1522,7 +1705,7 @@ fn layoutSplitChildren(
var divider: ?Widget = null;
var extra_start: ?usize = null;
for (widget.children, 0..) |child, child_index| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
if (child.kind == .split_divider) {
if (divider == null) divider = child;
continue;
@@ -1565,7 +1748,7 @@ fn layoutSplitChildren(
// frames so the node count still matches the source tree.
if (extra_start) |start| {
for (widget.children[start..]) |child| {
if (child.layout.anchor != null or child.kind == .split_divider) continue;
if (!widgetTakesFlowSlot(child) or child.kind == .split_divider) continue;
_ = try layoutWidgetDepth(child, geometry.RectF.init(content.maxX(), content.y, 0, 0), parent_index, depth + 1, output, len, tokens);
}
}
@@ -1584,12 +1767,18 @@ pub fn relayoutSplitChildren(
node_index: usize,
depth: usize,
output: []WidgetLayoutNode,
root_bounds: geometry.RectF,
tokens: DesignTokens,
) Error!void {
if (output.len == 0) return;
const root_frame = output[0].frame;
output[0].frame = root_bounds;
defer output[0].frame = root_frame;
var len: usize = node_index + 1;
const content = frame.inset(widget.layout.padding);
try layoutSplitChildren(widget, content, node_index, depth, output, &len, tokens);
try layoutAnchoredChildren(widget.children, frame, node_index, depth, output, &len, tokens);
try layoutRootRelativeModalChildren(widget.children, node_index, depth, output, &len, root_bounds, tokens);
try layoutAnchoredChildren(widget.children, frame, node_index, depth, output, &len, root_bounds, tokens);
}
/// Slide a laid split's pane boundary to `fraction` GEOMETRICALLY, over
@@ -1616,7 +1805,7 @@ pub fn slideSplitChildren(
var child = node_index + 1;
while (child < nodes.len and nodes[child].depth > split_depth) : (child += 1) {
if (nodes[child].parent_index != node_index) continue;
if (nodes[child].widget.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(nodes[child].widget)) continue;
if (nodes[child].widget.kind == .split_divider) {
if (divider_index == null) divider_index = child;
} else if (pane_indices[0] == null) {
@@ -1744,7 +1933,7 @@ fn accordionContentFrame(widget: Widget, content: geometry.RectF, tokens: Design
fn accordionOpenContentExtent(widget: Widget, width: f32, tokens: DesignTokens, depth: usize) f32 {
var max_height: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const child_width = if (child.frame.width > 0) child.frame.width else width;
max_height = @max(max_height, wrappedVerticalExtentForWidth(child, child_width, tokens, depth + 1));
}
@@ -1926,8 +2115,8 @@ fn intrinsicAxisChildrenSize(widget: Widget, tokens: DesignTokens, axis: LayoutA
var main_sum: f32 = 0;
var cross_max: f32 = 0;
for (widget.children) |child| {
// Anchored floating children never grow their parent.
if (child.layout.anchor != null) continue;
// Window-level surfaces never grow their parent.
if (!widgetTakesFlowSlot(child)) continue;
flow_count += 1;
const size = intrinsicChildSizeInAxis(child, tokens, depth + 1, axis);
switch (axis) {
@@ -1964,7 +2153,7 @@ fn intrinsicOverlayChildrenSize(widget: Widget, tokens: DesignTokens, depth: usi
var width_max: f32 = 0;
var height_max: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const size = intrinsicChildSize(child, tokens, depth + 1);
width_max = @max(width_max, size.width);
height_max = @max(height_max, size.height);
@@ -1982,7 +2171,7 @@ fn intrinsicHorizontalScrollSize(widget: Widget, tokens: DesignTokens, depth: us
}
var height_max: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const intrinsic = intrinsicChildSize(child, tokens, depth + 1);
const child_width = if (child.frame.width > 0) child.frame.width else intrinsic.width;
height_max = @max(
@@ -1996,19 +2185,21 @@ fn intrinsicHorizontalScrollSize(widget: Widget, tokens: DesignTokens, depth: us
fn intrinsicGridChildrenSize(widget: Widget, tokens: DesignTokens, depth: usize) geometry.SizeF {
if (depth >= max_widget_depth or widget.children.len == 0) return intrinsicOwnMinSize(widget);
const flow_count = widgetFlowChildCount(widget.children);
if (flow_count == 0) return intrinsicOwnMinSize(widget);
var cell_width: f32 = 0;
var cell_height: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const size = intrinsicChildSize(child, tokens, depth + 1);
cell_width = @max(cell_width, size.width);
cell_height = @max(cell_height, size.height);
}
const columns = gridColumnCount(widget.children.len, widget.layout.columns);
const columns = gridColumnCount(flow_count, widget.layout.columns);
// `gridRowCount`, not the additive ceil-div: declared columns are
// engine input, and `children.len + columns` overflows in safe
// engine input, and `flow_count + columns` overflows in safe
// builds when a parent intrinsically measures a maxInt-columns grid.
const rows = gridRowCount(widget.children.len, columns);
const rows = gridRowCount(flow_count, columns);
const gap = nonNegative(widget.layout.gap);
return paddedIntrinsicSize(widget, geometry.SizeF.init(
cell_width * @as(f32, @floatFromInt(columns)) + gap * @as(f32, @floatFromInt(columns - 1)),
@@ -2097,7 +2288,7 @@ fn intrinsicStackedChildrenSize(widget: Widget, tokens: DesignTokens, depth: usi
var width_max: f32 = 0;
var height_max: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
if (!widgetTakesFlowSlot(child)) continue;
const size = intrinsicChildSize(child, tokens, depth + 1);
width_max = @max(width_max, size.width);
height_max = @max(height_max, size.height);
@@ -2513,12 +2704,13 @@ pub fn virtualWidgetScrollContentExtentWithTokens(widget: Widget, viewport_exten
}
const item_count = virtualWidgetScrollItemCount(widget);
if (item_count == 0) return 0;
const flow_count = widgetFlowChildCount(widget.children);
const item_extent = if (widget.layout.virtual_item_extent > 0)
widget.layout.virtual_item_extent
else if (widget.kind == .grid and widget.children.len > 0)
preferredGridRowExtent(widget.children, gridColumnCount(widget.children.len, widget.layout.columns), tokens)
else if (widget.children.len > 0)
preferredMainExtent(widget.children[0], .vertical, tokens)
else if (widget.kind == .grid and flow_count > 0)
preferredGridRowExtent(widget.children, gridColumnCount(flow_count, widget.layout.columns), tokens)
else if (firstWidgetFlowChild(widget.children)) |child|
preferredMainExtent(child, .vertical, tokens)
else
return 0;
return virtualListRange(.{
@@ -2535,14 +2727,15 @@ fn virtualWidgetScrollItemCount(widget: Widget) usize {
// children hold only the built window, so counting them would
// collapse the scrollbar (and the native driver's content size) to
// the window.
const flow_count = widgetFlowChildCount(widget.children);
if (widget.layout.virtual_item_count > 0) {
return @max(widget.layout.virtual_item_count, widget.layout.virtual_first_index + widget.children.len);
return @max(widget.layout.virtual_item_count, widget.layout.virtual_first_index + flow_count);
}
if (widget.kind == .grid and widget.children.len > 0) {
const columns = gridColumnCount(widget.children.len, widget.layout.columns);
return gridRowCount(widget.children.len, columns);
if (widget.kind == .grid and flow_count > 0) {
const columns = gridColumnCount(flow_count, widget.layout.columns);
return gridRowCount(flow_count, columns);
}
if (widget.children.len > 0) return widget.children.len;
if (flow_count > 0) return flow_count;
if (widget.semantics.list_item_count) |count| return @intCast(count);
return 0;
}
@@ -1490,6 +1490,175 @@ test "horizontal scroll views draw the bottom-edge scrollbar and two-axis region
try std.testing.expect(sheet_list.findCommandById(widgetPartId(1, 5)) != null);
}
test "modal surfaces resolve against root bounds through nested layout" {
const modal_children = [_]Widget{
.{ .id = 3, .kind = .dialog, .frame = geometry.RectF.init(7, 9, 240, 120) },
.{ .id = 4, .kind = .drawer, .frame = geometry.RectF.init(11, 13, 180, 100) },
.{ .id = 5, .kind = .sheet, .frame = geometry.RectF.init(17, 19, 160, 80) },
};
const nested = Widget{
.id = 2,
.kind = .stack,
.frame = geometry.RectF.init(30, 40, 200, 150),
.layout = .{ .padding = .{ .top = 12, .right = 12, .bottom = 12, .left = 12 } },
.children = &modal_children,
};
const root = Widget{ .id = 1, .kind = .stack, .children = &.{nested} };
const root_bounds = geometry.RectF.init(10, 20, 600, 400);
var nodes: [5]WidgetLayoutNode = undefined;
const layout = try layoutWidgetTree(root, root_bounds, &nodes);
// The nested container's frame, padding, and child offsets have no say
// in modal placement: all three use the root bounds.
try expectLayoutFrame(layout, 3, geometry.RectF.init(190, 160, 240, 120));
try expectLayoutFrame(layout, 4, geometry.RectF.init(10, 320, 600, 100));
try expectLayoutFrame(layout, 5, geometry.RectF.init(450, 20, 160, 400));
// The dialog's own scrim names the same root geometry its placement
// used, even though the surface was declared inside the padded stack.
var commands: [64]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try layout.emitDisplayList(&builder, .{});
switch (builder.displayList().findCommandById(widgetPartId(3, 14)).?.command) {
.fill_rect => |fill| try expectRect(root_bounds, fill.rect),
else => return error.TestUnexpectedResult,
}
}
test "root-relative modals consume neither flow extent nor grid cells" {
const first = Widget{ .id = 2, .kind = .button, .frame = geometry.RectF.init(0, 0, 80, 20), .text = "First" };
const dialog = Widget{ .id = 3, .kind = .dialog, .frame = geometry.RectF.init(0, 0, 200, 100) };
const second = Widget{ .id = 4, .kind = .button, .frame = geometry.RectF.init(0, 0, 80, 20), .text = "Second" };
const column = Widget{
.id = 1,
.kind = .column,
.layout = .{ .gap = 5 },
.children = &.{ first, dialog, second },
};
const column_without_modal = Widget{
.id = 1,
.kind = .column,
.layout = .{ .gap = 5 },
.children = &.{ first, second },
};
try std.testing.expectEqualDeep(intrinsicWidgetSize(column_without_modal, .{}), intrinsicWidgetSize(column, .{}));
const bounds = geometry.RectF.init(0, 0, 400, 200);
var column_nodes: [4]WidgetLayoutNode = undefined;
const column_layout = try layoutWidgetTree(column, bounds, &column_nodes);
try expectLayoutFrame(column_layout, 2, geometry.RectF.init(0, 0, 80, 20));
try expectLayoutFrame(column_layout, 4, geometry.RectF.init(0, 25, 80, 20));
try expectLayoutFrame(column_layout, 3, geometry.RectF.init(100, 50, 200, 100));
const grid = Widget{
.id = 1,
.kind = .grid,
.layout = .{ .columns = 2 },
.children = &.{ first, dialog, second },
};
var grid_nodes: [4]WidgetLayoutNode = undefined;
const grid_layout = try layoutWidgetTree(grid, bounds, &grid_nodes);
try expectLayoutFrame(grid_layout, 2, geometry.RectF.init(0, 0, 80, 20));
try expectLayoutFrame(grid_layout, 4, geometry.RectF.init(200, 0, 80, 20));
try expectLayoutFrame(grid_layout, 3, geometry.RectF.init(100, 50, 200, 100));
}
test "a modal layout root retains viewport bounds for scrims and anchored children" {
const menu = Widget{
.id = 2,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 120, 100),
.layout = .{ .anchor = .{} },
};
const dialog = Widget{
.id = 1,
.kind = .dialog,
.frame = geometry.RectF.init(0, 0, 240, 120),
.children = &.{menu},
};
const viewport = geometry.RectF.init(10, 20, 600, 400);
var nodes: [3]WidgetLayoutNode = undefined;
const layout = try layoutWidgetTree(dialog, viewport, &nodes);
const dialog_frame = layout.findById(1).?.frame;
try expectRect(geometry.RectF.init(190, 160, 240, 120), dialog_frame);
try expectRect(viewport, canvas.widgetLayoutRootBounds(layout).?);
// The menu clamps against the viewport, not the smaller dialog root.
const menu_frame = layout.findById(2).?.frame;
try std.testing.expectEqual(dialog_frame.maxY() + 4, menu_frame.y);
try std.testing.expectEqual(@as(f32, 100), menu_frame.height);
var commands: [32]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try layout.emitDisplayList(&builder, .{});
switch (builder.displayList().findCommandById(widgetPartId(1, 14)).?.command) {
.fill_rect => |fill| try expectRect(viewport, fill.rect),
else => return error.TestUnexpectedResult,
}
// A viewport may resize around the same center, leaving the dialog's
// own frame unchanged. The separate root bounds still make that a full
// scrim invalidation.
var resized_nodes: [3]WidgetLayoutNode = undefined;
const resized_viewport = geometry.RectF.init(-40, 20, 700, 400);
const resized = try layoutWidgetTree(dialog, resized_viewport, &resized_nodes);
try expectRect(dialog_frame, resized.findById(1).?.frame);
var invalidations: [3]canvas.WidgetInvalidation = undefined;
const changes = try layout.diff(resized, &invalidations);
try std.testing.expectEqual(@as(usize, 1), changes.len);
try std.testing.expect(changes[0].layout_dirty);
try expectRect(geometry.RectF.unionWith(viewport, resized_viewport), changes[0].dirty_bounds.?);
}
test "modal nested in a scroll viewport paints and routes at window level" {
const panel_children = [_]Widget{.{
.id = 4,
.kind = .dialog,
.frame = geometry.RectF.init(0, 0, 200, 100),
}};
const scroll_children = [_]Widget{.{
.id = 3,
.kind = .panel,
.frame = geometry.RectF.init(0, 0, 120, 200),
.children = &panel_children,
}};
const root_children = [_]Widget{.{
.id = 2,
.kind = .scroll_view,
.frame = geometry.RectF.init(0, 0, 120, 80),
.children = &scroll_children,
}};
const root = Widget{ .id = 1, .kind = .stack, .children = &root_children };
var nodes: [4]WidgetLayoutNode = undefined;
const layout = try layoutWidgetTree(root, geometry.RectF.init(0, 0, 400, 300), &nodes);
try expectLayoutFrame(layout, 4, geometry.RectF.init(100, 100, 200, 100));
// This point is inside the dialog but below the scroll viewport. The
// modal's window-level hit pass must still win.
const hit = layout.hitTest(geometry.PointF.init(150, 120)) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(ObjectId, 4), hit.id);
// The modal chrome is emitted after the scroll viewport's clip closes.
var commands: [64]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try layout.emitDisplayList(&builder, .{});
var clip_depth: usize = 0;
var saw_dialog_chrome = false;
for (builder.displayList().commands) |command| {
switch (command) {
.push_clip => clip_depth += 1,
.pop_clip => clip_depth -= 1,
else => {},
}
if (command.objectId() == widgetPartId(4, 1)) {
saw_dialog_chrome = true;
try std.testing.expectEqual(@as(usize, 0), clip_depth);
}
}
try std.testing.expect(saw_dialog_chrome);
}
test "a both-axes region whose content only overflows sideways reports horizontal scroll semantics" {
// 500-wide content in a 200 x 100 both-axes viewport: no vertical
// range, real horizontal range. The assistive node must read the
+34 -25
View File
@@ -165,7 +165,7 @@ pub fn emitWidgetLayoutWithState(builder: *Builder, layout: anytype, tokens: Des
scrim_viewport = widgetLayoutRootBounds(layout);
try emitWidgetLayoutChildren(builder, layout, null, tokens, state);
try emitWidgetLayoutClipEscapingMotions(builder, layout, tokens, state);
try emitWidgetLayoutAnchored(builder, layout, tokens, state);
try emitWidgetLayoutWindowSurfaces(builder, layout, tokens, state);
try emitWidgetLayoutChartHoverDetails(builder, layout, tokens, state);
try emitWidgetLayoutDragPreview(builder, layout, tokens, state);
}
@@ -177,7 +177,7 @@ pub fn emitWidgetLayoutWithState(builder: *Builder, layout: anytype, tokens: Des
/// ordinary tree walk and retain their scroll clipping.
fn emitWidgetLayoutClipEscapingMotions(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void {
for (layout.nodes, 0..) |node, index| {
if (widget_tree.widgetIsAnchored(node.widget)) continue;
if (widget_tree.widgetEscapesAncestorClips(node.widget)) continue;
if (!state.layoutMotionEscapesAncestorClips(node.widget.id)) continue;
if (widget_tree.isWidgetHiddenInAncestors(layout, index)) continue;
if (widget_tree.isWidgetConcealedByDisclosure(layout, index)) continue;
@@ -245,9 +245,14 @@ fn widgetLayoutDragPreviewTranslation(
);
}
/// The union of the layout's root-node frames: the whole laid-out
/// surface, which is what a modal scrim covers.
/// The union of the layout's root bounds: the whole viewport that produced
/// the roots, which is what a modal scrim covers. Layout-produced roots
/// retain those bounds separately from their resolved frame so a root
/// dialog can be centered without shrinking its own scrim to the dialog.
pub fn widgetLayoutRootBounds(layout: anytype) ?geometry.RectF {
if (@hasField(@TypeOf(layout), "root_bounds")) {
if (layout.root_bounds) |root_bounds| return root_bounds.normalized();
}
var bounds: ?geometry.RectF = null;
for (layout.nodes) |node| {
if (node.parent_index != null) continue;
@@ -257,9 +262,9 @@ pub fn widgetLayoutRootBounds(layout: anytype) ?geometry.RectF {
return bounds;
}
/// Accumulated transform active while `node_index` emits. Anchored surfaces
/// are hoisted into the late top-level pass, so their original ancestors do
/// not contribute transforms there.
/// Accumulated transform active while `node_index` emits. Window-level
/// surfaces are hoisted into the late top-level pass, so their original
/// ancestors do not contribute transforms there.
fn widgetLayoutNodeEmissionTransform(layout: anytype, node_index: usize) ?Affine {
if (node_index >= layout.nodes.len) return null;
var indices: [widget_layout.max_widget_depth]usize = undefined;
@@ -269,7 +274,7 @@ fn widgetLayoutNodeEmissionTransform(layout: anytype, node_index: usize) ?Affine
if (index >= layout.nodes.len or len >= indices.len) return null;
indices[len] = index;
len += 1;
if (widget_tree.widgetIsAnchored(layout.nodes[index].widget)) break;
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[index].widget)) break;
current = layout.nodes[index].parent_index;
}
@@ -304,7 +309,7 @@ fn widgetLayoutNodePresentationTransform(
if (index >= layout.nodes.len or len >= indices.len) return null;
indices[len] = index;
len += 1;
if (widget_tree.widgetIsAnchored(layout.nodes[index].widget)) break;
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[index].widget)) break;
current = layout.nodes[index].parent_index;
}
@@ -324,11 +329,11 @@ fn widgetLayoutNodePresentationTransform(
/// The transform stack a node inherits at its ordinary paint position. A
/// drag preview is hoisted to the window-level late pass to escape clipping,
/// so it must explicitly restore this stack before painting the source.
/// Anchored nodes were already hoisted and therefore inherit no original
/// ancestors there.
/// Window-level nodes were already hoisted and therefore inherit no
/// original ancestors there.
fn widgetLayoutNodeAncestorEmissionTransform(layout: anytype, node_index: usize) ?Affine {
if (node_index >= layout.nodes.len) return null;
if (widget_tree.widgetIsAnchored(layout.nodes[node_index].widget)) return Affine.identity();
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[node_index].widget)) return Affine.identity();
const parent_index = layout.nodes[node_index].parent_index orelse return Affine.identity();
return widgetLayoutNodeEmissionTransform(layout, parent_index);
}
@@ -363,7 +368,7 @@ fn widgetLayoutNodeVisibleBounds(
state.drag_preview_id.? == current_widget.id;
// Every late window-level pass escapes clips ABOVE its lifted root,
// but nested clips inside that subtree still constrain descendants.
if (widget_tree.widgetIsAnchored(current_widget) or
if (widget_tree.widgetEscapesAncestorClips(current_widget) or
is_drag_preview_root or
has_layout_motion and state.layoutMotionEscapesAncestorClips(current_widget.id))
{
@@ -388,15 +393,19 @@ fn widgetLayoutNodeVisibleBounds(
return if (clipped.isEmpty()) null else clipped;
}
/// The late z-pass for anchored floating surfaces: they are skipped by
/// the in-tree walk above and emitted here LAST, at the top level, so no
/// ancestor scroll/clip region crops them (window-clipped, not
/// parent-clipped) and they paint above everything in the tree. Node
/// order is tree order, so a nested anchored surface (submenu) paints
/// above the surface it hangs from. Ancestor hiding still applies.
fn emitWidgetLayoutAnchored(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void {
for (layout.nodes, 0..) |node, index| {
if (!widget_tree.widgetIsAnchored(node.widget)) continue;
/// The late z-pass for window-level surfaces: anchored overlays and
/// root-relative modals are skipped by the in-tree walk and emitted here
/// LAST, so ancestor scroll/clip regions cannot crop them. Window-surface
/// layers order the lifted roots; equal layers retain tree order, so nested
/// floating surfaces preserve their structural z-order.
/// Ancestor hiding still applies.
fn emitWidgetLayoutWindowSurfaces(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void {
const surface_count = widget_tree.widgetLayoutWindowSurfaceCount(layout);
var emitted: usize = 0;
var previous: ?widget_tree.WidgetPaintOrder = null;
while (emitted < surface_count) : (emitted += 1) {
const index = widget_tree.nextWidgetLayoutWindowSurface(layout, tokens, previous) orelse return;
previous = widget_tree.widgetLayoutWindowSurfaceOrder(layout, index, tokens);
if (widget_tree.isWidgetHiddenInAncestors(layout, index)) continue;
// A floating surface anchored inside a concealed disclosure
// subtree stays down with its anchor — concealed content is
@@ -693,9 +702,9 @@ fn emitWidgetLayoutChildren(
var previous: ?WidgetPaintOrder = null;
while (emitted < child_count) : (emitted += 1) {
const child_index = nextWidgetLayoutPaintChild(layout, parent_index, tokens, previous) orelse return;
// Anchored floating children paint in the late z-pass
// (`emitWidgetLayoutAnchored`), never in tree position.
if (!widget_tree.widgetIsAnchored(layout.nodes[child_index].widget) and
// Window-level surfaces paint in the late z-pass
// (`emitWidgetLayoutWindowSurfaces`), never in tree position.
if (!widget_tree.widgetEscapesAncestorClips(layout.nodes[child_index].widget) and
!state.layoutMotionEscapesAncestorClips(layout.nodes[child_index].widget.id))
{
const segment = if (group_index) |index|
+17 -17
View File
@@ -55,13 +55,14 @@ pub fn hitTestWidgetLayout(layout: anytype, point: geometry.PointF, tokens: Desi
}
pub fn hitTestWidgetLayoutWithPolicy(layout: anytype, point: geometry.PointF, tokens: DesignTokens, comptime policy: HitTestPolicy) ?WidgetHit {
// Anchored floating surfaces paint in the late z-pass — topmost — so
// they hit-test FIRST, in reverse tree order (a nested anchored
// submenu has a higher node index than the surface it hangs from).
var index = layout.nodes.len;
while (index > 0) {
index -= 1;
if (!widget_tree.widgetIsAnchored(layout.nodes[index].widget)) continue;
// Window-level surfaces paint in the late z-pass — topmost — so they
// hit-test FIRST, in reverse window-surface paint order.
const surface_count = widget_tree.widgetLayoutWindowSurfaceCount(layout);
var tested: usize = 0;
var previous: ?widget_tree.WidgetPaintOrder = null;
while (tested < surface_count) : (tested += 1) {
const index = widget_tree.previousWidgetLayoutWindowSurface(layout, tokens, previous) orelse break;
previous = widget_tree.widgetLayoutWindowSurfaceOrder(layout, index, tokens);
if (isWidgetHiddenInAncestors(layout, index)) continue;
if (widget_tree.isWidgetConcealedByDisclosure(layout, index)) continue;
if (hitTestWidgetLayoutNode(layout, index, point, tokens, policy)) |hit| return hit;
@@ -75,9 +76,9 @@ fn hitTestWidgetLayoutChildren(layout: anytype, parent_index: ?usize, point: geo
var previous: ?widget_tree.WidgetPaintOrder = null;
while (tested < child_count) : (tested += 1) {
const child_index = widget_tree.previousWidgetLayoutPaintChild(layout, parent_index, tokens, previous) orelse return null;
// Anchored floating children live in the hoisted pre-pass above,
// never at their tree position.
if (!widget_tree.widgetIsAnchored(layout.nodes[child_index].widget)) {
// Window-level surfaces live in the hoisted pre-pass above, never
// at their tree position.
if (!widget_tree.widgetEscapesAncestorClips(layout.nodes[child_index].widget)) {
if (hitTestWidgetLayoutNode(layout, child_index, point, tokens, policy)) |hit| return hit;
}
previous = .{ .layer = widget_tree.widgetPaintLayer(layout.nodes[child_index].widget, tokens), .index = child_index };
@@ -234,9 +235,9 @@ pub fn widgetWindowDragTargetIndexFromNode(layout: anytype, node_index: usize) ?
fn isPointVisibleInWidgetAncestors(layout: anytype, node_index: usize, point: geometry.PointF) bool {
var current: usize = node_index;
while (true) {
// An anchored floating widget escapes its ancestors' clip regions
// (window-clipped, not parent-clipped), so the walk stops here.
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) return true;
// A window-level surface escapes its ancestors' clip regions, so
// the walk stops here.
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[current].widget)) return true;
const parent_index = layout.nodes[current].parent_index orelse return true;
if (parent_index >= layout.nodes.len) return true;
const parent = layout.nodes[parent_index];
@@ -251,10 +252,9 @@ fn isWidgetFrameVisibleInWidgetAncestors(layout: anytype, node_index: usize) boo
if (frame.isEmpty()) return false;
var current: usize = node_index;
while (true) {
// Anchored floating widgets escape ancestor clips — focus targets
// inside an open overlay stay live outside the scroll ancestor's
// bounds.
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) return true;
// Window-level surfaces escape ancestor clips, keeping their focus
// targets live outside the authored container's bounds.
if (widget_tree.widgetEscapesAncestorClips(layout.nodes[current].widget)) return true;
const parent_index = layout.nodes[current].parent_index orelse return true;
if (parent_index >= layout.nodes.len) return true;
const parent = layout.nodes[parent_index];
+7 -2
View File
@@ -60,6 +60,10 @@ pub const textGeometryForWidget = widget_text_input.textGeometryForWidget;
pub const WidgetLayoutTree = struct {
nodes: []const WidgetLayoutNode = &.{},
/// Viewport bounds that produced the layout. Kept on the tree instead
/// of every node so root-authored modals retain their window-space
/// contract without widening the hot node array.
root_bounds: ?geometry.RectF = null,
pub fn nodeCount(self: WidgetLayoutTree) usize {
return self.nodes.len;
@@ -201,9 +205,10 @@ pub fn layoutWidgetTree(widget: Widget, bounds: geometry.RectF, output: []Widget
}
pub fn layoutWidgetTreeWithTokens(widget: Widget, bounds: geometry.RectF, tokens: DesignTokens, output: []WidgetLayoutNode) Error!WidgetLayoutTree {
const root_bounds = bounds.normalized();
var len: usize = 0;
_ = try widget_layout.layoutWidgetDepth(widget, bounds.normalized(), null, 0, output, &len, tokens);
return .{ .nodes = output[0..len] };
_ = try widget_layout.layoutWidgetDepth(widget, root_bounds, null, 0, output, &len, tokens);
return .{ .nodes = output[0..len], .root_bounds = root_bounds };
}
pub fn intrinsicWidgetSize(widget: Widget, tokens: DesignTokens) geometry.SizeF {
+6 -2
View File
@@ -520,6 +520,10 @@ fn widgetScrollContentExtent(layout: anytype, scroll_index: usize, viewport: geo
var index = scroll_index + 1;
while (index < layout.nodes.len and layout.nodes[index].depth > scroll_depth) {
const node = layout.nodes[index];
if (widget_tree.widgetIsRootRelativeModal(node.widget)) {
index = skipSubtree(layout, index);
continue;
}
// A subtree anchored DIRECTLY to the scroll region stays
// stationary under scrolling (its anchor base never moves), so
// `frame + offset` is not a content-space position for it —
@@ -552,7 +556,7 @@ fn widgetScrollContentExtent(layout: anytype, scroll_index: usize, viewport: geo
/// The horizontal content reach for a horizontal scroll view's
/// semantics — the sideways mirror of `widgetScrollContentExtent`, with
/// the honest-range exclusions the engine's clamp/driver walker applies
/// (`canvasWidgetLayoutScrollContentExtentX`): anchored floating
/// (`canvasWidgetLayoutScrollContentExtentX`): window-level floating
/// subtrees are out of flow, a nested clip scope bounds its own
/// children, and disclosure content counts only while settled open.
fn widgetScrollContentExtentX(layout: anytype, scroll_index: usize, viewport: geometry.RectF) f32 {
@@ -563,7 +567,7 @@ fn widgetScrollContentExtentX(layout: anytype, scroll_index: usize, viewport: ge
var index = scroll_index + 1;
while (index < layout.nodes.len and layout.nodes[index].depth > scroll_depth) {
const node = layout.nodes[index];
if (node.widget.layout.anchor != null) {
if (widget_tree.widgetEscapesAncestorClips(node.widget)) {
index = skipSubtree(layout, index);
continue;
}
+99 -5
View File
@@ -17,12 +17,70 @@ pub const WidgetPaintOrder = struct {
pub fn widgetPaintLayer(widget: Widget, tokens: DesignTokens) i32 {
if (widget.layer) |layer| return layer;
return switch (widget.kind) {
.dialog, .drawer, .sheet => tokens.layer.modal,
.popover, .menu_surface, .dropdown_menu => tokens.layer.overlay,
.tooltip => tokens.layer.floating,
else => tokens.layer.base,
};
}
/// Window-surface ordering keeps anchored siblings in their established
/// mount-order stack while lifting root-relative modals into the modal token
/// stratum. An explicit widget layer always wins. Anchored tooltip/menu
/// coexistence deliberately shares the overlay stratum here: their late-pass
/// contract is last-mounted-on-top even though their ordinary in-tree token
/// defaults differ.
pub fn widgetWindowSurfaceLayer(widget: Widget, tokens: DesignTokens) i32 {
if (widget.layer) |layer| return layer;
if (widgetIsRootRelativeModal(widget)) return tokens.layer.modal;
if (widgetIsAnchored(widget)) return tokens.layer.overlay;
return widgetPaintLayer(widget, tokens);
}
/// Effective layer of a hoisted window surface in a laid tree. A surface
/// without an explicit layer cannot fall beneath the window surface that
/// contains it: a default dropdown/tooltip opened inside a modal therefore
/// stays in the modal stratum, while an explicitly authored layer remains an
/// intentional escape hatch.
pub fn widgetLayoutWindowSurfaceLayer(layout: anytype, node_index: usize, tokens: DesignTokens) i32 {
const node = layout.nodes[node_index];
if (node.widget.layer) |layer| return layer;
var layer = widgetWindowSurfaceLayer(node.widget, tokens);
var current = node.parent_index;
while (current) |index| {
if (index >= layout.nodes.len) break;
const ancestor = layout.nodes[index];
if (widgetEscapesAncestorClips(ancestor.widget)) {
layer = @max(layer, widgetWindowSurfaceLayer(ancestor.widget, tokens));
}
current = ancestor.parent_index;
}
return layer;
}
pub fn widgetLayoutWindowSurfaceOrder(layout: anytype, node_index: usize, tokens: DesignTokens) WidgetPaintOrder {
return .{
.layer = widgetLayoutWindowSurfaceLayer(layout, node_index, tokens),
.index = node_index,
};
}
/// Modal surfaces are placed in layout-root coordinates and never ride an
/// ancestor scroll region's content translation.
pub fn widgetIsRootRelativeModal(widget: Widget) bool {
return switch (widget.kind) {
.dialog, .drawer, .sheet => true,
else => false,
};
}
/// Surfaces whose layout, paint, routing, and visibility are resolved at
/// the window level rather than through their authored ancestor clips.
pub fn widgetEscapesAncestorClips(widget: Widget) bool {
return widgetIsAnchored(widget) or widgetIsRootRelativeModal(widget);
}
pub fn nextWidgetPaintChild(children: []const Widget, tokens: DesignTokens, previous: ?WidgetPaintOrder) ?usize {
var best: ?WidgetPaintOrder = null;
for (children, 0..) |child, index| {
@@ -63,6 +121,36 @@ pub fn previousWidgetLayoutPaintChild(layout: anytype, parent_index: ?usize, tok
return if (best) |order| order.index else null;
}
pub inline fn widgetLayoutWindowSurfaceCount(layout: anytype) usize {
var count: usize = 0;
for (layout.nodes) |node| {
if (widgetEscapesAncestorClips(node.widget)) count += 1;
}
return count;
}
pub fn nextWidgetLayoutWindowSurface(layout: anytype, tokens: DesignTokens, previous: ?WidgetPaintOrder) ?usize {
var best: ?WidgetPaintOrder = null;
for (layout.nodes, 0..) |node, index| {
if (!widgetEscapesAncestorClips(node.widget)) continue;
const order = widgetLayoutWindowSurfaceOrder(layout, index, tokens);
if (!widgetPaintOrderAfter(order, previous)) continue;
if (best == null or widgetPaintOrderLess(order, best.?)) best = order;
}
return if (best) |order| order.index else null;
}
pub fn previousWidgetLayoutWindowSurface(layout: anytype, tokens: DesignTokens, previous: ?WidgetPaintOrder) ?usize {
var best: ?WidgetPaintOrder = null;
for (layout.nodes, 0..) |node, index| {
if (!widgetEscapesAncestorClips(node.widget)) continue;
const order = widgetLayoutWindowSurfaceOrder(layout, index, tokens);
if (!widgetPaintOrderBefore(order, previous)) continue;
if (best == null or widgetPaintOrderLess(best.?, order)) best = order;
}
return if (best) |order| order.index else null;
}
fn widgetPaintOrderAfter(order: WidgetPaintOrder, previous: ?WidgetPaintOrder) bool {
const value = previous orelse return true;
return order.layer > value.layer or (order.layer == value.layer and order.index > value.index);
@@ -73,7 +161,7 @@ fn widgetPaintOrderBefore(order: WidgetPaintOrder, previous: ?WidgetPaintOrder)
return order.layer < value.layer or (order.layer == value.layer and order.index < value.index);
}
fn widgetPaintOrderLess(a: WidgetPaintOrder, b: WidgetPaintOrder) bool {
pub fn widgetPaintOrderLess(a: WidgetPaintOrder, b: WidgetPaintOrder) bool {
return a.layer < b.layer or (a.layer == b.layer and a.index < b.index);
}
@@ -186,16 +274,22 @@ pub fn widgetKindDisclosureAnimated(kind: widget_model.WidgetKind) bool {
const disclosure_settle_slack: f32 = 0.5;
/// The bottom edge the disclosure widget's content REACHES: the deepest
/// maxY among its in-flow children's subtrees. Anchored children float
/// outside the flow (window-positioned) and never count.
/// maxY among its in-flow children's subtrees. Window-level surfaces float
/// outside the flow and never count.
pub fn disclosureContentBottom(layout: anytype, node_index: usize) f32 {
const node = layout.nodes[node_index];
var bottom = -std.math.inf(f32);
var index = node_index + 1;
while (index < layout.nodes.len and layout.nodes[index].depth > node.depth) : (index += 1) {
while (index < layout.nodes.len and layout.nodes[index].depth > node.depth) {
const child = layout.nodes[index];
if (widgetIsAnchored(child.widget)) continue;
if (widgetEscapesAncestorClips(child.widget)) {
const subtree_depth = child.depth;
index += 1;
while (index < layout.nodes.len and layout.nodes[index].depth > subtree_depth) : (index += 1) {}
continue;
}
bottom = @max(bottom, child.frame.normalized().maxY());
index += 1;
}
return bottom;
}
+4 -5
View File
@@ -1042,9 +1042,8 @@ pub const Widget = struct {
layer: ?i32 = null,
/// Modal surfaces (dialog/drawer/sheet) paint a token-driven scrim
/// (dim + backdrop blur) across the whole tree behind them. False
/// opts a surface out — for embedding one as an inline PREVIEW
/// (a component catalog card, a docs specimen) where it is not
/// actually modal. Ignored on every other kind.
/// opts out of the scrim for a non-modal specimen; placement remains
/// root-relative. Ignored on every other kind.
scrim: bool = true,
state: WidgetState = .{},
layout: WidgetLayoutStyle = .{},
@@ -1212,8 +1211,8 @@ pub const BuiltinComponentOptions = struct {
text_composition: ?TextRange = null,
value: f32 = 0,
layer: ?i32 = null,
/// See `Widget.scrim`: false embeds a modal surface as an inline
/// preview without the behind-it dim + blur.
/// See `Widget.scrim`: false keeps root-relative placement but omits
/// the behind-it dim + blur.
scrim: bool = true,
state: WidgetState = .{},
layout: WidgetLayoutStyle = .{},
@@ -352,6 +352,60 @@ test "escape from an unrelated focused widget falls back to the topmost mounted
try std.testing.expect(!fixture.app_state.model.switcher_open);
}
test "outside click dismisses an anchored surface opened from a NON-focusable trigger" {
const fixture = try Fixture.create();
defer fixture.destroy();
const trigger_id = fixture.widgetIdByText(.text, "Files").?;
try fixture.clickWidget(trigger_id);
try std.testing.expect(fixture.app_state.model.switcher_open);
try std.testing.expectEqual(@as(canvas.ObjectId, 0), fixture.harness.runtime.views[0].canvas_widget_focused_id);
try fixture.click(geometry.PointF.init(360, 280));
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.switcher_dismissals);
try std.testing.expect(!fixture.app_state.model.switcher_open);
try std.testing.expect(fixture.widgetIdByText(.menu_item, "Sibling") == null);
}
test "outside click falls back from unrelated focus to the mounted anchored surface" {
const fixture = try Fixture.create();
defer fixture.destroy();
const trigger_id = fixture.widgetIdByText(.text, "Files").?;
try fixture.clickWidget(trigger_id);
const select_id = fixture.widgetIdByText(.select, "Repo").?;
var command_buffer: [96]u8 = undefined;
const focus_command = try std.fmt.bufPrint(&command_buffer, "widget-action {s} {d} focus", .{ canvas_label, select_id });
try fixture.harness.runtime.dispatchAutomationCommand(fixture.app, focus_command);
try std.testing.expectEqual(select_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
try fixture.click(geometry.PointF.init(360, 280));
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.switcher_dismissals);
try std.testing.expect(!fixture.app_state.model.switcher_open);
}
test "inside and trigger clicks do not outside-dismiss a non-focusable anchored surface" {
const fixture = try Fixture.create();
defer fixture.destroy();
const trigger_id = fixture.widgetIdByText(.text, "Files").?;
try fixture.clickWidget(trigger_id);
const item_id = fixture.widgetIdByText(.menu_item, "Sibling").?;
// The menu item is inside the surface. Its ordinary Msg fires, but
// outside dismissal does not race it or close the surface.
try fixture.clickWidget(item_id);
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.crumb_presses);
try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.switcher_dismissals);
try std.testing.expect(fixture.app_state.model.switcher_open);
// The trigger stack owns its toggle gesture: it closes exactly once,
// with no preceding dismissal Msg.
try fixture.clickWidget(trigger_id);
try std.testing.expect(!fixture.app_state.model.switcher_open);
try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.switcher_dismissals);
}
test "anchored picker: click outside dismisses as a Msg; clicking the trigger toggles exactly once" {
const fixture = try Fixture.create();
defer fixture.destroy();
-1
View File
@@ -2526,7 +2526,6 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
const index = runtimeFindViewIndex(self, pointer_event.window_id, pointer_event.view_label) orelse return 0;
if (self.views[index].kind != .gpu_surface or !self.views[index].focused) return 0;
const focused_id = self.views[index].canvas_widget_focused_id;
if (focused_id == 0) return 0;
const previous_cursor = self.views[index].canvas_widget_cursor;
const dismissal = try self.views[index].dismissCanvasWidgetSurfaceForPointerOutsideFocusedTarget(focused_id, pointer_event.route) orelse return 0;
@@ -236,6 +236,54 @@ test "runtime escape with no focused widget dismisses the topmost mounted anchor
try std.testing.expect(!retained.findById(1).?.widget.semantics.hidden);
}
test "escape and outside dismissal follow explicit anchored surface layers" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-layered-surface-dismiss", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
try harness.start(app_state.app());
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 320, 180),
});
const elevated = canvas.Widget{
.id = 2,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 120, 60),
.layer = 500,
.layout = .{ .anchor = .{ .offset = 0, .point = geometry.PointF.init(20, 20) } },
};
const later_default = canvas.Widget{
.id = 3,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 120, 60),
.layout = .{ .anchor = .{ .offset = 0, .point = geometry.PointF.init(40, 40) } },
};
const root = canvas.Widget{ .id = 1, .kind = .stack, .children = &.{ elevated, later_default } };
var outside_nodes: [4]canvas.WidgetLayoutNode = undefined;
const outside_layout = try canvas.layoutWidgetTree(root, geometry.RectF.init(0, 0, 320, 180), &outside_nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", outside_layout);
const outside = try harness.runtime.views[0].dismissCanvasWidgetSurfaceForPointerOutsideFocusedTarget(0, &.{}) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(canvas.ObjectId, 2), outside.id);
var escape_nodes: [4]canvas.WidgetLayoutNode = undefined;
const escape_layout = try canvas.layoutWidgetTree(root, geometry.RectF.init(0, 0, 320, 180), &escape_nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", escape_layout);
const escape = try harness.runtime.views[0].dismissCanvasWidgetSurfaceFromEscape(0) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(canvas.ObjectId, 2), escape.id);
}
test "runtime dismisses canvas floating surfaces from automation and accessibility actions" {
const TestApp = struct {
fn app(self: *@This()) App {
+146 -77
View File
@@ -223,9 +223,21 @@ pub fn restoreCanvasWidgetLayoutScrollOffsets(
nodes: []canvas.WidgetLayoutNode,
previous_runtime_offsets: []const CanvasWidgetSourceScrollEntry,
previous_source_offsets: []const CanvasWidgetSourceScrollEntry,
) void {
restoreCanvasWidgetLayoutScrollOffsetsAtAnchoredDepth(nodes, previous_runtime_offsets, previous_source_offsets, null);
}
fn restoreCanvasWidgetLayoutScrollOffsetsAtAnchoredDepth(
nodes: []canvas.WidgetLayoutNode,
previous_runtime_offsets: []const CanvasWidgetSourceScrollEntry,
previous_source_offsets: []const CanvasWidgetSourceScrollEntry,
anchored_depth: ?usize,
) void {
for (nodes, 0..) |node, index| {
if (node.widget.kind != .scroll_view or node.widget.id == 0) continue;
if (anchored_depth) |depth| {
if (canvas.anchoredNestingDepth(nodes, index) != depth) continue;
}
const previous_runtime = canvasWidgetSourceScrollEntryById(previous_runtime_offsets, node.widget.id) orelse continue;
const previous_source = canvasWidgetSourceScrollEntryById(previous_source_offsets, node.widget.id) orelse continue;
// Each axis reconciles on its own: a programmatic vertical
@@ -268,10 +280,14 @@ fn clampCanvasWidgetLayoutProgrammaticScrollOffsets(
source: canvas.WidgetLayoutTree,
previous_runtime_offsets: []const CanvasWidgetSourceScrollEntry,
previous_source_offsets: []const CanvasWidgetSourceScrollEntry,
anchored_depth: ?usize,
) void {
for (nodes, 0..) |node, index| {
if (node.widget.kind != .scroll_view or node.widget.id == 0) continue;
if (node.widget.layout.virtualized and !canvas.widgetVirtualRuntimeScrolled(node.widget)) continue;
if (anchored_depth) |depth| {
if (canvas.anchoredNestingDepth(nodes, index) != depth) continue;
}
const source_node = source.findById(node.widget.id) orelse continue;
const previous_runtime = canvasWidgetSourceScrollEntryById(previous_runtime_offsets, node.widget.id);
@@ -326,11 +342,14 @@ fn previousLayoutHasSelectedTab(previous: canvas.WidgetLayoutTree, id: canvas.Ob
/// viewport. This runs after retained scroll restoration, against final layout
/// frames, so an already-visible tab preserves the exact offset and a newly
/// mounted native scroll driver never paints a speculative leading-edge jump.
fn revealNewlySelectedTabs(previous: canvas.WidgetLayoutTree, nodes: []canvas.WidgetLayoutNode) void {
fn revealNewlySelectedTabs(previous: canvas.WidgetLayoutTree, nodes: []canvas.WidgetLayoutNode, anchored_depth: ?usize) void {
for (nodes, 0..) |tab_node, tab_index| {
const tab = tab_node.widget;
if (tab.semantics.role != .tab or !tab.state.selected) continue;
if (tab.id != 0 and previousLayoutHasSelectedTab(previous, tab.id)) continue;
if (anchored_depth) |depth| {
if (canvas.anchoredNestingDepth(nodes, tab_index) != depth) continue;
}
var ancestor = tab_node.parent_index;
while (ancestor) |index| {
@@ -490,9 +509,9 @@ pub fn canvasWidgetLayoutNodeFrameVisible(layout: canvas.WidgetLayoutTree, node_
if (frame.isEmpty()) return false;
var current: usize = node_index;
while (true) {
// Anchored floating widgets escape ancestor clip regions (they
// render in the hoisted window-level pass).
if (canvas.widgetIsAnchored(layout.nodes[current].widget)) return true;
// Window-level surfaces render in the hoisted pass and escape
// ancestor clip regions.
if (canvas.widgetEscapesAncestorClips(layout.nodes[current].widget)) return true;
const index = layout.nodes[current].parent_index orelse return true;
if (index >= layout.nodes.len) return true;
const ancestor = layout.nodes[index];
@@ -510,9 +529,9 @@ pub fn canvasWidgetLayoutNodeClippedBounds(layout: canvas.WidgetLayoutTree, node
var current: usize = node_index;
while (true) {
// Anchored floating widgets escape ancestor clip regions, so
// dirty bounds under them clip to the window only.
if (canvas.widgetIsAnchored(layout.nodes[current].widget)) break;
// Window-level surfaces escape ancestor clip regions, so dirty
// bounds under them clip to the window only.
if (canvas.widgetEscapesAncestorClips(layout.nodes[current].widget)) break;
const index = layout.nodes[current].parent_index orelse break;
if (index >= layout.nodes.len) return null;
const ancestor = layout.nodes[index];
@@ -1031,11 +1050,19 @@ pub fn canvasWidgetLayoutTreeWithRuntimeReconcileState(
&text_len,
);
// Split fractions reconcile FIRST, as a staged copy of the laid-out
// tree, so the per-node passes below see final geometry. Outer
// splits restore before nested ones (ascending node order), so a
// nested split re-laid (or slid) by its ancestor still restores its
// own fraction afterwards. Two restore shapes:
// Geometry reconciles in anchored-surface strata. The authored source
// layout is first finalized outside every surface, then direct anchored
// children are replayed against that final geometry. Runtime-owned
// scroll/split/tab state inside those surfaces is restored before their
// own nested surfaces replay, and so on. A surface replay can therefore
// recover authored intrinsic sizing without erasing already-finalized
// descendant state.
//
// Within each stratum split fractions reconcile FIRST, as a staged copy
// of the laid-out tree, so the per-node passes below see final geometry.
// Outer splits restore before nested ones (ascending node order), so a
// nested split re-laid (or slid) by its ancestor still restores its own
// fraction afterwards. Two restore shapes:
// - SETTLED runtime-owned fraction (a past drag, no tween in
// flight): re-run the split's child layout in place — content
// honestly wraps at the restored width;
@@ -1048,71 +1075,86 @@ pub fn canvasWidgetLayoutTreeWithRuntimeReconcileState(
// frame at a time (the disclosure doctrine, horizontal).
const staged_nodes = node_buffer[0..next.nodes.len];
@memcpy(staged_nodes, next.nodes);
for (staged_nodes, 0..) |node, index| {
if (node.widget.kind != .split or node.widget.id == 0) continue;
const tween_armed = objectIdInList(armed_split_tween_ids, node.widget.id);
const previous_runtime = canvasWidgetSourceScrollById(previous_runtime_offsets, node.widget.id) orelse {
// A FRESH split (no retained fraction): a declared enter
// origin slides the first layout's boundary to the origin
// pose — children keep the declared value's (target) wrap —
// so the tween armed right after this reconcile eases it in
// instead of the mount popping to its value.
if (node.widget.resize_duration_ms != 0 and node.widget.resize_origin >= 0 and node.widget.children.len != 0) {
canvas.slideSplitChildren(node.frame, node.widget.resize_origin, index, staged_nodes);
const staged_root_bounds = next.root_bounds orelse if (staged_nodes.len > 0) staged_nodes[0].frame else geometry.RectF.init(0, 0, 0, 0);
const max_anchored_depth = canvas.maxAnchoredNestingDepth(staged_nodes);
var anchored_depth: usize = 0;
while (anchored_depth <= max_anchored_depth) : (anchored_depth += 1) {
for (staged_nodes, 0..) |node, index| {
if (node.widget.kind != .split or node.widget.id == 0) continue;
if (canvas.anchoredNestingDepth(staged_nodes, index) != anchored_depth) continue;
const tween_armed = objectIdInList(armed_split_tween_ids, node.widget.id);
const previous_runtime = canvasWidgetSourceScrollById(previous_runtime_offsets, node.widget.id) orelse {
// A FRESH split (no retained fraction): a declared enter
// origin slides the first layout's boundary to the origin
// pose — children keep the declared value's (target) wrap —
// so the tween armed right after this reconcile eases it in
// instead of the mount popping to its value.
if (node.widget.resize_duration_ms != 0 and node.widget.resize_origin >= 0 and node.widget.children.len != 0) {
canvas.slideSplitChildren(node.frame, node.widget.resize_origin, index, staged_nodes);
}
continue;
};
const previous_source = canvasWidgetSourceScrollById(previous_source_scroll_entries, node.widget.id) orelse continue;
// Source-wins: the runtime-owned fraction survives rebuilds only
// while the SOURCE fraction is unchanged; a source-side change
// (the model echoing or driving the fraction) wins — UNLESS the
// split declares a layout tween (`resize_duration_ms` nonzero)
// or one is already in flight: then the moved source value is a
// TARGET, the rendered fraction stays where it is, and the
// runtime's tween lowering (armed right after this reconcile
// lands, in setCanvasWidgetLayout) eases it there one presented
// frame at a time. Reduced motion still snaps: the tween
// lowering's snap path applies the target through this same
// mutation family in the same rebuild.
const source_moved = node.widget.value != previous_source;
if (source_moved and node.widget.resize_duration_ms == 0 and !tween_armed) continue;
if (node.widget.value == previous_runtime) continue;
staged_nodes[index].widget.value = previous_runtime;
// Retained trees clear children; a split without them keeps the
// value restore only (frames follow on the next full layout).
if (node.widget.children.len == 0) continue;
// A pressed divider is a live drag: its echo rebuilds must keep
// re-wrapping at the dragged width (the pinned drag behavior),
// so the slide shape only applies to tween-owned motion.
const dragging = pressed_split_id != 0 and node.widget.id == pressed_split_id;
if (!dragging and (tween_armed or (source_moved and node.widget.resize_duration_ms != 0))) {
canvas.slideSplitChildren(node.frame, previous_runtime, index, staged_nodes);
} else {
try canvas.relayoutSplitChildren(staged_nodes[index].widget, node.frame, index, node.depth, node_buffer, staged_root_bounds, tokens);
}
continue;
};
const previous_source = canvasWidgetSourceScrollById(previous_source_scroll_entries, node.widget.id) orelse continue;
// Source-wins: the runtime-owned fraction survives rebuilds only
// while the SOURCE fraction is unchanged; a source-side change
// (the model echoing or driving the fraction) wins — UNLESS the
// split declares a layout tween (`resize_duration_ms` nonzero)
// or one is already in flight: then the moved source value is a
// TARGET, the rendered fraction stays where it is, and the
// runtime's tween lowering (armed right after this reconcile
// lands, in setCanvasWidgetLayout) eases it there one presented
// frame at a time. Reduced motion still snaps: the tween
// lowering's snap path applies the target through this same
// mutation family in the same rebuild.
const source_moved = node.widget.value != previous_source;
if (source_moved and node.widget.resize_duration_ms == 0 and !tween_armed) continue;
if (node.widget.value == previous_runtime) continue;
staged_nodes[index].widget.value = previous_runtime;
// Retained trees clear children; a split without them keeps the
// value restore only (frames follow on the next full layout).
if (node.widget.children.len == 0) continue;
// A pressed divider is a live drag: its echo rebuilds must keep
// re-wrapping at the dragged width (the pinned drag behavior),
// so the slide shape only applies to tween-owned motion.
const dragging = pressed_split_id != 0 and node.widget.id == pressed_split_id;
if (!dragging and (tween_armed or (source_moved and node.widget.resize_duration_ms != 0))) {
canvas.slideSplitChildren(node.frame, previous_runtime, index, staged_nodes);
} else {
try canvas.relayoutSplitChildren(staged_nodes[index].widget, node.frame, index, node.depth, node_buffer, tokens);
}
// Scroll restore is staged too (after splits, so translated frames
// are final geometry): the retained offset comes back WITH its
// descendants translated to match. Engine-side clamping happens at
// the caller AFTER native scroll drivers are stamped — a rebuild
// mid-rubber-band must not clamp an offset the OS scroller owns.
restoreCanvasWidgetLayoutScrollOffsetsAtAnchoredDepth(
staged_nodes,
previous_runtime_offsets,
previous_source_scroll_entries,
anchored_depth,
);
clampCanvasWidgetLayoutProgrammaticScrollOffsets(
staged_nodes,
next,
previous_runtime_offsets,
previous_source_scroll_entries,
anchored_depth,
);
revealNewlySelectedTabs(previous, staged_nodes, anchored_depth);
if (anchored_depth < max_anchored_depth) {
try canvas.relayoutAnchoredChildrenAtDepth(staged_nodes, staged_root_bounds, tokens, anchored_depth);
}
}
// Scroll restore is staged too (after splits, so translated frames
// are final geometry): the retained offset comes back WITH its
// descendants translated to match. Engine-side clamping happens at
// the caller AFTER native scroll drivers are stamped — a rebuild
// mid-rubber-band must not clamp an offset the OS scroller owns.
restoreCanvasWidgetLayoutScrollOffsets(staged_nodes, previous_runtime_offsets, previous_source_scroll_entries);
clampCanvasWidgetLayoutProgrammaticScrollOffsets(
staged_nodes,
next,
previous_runtime_offsets,
previous_source_scroll_entries,
);
revealNewlySelectedTabs(previous, staged_nodes);
const index_scratch = canvas_widget_reconcile_index_scratch.get();
index_scratch.controls.build(previous_control_states);
index_scratch.source_controls.build(previous_source_control_entries);
index_scratch.texts.build(previous_text_states);
index_scratch.semantics.build(source_semantics);
const staged = canvas.WidgetLayoutTree{ .nodes = staged_nodes };
const staged = canvas.WidgetLayoutTree{ .nodes = staged_nodes, .root_bounds = next.root_bounds };
for (staged_nodes, 0..) |node, index| {
const text_copy = canvasWidgetLayoutNodeWithTextReconcileState(node, staged, index, &index_scratch.texts);
const control_copy = canvasWidgetLayoutNodeWithControlReconcileState(text_copy, staged, index, &index_scratch.controls, &index_scratch.source_controls);
@@ -1120,7 +1162,7 @@ pub fn canvasWidgetLayoutTreeWithRuntimeReconcileState(
}
const reconciled = node_buffer[0..next.nodes.len];
clampCanvasWidgetLayoutTextOffsets(reconciled, tokens);
return .{ .nodes = reconciled };
return .{ .nodes = reconciled, .root_bounds = next.root_bounds };
}
pub fn canvasWidgetLayoutNodeWithSourceSemantics(
@@ -1151,7 +1193,24 @@ pub fn applyCanvasWidgetSourceScrollSemantics(
}
}
pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, states: ?[]canvas.ScrollState) void {
pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, states: ?[]canvas.ScrollState, root_bounds: ?geometry.RectF, tokens: canvas.DesignTokens) anyerror!void {
try clampCanvasWidgetLayoutScrollOffsetsImpl(nodes, states, root_bounds, tokens, true);
}
/// The staged rebuild reconcile already replayed anchored surfaces in
/// top-down strata. Its final engine clamp must not replay them a second
/// time: at this point their descendants carry restored runtime state.
pub fn clampCanvasWidgetLayoutScrollOffsetsAfterReconcile(nodes: []canvas.WidgetLayoutNode, states: ?[]canvas.ScrollState, root_bounds: ?geometry.RectF, tokens: canvas.DesignTokens) anyerror!void {
try clampCanvasWidgetLayoutScrollOffsetsImpl(nodes, states, root_bounds, tokens, false);
}
fn clampCanvasWidgetLayoutScrollOffsetsImpl(
nodes: []canvas.WidgetLayoutNode,
states: ?[]canvas.ScrollState,
root_bounds: ?geometry.RectF,
tokens: canvas.DesignTokens,
relayout_anchored: bool,
) anyerror!void {
for (nodes, 0..) |node, index| {
if (node.widget.kind != .scroll_view) continue;
// Legacy virtualized containers are model-driven: the source
@@ -1234,6 +1293,9 @@ pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, st
}
}
}
if (relayout_anchored and nodes.len > 0) {
try canvas.relayoutAnchoredChildrenWithRootBounds(nodes, root_bounds orelse nodes[0].frame, tokens);
}
}
pub fn clampCanvasWidgetLayoutTextOffsets(nodes: []canvas.WidgetLayoutNode, tokens: canvas.DesignTokens) void {
@@ -1273,6 +1335,10 @@ pub fn canvasWidgetLayoutScrollContentExtent(nodes: []const canvas.WidgetLayoutN
var bottom = viewport.maxY();
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) {
if (canvas.widgetIsRootRelativeModal(nodes[index].widget)) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
// A subtree anchored DIRECTLY to the scroll region stays
// stationary while content scrolls (its anchor base never
// moves), so `frame + offset` is not a content-space position
@@ -1296,7 +1362,7 @@ pub fn canvasWidgetLayoutScrollContentExtent(nodes: []const canvas.WidgetLayoutN
/// content pins to the viewport width. Three subtree exclusions keep the
/// range honest — each names blank space the user could otherwise scroll
/// to (or live content they otherwise could not reach):
/// - ANCHORED floating subtrees are out of flow and window-clipped;
/// - WINDOW-LEVEL floating subtrees are out of flow and window-clipped;
/// an open dropdown to the right of the viewport is not content;
/// - a NESTED CLIP SCOPE (scroll view, `clip_content` surface,
/// virtualized container) bounds its own children — its frame is
@@ -1315,7 +1381,7 @@ pub fn canvasWidgetLayoutScrollContentExtentX(nodes: []const canvas.WidgetLayout
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) {
const node = nodes[index];
if (node.widget.layout.anchor != null) {
if (canvas.widgetEscapesAncestorClips(node.widget)) {
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
@@ -1341,18 +1407,21 @@ fn skipCanvasWidgetSubtree(nodes: []const canvas.WidgetLayoutNode, index: usize)
return next;
}
/// Scrolled content carries its descendants — including floating
/// surfaces anchored to widgets INSIDE it — but a surface anchored to
/// the SCROLL REGION ITSELF stays put: its anchor base is the region's
/// own frame, which never moves when the content under it does (the
/// live-scroll translate applies the same rule).
/// Scrolled content carries its descendants — including floating surfaces
/// anchored to widgets INSIDE it — but root-relative modal surfaces never
/// ride content, and a surface anchored to the SCROLL REGION ITSELF stays
/// put: its anchor base is the region's own frame, which never moves when
/// the content under it does (the live-scroll translate applies the same
/// rule).
pub fn translateCanvasWidgetLayoutScrollDescendants(nodes: []canvas.WidgetLayoutNode, scroll_index: usize, offset: geometry.OffsetF) void {
if (scroll_index >= nodes.len) return;
const scroll_depth = nodes[scroll_index].depth;
var index = scroll_index + 1;
while (index < nodes.len and nodes[index].depth > scroll_depth) {
const node = nodes[index];
if (node.widget.layout.anchor != null and node.parent_index == scroll_index) {
if (canvas.widgetIsRootRelativeModal(node.widget) or
node.widget.layout.anchor != null and node.parent_index == scroll_index)
{
index = skipCanvasWidgetSubtree(nodes, index);
continue;
}
@@ -845,3 +845,37 @@ test "floating surfaces push occluders that block underlying drivers but not the
try std.testing.expectEqual(@as(u64, 0), drivers[0].parent_id);
try std.testing.expectEqual(@as(u64, 1), drivers[1].parent_id);
}
test "lower-layer later surfaces do not occlude a modal scroll driver" {
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.null_platform.gpu_surface_scroll_drivers = true;
var app_state: PassiveApp = .{};
try harness.start(app_state.app());
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 240, 160),
});
// The modal mounts before a default anchored popover, but its effective
// layer is higher. Its nested scroll driver must therefore ignore both
// its own modal catcher and the later surface painted beneath it.
const nodes = [_]canvas.WidgetLayoutNode{
.{ .widget = .{ .id = 1, .kind = .stack }, .frame = geometry.RectF.init(0, 0, 240, 160), .depth = 0 },
.{ .widget = .{ .id = 10, .kind = .dialog, .scrim = true }, .frame = geometry.RectF.init(20, 20, 200, 120), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 11, .kind = .scroll_view }, .frame = geometry.RectF.init(30, 30, 180, 80), .depth = 2, .parent_index = 1 },
.{ .widget = .{ .id = 12, .kind = .panel }, .frame = geometry.RectF.init(30, 30, 180, 200), .depth = 3, .parent_index = 2 },
.{ .widget = .{ .id = 20, .kind = .popover, .layout = .{ .anchor = .{} } }, .frame = geometry.RectF.init(40, 40, 100, 60), .depth = 1, .parent_index = 0 },
};
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", .{ .nodes = &nodes });
try std.testing.expectEqual(@as(usize, 2), harness.null_platform.scroll_occluder_count);
const drivers = harness.null_platform.scrollDrivers();
try std.testing.expectEqual(@as(usize, 1), drivers.len);
try std.testing.expectEqual(@as(u64, 11), drivers[0].id);
try std.testing.expectEqual(@as(u32, 0), drivers[0].occluder_mask);
}
+12 -10
View File
@@ -238,18 +238,20 @@ pub fn canvasWidgetScrollDriverEligible(node: canvas.WidgetLayoutNode) bool {
/// - ITSELF (a directly anchored scroll region is its own surface);
/// - its own subtree (a scroll region inside an open popover or
/// modal is above the surface, not beneath it);
/// - a driver whose anchored ROOT paints LATER in the floating pass
/// (anchored surfaces paint in tree order above all in-flow
/// content, so a scroll region inside the topmost of two
/// overlapping popovers sits above the lower one).
/// - a driver whose window-level ROOT paints LATER in the floating pass
/// (window-level surfaces paint by effective layer and then tree order,
/// so a scroll region inside the topmost surface sits above lower ones).
fn driverOccluderMask(view: anytype, driver_node: usize, occluder_nodes: []const usize) u32 {
const driver_anchor_root = anchoredRootIndex(view, driver_node);
const driver_window_root = windowSurfaceRootIndex(view, driver_node);
var mask: u32 = 0;
for (occluder_nodes, 0..) |occluder_node, bit| {
if (occluder_node == driver_node) continue;
if (driver_anchor_root) |root| {
if (driver_window_root) |root| {
if (occluder_node == root) continue;
if (root > occluder_node) continue;
const layout = view.widgetLayoutTree();
const root_order = canvas.widgetLayoutWindowSurfaceOrder(layout, root, view.widget_tokens);
const occluder_order = canvas.widgetLayoutWindowSurfaceOrder(layout, occluder_node, view.widget_tokens);
if (canvas.widgetPaintOrderLess(occluder_order, root_order)) continue;
}
if (nodeIsAncestor(view, occluder_node, driver_node)) continue;
mask |= @as(u32, 1) << @intCast(bit);
@@ -268,12 +270,12 @@ fn transformedOccluderFrame(node: canvas.WidgetLayoutNode) geometry.RectF {
return transform.transformRect(frame).normalized();
}
/// The nearest self-or-ancestor node that is an anchored floating root,
/// The nearest self-or-ancestor node that is a window-level surface root,
/// or null for in-flow content.
fn anchoredRootIndex(view: anytype, node_index: usize) ?usize {
fn windowSurfaceRootIndex(view: anytype, node_index: usize) ?usize {
var current: ?usize = node_index;
while (current) |index| {
if (view.widget_layout_nodes[index].widget.layout.anchor != null) return index;
if (canvas.widgetEscapesAncestorClips(view.widget_layout_nodes[index].widget)) return index;
current = view.widget_layout_nodes[index].parent_index;
}
return null;
+183
View File
@@ -1481,6 +1481,147 @@ test "user scroll offsets survive rebuilds until the source offset changes" {
try std.testing.expectEqual(@as(f32, 18), (try harness.runtime.canvasWidgetLayout(1, "canvas")).findById(1).?.widget.value);
}
test "anchored surfaces relayout against restored scroll geometry across rebuilds" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-scroll-anchored-reconcile", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 180, 100),
});
const menu_items = [_]canvas.Widget{.{ .id = 5, .kind = .menu_item, .frame = geometry.RectF.init(0, 0, 0, 24), .text = "One" }};
const dropdown = canvas.Widget{
.id = 4,
.kind = .dropdown_menu,
.frame = geometry.RectF.init(0, 0, 140, 50),
.layout = .{ .anchor = .{} },
.children = &menu_items,
};
const wrap_children = [_]canvas.Widget{
.{ .id = 3, .kind = .text, .text = "Files" },
dropdown,
};
const scroll_children = [_]canvas.Widget{
.{ .id = 2, .kind = .stack, .frame = geometry.RectF.init(0, 140, 160, 28), .children = &wrap_children },
.{ .id = 6, .kind = .panel, .frame = geometry.RectF.init(0, 320, 160, 20) },
};
const source_root = canvas.Widget{ .id = 1, .kind = .scroll_view, .children = &scroll_children };
var nodes: [6]canvas.WidgetLayoutNode = undefined;
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", try canvas.layoutWidgetTree(source_root, geometry.RectF.init(0, 0, 180, 100), &nodes));
// Bring the trigger near the top. The source rebuild still lays it at
// y=140 before the retained offset returns; the anchored pass must run
// again and place the full-height menu below the final y=10 trigger.
try harness.runtime.dispatchAutomationCommand(app, "widget-wheel canvas 1 130");
var rebuild_nodes: [6]canvas.WidgetLayoutNode = undefined;
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", try canvas.layoutWidgetTree(source_root, geometry.RectF.init(0, 0, 180, 100), &rebuild_nodes));
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
var trigger = retained.findById(2).?.frame;
var surface = retained.findById(4).?.frame;
try std.testing.expectEqual(@as(f32, 10), trigger.y);
try std.testing.expectEqual(trigger.maxY() + 4, surface.y);
try std.testing.expectEqual(@as(f32, 50), surface.height);
// Move the trigger near the bottom and rebuild again. Now the final
// geometry must choose the other side and preserve the authored height.
try harness.runtime.dispatchAutomationCommand(app, "widget-wheel canvas 1 -60");
var flipped_nodes: [6]canvas.WidgetLayoutNode = undefined;
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", try canvas.layoutWidgetTree(source_root, geometry.RectF.init(0, 0, 180, 100), &flipped_nodes));
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
trigger = retained.findById(2).?.frame;
surface = retained.findById(4).?.frame;
try std.testing.expectEqual(@as(f32, 70), trigger.y);
try std.testing.expectEqual(trigger.y - 4 - surface.height, surface.y);
try std.testing.expectEqual(@as(f32, 50), surface.height);
}
test "anchored relayout preserves a descendant scroll region across rebuilds" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-scroll-anchored-descendant-reconcile", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 220, 160),
});
const scroll_content = [_]canvas.Widget{.{
.id = 7,
.kind = .panel,
.frame = geometry.RectF.init(0, 0, 120, 180),
}};
const popover_children = [_]canvas.Widget{.{
.id = 6,
.kind = .scroll_view,
.frame = geometry.RectF.init(0, 0, 120, 56),
.children = &scroll_content,
}};
const popover = canvas.Widget{
.id = 4,
.kind = .popover,
.frame = geometry.RectF.init(0, 0, 140, 80),
.layout = .{ .anchor = .{} },
.children = &popover_children,
};
const trigger_children = [_]canvas.Widget{
.{ .id = 3, .kind = .button, .frame = geometry.RectF.init(0, 0, 140, 28), .text = "Open" },
popover,
};
const source_root = canvas.Widget{
.id = 1,
.kind = .stack,
.frame = geometry.RectF.init(20, 20, 140, 28),
.children = &trigger_children,
};
var nodes: [8]canvas.WidgetLayoutNode = undefined;
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", try canvas.layoutWidgetTree(
source_root,
geometry.RectF.init(0, 0, 220, 160),
&nodes,
));
try harness.runtime.dispatchAutomationCommand(app, "widget-wheel canvas 6 24");
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(6).?.widget.value);
const scrolled_content_y = retained.findById(7).?.frame.y;
// Replaying the anchored surface recovers its authored size, but must
// not replay over runtime-owned state inside that surface.
var rebuild_nodes: [8]canvas.WidgetLayoutNode = undefined;
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", try canvas.layoutWidgetTree(
source_root,
geometry.RectF.init(0, 0, 220, 160),
&rebuild_nodes,
));
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 24), retained.findById(6).?.widget.value);
try std.testing.expectEqual(scrolled_content_y, retained.findById(7).?.frame.y);
}
test "engine wheel scrolls a windowed virtual list against its declared extent" {
const TestApp = struct {
fn app(self: *@This()) App {
@@ -1913,6 +2054,48 @@ test "a surface anchored to the scroll region itself never rides its content" {
try std.testing.expectEqualDeep(geometry.RectF.init(10, 72, 100, 30), retained.findById(3).?.frame);
}
test "a root-relative modal nested in a scroll region never rides its content" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-modal-scroll", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
const app = app_state.app();
try harness.start(app);
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 180, 72),
});
const nodes = [_]canvas.WidgetLayoutNode{
.{ .widget = .{ .id = 1, .kind = .scroll_view, .frame = geometry.RectF.init(0, 0, 180, 72) }, .frame = geometry.RectF.init(0, 0, 180, 72), .depth = 0 },
.{ .widget = .{ .id = 2, .kind = .panel, .frame = geometry.RectF.init(0, 0, 180, 200) }, .frame = geometry.RectF.init(0, 0, 180, 200), .depth = 1, .parent_index = 0 },
.{ .widget = .{ .id = 3, .kind = .dialog, .frame = geometry.RectF.init(40, 16, 100, 40) }, .frame = geometry.RectF.init(40, 16, 100, 40), .depth = 2, .parent_index = 1 },
};
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", .{ .nodes = &nodes });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = "canvas",
.timestamp_ns = 1_000_000_000,
.kind = .scroll,
.x = 20,
.y = 20,
.delta_y = 24,
} });
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqualDeep(geometry.RectF.init(0, -24, 180, 200), retained.findById(2).?.frame);
try std.testing.expectEqualDeep(geometry.RectF.init(40, 16, 100, 40), retained.findById(3).?.frame);
}
test "assistive steps on a both-axes region page its live axis" {
const TestApp = struct {
fn app(self: *@This()) App {
+1 -1
View File
@@ -119,7 +119,7 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
// the OS scroller's overscrolled offset instead of clamping it
// and force-pushing the clamp into the live bounce (visible
// jitter). Non-driver platforms clamp exactly as before.
canvas_widget_runtime.clampCanvasWidgetLayoutScrollOffsets(reconciled_nodes[0..reconciled_layout.nodes.len], null);
try canvas_widget_runtime.clampCanvasWidgetLayoutScrollOffsetsAfterReconcile(reconciled_nodes[0..reconciled_layout.nodes.len], null, reconciled_layout.root_bounds, tokens);
// Runtime-owned tooltip visibility normalizes BEFORE the
// diff: the retained tree carries the intent machine's
// hidden stamps on anchored tooltips while the source
+2 -2
View File
@@ -6028,8 +6028,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
const tree = self.treeForViewLabel(drag_event.view_label);
const live_template = if (tree) |value| value.msgFor(source.id, .drag) else null;
const layout: ?canvas.WidgetLayoutTree = runtime.canvasWidgetLayout(drag_event.window_id, drag_event.view_label) catch null;
const live_view_size: ?geometry.SizeF = if (layout) |value| if (value.nodes.len > 0) blk: {
const root = value.nodes[0].frame.normalized();
const live_view_size: ?geometry.SizeF = if (layout) |value| if (canvas.widgetLayoutRootBounds(value)) |root_value| blk: {
const root = root_value.normalized();
break :blk geometry.SizeF.init(root.width, root.height);
} else null else null;
+12 -6
View File
@@ -809,13 +809,19 @@ test "a declared context menu presents as the anchored fallback surface on prese
try std.testing.expect(app_state.tree.?.context_menu_fallback == null);
try std.testing.expect(!try retainedWidgetKindExists(&harness.runtime, .dropdown_menu));
// Reopen and dismiss (Escape/outside-click/automation all land on
// the same dismissal machinery): the surface closes, no Msg fires.
// Reopen and click outside. The fallback was opened from a typically
// non-focusable secondary-click target, so no focused ancestry is
// available to find it; the whole-view anchored fallback must still
// dismiss it. Its internal open state closes and no app Msg fires.
try harness.runtime.dispatchAutomationCommand(app, context_press);
const reopened = app_state.tree.?.context_menu_fallback orelse return error.TestUnexpectedResult;
var dismiss_buffer: [96]u8 = undefined;
const dismiss = try std.fmt.bufPrint(&dismiss_buffer, "widget-action {s} {d} dismiss", .{ canvas_label, reopened.surface_id });
try harness.runtime.dispatchAutomationCommand(app, dismiss);
_ = app_state.tree.?.context_menu_fallback orelse return error.TestUnexpectedResult;
harness.runtime.views[0].canvas_widget_focused_id = 0;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
.label = canvas_label,
.kind = .pointer_down,
.x = 360,
.y = 280,
} });
try std.testing.expect(app_state.tree.?.context_menu_fallback == null);
try std.testing.expect(!try retainedWidgetKindExists(&harness.runtime, .dropdown_menu));
try std.testing.expectEqual(@as(u32, 1), app_state.model.deleted);
+1
View File
@@ -445,6 +445,7 @@ pub const RuntimeView = struct {
canvas_frame_profile_dirty_ratio: f32 = 0,
widget_layout_nodes: [max_canvas_widget_nodes_per_view]canvas.WidgetLayoutNode = undefined,
widget_layout_node_count: usize = 0,
widget_layout_root_bounds: ?geometry.RectF = null,
widget_semantics_nodes: [max_canvas_widget_semantics_per_view]canvas.WidgetSemanticsNode = undefined,
widget_semantics_node_count: usize = 0,
/// Fingerprint of the last accessibility tree actually handed to the
+9 -6
View File
@@ -393,9 +393,9 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
if (ancestor_index >= self.widget_layout_node_count) break;
const ancestor = self.widget_layout_nodes[ancestor_index];
// Anchored surfaces escape ancestor clips rather than
// Window-level surfaces escape ancestor clips rather than
// scrolling with their source branch.
if (canvas.widgetIsAnchored(ancestor.widget)) break;
if (canvas.widgetEscapesAncestorClips(ancestor.widget)) break;
if (canvasWidgetScrollableKind(ancestor.widget.kind) and ancestor.widget.kind != .textarea) {
const viewport = ancestor.frame.inset(ancestor.widget.layout.padding).normalized();
@@ -540,15 +540,18 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
/// Scrolled content carries its descendants — INCLUDING floating
/// surfaces anchored to widgets inside it (their anchor bases
/// moved) — but a surface anchored to the SCROLL REGION ITSELF
/// stays put: its anchor base is the region's own frame, which
/// never moves when the content under it does.
/// moved) — but root-relative modal surfaces never ride content,
/// and a surface anchored to the SCROLL REGION ITSELF stays put:
/// its anchor base is the region's own frame, which never moves
/// when the content under it does.
pub fn translateCanvasWidgetScrollDescendants(self: *RuntimeView, scroll_index: usize, offset: geometry.OffsetF) void {
const scroll_depth = self.widget_layout_nodes[scroll_index].depth;
var index = scroll_index + 1;
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > scroll_depth) {
const node = self.widget_layout_nodes[index];
if (node.widget.layout.anchor != null and node.parent_index == scroll_index) {
if (canvas.widgetIsRootRelativeModal(node.widget) or
node.widget.layout.anchor != null and node.parent_index == scroll_index)
{
const subtree_depth = node.depth;
index += 1;
while (index < self.widget_layout_node_count and self.widget_layout_nodes[index].depth > subtree_depth) : (index += 1) {}
+46 -13
View File
@@ -95,7 +95,10 @@ fn validateWidgetLayoutPoolBudgets(
pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return struct {
pub fn widgetLayoutTree(self: *const RuntimeView) canvas.WidgetLayoutTree {
return .{ .nodes = self.widget_layout_nodes[0..self.widget_layout_node_count] };
return .{
.nodes = self.widget_layout_nodes[0..self.widget_layout_node_count],
.root_bounds = self.widget_layout_root_bounds,
};
}
pub fn widgetSemantics(self: *const RuntimeView) []const canvas.WidgetSemanticsNode {
@@ -194,6 +197,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
}
if (anchored_count > canvas_limits.max_canvas_widget_anchored_per_view) return error.WidgetAnchoredSurfaceLimitReached;
if (layout.nodes.len > 0 and layout.nodes.ptr == self.widget_layout_nodes[0..].ptr) {
self.widget_layout_root_bounds = layout.root_bounds;
self.widget_revision += 1;
self.canvas_widget_layout_adoptions +%= 1;
return;
@@ -285,6 +289,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
self.canvas_widget_layout_adoptions +%= 1;
errdefer self.pruneCanvasWidgetHoverMsgChain();
self.widget_layout_node_count = 0;
self.widget_layout_root_bounds = layout.root_bounds;
self.widget_semantics_node_count = 0;
self.widget_text_len = 0;
self.widget_span_len = 0;
@@ -302,9 +307,11 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
self.widget_layout_node_count += 1;
}
clampCanvasWidgetLayoutScrollOffsets(
try clampCanvasWidgetLayoutScrollOffsets(
self.widget_layout_nodes[0..self.widget_layout_node_count],
self.widget_scroll_states[0..self.widget_layout_node_count],
self.widget_layout_root_bounds,
self.widget_tokens,
);
clampCanvasWidgetLayoutTextOffsets(
self.widget_layout_nodes[0..self.widget_layout_node_count],
@@ -495,7 +502,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// Escape's dismissal resolution: the nearest dismissible surface
/// up the focused widget's chain when something is focused, and
/// otherwise — or when the chain finds none — the topmost MOUNTED
/// otherwise — or when the chain finds none — the topmost painted
/// anchored surface in the view. The fallback is what makes
/// surfaces opened from NON-focusable triggers dismissible: a
/// text-crumb trigger takes no focus on click, so nothing is
@@ -505,7 +512,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// never dismisses a surface, not even through the fallback.
/// With SEVERAL surfaces anchored on one stack (the select
/// trigger's focus-shown tooltip floating over its open menu),
/// each Escape peels exactly one, topmost (last-mounted) first —
/// each Escape peels exactly one, topmost by effective layer first —
/// the tooltip goes, then the menu.
pub fn dismissCanvasWidgetSurfaceFromEscape(self: *RuntimeView, focused_id: canvas.ObjectId) anyerror!?CanvasWidgetSurfaceDismissal {
if (focused_id != 0) {
@@ -553,8 +560,20 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// double-cover those and leave the menu beneath it floating
/// after the user clicked away.
pub fn dismissCanvasWidgetSurfaceForPointerOutsideFocusedTarget(self: *RuntimeView, focused_id: canvas.ObjectId, route: []const canvas.WidgetEventRouteEntry) anyerror!?CanvasWidgetSurfaceDismissal {
const focused_index = self.canvasWidgetNodeIndexById(focused_id) orelse return null;
const surface_index = canvasWidgetSurfaceIndexForTargetInScope(self, focused_index, .interactive) orelse return null;
const focused_surface_index = if (focused_id != 0)
if (self.canvasWidgetNodeIndexById(focused_id)) |focused_index|
canvasWidgetSurfaceIndexForTargetInScope(self, focused_index, .interactive)
else
null
else
null;
// A non-focusable trigger (text/icon/stack) can open an
// anchored menu while leaving no focus behind, and focus may
// also live in an unrelated branch. Match Escape's deliberate
// whole-view fallback, scoped to interactive surfaces so the
// tooltip intent machine keeps owning tooltip dismissal.
const surface_index = focused_surface_index orelse
canvasWidgetTopmostAnchoredSurfaceIndexInScope(self, .interactive) orelse return null;
if (self.canvasWidgetRouteDescendsFromIndex(route, surface_index)) return null;
// Clicking the ANCHOR region of an anchored surface (the
// trigger, or the stack that wraps trigger + surface) is the
@@ -713,7 +732,7 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return null;
}
/// The topmost (last-mounted) visible anchored dismissible surface
/// The topmost visible anchored dismissible surface
/// whose anchor is `anchor_index`, or null.
pub fn canvasWidgetAnchoredDismissibleChildIndex(self: *const RuntimeView, anchor_index: usize) ?usize {
return canvasWidgetAnchoredChildIndexInScope(self, anchor_index, .any);
@@ -725,12 +744,17 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// menu) never masks the surface the caller asked for.
fn canvasWidgetAnchoredChildIndexInScope(self: *const RuntimeView, anchor_index: usize, comptime scope: CanvasWidgetAnchoredSurfaceScope) ?usize {
var found: ?usize = null;
var found_order: ?canvas.WidgetPaintOrder = null;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |node, index| {
if (node.parent_index != anchor_index) continue;
if (!canvas.widgetIsAnchored(node.widget)) continue;
if (!canvasWidgetAnchoredSurfaceKindInScope(node.widget.kind, scope)) continue;
if (node.widget.semantics.hidden) continue;
found = index;
const order = canvas.widgetLayoutWindowSurfaceOrder(self.widgetLayoutTree(), index, self.widget_tokens);
if (found_order == null or canvas.widgetPaintOrderLess(found_order.?, order)) {
found = index;
found_order = order;
}
}
return found;
}
@@ -944,18 +968,27 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
}
/// The topmost visible anchored dismissible surface in the whole
/// view — highest node index, matching both the anchored late
/// z-pass paint order and reverse-order hit-testing, so "topmost"
/// here is the surface the user sees on top. Ancestor-hidden
/// view — highest effective `(layer, node index)`, matching both
/// the anchored late z-pass and reverse-order hit-testing, so
/// "topmost" here is the surface the user sees on top. Ancestor-hidden
/// subtrees are skipped: a surface inside a hidden branch is not
/// on screen and must not swallow Escape.
pub fn canvasWidgetTopmostAnchoredDismissibleIndex(self: *const RuntimeView) ?usize {
return canvasWidgetTopmostAnchoredSurfaceIndexInScope(self, .any);
}
fn canvasWidgetTopmostAnchoredSurfaceIndexInScope(self: *const RuntimeView, comptime scope: CanvasWidgetAnchoredSurfaceScope) ?usize {
var found: ?usize = null;
var found_order: ?canvas.WidgetPaintOrder = null;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |node, index| {
if (!canvas.widgetIsAnchored(node.widget)) continue;
if (!canvasWidgetDismissibleSurfaceKind(node.widget.kind)) continue;
if (!canvasWidgetAnchoredSurfaceKindInScope(node.widget.kind, scope)) continue;
if (canvasWidgetNodeHiddenInTree(self, index)) continue;
found = index;
const order = canvas.widgetLayoutWindowSurfaceOrder(self.widgetLayoutTree(), index, self.widget_tokens);
if (found_order == null or canvas.widgetPaintOrderLess(found_order.?, order)) {
found = index;
found_order = order;
}
}
return found;
}
+4 -4
View File
@@ -980,7 +980,7 @@ fn buildDialog(ui: *Ui, model: *const SceneModel) Node {
fn buildDrawer(ui: *Ui) Node {
return tileStart(ui, .{
ui.el(.drawer, .{ .text = "Filters", .width = 260, .height = 230, .padding = 24 }, .{
ui.el(.drawer, .{ .text = "Filters", .height = 230, .padding = 24 }, .{
ui.column(.{ .gap = 12 }, .{
surfaceTitleSpacer(ui),
ui.checkbox(.{ .text = "Only unread", .checked = true }),
@@ -993,7 +993,7 @@ fn buildDrawer(ui: *Ui) Node {
fn buildSheet(ui: *Ui) Node {
return tile(ui, .{
ui.el(.sheet, .{ .text = "Share", .width = 380, .height = 190, .padding = 24 }, .{
ui.el(.sheet, .{ .text = "Share", .width = 380, .padding = 24 }, .{
ui.column(.{ .gap = 12 }, .{
surfaceTitleSpacer(ui),
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Anyone with the link can view this board."),
@@ -1379,7 +1379,7 @@ fn buildDialogHero(ui: *Ui) Node {
fn buildDrawerHero(ui: *Ui) Node {
return heroTile(ui, .{
ui.el(.drawer, .{ .text = "Filters", .width = 220, .height = 155, .padding = 18 }, .{
ui.el(.drawer, .{ .text = "Filters", .height = 155, .padding = 18 }, .{
ui.column(.{ .gap = 10 }, .{
surfaceTitleSpacer(ui),
ui.checkbox(.{ .text = "Only unread", .checked = true }),
@@ -1571,7 +1571,7 @@ fn buildSeparatorHero(ui: *Ui) Node {
fn buildSheetHero(ui: *Ui) Node {
return heroTile(ui, .{
ui.el(.sheet, .{ .text = "Share", .width = 300, .height = 158, .padding = 14 }, .{
ui.el(.sheet, .{ .text = "Share", .width = 300, .padding = 14 }, .{
ui.column(.{ .gap = 10 }, .{
surfaceTitleSpacer(ui),
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Anyone with the link can view this board."),
+3 -3
View File
@@ -57,9 +57,9 @@ pub const element_docs = [_]Doc{
.{ .name = "accordion", .doc = "Surface with a header (text attribute); children show when selected, dispatch with on-toggle." },
.{ .name = "alert", .doc = "Alert surface; title via the text attribute, children stack inside." },
.{ .name = "bubble", .doc = "Bubble surface (chat message); children stack inside. Hugs its message up to 80% of the thread's width (ghost is exempt; an explicit width wins). A <reactions> child docks the reaction pill on its bottom edge; text does nothing here (that channel belongs to the pill — use label for an accessible name)." },
.{ .name = "dialog", .doc = "Modal dialog surface rendered in place; title via text, wrap in an if to show conditionally." },
.{ .name = "drawer", .doc = "Drawer surface rendered in place; title via text, wrap in an if to show conditionally." },
.{ .name = "sheet", .doc = "Sheet surface rendered in place; title via text, wrap in an if to show conditionally." },
.{ .name = "dialog", .doc = "Root-relative modal centered in the viewport and unaffected by ancestor scroll or clipping; title via text, wrap in an if to show conditionally." },
.{ .name = "drawer", .doc = "Root-relative modal spanning the viewport width and docked to its bottom; title via text, wrap in an if to show conditionally." },
.{ .name = "sheet", .doc = "Root-relative modal spanning the viewport height and docked to its right edge; title via text, wrap in an if to show conditionally." },
.{ .name = "resizable", .doc = "Resizable panel with an engine-managed drag handle; width sets the initial width." },
.{ .name = "avatar", .doc = "Avatar leaf; the text content renders as initials, image takes one {binding} to a runtime-registered ImageId (0 keeps the initials)." },
.{ .name = "select", .doc = "Select trigger only (no options attribute): content is the current value, placeholder while empty, on-press opens. Compose the options as an ANCHORED dropdown-menu of menu-items under an if, beside the trigger in a stack (anchor=\"below\" + on-dismiss; model-owned open state)." },