Skip to main content

frust_core/
semantics.rs

1//! Layer 2: the semantics seam — a pull-based accessibility tree pass.
2//!
3//! Each widget *optionally* contributes an [`accesskit::Node`] describing its
4//! role, label, and state ([`Widget::semantics`](crate::widget::Widget::semantics),
5//! defaulted to a no-op so no existing widget is affected). The collection pass
6//! mirrors paint, not the arena tree-walk: a container's children are
7//! [`ChildPod`](crate::widget::ChildPod)s outside the arena, so — exactly like
8//! [`ChildPod::paint_child`](crate::widget::ChildPod::paint_child) threads an
9//! absolute origin down — [`ChildPod::semantics_child`](crate::widget::ChildPod::semantics_child)
10//! translates the current absolute origin into the child's space and recurses.
11//!
12//! [`RenderRoot::semantics`](crate::app::RenderRoot::semantics) drives it *after*
13//! layout (so bounds are valid) and returns a [`SemanticsUpdate`]: a flat
14//! `(NodeId, Node)` list plus the root id and the focused node id, ready for a
15//! platform adapter (`accesskit_*`) to consume. This crate owns **no** platform
16//! wiring or per-frame scheduling — those live in each platform shell.
17//!
18//! # Contributing a node
19//!
20//! A leaf widget calls [`SemanticsCtx::push_node`] with its role and a closure
21//! that sets label/state; bounds are filled from the current absolute
22//! origin/size automatically. A widget that also has semantics-visible children
23//! (e.g. a scroll view) uses [`SemanticsCtx::push_container`], whose `visit`
24//! closure recurses into the children (via `semantics_child`) so they become the
25//! node's accesskit children. A *transparent* container (Flex/Stack/Padding/…)
26//! contributes no node of its own — it just calls `semantics_child` for each
27//! child, so their nodes attach to whatever encloses the container.
28
29use std::num::NonZeroU64;
30
31use accesskit::{Node, NodeId, Rect as AccessRect, Role};
32use kurbo::{Point, Size, Vec2};
33
34/// The reserved [`NodeId`] of the synthetic window/root node
35/// ([`RenderRoot::semantics`](crate::app::RenderRoot::semantics) always roots the
36/// tree here). It is a fixed constant — the one id never drawn from the
37/// per-`ChildPod` allocator — so a platform adapter can treat "the root" as a
38/// stable anchor across every frame. Pod-derived ids start well
39/// above it (see [`SemanticsCtx::alloc_base`]).
40pub const ROOT_NODE_ID: NodeId = NodeId(1);
41
42/// Number of low bits of a composed [`NodeId`] reserved for a widget's per-pod
43/// sub-node *slot* ordinal.
44///
45/// A [`ChildPod`](crate::widget::ChildPod)'s stable base id occupies the high
46/// bits; the low [`SLOT_BITS`] bits distinguish the (at most `2^SLOT_BITS`)
47/// nodes a single widget contributes for that pod — its own node is slot `0`,
48/// and any extra nodes it pushes (navbar items, list rows, …) take slots
49/// `1, 2, …` in a stable push order. This keeps every node id stable across
50/// frames (the base survives the pod's lifetime, including keyed relocation)
51/// while staying collision-free: distinct bases never overlap as long as a
52/// single widget contributes fewer than `2^SLOT_BITS` nodes and the base fits
53/// the remaining `64 - SLOT_BITS` bits.
54const SLOT_BITS: u32 = 16;
55
56/// Compose a stable [`NodeId`] from a pod's `base` id and a per-pod sub-node
57/// `slot` ordinal (see [`SLOT_BITS`]).
58///
59/// `base` is a monotonically-allocated, never-reused per-pod id (`>= 2`, so the
60/// composed value never collides with the reserved [`ROOT_NODE_ID`]); `slot`
61/// distinguishes the nodes one widget contributes for that pod. The mapping is
62/// injective over `(base, slot)` while `base < 2^(64 - SLOT_BITS)`, which a
63/// `NonZeroU64` allocator exhausts only after ~2.8e14 pods.
64pub(crate) fn compose_node_id(base: NonZeroU64, slot: u16) -> NodeId {
65    debug_assert!(
66        base.get() < (1u64 << (64 - SLOT_BITS)),
67        "semantics base id {} overflows the {}-bit base field",
68        base.get(),
69        64 - SLOT_BITS
70    );
71    NodeId((base.get() << SLOT_BITS) | slot as u64)
72}
73
74/// A collected accessibility tree produced by
75/// [`RenderRoot::semantics`](crate::app::RenderRoot::semantics).
76///
77/// `nodes` is the flat `(NodeId, Node)` map every accesskit platform adapter
78/// consumes; `root` is the id of the enclosing window node (always present, even
79/// for an empty tree); `focus` is the node a focused widget claimed via
80/// [`SemanticsCtx::set_focused`], or `None` when nothing in the tree is focused
81/// (a platform adapter defaults the platform focus to `root` in that case).
82#[derive(Clone, Debug, PartialEq)]
83pub struct SemanticsUpdate {
84    /// The flat node map, root first, then each contributed node in the order it
85    /// was collected (pre-order, mirroring paint).
86    pub nodes: Vec<(NodeId, Node)>,
87    /// The id of the root (window) node.
88    pub root: NodeId,
89    /// The focused node, if any widget claimed focus this pass.
90    pub focus: Option<NodeId>,
91}
92
93impl SemanticsUpdate {
94    /// The node a platform adapter should report as focused — the focused widget's
95    /// node, or [`root`](SemanticsUpdate::root) when nothing in the tree is
96    /// focused.
97    ///
98    /// accesskit's `TreeUpdate::focus` is a non-optional [`NodeId`]: an adapter
99    /// must always name *some* focus target, and the window root is the
100    /// conventional fallback. This is the value a shell feeds
101    /// straight into the adapter, versus reading [`focus`](SemanticsUpdate::focus)
102    /// when it needs to distinguish "root, because focused" from "root, because
103    /// nothing is focused".
104    pub fn focus_id(&self) -> NodeId {
105        self.focus.unwrap_or(self.root)
106    }
107}
108
109/// Collects [`accesskit::Node`]s during a semantics pass, tracking the current
110/// absolute origin/size (mirroring paint's origin threading) and the
111/// parent/child structure.
112///
113/// Node ids are **stable across passes**: each node's id is
114/// composed from the owning [`ChildPod`](crate::widget::ChildPod)'s persistent
115/// base id (assigned on first visit from a monotonic, never-reused
116/// [`RenderRoot`](crate::app::RenderRoot) allocator and threaded in through
117/// [`SemanticsCtx::new`]) and a per-pod sub-node slot (see
118/// [`compose_node_id`]) — so the same widget keeps the same id every frame,
119/// including across a keyed reorder that relocates its pod. accesskit
120/// `TreeUpdate`s require stable ids for assistive-technology focus continuity.
121/// Bounds are derived from the current absolute origin/size when a node is
122/// pushed, so a widget never computes its own absolute rect.
123pub struct SemanticsCtx {
124    /// Absolute origin of the widget currently being visited.
125    origin: Point,
126    /// Size of the widget currently being visited.
127    size: Size,
128    /// Monotonic *fallback* node-id counter, used only for a node pushed with no
129    /// current pod base (the direct-`push_node` unit-test path). Real tree walks
130    /// always compose ids from a pod base — see [`SemanticsCtx::next_node_id`].
131    next_id: u64,
132    /// The persistent per-pod base-id allocator's next value, seeded by
133    /// [`RenderRoot`](crate::app::RenderRoot) from its cross-pass high-water mark
134    /// and read back via [`SemanticsCtx::next_base`] after the pass so newly-seen
135    /// pods never reuse an already-assigned base.
136    next_base: u64,
137    /// The base id of the pod currently being visited (set by
138    /// [`SemanticsCtx::descend_into_pod`]); `None` at the window-root level and in
139    /// direct `push_node` unit tests.
140    current_base: Option<NonZeroU64>,
141    /// The next sub-node slot ordinal for the current pod — bumped by each
142    /// [`SemanticsCtx::next_node_id`] so a widget contributing several nodes gets
143    /// stable, distinct ids.
144    current_slot: u16,
145    /// The flat node map accumulated so far.
146    nodes: Vec<(NodeId, Node)>,
147    /// A stack of child-id collection frames: the top frame gathers the ids of
148    /// nodes contributed at the current nesting level, so a container can set
149    /// them as its node's children after recursing.
150    frames: Vec<Vec<NodeId>>,
151    /// The focused node, recorded by [`SemanticsCtx::set_focused`].
152    focus: Option<NodeId>,
153}
154
155impl SemanticsCtx {
156    /// Create a context for a pass over a `window_size`-sized root, positioned at
157    /// the origin, seeding the persistent per-pod base allocator at `next_base`
158    /// (the caller's cross-pass high-water mark). One (root-level) child frame is
159    /// open.
160    pub(crate) fn new(window_size: Size, next_base: u64) -> Self {
161        Self {
162            origin: Point::ZERO,
163            size: window_size,
164            next_id: 0,
165            next_base,
166            current_base: None,
167            current_slot: 0,
168            nodes: Vec::new(),
169            frames: vec![Vec::new()],
170            focus: None,
171        }
172    }
173
174    /// The absolute origin of the widget currently being visited.
175    pub fn origin(&self) -> Point {
176        self.origin
177    }
178
179    /// The size of the widget currently being visited.
180    pub fn size(&self) -> Size {
181        self.size
182    }
183
184    /// Allocate the next persistent per-pod base id, advancing the allocator.
185    ///
186    /// A [`ChildPod`](crate::widget::ChildPod) calls this on its *first* semantics
187    /// visit and caches the result for its whole lifetime; the
188    /// [`RenderRoot`](crate::app::RenderRoot) reads the final value back via
189    /// [`SemanticsCtx::next_base`] so the next pass never reuses it.
190    pub(crate) fn alloc_base(&mut self) -> NonZeroU64 {
191        let id = self.next_base;
192        self.next_base += 1;
193        NonZeroU64::new(id).expect("base allocator is seeded >= 2, never zero")
194    }
195
196    /// The allocator's next value after the pass — the caller's new cross-pass
197    /// high-water mark (see [`SemanticsCtx::alloc_base`]).
198    pub(crate) fn next_base(&self) -> u64 {
199        self.next_base
200    }
201
202    /// Allocate the next sequential *fallback* node id (no pod base in scope).
203    fn alloc_id(&mut self) -> NodeId {
204        let id = NodeId(self.next_id);
205        self.next_id += 1;
206        id
207    }
208
209    /// The [`NodeId`] for the next node the current widget contributes: composed
210    /// from the current pod base and its running slot ([`compose_node_id`]) when a
211    /// pod is in scope, else the sequential fallback (direct-`push_node` tests).
212    fn next_node_id(&mut self) -> NodeId {
213        match self.current_base {
214            Some(base) => {
215                let slot = self.current_slot;
216                self.current_slot = self
217                    .current_slot
218                    .checked_add(1)
219                    .expect("a single widget contributes fewer than 2^16 nodes per pod");
220                compose_node_id(base, slot)
221            }
222            None => self.alloc_id(),
223        }
224    }
225
226    /// The accesskit bounds of the widget currently being visited (absolute,
227    /// top-down y), derived from the current origin/size.
228    fn bounds(&self) -> AccessRect {
229        AccessRect {
230            x0: self.origin.x,
231            y0: self.origin.y,
232            x1: self.origin.x + self.size.width,
233            y1: self.origin.y + self.size.height,
234        }
235    }
236
237    /// Contribute a **leaf** semantics node for the current widget.
238    ///
239    /// Allocates a node id, creates an [`accesskit::Node`] of `role` with its
240    /// bounds set from the current absolute origin/size, lets `build` set the
241    /// label/state, records it as a child of the enclosing node, and appends it
242    /// to the flat map. Returns the allocated id (e.g. so a widget can
243    /// [`set_focused`](SemanticsCtx::set_focused) it).
244    pub fn push_node(&mut self, role: Role, build: impl FnOnce(&mut Node)) -> NodeId {
245        let id = self.next_node_id();
246        let mut node = Node::new(role);
247        node.set_bounds(self.bounds());
248        build(&mut node);
249        self.attach(id, node);
250        id
251    }
252
253    /// Contribute a **container** semantics node whose accesskit children are the
254    /// nodes contributed during `visit`.
255    ///
256    /// Like [`push_node`](SemanticsCtx::push_node) but opens a fresh child frame,
257    /// runs `visit` (which recurses into the container's
258    /// [`ChildPod`](crate::widget::ChildPod)s via
259    /// [`semantics_child`](crate::widget::ChildPod::semantics_child)), then sets
260    /// the collected ids as this node's [`Node::set_children`]. The container node
261    /// is appended to the flat map *after* its children (post-order), but is still
262    /// registered as a child of its own parent frame.
263    pub fn push_container(
264        &mut self,
265        role: Role,
266        build: impl FnOnce(&mut Node),
267        visit: impl FnOnce(&mut SemanticsCtx),
268    ) -> NodeId {
269        let id = self.next_node_id();
270        self.push_container_inner(id, role, build, visit)
271    }
272
273    /// Like [`push_container`](SemanticsCtx::push_container) but with an explicit,
274    /// caller-chosen `id` — used for the reserved [`ROOT_NODE_ID`] window node,
275    /// which is not owned by any pod.
276    pub(crate) fn push_container_with_id(
277        &mut self,
278        id: NodeId,
279        role: Role,
280        build: impl FnOnce(&mut Node),
281        visit: impl FnOnce(&mut SemanticsCtx),
282    ) -> NodeId {
283        self.push_container_inner(id, role, build, visit)
284    }
285
286    /// Shared container body: build the node, open a fresh child frame, run
287    /// `visit`, then set the collected ids as this node's children.
288    fn push_container_inner(
289        &mut self,
290        id: NodeId,
291        role: Role,
292        build: impl FnOnce(&mut Node),
293        visit: impl FnOnce(&mut SemanticsCtx),
294    ) -> NodeId {
295        let mut node = Node::new(role);
296        node.set_bounds(self.bounds());
297        build(&mut node);
298        self.frames.push(Vec::new());
299        visit(self);
300        let children = self.frames.pop().expect("container frame was just pushed");
301        node.set_children(children);
302        self.attach(id, node);
303        id
304    }
305
306    /// Register `id` as a child of the enclosing frame and store its node.
307    fn attach(&mut self, id: NodeId, node: Node) {
308        self.frames
309            .last_mut()
310            .expect("a child frame is always open during a pass")
311            .push(id);
312        self.nodes.push((id, node));
313    }
314
315    /// Record that the node `id` holds input focus (accesskit tracks focus at the
316    /// tree level, not per-node). The last widget to call this in a pass wins.
317    pub fn set_focused(&mut self, id: NodeId) {
318        self.focus = Some(id);
319    }
320
321    /// Run `f` with the current geometry translated into a child's space:
322    /// `child_offset` is the child's origin *relative to the current origin*
323    /// (matching [`ChildPod::paint_child`](crate::widget::ChildPod::paint_child)'s
324    /// `ctx.origin() + pod.origin` absolute-origin rule), and `child_size` its
325    /// resolved size. The previous geometry is restored afterward.
326    ///
327    /// Geometry-only sibling of [`descend_into_pod`](SemanticsCtx::descend_into_pod)
328    /// (which also enters a pod's stable-id scope); real tree walks always go
329    /// through the pod variant, so this is retained only for the direct-`push_node`
330    /// unit tests that exercise origin threading without a pod.
331    #[cfg(test)]
332    pub(crate) fn descend(
333        &mut self,
334        child_offset: Vec2,
335        child_size: Size,
336        f: impl FnOnce(&mut Self),
337    ) {
338        let saved_origin = self.origin;
339        let saved_size = self.size;
340        self.origin = saved_origin + child_offset;
341        self.size = child_size;
342        f(self);
343        self.origin = saved_origin;
344        self.size = saved_size;
345    }
346
347    /// Enter a [`ChildPod`](crate::widget::ChildPod)'s geometry **and** its stable
348    /// id scope: like [`descend`](SemanticsCtx::descend) it translates into the
349    /// child's absolute space, and additionally makes `base` the current pod base
350    /// (resetting the sub-node slot counter) so nodes the child contributes get
351    /// stable [`compose_node_id`]-composed ids. The previous geometry, base, and
352    /// slot are all restored afterward.
353    pub(crate) fn descend_into_pod(
354        &mut self,
355        base: NonZeroU64,
356        child_offset: Vec2,
357        child_size: Size,
358        f: impl FnOnce(&mut Self),
359    ) {
360        let saved_origin = self.origin;
361        let saved_size = self.size;
362        let saved_base = self.current_base;
363        let saved_slot = self.current_slot;
364        self.origin = saved_origin + child_offset;
365        self.size = child_size;
366        self.current_base = Some(base);
367        self.current_slot = 0;
368        f(self);
369        self.origin = saved_origin;
370        self.size = saved_size;
371        self.current_base = saved_base;
372        self.current_slot = saved_slot;
373    }
374
375    /// Finish the pass, returning the collected update rooted at `root`.
376    pub(crate) fn finish(self, root: NodeId) -> SemanticsUpdate {
377        SemanticsUpdate {
378            nodes: self.nodes,
379            root,
380            focus: self.focus,
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    /// The base seed a `RenderRoot` passes on the first pass (ids 0 and 1 are
390    /// reserved: 0 keeps `NonZeroU64` valid, 1 is the window root).
391    const BASE_SEED: u64 = 2;
392
393    #[test]
394    fn alloc_ids_are_sequential_and_deterministic() {
395        let mut ctx = SemanticsCtx::new(Size::new(100.0, 100.0), BASE_SEED);
396        assert_eq!(ctx.alloc_id(), NodeId(0));
397        assert_eq!(ctx.alloc_id(), NodeId(1));
398        assert_eq!(ctx.alloc_id(), NodeId(2));
399    }
400
401    #[test]
402    fn push_node_sets_absolute_bounds_from_origin_and_size() {
403        let mut ctx = SemanticsCtx::new(Size::new(200.0, 200.0), BASE_SEED);
404        // Descend into a child placed at (10, 20) sized 30x40.
405        ctx.descend(Vec2::new(10.0, 20.0), Size::new(30.0, 40.0), |ctx| {
406            let id = ctx.push_node(Role::Label, |node| node.set_label("hi"));
407            assert_eq!(id, NodeId(0));
408        });
409        let (_, node) = &ctx.nodes[0];
410        assert_eq!(
411            node.bounds(),
412            Some(AccessRect {
413                x0: 10.0,
414                y0: 20.0,
415                x1: 40.0,
416                y1: 60.0,
417            })
418        );
419        assert_eq!(node.label(), Some("hi"));
420        assert_eq!(node.role(), Role::Label);
421    }
422
423    #[test]
424    fn descend_composes_nested_origins() {
425        // A grandchild's absolute origin is the sum of the whole ancestor chain,
426        // mirroring paint_child's `ctx.origin() + pod.origin`.
427        let mut ctx = SemanticsCtx::new(Size::new(500.0, 500.0), BASE_SEED);
428        ctx.descend(Vec2::new(100.0, 200.0), Size::new(300.0, 300.0), |ctx| {
429            ctx.descend(Vec2::new(5.0, 7.0), Size::new(10.0, 10.0), |ctx| {
430                ctx.push_node(Role::Button, |_| {});
431            });
432        });
433        let (_, node) = &ctx.nodes[0];
434        assert_eq!(
435            node.bounds(),
436            Some(AccessRect {
437                x0: 105.0,
438                y0: 207.0,
439                x1: 115.0,
440                y1: 217.0,
441            })
442        );
443    }
444
445    #[test]
446    fn push_container_collects_children_and_restores_frame() {
447        let mut ctx = SemanticsCtx::new(Size::new(100.0, 100.0), BASE_SEED);
448        let container = ctx.push_container(
449            Role::ScrollView,
450            |_| {},
451            |ctx| {
452                ctx.push_node(Role::Label, |n| n.set_label("a"));
453                ctx.push_node(Role::Label, |n| n.set_label("b"));
454            },
455        );
456        // The container is the last node pushed (post-order); its children are the
457        // two labels, allocated ids 1 and 2 (container reserved id 0).
458        assert_eq!(container, NodeId(0));
459        let update = ctx.finish(container);
460        // Flat map order: label a (1), label b (2), then the container (0).
461        assert_eq!(update.nodes.len(), 3);
462        let container_node = &update
463            .nodes
464            .iter()
465            .find(|(id, _)| *id == container)
466            .unwrap()
467            .1;
468        assert_eq!(container_node.children(), &[NodeId(1), NodeId(2)]);
469    }
470
471    #[test]
472    fn set_focused_records_the_focus_node() {
473        let mut ctx = SemanticsCtx::new(Size::new(10.0, 10.0), BASE_SEED);
474        let id = ctx.push_node(Role::TextInput, |_| {});
475        ctx.set_focused(id);
476        let update = ctx.finish(id);
477        assert_eq!(update.focus, Some(id));
478    }
479
480    #[test]
481    fn focus_id_defaults_to_root_when_unfocused() {
482        let mut ctx = SemanticsCtx::new(Size::new(10.0, 10.0), BASE_SEED);
483        let root = ctx.push_container(Role::Window, |_| {}, |_| {});
484        let update = ctx.finish(root);
485        assert!(update.focus.is_none());
486        // With nothing focused, the adapter-facing focus id is the root itself.
487        assert_eq!(update.focus_id(), update.root);
488    }
489
490    #[test]
491    fn compose_node_id_is_collision_free_across_bases_and_slots() {
492        // Distinct (base, slot) pairs must map to distinct ids, and one base's
493        // slot range must never overlap the next base's — the property the
494        // stable-id scheme relies on.
495        use std::collections::HashSet;
496        let mut seen = HashSet::new();
497        for base in 2u64..40 {
498            let base = NonZeroU64::new(base).unwrap();
499            for slot in 0u16..1000 {
500                let id = compose_node_id(base, slot);
501                assert!(id != ROOT_NODE_ID, "never collides with the reserved root");
502                assert!(seen.insert(id), "duplicate id for base={base} slot={slot}");
503            }
504        }
505        // The max slot of one base sits strictly below the next base's slot 0.
506        let a = NonZeroU64::new(2).unwrap();
507        let b = NonZeroU64::new(3).unwrap();
508        assert!(compose_node_id(a, u16::MAX).0 < compose_node_id(b, 0).0);
509    }
510
511    #[test]
512    fn descend_into_pod_composes_stable_ids_and_restores_scope() {
513        let mut ctx = SemanticsCtx::new(Size::new(100.0, 100.0), BASE_SEED);
514        let base = ctx.alloc_base();
515        assert_eq!(base.get(), BASE_SEED);
516        // A widget contributing two nodes for its pod gets slots 0 and 1.
517        let (first, second) = {
518            let mut ids = (NodeId(0), NodeId(0));
519            ctx.descend_into_pod(base, Vec2::ZERO, Size::new(10.0, 10.0), |ctx| {
520                ids.0 = ctx.push_node(Role::Label, |_| {});
521                ids.1 = ctx.push_node(Role::Label, |_| {});
522            });
523            ids
524        };
525        assert_eq!(first, compose_node_id(base, 0));
526        assert_eq!(second, compose_node_id(base, 1));
527        // After the pod scope closes, a fresh pod at the top level uses the
528        // fallback sequential id (no base in scope) — proving the base/slot were
529        // restored rather than leaking out.
530        let outside = ctx.push_node(Role::Label, |_| {});
531        assert_eq!(outside, NodeId(0));
532    }
533}