Another project
1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use accesskit::{Node, NodeId, Rect, Role, Toggled, TreeUpdate};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::scenario::NodeRef;
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct TreeDump {
12 pub root: DumpNode,
13}
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct DumpNode {
17 #[serde(rename = "ref")]
18 pub node: NodeRef,
19 pub role: Role,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub label: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub bounds: Option<Rect>,
24 pub states: DumpStates,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub children: Vec<DumpNode>,
27}
28
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30pub struct DumpStates {
31 pub enabled: bool,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub selected: Option<bool>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub expanded: Option<bool>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub toggled: Option<Toggled>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Error)]
41pub enum TreeDumpError {
42 #[error("tree update carries no root")]
43 MissingTree,
44 #[error("node {0:?} referenced but absent from update")]
45 MissingNode(NodeId),
46 #[error("node id zero cannot become a ref")]
47 ZeroNodeId,
48}
49
50#[derive(Debug, Error)]
51pub enum TreeLoadError {
52 #[error("read {path}: {error}")]
53 Read {
54 path: PathBuf,
55 error: std::io::Error,
56 },
57 #[error("parse {path}: {error}")]
58 Parse {
59 path: PathBuf,
60 error: serde_json::Error,
61 },
62}
63
64impl TreeDump {
65 pub fn from_update(update: &TreeUpdate) -> Result<Self, TreeDumpError> {
66 let nodes: BTreeMap<NodeId, &Node> =
67 update.nodes.iter().map(|(id, node)| (*id, node)).collect();
68 let root = update.tree.as_ref().ok_or(TreeDumpError::MissingTree)?.root;
69 Ok(Self {
70 root: dump_node(root, &nodes)?,
71 })
72 }
73
74 pub fn load(path: &Path) -> Result<Self, TreeLoadError> {
75 let text = std::fs::read_to_string(path).map_err(|error| TreeLoadError::Read {
76 path: path.to_path_buf(),
77 error,
78 })?;
79 serde_json::from_str(&text).map_err(|error| TreeLoadError::Parse {
80 path: path.to_path_buf(),
81 error,
82 })
83 }
84
85 #[must_use]
86 pub fn nodes(&self) -> Vec<&DumpNode> {
87 self.root.subtree()
88 }
89
90 #[must_use]
91 pub fn matching(&self, role: Role, label: &str) -> Vec<&DumpNode> {
92 self.nodes()
93 .into_iter()
94 .filter(|n| n.role == role && n.label.as_deref() == Some(label))
95 .collect()
96 }
97
98 #[must_use]
99 pub fn to_text(&self) -> String {
100 lines(&self.root, 0).into_iter().map(|l| l + "\n").collect()
101 }
102}
103
104impl DumpNode {
105 #[must_use]
106 pub fn subtree(&self) -> Vec<&Self> {
107 core::iter::once(self)
108 .chain(self.children.iter().flat_map(Self::subtree))
109 .collect()
110 }
111}
112
113fn dump_node(id: NodeId, nodes: &BTreeMap<NodeId, &Node>) -> Result<DumpNode, TreeDumpError> {
114 let node = nodes.get(&id).ok_or(TreeDumpError::MissingNode(id))?;
115 let node_ref = NodeRef::from_node_id(id).ok_or(TreeDumpError::ZeroNodeId)?;
116 let children = node
117 .children()
118 .iter()
119 .map(|child| dump_node(*child, nodes))
120 .collect::<Result<Vec<_>, _>>()?;
121 Ok(DumpNode {
122 node: node_ref,
123 role: node.role(),
124 label: node.label().map(str::to_owned),
125 bounds: node.bounds(),
126 states: DumpStates {
127 enabled: !node.is_disabled(),
128 selected: node.is_selected(),
129 expanded: node.is_expanded(),
130 toggled: node.toggled(),
131 },
132 children,
133 })
134}
135
136fn lines(node: &DumpNode, depth: usize) -> Vec<String> {
137 std::iter::once(line(node, depth))
138 .chain(
139 node.children
140 .iter()
141 .flat_map(|child| lines(child, depth + 1)),
142 )
143 .collect()
144}
145
146fn line(node: &DumpNode, depth: usize) -> String {
147 [
148 Some(format!(
149 "{}{} {}",
150 " ".repeat(depth),
151 node.node,
152 role_token(node.role),
153 )),
154 node.label.as_ref().map(|label| format!("{label:?}")),
155 node.bounds.map(bounds_token),
156 (!node.states.enabled).then(|| "disabled".to_owned()),
157 node.states.selected.map(|v| format!("selected={v}")),
158 node.states.expanded.map(|v| format!("expanded={v}")),
159 node.states
160 .toggled
161 .map(|t| format!("toggled={}", toggled_token(t))),
162 ]
163 .into_iter()
164 .flatten()
165 .collect::<Vec<_>>()
166 .join(" ")
167}
168
169fn role_token(role: Role) -> String {
170 serde_json::to_string(&role).map_or_else(
171 |_| format!("{role:?}"),
172 |quoted| quoted.trim_matches('"').to_owned(),
173 )
174}
175
176fn bounds_token(rect: Rect) -> String {
177 format!("({},{})-({},{})", rect.x0, rect.y0, rect.x1, rect.y1)
178}
179
180const fn toggled_token(toggled: Toggled) -> &'static str {
181 match toggled {
182 Toggled::False => "false",
183 Toggled::True => "true",
184 Toggled::Mixed => "mixed",
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use accesskit::Toggled;
191 use bone_ui::a11y::{AccessNode, AccessTreeBuilder};
192 use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
193 use bone_ui::strings::StringTable;
194 use bone_ui::widgets::LabelText;
195 use bone_ui::{Role, WidgetId, WidgetKey};
196
197 use super::*;
198
199 const RIBBON: WidgetKey = WidgetKey::new("ribbon");
200 const BUTTON: WidgetKey = WidgetKey::new("button");
201
202 fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect {
203 LayoutRect::new(
204 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
205 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)),
206 )
207 }
208
209 fn labeled_button(label: &str) -> AccessNode {
210 AccessNode::new(Role::Button).with_label_text(LabelText::Owned(label.to_owned()))
211 }
212
213 fn dumped(builder: &AccessTreeBuilder) -> TreeDump {
214 let update = builder.build(&StringTable::new(), None);
215 match TreeDump::from_update(&update) {
216 Ok(dump) => dump,
217 Err(e) => panic!("dump failed: {e}"),
218 }
219 }
220
221 #[test]
222 fn dump_nests_children_under_the_window_root() {
223 let mut builder = AccessTreeBuilder::new();
224 builder.push(
225 WidgetId::ROOT.child(RIBBON),
226 rect(10.0, 20.0, 20.0, 20.0),
227 labeled_button("Extrude").with_toggled(Toggled::True),
228 );
229 builder.push(
230 WidgetId::ROOT.child(BUTTON),
231 rect(40.0, 20.0, 20.0, 20.0),
232 labeled_button("Fillet").with_disabled(true),
233 );
234 let dump = dumped(&builder);
235 assert_eq!(dump.root.role, Role::Window);
236 assert_eq!(dump.root.node, NodeRef::from_widget(WidgetId::ROOT));
237 let labels: Vec<Option<&str>> = dump
238 .root
239 .children
240 .iter()
241 .map(|c| c.label.as_deref())
242 .collect();
243 assert_eq!(labels, vec![Some("Extrude"), Some("Fillet")]);
244 assert_eq!(dump.root.children[0].states.toggled, Some(Toggled::True));
245 assert!(!dump.root.children[1].states.enabled);
246 }
247
248 #[test]
249 fn refs_stay_stable_across_frames() {
250 let extrude = WidgetId::ROOT.child(RIBBON).child_named(BUTTON, "extrude");
251 let mut builder = AccessTreeBuilder::new();
252 builder.push(
253 extrude,
254 rect(10.0, 20.0, 20.0, 20.0),
255 labeled_button("Extrude"),
256 );
257 let first = dumped(&builder);
258 builder.begin_frame();
259 builder.push(
260 WidgetId::ROOT.child(BUTTON),
261 rect(0.0, 0.0, 5.0, 5.0),
262 labeled_button("New"),
263 );
264 builder.push(
265 extrude,
266 rect(10.0, 60.0, 20.0, 20.0),
267 labeled_button("Extrude"),
268 );
269 let second = dumped(&builder);
270 let ref_of = |dump: &TreeDump, label: &str| {
271 dump.root
272 .children
273 .iter()
274 .find(|c| c.label.as_deref() == Some(label))
275 .map(|c| c.node)
276 };
277 assert_eq!(ref_of(&first, "Extrude"), ref_of(&second, "Extrude"));
278 assert_eq!(
279 ref_of(&first, "Extrude"),
280 Some(NodeRef::from_widget(extrude))
281 );
282 }
283
284 #[test]
285 fn text_form_is_one_indented_line_per_node() {
286 let mut builder = AccessTreeBuilder::new();
287 builder.push(
288 WidgetId::ROOT.child(RIBBON),
289 rect(10.0, 20.0, 20.0, 20.0),
290 labeled_button("Extrude").with_selected(true),
291 );
292 let dump = dumped(&builder);
293 let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON));
294 let expected = format!(
295 "b0feb0feb0feb0fe window\n {child} button \"Extrude\" (10,20)-(30,40) selected=true\n",
296 );
297 assert_eq!(dump.to_text(), expected);
298 }
299
300 #[test]
301 fn json_form_carries_refs_roles_bounds_and_states() {
302 let mut builder = AccessTreeBuilder::new();
303 builder.push(
304 WidgetId::ROOT.child(RIBBON),
305 rect(10.0, 20.0, 20.0, 20.0),
306 labeled_button("Extrude").with_expanded(false),
307 );
308 let dump = dumped(&builder);
309 let child = NodeRef::from_widget(WidgetId::ROOT.child(RIBBON));
310 let value = match serde_json::to_value(&dump) {
311 Ok(v) => v,
312 Err(e) => panic!("serialize failed: {e}"),
313 };
314 assert_eq!(
315 value,
316 serde_json::json!({
317 "root": {
318 "ref": "b0feb0feb0feb0fe",
319 "role": "window",
320 "states": { "enabled": true },
321 "children": [{
322 "ref": child.to_string(),
323 "role": "button",
324 "label": "Extrude",
325 "bounds": { "x0": 10.0, "y0": 20.0, "x1": 30.0, "y1": 40.0 },
326 "states": { "enabled": true, "expanded": false },
327 }],
328 },
329 }),
330 );
331 }
332
333 #[test]
334 fn missing_root_is_a_typed_error() {
335 let update = TreeUpdate {
336 nodes: vec![],
337 tree: None,
338 tree_id: accesskit::TreeId::ROOT,
339 focus: NodeId(1),
340 };
341 assert_eq!(
342 TreeDump::from_update(&update),
343 Err(TreeDumpError::MissingTree),
344 );
345 }
346}