Skip to main content

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}