Another project
1use core::fmt;
2
3use accesskit::{Node, TreeUpdate};
4use bone_app::WindowPoint;
5use thiserror::Error;
6
7use crate::scenario::{NodeRef, PointerTarget};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct Candidate {
11 pub node: NodeRef,
12 pub label: String,
13}
14
15impl fmt::Display for Candidate {
16 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 write!(f, "{} {:?}", self.node, self.label)
18 }
19}
20
21fn list(candidates: &[Candidate]) -> String {
22 if candidates.is_empty() {
23 return "none".to_owned();
24 }
25 candidates
26 .iter()
27 .map(ToString::to_string)
28 .collect::<Vec<_>>()
29 .join(", ")
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Error)]
33pub enum ResolveError {
34 #[error("node {node} not in tree; labeled nodes: {}", list(.candidates))]
35 NodeNotFound {
36 node: NodeRef,
37 candidates: Vec<Candidate>,
38 },
39 #[error("node {node} has no bounds")]
40 NodeWithoutBounds { node: NodeRef },
41 #[error("no node label contains {label:?}; labeled nodes: {}", list(.candidates))]
42 LabelNotFound {
43 label: String,
44 candidates: Vec<Candidate>,
45 },
46 #[error("label {label:?} is ambiguous; matches: {}", list(.matches))]
47 LabelAmbiguous {
48 label: String,
49 matches: Vec<Candidate>,
50 },
51}
52
53pub fn resolve_target(
54 target: &PointerTarget,
55 tree: &TreeUpdate,
56) -> Result<WindowPoint, ResolveError> {
57 match target {
58 PointerTarget::At { x, y } => Ok(WindowPoint::new(*x, *y)),
59 PointerTarget::Node(node) => resolve_node(*node, tree),
60 PointerTarget::Label(label) => resolve_label(label, tree),
61 }
62}
63
64fn resolve_node(node: NodeRef, tree: &TreeUpdate) -> Result<WindowPoint, ResolveError> {
65 tree.nodes
66 .iter()
67 .find(|(id, _)| *id == node.node_id())
68 .ok_or_else(|| ResolveError::NodeNotFound {
69 node,
70 candidates: labeled(tree),
71 })
72 .and_then(|(_, n)| center(node, n))
73}
74
75fn resolve_label(needle: &str, tree: &TreeUpdate) -> Result<WindowPoint, ResolveError> {
76 let needle_fold = needle.to_lowercase();
77 let matches: Vec<(NodeRef, &Node, String)> = tree
78 .nodes
79 .iter()
80 .filter_map(|(id, n)| {
81 NodeRef::from_node_id(*id)
82 .zip(n.label())
83 .filter(|(_, label)| label.to_lowercase().contains(&needle_fold))
84 .map(|(node, label)| (node, n, label.to_owned()))
85 })
86 .collect();
87 let exact: Vec<&(NodeRef, &Node, String)> = matches
88 .iter()
89 .filter(|(_, _, label)| label.to_lowercase() == needle_fold)
90 .collect();
91 match (matches.as_slice(), exact.as_slice()) {
92 ([], _) => Err(ResolveError::LabelNotFound {
93 label: needle.to_owned(),
94 candidates: labeled(tree),
95 }),
96 ([(node, n, _)], _) | (_, [(node, n, _)]) => center(*node, n),
97 _ => Err(ResolveError::LabelAmbiguous {
98 label: needle.to_owned(),
99 matches: matches
100 .iter()
101 .map(|(node, _, label)| Candidate {
102 node: *node,
103 label: label.clone(),
104 })
105 .collect(),
106 }),
107 }
108}
109
110fn center(node: NodeRef, n: &Node) -> Result<WindowPoint, ResolveError> {
111 n.bounds()
112 .map(|r| WindowPoint::new((r.x0 + r.x1) * 0.5, (r.y0 + r.y1) * 0.5))
113 .ok_or(ResolveError::NodeWithoutBounds { node })
114}
115
116fn labeled(tree: &TreeUpdate) -> Vec<Candidate> {
117 tree.nodes
118 .iter()
119 .filter_map(|(id, n)| {
120 NodeRef::from_node_id(*id)
121 .zip(n.label())
122 .map(|(node, label)| Candidate {
123 node,
124 label: label.to_owned(),
125 })
126 })
127 .collect()
128}
129
130#[cfg(test)]
131mod tests {
132 use accesskit::{NodeId, Rect, Role, Tree, TreeId};
133
134 use super::*;
135
136 fn button(label: &str, bounds: Option<Rect>) -> Node {
137 let mut node = Node::new(Role::Button);
138 node.set_label(label);
139 if let Some(rect) = bounds {
140 node.set_bounds(rect);
141 }
142 node
143 }
144
145 fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect {
146 Rect { x0, y0, x1, y1 }
147 }
148
149 fn tree(nodes: Vec<(NodeId, Node)>) -> TreeUpdate {
150 TreeUpdate {
151 nodes,
152 tree: Some(Tree::new(NodeId(1))),
153 tree_id: TreeId::ROOT,
154 focus: NodeId(1),
155 }
156 }
157
158 fn node_ref(raw: u64) -> NodeRef {
159 match NodeRef::from_node_id(NodeId(raw)) {
160 Some(node) => node,
161 None => panic!("raw must be non-zero"),
162 }
163 }
164
165 fn shell() -> TreeUpdate {
166 tree(vec![
167 (NodeId(1), Node::new(Role::Window)),
168 (
169 NodeId(2),
170 button("Extrude", Some(rect(10.0, 20.0, 30.0, 40.0))),
171 ),
172 (
173 NodeId(3),
174 button("Extrude Cut", Some(rect(40.0, 20.0, 60.0, 40.0))),
175 ),
176 (
177 NodeId(4),
178 button("Fillet", Some(rect(70.0, 20.0, 90.0, 40.0))),
179 ),
180 ])
181 }
182
183 fn resolved(target: &PointerTarget, tree: &TreeUpdate) -> WindowPoint {
184 match resolve_target(target, tree) {
185 Ok(point) => point,
186 Err(e) => panic!("resolve failed: {e}"),
187 }
188 }
189
190 #[test]
191 fn coordinates_pass_through() {
192 let point = resolved(&PointerTarget::At { x: 12.5, y: 7.0 }, &shell());
193 assert_eq!(point, WindowPoint::new(12.5, 7.0));
194 }
195
196 #[test]
197 fn unique_label_substring_hits_bounds_center() {
198 let point = resolved(&PointerTarget::Label("fill".to_owned()), &shell());
199 assert_eq!(point, WindowPoint::new(80.0, 30.0));
200 }
201
202 #[test]
203 fn exact_label_wins_over_substring_ambiguity() {
204 let point = resolved(&PointerTarget::Label("Extrude".to_owned()), &shell());
205 assert_eq!(point, WindowPoint::new(20.0, 30.0));
206 }
207
208 #[test]
209 fn ambiguous_label_names_the_matches() {
210 let needle = PointerTarget::Label("trude".to_owned());
211 match resolve_target(&needle, &shell()) {
212 Err(ResolveError::LabelAmbiguous { matches, .. }) => {
213 let labels: Vec<&str> = matches.iter().map(|c| c.label.as_str()).collect();
214 assert_eq!(labels, vec!["Extrude", "Extrude Cut"]);
215 }
216 other => panic!("expected ambiguity, got {other:?}"),
217 }
218 }
219
220 #[test]
221 fn missing_label_names_the_candidates() {
222 let needle = PointerTarget::Label("Revolve".to_owned());
223 match resolve_target(&needle, &shell()) {
224 Err(ref err @ ResolveError::LabelNotFound { ref candidates, .. }) => {
225 assert_eq!(candidates.len(), 3);
226 let message = err.to_string();
227 assert!(message.contains("Extrude Cut"), "message was: {message}");
228 assert!(message.contains("Fillet"), "message was: {message}");
229 }
230 other => panic!("expected not-found, got {other:?}"),
231 }
232 }
233
234 #[test]
235 fn node_ref_resolves_to_bounds_center() {
236 let point = resolved(&PointerTarget::Node(node_ref(3)), &shell());
237 assert_eq!(point, WindowPoint::new(50.0, 30.0));
238 }
239
240 #[test]
241 fn missing_node_ref_names_the_candidates() {
242 match resolve_target(&PointerTarget::Node(node_ref(99)), &shell()) {
243 Err(ResolveError::NodeNotFound { candidates, .. }) => {
244 assert_eq!(candidates.len(), 3);
245 }
246 other => panic!("expected not-found, got {other:?}"),
247 }
248 }
249
250 #[test]
251 fn node_without_bounds_is_an_error() {
252 let update = tree(vec![
253 (NodeId(1), Node::new(Role::Window)),
254 (NodeId(2), button("Ghost", None)),
255 ]);
256 match resolve_target(&PointerTarget::Label("Ghost".to_owned()), &update) {
257 Err(ResolveError::NodeWithoutBounds { node }) => assert_eq!(node, node_ref(2)),
258 other => panic!("expected missing bounds, got {other:?}"),
259 }
260 }
261}