Skip to main content

cranpose_app_shell/
placed_semantics.rs

1//! What a screen reader is told, and where each of those things actually is.
2//!
3//! [`build_semantics_tree_from_applier`] answers the first half and carries no
4//! geometry at all; a [`LayoutBox`] carries the geometry and knows nothing about
5//! semantics. Every question of the form "is this control big enough / on the
6//! display / where the label says it is" needs both halves joined, and the join
7//! existed in exactly one place: inside the desktop robot, behind an `AppShell`,
8//! a window and an event loop. A unit test that wants to audit a widget tree's
9//! touch targets could not reach it.
10//!
11//! This is that join, headless. One composition, one measure pass, and a tree of
12//! [`PlacedSemanticsNode`] carrying **two** boxes per node, because they are two
13//! different questions and a Wear scaling list is precisely where they diverge:
14//!
15//! - [`PlacedSemanticsNode::layout_bounds`] is the box the measure pass gave the
16//!   node — what the widget asked for and what an unscaled row occupies. This is
17//!   the box the desktop robot reports, and the one to assert a widget's own
18//!   declared minimum against.
19//! - [`PlacedSemanticsNode::touch_bounds`] is the axis-aligned box the renderer
20//!   draws and the hit test inverts, ancestor graphics layers included. A row
21//!   that a scaling ramp shrinks to 0.73 is only tappable where it is drawn
22//!   (`cranpose-render-common`'s `a_shrunken_row_is_only_tappable_where_it_is_drawn`
23//!   settles that), so this is the box a finger has to find. It is `None` for a
24//!   node the hit graph carries no region for — a label, a header, anything that
25//!   is described but not interactive. Gesture containers such as scrollable
26//!   lists are interactive even though they do not expose a click action.
27//!
28//! ## Every walk here reads the RETAINED tree, deliberately
29//!
30//! `compute_layout` returns a `LayoutTree` built from the `Placement`s a
31//! `MeasurePolicy` returned. The scene the
32//! renderer is handed is not: [`build_graph_from_applier`] walks the retained
33//! node state and drops any node whose `is_placed` is false, and `is_placed` is
34//! set by `placeable.place(x, y)` — *not* by pushing a `Placement` into a vec.
35//! The two disagreed once and a whole widget set laid out correctly in every
36//! assertion while reaching the device as an empty screen.
37//!
38//! So the layout boxes here come from [`build_layout_tree_from_applier`] and not
39//! from the tree `compute_layout` hands back. All three walks — layout,
40//! semantics, scene — then apply the same `is_placed` filter, and a node that
41//! one of them loses is lost by all of them. A caller cannot be handed bounds
42//! for a control the renderer never drew.
43
44use std::collections::HashMap;
45
46use cranpose_core::{MemoryApplier, NodeError, NodeId};
47use cranpose_render_common::{
48    Renderer,
49    graph::{HitTestNode, ProjectiveTransform},
50    graph_scene::HitGeometry,
51    hit_graph::{HitGraphSink, collect_hits_from_graph},
52    scene_builder::build_graph_from_applier,
53};
54use cranpose_ui::{
55    LayoutBox, LayoutEngine, LayoutTree, Rect, SemanticsAction, SemanticsNode, SemanticsRole,
56    SemanticsWidgetRole, Size, build_layout_tree_from_applier, build_semantics_tree_from_applier,
57};
58
59use crate::AppShell;
60
61/// One semantics node, with the geometry it was placed and drawn at.
62///
63/// Field-for-field a subset of [`SemanticsNode`] plus the two boxes; the fields
64/// that are dropped are the ones a geometry audit has no use for (custom
65/// actions, text selection, the canvas children a drawn control publishes —
66/// those already carry their own bounds).
67#[derive(Clone, Debug, PartialEq)]
68pub struct PlacedSemanticsNode {
69    pub node_id: NodeId,
70    /// Where the node sits in the tree: layout, text, subcomposition …
71    pub role: SemanticsRole,
72    /// What a screen reader announces it as — Compose's `Role`.
73    pub widget_role: Option<SemanticsWidgetRole>,
74    /// The text of a `Text` node, or the content description of anything else.
75    pub label: Option<String>,
76    pub state_description: Option<String>,
77    /// Whether the node carries a click action, which is what separates a
78    /// control from something merely described.
79    pub clickable: bool,
80    /// Whether the renderer carries any pointer-dispatch region for the node.
81    /// This also includes gesture-only containers such as scrollable lists.
82    pub interactive: bool,
83    pub toggled: Option<bool>,
84    pub selected: Option<bool>,
85    pub enabled: bool,
86    /// Whether a reader can type into the node.
87    pub editable_text: bool,
88    /// Whether Tab and a reader's focus reach the node.
89    pub focusable: bool,
90    /// Whether a reader skips the node and everything under it.
91    pub hidden: bool,
92    /// The title the node gives the screen, when it is the screen's root.
93    pub pane_title: Option<String>,
94    /// Where the app moved the node in the reading order; 0 leaves it be.
95    pub traversal_index: f32,
96    /// The place of a row among the rows of its list, counted from 1, when
97    /// the parent says it is a collection. A reader speaks it, so two rows
98    /// with one name are told apart.
99    pub list_position: Option<usize>,
100    /// The box the measure pass gave this node, in window coordinates.
101    pub layout_bounds: Rect,
102    /// The box the renderer draws and the hit test inverts, ancestor graphics
103    /// layers included. `None` when the hit graph carries no region for it.
104    pub touch_bounds: Option<Rect>,
105    pub children: Vec<PlacedSemanticsNode>,
106}
107
108impl PlacedSemanticsNode {
109    /// The box a finger has to find: the drawn quad where there is one, and the
110    /// layout box where the node is not a hit target at all.
111    pub fn target_bounds(&self) -> Rect {
112        self.touch_bounds.unwrap_or(self.layout_bounds)
113    }
114
115    /// Depth-first walk, self first.
116    pub fn visit(&self, visitor: &mut impl FnMut(&PlacedSemanticsNode)) {
117        visitor(self);
118        for child in &self.children {
119            child.visit(visitor);
120        }
121    }
122
123    /// Every node in the subtree, self first, in tree order.
124    pub fn flatten(&self) -> Vec<&PlacedSemanticsNode> {
125        let mut all = Vec::new();
126        self.collect(&mut all);
127        all
128    }
129
130    fn collect<'a>(&'a self, out: &mut Vec<&'a PlacedSemanticsNode>) {
131        out.push(self);
132        for child in &self.children {
133            child.collect(out);
134        }
135    }
136
137    /// Every node a tap would do something to.
138    pub fn controls(&self) -> Vec<&PlacedSemanticsNode> {
139        self.flatten()
140            .into_iter()
141            .filter(|node| node.clickable)
142            .collect()
143    }
144
145    /// A name for an assertion message: the label if there is one, else the
146    /// role and the node id, which at least says which one it was.
147    pub fn describe(&self) -> String {
148        match &self.label {
149            Some(label) => format!("{label:?}"),
150            None => format!("{:?}#{}", self.role, self.node_id),
151        }
152    }
153}
154
155/// Lay `root` out at `size` and read back its semantics with geometry.
156///
157/// The applier must already be carrying a runtime handle
158/// (`MemoryApplier::set_runtime_handle`) — a subcomposing widget cannot be
159/// measured without one. `ComposeTestRule::placed_semantics` in cranpose-testing
160/// does that part; this is for a caller driving a `TestComposition` by hand.
161///
162/// `None` means the composition placed nothing at all, which for a root that
163/// composed content is itself the answer to a bug hunt.
164pub fn placed_semantics_from_applier(
165    applier: &mut MemoryApplier,
166    root: NodeId,
167    size: Size,
168) -> Result<Option<PlacedSemanticsNode>, NodeError> {
169    applier.compute_layout(root, size)?;
170
171    let Some(layout) = build_layout_tree_from_applier(applier, root)? else {
172        return Ok(None);
173    };
174    let Some(semantics) = build_semantics_tree_from_applier(applier, root)? else {
175        return Ok(None);
176    };
177
178    let mut layout_bounds = HashMap::new();
179    index_layout_bounds(layout.root(), &mut layout_bounds);
180
181    let mut touch_bounds = HashMap::new();
182    if let Some(graph) = build_graph_from_applier(applier, root, 1.0) {
183        let mut sink = TouchBoundsSink {
184            bounds: &mut touch_bounds,
185        };
186        collect_hits_from_graph(
187            &graph.root,
188            ProjectiveTransform::identity(),
189            &mut sink,
190            None,
191        );
192    }
193
194    join(semantics.root(), &layout_bounds, &touch_bounds).map(Some)
195}
196
197fn index_layout_bounds(layout_box: &LayoutBox, out: &mut HashMap<NodeId, Rect>) {
198    out.insert(layout_box.node_id, layout_box.rect);
199    for child in &layout_box.children {
200        index_layout_bounds(child, out);
201    }
202}
203
204struct TouchBoundsSink<'a> {
205    bounds: &'a mut HashMap<NodeId, Rect>,
206}
207
208impl HitGraphSink for TouchBoundsSink<'_> {
209    fn push_hit(
210        &mut self,
211        node_id: NodeId,
212        _capture_path: &[NodeId],
213        geometry: HitGeometry<'_>,
214        _hit: &HitTestNode,
215    ) {
216        self.bounds.entry(node_id).or_insert(geometry.rect);
217    }
218}
219
220/// Gives a row and everything inside it the place of the row, so that two
221/// rows with one name, and the controls inside them, are apart. A list inside
222/// a row keeps the places it gave its own rows.
223pub(crate) fn place_row(row: &mut PlacedSemanticsNode, position: usize) {
224    if row.list_position.is_some() {
225        return;
226    }
227    row.list_position = Some(position);
228    for child in &mut row.children {
229        place_row(child, position);
230    }
231}
232fn join(
233    node: &SemanticsNode,
234    layout_bounds: &HashMap<NodeId, Rect>,
235    touch_bounds: &HashMap<NodeId, Rect>,
236) -> Result<PlacedSemanticsNode, NodeError> {
237    let bounds = layout_bounds
238        .get(&node.node_id)
239        .copied()
240        .ok_or(NodeError::MissingContext {
241            id: node.node_id,
242            reason: "semantics node has no layout box: the semantics walk and the \
243                         layout walk disagree about what was placed",
244        })?;
245    let mut children = Vec::with_capacity(node.children.len());
246    for child in &node.children {
247        children.push(join(child, layout_bounds, touch_bounds)?);
248    }
249    if node.collection.is_some() {
250        for (child, position) in children.iter_mut().zip(1..) {
251            place_row(child, position);
252        }
253    }
254    Ok(PlacedSemanticsNode {
255        node_id: node.node_id,
256        role: node.role.clone(),
257        widget_role: node.widget_role,
258        label: node.description.clone().or_else(|| match &node.role {
259            SemanticsRole::Text { value } => Some(value.clone()),
260            _ => None,
261        }),
262        state_description: node.state_description.clone(),
263        clickable: node
264            .actions
265            .iter()
266            .any(|action| matches!(action, SemanticsAction::Click { .. })),
267        interactive: touch_bounds.contains_key(&node.node_id),
268        toggled: node.toggled,
269        selected: node.selected,
270        enabled: node.enabled,
271        editable_text: node.editable_text,
272        focusable: node.focusable,
273        hidden: node.hidden,
274        pane_title: node.pane_title.clone(),
275        traversal_index: node.traversal_index,
276        list_position: None,
277        layout_bounds: bounds,
278        touch_bounds: touch_bounds.get(&node.node_id).copied(),
279        children,
280    })
281}
282
283/// The placed tree of a semantics tree and the layout it was measured in,
284/// without touch bounds: what a shell offers after a frame.
285pub fn placed_semantics_from_trees(
286    semantics: &SemanticsNode,
287    layout: &LayoutTree,
288) -> Result<PlacedSemanticsNode, NodeError> {
289    let mut layout_bounds = HashMap::new();
290    index_layout_bounds(layout.root(), &mut layout_bounds);
291    join(semantics, &layout_bounds, &HashMap::new())
292}
293
294/// The placed tree of what a shell shows right now. `None` before the first
295/// frame, or while the shell does not build semantics; turn them on with
296/// `set_semantics_enabled(true)` first.
297pub fn placed_semantics_from_shell<R>(shell: &mut AppShell<R>) -> Option<PlacedSemanticsNode>
298where
299    R: Renderer,
300    R::Error: std::fmt::Debug,
301{
302    let layout = shell.layout_tree()?.clone();
303    let semantics = shell.semantics_tree()?.root().clone();
304    placed_semantics_from_trees(&semantics, &layout).ok()
305}