Skip to main content

cranpose_testing/
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 dispatchable.
26//!
27//! ## Every walk here reads the RETAINED tree, deliberately
28//!
29//! `compute_layout` returns a [`LayoutTree`] built from the `Placement`s a
30//! [`MeasurePolicy`](cranpose_ui_layout::MeasurePolicy) returned. The scene the
31//! renderer is handed is not: [`build_graph_from_applier`] walks the retained
32//! node state and drops any node whose `is_placed` is false, and `is_placed` is
33//! set by `placeable.place(x, y)` — *not* by pushing a `Placement` into a vec.
34//! The two disagreed once and a whole widget set laid out correctly in every
35//! assertion while reaching the device as an empty screen.
36//!
37//! So the layout boxes here come from [`build_layout_tree_from_applier`] and not
38//! from the tree `compute_layout` hands back. All three walks — layout,
39//! semantics, scene — then apply the same `is_placed` filter, and a node that
40//! one of them loses is lost by all of them. A caller cannot be handed bounds
41//! for a control the renderer never drew.
42
43use std::collections::HashMap;
44use std::rc::Rc;
45
46use cranpose_core::{MemoryApplier, NodeError, NodeId};
47use cranpose_foundation::PointerEvent;
48use cranpose_render_common::graph::ProjectiveTransform;
49use cranpose_render_common::graph_scene::HitGeometry;
50use cranpose_render_common::hit_graph::{collect_hits_from_graph, HitGraphSink};
51use cranpose_render_common::scene_builder::build_graph_from_applier;
52use cranpose_ui::{
53    build_layout_tree_from_applier, build_semantics_tree_from_applier, LayoutBox, LayoutEngine,
54    Point, Rect, SemanticsAction, SemanticsNode, SemanticsRole, SemanticsWidgetRole, Size,
55};
56use cranpose_ui_graphics::RoundedCornerShape;
57
58/// One semantics node, with the geometry it was placed and drawn at.
59///
60/// Field-for-field a subset of [`SemanticsNode`] plus the two boxes; the fields
61/// that are dropped are the ones a geometry audit has no use for (custom
62/// actions, text selection, the canvas children a drawn control publishes —
63/// those already carry their own bounds).
64#[derive(Clone, Debug, PartialEq)]
65pub struct PlacedSemanticsNode {
66    pub node_id: NodeId,
67    /// Where the node sits in the tree: layout, text, subcomposition …
68    pub role: SemanticsRole,
69    /// What a screen reader announces it as — Compose's `Role`.
70    pub widget_role: Option<SemanticsWidgetRole>,
71    /// The text of a `Text` node, or the content description of anything else.
72    pub label: Option<String>,
73    pub state_description: Option<String>,
74    /// Whether the node carries a click action, which is what separates a
75    /// control from something merely described.
76    pub clickable: bool,
77    pub toggled: Option<bool>,
78    pub enabled: bool,
79    /// The box the measure pass gave this node, in window coordinates.
80    pub layout_bounds: Rect,
81    /// The box the renderer draws and the hit test inverts, ancestor graphics
82    /// layers included. `None` when the hit graph carries no region for it.
83    pub touch_bounds: Option<Rect>,
84    pub children: Vec<PlacedSemanticsNode>,
85}
86
87impl PlacedSemanticsNode {
88    /// The box a finger has to find: the drawn quad where there is one, and the
89    /// layout box where the node is not a hit target at all.
90    pub fn target_bounds(&self) -> Rect {
91        self.touch_bounds.unwrap_or(self.layout_bounds)
92    }
93
94    /// Depth-first walk, self first.
95    pub fn visit(&self, visitor: &mut impl FnMut(&PlacedSemanticsNode)) {
96        visitor(self);
97        for child in &self.children {
98            child.visit(visitor);
99        }
100    }
101
102    /// Every node in the subtree, self first, in tree order.
103    pub fn flatten(&self) -> Vec<&PlacedSemanticsNode> {
104        let mut all = Vec::new();
105        self.collect(&mut all);
106        all
107    }
108
109    fn collect<'a>(&'a self, out: &mut Vec<&'a PlacedSemanticsNode>) {
110        out.push(self);
111        for child in &self.children {
112            child.collect(out);
113        }
114    }
115
116    /// Every node a tap would do something to.
117    pub fn controls(&self) -> Vec<&PlacedSemanticsNode> {
118        self.flatten()
119            .into_iter()
120            .filter(|node| node.clickable)
121            .collect()
122    }
123
124    /// A name for an assertion message: the label if there is one, else the
125    /// role and the node id, which at least says which one it was.
126    pub fn describe(&self) -> String {
127        match &self.label {
128            Some(label) => format!("{label:?}"),
129            None => format!("{:?}#{}", self.role, self.node_id),
130        }
131    }
132}
133
134/// Lay `root` out at `size` and read back its semantics with geometry.
135///
136/// The applier must already be carrying a runtime handle
137/// (`MemoryApplier::set_runtime_handle`) — a subcomposing widget cannot be
138/// measured without one. [`crate::testing::ComposeTestRule::placed_semantics`]
139/// does that part; this is for a caller driving a `TestComposition` by hand.
140///
141/// `None` means the composition placed nothing at all, which for a root that
142/// composed content is itself the answer to a bug hunt.
143pub fn placed_semantics_from_applier(
144    applier: &mut MemoryApplier,
145    root: NodeId,
146    size: Size,
147) -> Result<Option<PlacedSemanticsNode>, NodeError> {
148    applier.compute_layout(root, size)?;
149
150    let Some(layout) = build_layout_tree_from_applier(applier, root)? else {
151        return Ok(None);
152    };
153    let Some(semantics) = build_semantics_tree_from_applier(applier, root)? else {
154        return Ok(None);
155    };
156
157    let mut layout_bounds = HashMap::new();
158    index_layout_bounds(layout.root(), &mut layout_bounds);
159
160    let mut touch_bounds = HashMap::new();
161    if let Some(graph) = build_graph_from_applier(applier, root, 1.0) {
162        let mut sink = TouchBoundsSink {
163            bounds: &mut touch_bounds,
164        };
165        collect_hits_from_graph(
166            &graph.root,
167            ProjectiveTransform::identity(),
168            &mut sink,
169            None,
170        );
171    }
172
173    join(semantics.root(), &layout_bounds, &touch_bounds).map(Some)
174}
175
176fn index_layout_bounds(layout_box: &LayoutBox, out: &mut HashMap<NodeId, Rect>) {
177    out.insert(layout_box.node_id, layout_box.rect);
178    for child in &layout_box.children {
179        index_layout_bounds(child, out);
180    }
181}
182
183/// Keeps the FIRST region pushed for a node.
184///
185/// `collect_hits_from_graph` walks in paint order and a node can reach the sink
186/// more than once when it carries both a click handler and a pointer input; the
187/// geometry is the same either way, and taking the first keeps the result
188/// independent of how many handlers a widget happened to install.
189struct TouchBoundsSink<'a> {
190    bounds: &'a mut HashMap<NodeId, Rect>,
191}
192
193impl HitGraphSink for TouchBoundsSink<'_> {
194    fn push_hit(
195        &mut self,
196        node_id: NodeId,
197        _capture_path: &[NodeId],
198        geometry: HitGeometry,
199        _shape: Option<RoundedCornerShape>,
200        _click_actions: &[Rc<dyn Fn(Point)>],
201        _pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
202    ) {
203        self.bounds.entry(node_id).or_insert(geometry.rect);
204    }
205}
206
207/// Refuses rather than invents.
208///
209/// The semantics walk and the layout walk apply the SAME `is_placed` filter to
210/// the same retained children, so a semantics node without a layout box cannot
211/// happen while the two agree. Handing back a zero rect for one would turn a
212/// framework divergence into a caller-side "0x0dp control", which is the wrong
213/// bug to go and look for.
214fn join(
215    node: &SemanticsNode,
216    layout_bounds: &HashMap<NodeId, Rect>,
217    touch_bounds: &HashMap<NodeId, Rect>,
218) -> Result<PlacedSemanticsNode, NodeError> {
219    let bounds = layout_bounds
220        .get(&node.node_id)
221        .copied()
222        .ok_or(NodeError::MissingContext {
223            id: node.node_id,
224            reason: "semantics node has no layout box: the semantics walk and the \
225                         layout walk disagree about what was placed",
226        })?;
227    let mut children = Vec::with_capacity(node.children.len());
228    for child in &node.children {
229        children.push(join(child, layout_bounds, touch_bounds)?);
230    }
231    Ok(PlacedSemanticsNode {
232        node_id: node.node_id,
233        role: node.role.clone(),
234        widget_role: node.widget_role,
235        label: match &node.role {
236            SemanticsRole::Text { value } => Some(value.clone()),
237            _ => node.description.clone(),
238        },
239        state_description: node.state_description.clone(),
240        clickable: node
241            .actions
242            .iter()
243            .any(|action| matches!(action, SemanticsAction::Click { .. })),
244        toggled: node.toggled,
245        enabled: node.enabled,
246        layout_bounds: bounds,
247        touch_bounds: touch_bounds.get(&node.node_id).copied(),
248        children,
249    })
250}