frust_core/app/semantics.rs
1//! `RenderRoot`'s accessibility surface: the pull-based semantics tree, its
2//! dirty gate, and platform actions routed through the normal event path.
3
4use kurbo::Point;
5
6use super::RenderRoot;
7use crate::event::{EventOutcome, InputEvent, PointerButton, PointerEvent, PointerPhase};
8use crate::semantics::{ROOT_NODE_ID, SemanticsCtx, SemanticsUpdate};
9use crate::view::View;
10
11impl<State: 'static, V: View<State>> RenderRoot<State, V> {
12 /// Collect the accessibility tree as a [`SemanticsUpdate`] for a platform
13 /// `accesskit_*` adapter. Pull-based (the shell calls it on demand, never per
14 /// frame) and valid only after [`RenderRoot::layout`], since bounds come from
15 /// post-layout geometry. Always rooted at a synthetic window-sized
16 /// [`accesskit::Role::Window`] node; an unbuilt tree yields that node alone.
17 pub fn semantics(&self) -> SemanticsUpdate {
18 let mut ctx = SemanticsCtx::new(self.window_size, self.semantics_alloc.get());
19 let window = self.window_size;
20 let root_pod = self.root_id.and_then(|id| self.tree.pod(id));
21 // The root pod is no `ChildPod`, so its stable base id lives in
22 // `root_semantics_id`: assigned on the first pass, reused thereafter.
23 let root_widget_base = match self.root_semantics_id.get() {
24 Some(id) => id,
25 None => {
26 let id = ctx.alloc_base();
27 self.root_semantics_id.set(Some(id));
28 id
29 }
30 };
31 let root_node = ctx.push_container_with_id(
32 ROOT_NODE_ID,
33 accesskit::Role::Window,
34 |node| {
35 node.set_bounds(accesskit::Rect {
36 x0: 0.0,
37 y0: 0.0,
38 x1: window.width,
39 y1: window.height,
40 });
41 },
42 |ctx| {
43 if let Some(pod) = root_pod {
44 // Mirrors `ChildPod::semantics_child`.
45 ctx.descend_into_pod(
46 root_widget_base,
47 pod.origin().to_vec2(),
48 pod.size(),
49 |ctx| {
50 pod.widget().semantics(ctx);
51 },
52 );
53 }
54 },
55 );
56 // Persist the high-water mark so later pods never reuse a base.
57 self.semantics_alloc.set(ctx.next_base());
58 ctx.finish(root_node)
59 }
60
61 /// The semantics dirty gate: bumped by a rebuild that returned change flags,
62 /// a theme swap, and an inset change. A shell records the value it last
63 /// pushed and compares ([`RenderRoot::semantics_if_changed`]).
64 pub fn semantics_generation(&self) -> u64 {
65 self.semantics_gen
66 }
67
68 /// A fresh whole-tree [`SemanticsUpdate`] if the generation moved past
69 /// `last_seen`, else `None` (the shell skips the walk and the push). Call it
70 /// post-layout; on `Some`, store [`RenderRoot::semantics_generation`].
71 pub fn semantics_if_changed(&self, last_seen: u64) -> Option<SemanticsUpdate> {
72 (self.semantics_gen != last_seen).then(|| self.semantics())
73 }
74
75 /// Perform a platform accessibility action by synthesizing pointer input at
76 /// the node's bounds center through [`RenderRoot::event`], returning the
77 /// combined [`EventOutcome`]. Handles `Click` and `Focus` only:
78 ///
79 /// * [`accesskit::Action::Click`] → `Down` then `Up`, as a real tap.
80 /// * [`accesskit::Action::Focus`] → `Down` then `Cancel`: the `Down` claims
81 /// focus for a widget that opts in on `Down`; the `Cancel` releases its
82 /// capture without touching focus or firing an on-press callback.
83 ///
84 /// Any other action, or a `node_id` not in the tree, is a no-op. Requires a
85 /// prior [`RenderRoot::layout`]; a widget needing a real drag is not operable.
86 pub fn perform_accessibility_action(
87 &mut self,
88 state: &mut State,
89 node_id: accesskit::NodeId,
90 action: accesskit::Action,
91 ) -> EventOutcome {
92 // A fresh pass keeps the bounds in step with the live tree, no cache.
93 let update = self.semantics();
94 let Some(bounds) = update
95 .nodes
96 .iter()
97 .find(|(id, _)| *id == node_id)
98 .and_then(|(_, node)| node.bounds())
99 else {
100 return EventOutcome::default();
101 };
102 let center = Point::new((bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0);
103 let synth = |phase| {
104 InputEvent::Pointer(PointerEvent {
105 phase,
106 position: center,
107 button: PointerButton::Primary,
108 })
109 };
110 match action {
111 accesskit::Action::Click => {
112 let down = self.event(state, &synth(PointerPhase::Down));
113 let up = self.event(state, &synth(PointerPhase::Up));
114 EventOutcome {
115 handled: down.handled || up.handled,
116 needs_redraw: down.needs_redraw || up.needs_redraw,
117 }
118 }
119 accesskit::Action::Focus => {
120 // `Cancel` clears `active` and `capture_claimant` but never
121 // `focused`/`focus_active`; without it the widget keeps capture.
122 let down = self.event(state, &synth(PointerPhase::Down));
123 let cancel = self.event(state, &synth(PointerPhase::Cancel));
124 EventOutcome {
125 handled: down.handled || cancel.handled,
126 needs_redraw: down.needs_redraw || cancel.needs_redraw,
127 }
128 }
129 _ => EventOutcome::default(),
130 }
131 }
132}