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 cranpose_core::{MemoryApplier, NodeError, NodeId, collections::map::HashMap};
45use cranpose_render_common::{
46 Renderer,
47 graph::{HitTestNode, ProjectiveTransform},
48 graph_scene::HitGeometry,
49 hit_graph::{HitGraphSink, collect_hits_from_graph},
50 scene_builder::build_graph_from_applier,
51};
52use cranpose_ui::{
53 LayoutBox, LayoutEngine, LayoutTree, Rect, SemanticsAction, SemanticsNode, SemanticsRole,
54 SemanticsWidgetRole, Size, build_layout_tree_from_applier, build_semantics_tree_from_applier,
55};
56
57use crate::AppShell;
58
59/// One semantics node, with the geometry it was placed and drawn at.
60///
61/// Field-for-field a subset of [`SemanticsNode`] plus the two boxes; the fields
62/// that are dropped are the ones a geometry audit has no use for (custom
63/// actions, text selection, the canvas children a drawn control publishes —
64/// those already carry their own bounds).
65#[derive(Clone, Debug, PartialEq)]
66pub struct PlacedSemanticsNode {
67 /// Id of the layout node this semantics node describes.
68 pub node_id: NodeId,
69 /// Where the node sits in the tree: layout, text, subcomposition …
70 pub role: SemanticsRole,
71 /// What a screen reader announces it as — Compose's `Role`.
72 pub widget_role: Option<SemanticsWidgetRole>,
73 /// The name a reader speaks, including merged text and excluding password values.
74 pub label: Option<String>,
75 /// Extra state a reader speaks after the name, such as "Expanded".
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 /// Whether the control is on, off or partly on. `None` when it is not toggleable.
84 pub toggled: Option<cranpose_ui::ToggleableState>,
85 /// Whether the control is selected. `None` when it has no selected state.
86 pub selected: Option<bool>,
87 /// Whether the control accepts input. A reader announces a disabled control as such.
88 pub enabled: bool,
89 /// Whether a reader can type into the node.
90 pub editable_text: bool,
91 /// Whether Tab and a reader's focus reach the node.
92 pub focusable: bool,
93 /// Whether a reader skips the node and everything under it.
94 pub hidden: bool,
95 /// The title the node gives the screen, when it is the screen's root.
96 pub pane_title: Option<String>,
97 /// Where the app moved the node in the reading order; 0 leaves it be.
98 pub traversal_index: f32,
99 /// The name tests find the node by (`Modifier::test_tag`).
100 pub test_tag: Option<String>,
101 /// The place of a row among the rows of its list, counted from 1, when
102 /// the parent says it is a collection. A reader speaks it, so two rows
103 /// with one name are told apart.
104 pub list_position: Option<usize>,
105 /// The box the measure pass gave this node, in window coordinates.
106 pub layout_bounds: Rect,
107 /// The box the renderer draws and the hit test inverts, ancestor graphics
108 /// layers included. `None` when the hit graph carries no region for it.
109 pub touch_bounds: Option<Rect>,
110 /// The child nodes, in reading order.
111 pub children: Vec<PlacedSemanticsNode>,
112}
113
114impl PlacedSemanticsNode {
115 /// The box a finger has to find: the drawn quad where there is one, and the
116 /// layout box where the node is not a hit target at all.
117 pub fn target_bounds(&self) -> Rect {
118 self.touch_bounds.unwrap_or(self.layout_bounds)
119 }
120
121 /// Depth-first walk, self first.
122 pub fn visit(&self, visitor: &mut impl FnMut(&PlacedSemanticsNode)) {
123 visitor(self);
124 for child in &self.children {
125 child.visit(visitor);
126 }
127 }
128
129 /// Every node in the subtree, self first, in tree order.
130 pub fn flatten(&self) -> Vec<&PlacedSemanticsNode> {
131 let mut all = Vec::new();
132 self.collect(&mut all);
133 all
134 }
135
136 fn collect<'a>(&'a self, out: &mut Vec<&'a PlacedSemanticsNode>) {
137 out.push(self);
138 for child in &self.children {
139 child.collect(out);
140 }
141 }
142
143 /// The first node in the subtree, self first, that carries `tag`
144 /// (`Modifier::test_tag`).
145 pub fn find_by_tag(&self, tag: &str) -> Option<&PlacedSemanticsNode> {
146 if self.test_tag.as_deref() == Some(tag) {
147 return Some(self);
148 }
149 self.children
150 .iter()
151 .find_map(|child| child.find_by_tag(tag))
152 }
153
154 /// Every node a tap would do something to.
155 pub fn controls(&self) -> Vec<&PlacedSemanticsNode> {
156 self.flatten()
157 .into_iter()
158 .filter(|node| node.clickable)
159 .collect()
160 }
161
162 /// A name for an assertion message: the label if there is one, else the
163 /// role and the node id, which at least says which one it was.
164 pub fn describe(&self) -> String {
165 match &self.label {
166 Some(label) => format!("{label:?}"),
167 None => format!("{:?}#{}", self.role, self.node_id),
168 }
169 }
170}
171
172/// Lay `root` out at `size` and read back its semantics with geometry.
173///
174/// The applier must already be carrying a runtime handle
175/// (`MemoryApplier::set_runtime_handle`) — a subcomposing widget cannot be
176/// measured without one. `ComposeTestRule::placed_semantics` in cranpose-testing
177/// does that part; this is for a caller driving a `TestComposition` by hand.
178///
179/// `None` means the composition placed nothing at all, which for a root that
180/// composed content is itself the answer to a bug hunt.
181pub fn placed_semantics_from_applier(
182 applier: &mut MemoryApplier,
183 root: NodeId,
184 size: Size,
185) -> Result<Option<PlacedSemanticsNode>, NodeError> {
186 applier.compute_layout(root, size)?;
187
188 let Some(layout) = build_layout_tree_from_applier(applier, root)? else {
189 return Ok(None);
190 };
191 let Some(semantics) = build_semantics_tree_from_applier(applier, root)? else {
192 return Ok(None);
193 };
194
195 let mut layout_bounds = HashMap::default();
196 index_layout_bounds(layout.root(), &mut layout_bounds);
197
198 let mut touch_bounds = HashMap::default();
199 if let Some(graph) = build_graph_from_applier(applier, root, 1.0) {
200 let mut sink = TouchBoundsSink {
201 bounds: &mut touch_bounds,
202 };
203 collect_hits_from_graph(
204 &graph.root,
205 ProjectiveTransform::identity(),
206 &mut sink,
207 None,
208 );
209 }
210
211 join(semantics.root(), &layout_bounds, &touch_bounds).map(Some)
212}
213
214fn index_layout_bounds(layout_box: &LayoutBox, out: &mut HashMap<NodeId, Rect>) {
215 out.insert(layout_box.node_id, layout_box.rect);
216 for child in &layout_box.children {
217 index_layout_bounds(child, out);
218 }
219}
220
221struct TouchBoundsSink<'a> {
222 bounds: &'a mut HashMap<NodeId, Rect>,
223}
224
225impl HitGraphSink for TouchBoundsSink<'_> {
226 fn push_hit(
227 &mut self,
228 node_id: NodeId,
229 _capture_path: &[NodeId],
230 geometry: HitGeometry<'_>,
231 _hit: &HitTestNode,
232 ) {
233 self.bounds.entry(node_id).or_insert(geometry.rect);
234 }
235}
236
237/// Gives a row and everything inside it the place of the row, so that two
238/// rows with one name, and the controls inside them, are apart. A list inside
239/// a row keeps the places it gave its own rows.
240pub(crate) fn place_row(row: &mut PlacedSemanticsNode, position: usize) {
241 if row.list_position.is_some() {
242 return;
243 }
244 row.list_position = Some(position);
245 for child in &mut row.children {
246 place_row(child, position);
247 }
248}
249fn join(
250 node: &SemanticsNode,
251 layout_bounds: &HashMap<NodeId, Rect>,
252 touch_bounds: &HashMap<NodeId, Rect>,
253) -> Result<PlacedSemanticsNode, NodeError> {
254 let bounds = layout_bounds
255 .get(&node.node_id)
256 .copied()
257 .ok_or(NodeError::MissingContext {
258 id: node.node_id,
259 reason: "semantics node has no layout box: the semantics walk and the \
260 layout walk disagree about what was placed",
261 })?;
262 let mut children = Vec::with_capacity(node.children.len());
263 for child in &node.children {
264 children.push(join(child, layout_bounds, touch_bounds)?);
265 }
266 let details = node.details();
267 if details.collection.is_some() {
268 for (child, position) in children.iter_mut().zip(1..) {
269 place_row(child, position);
270 }
271 }
272 Ok(PlacedSemanticsNode {
273 node_id: node.node_id,
274 role: node.role.clone(),
275 widget_role: node.widget_role,
276 label: node
277 .accessibility_label()
278 .map(std::borrow::Cow::into_owned)
279 .or_else(|| node.merges_accessibility_descendants().then(String::new)),
280 state_description: details.state_description.clone(),
281 clickable: node
282 .actions
283 .iter()
284 .any(|action| matches!(action, SemanticsAction::Click { .. })),
285 interactive: touch_bounds.contains_key(&node.node_id),
286 toggled: node.toggled,
287 selected: node.selected,
288 enabled: node.enabled,
289 editable_text: details.editable_text,
290 focusable: node.focusable,
291 hidden: node.hidden,
292 pane_title: details.pane_title.clone(),
293 traversal_index: node.traversal_index,
294 test_tag: details.test_tag.clone(),
295 list_position: None,
296 layout_bounds: bounds,
297 touch_bounds: touch_bounds.get(&node.node_id).copied(),
298 children,
299 })
300}
301
302/// The placed tree of a semantics tree and the layout it was measured in,
303/// without touch bounds: what a shell offers after a frame.
304pub fn placed_semantics_from_trees(
305 semantics: &SemanticsNode,
306 layout: &LayoutTree,
307) -> Result<PlacedSemanticsNode, NodeError> {
308 let mut layout_bounds = HashMap::default();
309 index_layout_bounds(layout.root(), &mut layout_bounds);
310 join(semantics, &layout_bounds, &HashMap::default())
311}
312
313/// The placed tree of what a shell shows right now. `None` before the first
314/// frame, or while the shell does not build semantics; turn them on with
315/// `set_semantics_enabled(true)` first.
316pub fn placed_semantics_from_shell<R>(shell: &mut AppShell<R>) -> Option<PlacedSemanticsNode>
317where
318 R: Renderer,
319 R::Error: std::fmt::Debug,
320{
321 let layout = shell.layout_tree()?.clone();
322 let semantics = shell.semantics_tree()?.root().clone();
323 placed_semantics_from_trees(&semantics, &layout).ok()
324}