1use std::collections::{HashMap, HashSet};
7use std::fmt::Write as _;
8
9use egui::accesskit::{self as ak, Action, Toggled};
10use egui::{Pos2, Rect};
11use serde_json::{Value, json};
12
13use crate::automation::{Element, Role};
14
15pub const MAX_NODES: usize = 50_000;
17const MAX_DEPTH: usize = 256;
19const MAX_REFS: usize = 200_000;
21const OUTLINE_TEXT: usize = 80;
23
24#[derive(Clone, Debug, PartialEq)]
26pub struct Node {
27 pub key: u64,
29 pub ref_n: u32,
31 pub id: Option<String>,
33 pub role: &'static str,
34 pub name: String,
35 pub value: Option<String>,
36 pub enabled: bool,
37 pub focused: bool,
38 pub selected: Option<bool>,
39 pub checked: Option<bool>,
40 pub visible: bool,
42 pub rect: Rect,
44 pub interactive: bool,
46 pub egui: Option<u64>,
48 pub parent: Option<usize>,
49 pub children: Vec<usize>,
50 pub depth: usize,
51 order: f64,
52 echo: bool,
54}
55
56impl Default for Node {
57 fn default() -> Self {
58 Node {
59 key: 0,
60 ref_n: 0,
61 id: None,
62 role: "generic",
63 name: String::new(),
64 value: None,
65 enabled: true,
66 focused: false,
67 selected: None,
68 checked: None,
69 visible: false,
70 rect: Rect::NOTHING,
71 interactive: false,
72 egui: None,
73 parent: None,
74 children: Vec::new(),
75 depth: 0,
76 order: 0.0,
77 echo: false,
78 }
79 }
80}
81
82impl Node {
83 pub fn ref_str(&self) -> String {
84 format!("e{}", self.ref_n)
85 }
86
87 pub fn center(&self) -> Pos2 {
89 self.rect.center()
90 }
91
92 pub fn text(&self) -> &str {
94 match (self.role, &self.value) {
95 ("textbox" | "searchbox", Some(v)) => v,
96 _ => &self.name,
97 }
98 }
99
100 pub fn to_json(&self, nodes: &[Node]) -> Value {
101 let mut o = serde_json::Map::new();
102 o.insert("ref".into(), json!(self.ref_str()));
103 if let Some(id) = &self.id {
104 o.insert("id".into(), json!(id));
105 }
106 o.insert("role".into(), json!(self.role));
107 o.insert("name".into(), json!(self.name));
108 if let Some(v) = &self.value {
109 o.insert("value".into(), json!(v));
110 }
111 o.insert("enabled".into(), json!(self.enabled));
112 o.insert("focused".into(), json!(self.focused));
113 if let Some(s) = self.selected {
114 o.insert("selected".into(), json!(s));
115 }
116 if let Some(c) = self.checked {
117 o.insert("checked".into(), json!(c));
118 }
119 o.insert("visible".into(), json!(self.visible));
120 o.insert("rect".into(), rect_json(self.rect));
121 if let Some(p) = self.parent.and_then(|p| nodes.get(p)) {
122 o.insert("parent".into(), json!(p.ref_str()));
123 }
124 Value::Object(o)
125 }
126}
127
128pub fn rect_json(r: Rect) -> Value {
130 let t = |v: f32| ((v * 10.0).round() / 10.0) as f64;
131 json!([t(r.min.x), t(r.min.y), t(r.width()), t(r.height())])
132}
133
134#[derive(Debug, Default)]
136pub struct Refs {
137 by_key: HashMap<u64, u32>,
138 by_ref: HashMap<u32, u64>,
139 next: u32,
140}
141
142impl Refs {
143 fn get(&mut self, key: u64) -> u32 {
144 if let Some(r) = self.by_key.get(&key) {
145 return *r;
146 }
147 if self.by_key.len() >= MAX_REFS {
148 self.by_key.clear();
149 self.by_ref.clear();
150 }
151 self.next = self.next.wrapping_add(1).max(1);
152 self.by_key.insert(key, self.next);
153 self.by_ref.insert(self.next, key);
154 self.next
155 }
156
157 pub fn key_of(&self, r: u32) -> Option<u64> {
158 self.by_ref.get(&r).copied()
159 }
160}
161
162#[derive(Debug, Default)]
164pub struct Model {
165 pub nodes: Vec<Node>,
166 pub roots: Vec<usize>,
167 pub pass: u64,
169}
170
171pub struct Sources<'a> {
173 pub update: Option<&'a ak::TreeUpdate>,
174 pub elements: &'a [Element],
175 pub widgets: &'a HashMap<u64, Rect>,
177 pub screen: Rect,
178 pub exclude: &'a HashSet<u64>,
180 pub pass: u64,
181}
182
183fn fnv(s: &str) -> u64 {
184 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
185 for b in s.bytes() {
186 h ^= u64::from(b);
187 h = h.wrapping_mul(0x0100_0000_01b3);
188 }
189 h
190}
191
192pub fn ak_role(r: ak::Role) -> &'static str {
194 use ak::Role as R;
195 match r {
196 R::Button | R::DefaultButton => "button",
197 R::CheckBox => "checkbox",
198 R::Switch => "switch",
199 R::RadioButton => "radio",
200 R::RadioGroup => "radiogroup",
201 R::TextInput | R::MultilineTextInput | R::PasswordInput | R::EmailInput | R::NumberInput | R::PhoneNumberInput | R::UrlInput => "textbox",
202 R::SearchInput => "searchbox",
203 R::Slider => "slider",
204 R::SpinButton => "spinbutton",
205 R::ComboBox => "combobox",
206 R::Link => "link",
207 R::Label | R::Paragraph | R::TextRun => "label",
208 R::Image => "image",
209 R::ColorWell => "colorwell",
210 R::Pane => "panel",
211 R::Window => "window",
212 R::Dialog | R::AlertDialog => "dialog",
213 R::ScrollBar => "scrollbar",
214 R::Splitter => "separator",
215 R::ProgressIndicator => "progressbar",
216 R::Menu => "menu",
217 R::MenuBar => "menubar",
218 R::MenuItem | R::MenuItemCheckBox | R::MenuItemRadio => "menuitem",
219 R::Tab => "tab",
220 R::TabList => "tablist",
221 R::List | R::ListBox => "list",
222 R::ListItem | R::ListBoxOption => "listitem",
223 R::Tree => "tree",
224 R::TreeItem => "treeitem",
225 R::Table | R::Grid => "table",
226 R::Row => "row",
227 R::Cell | R::GridCell => "cell",
228 R::ColumnHeader | R::RowHeader => "columnheader",
229 R::Toolbar => "toolbar",
230 R::Status => "status",
231 R::Alert => "alert",
232 R::Tooltip => "tooltip",
233 R::Document => "document",
234 R::GenericContainer | R::Group => "group",
235 _ => "generic",
236 }
237}
238
239pub fn ashlar_role(r: Role) -> Option<&'static str> {
241 Some(match r {
242 Role::Button | Role::Swatch => "button",
243 Role::Toggle => "checkbox",
244 Role::Field => "textbox",
245 Role::Slider | Role::Knob => "slider",
246 Role::List => "list",
247 Role::Row => "listitem",
248 Role::Table => "table",
249 Role::Cell => "cell",
250 Role::Header => "columnheader",
251 Role::Tree => "tree",
252 Role::TreeItem => "treeitem",
253 Role::Menu => "menu",
254 Role::MenuItem => "menuitem",
255 Role::Dialog | Role::Palette => "dialog",
256 Role::Toast => "alert",
257 Role::Search => "searchbox",
258 Role::Status => "status",
259 Role::Tab => "tab",
260 Role::Switch => "switch",
261 Role::Radio => "radio",
262 Role::RadioGroup => "radiogroup",
263 Role::Progress => "progressbar",
264 Role::Label => "label",
265 Role::Group => "group",
266 Role::Disclosure => "button",
267 Role::Alert => "alert",
268 Role::Link => "link",
269 Role::ComboBox => "combobox",
270 Role::ListBox => "listbox",
271 Role::ListOption => "option",
272 Role::Tooltip => "tooltip",
273 Role::Document => "document",
274 Role::Other => return None,
275 })
276}
277
278fn checkable(r: Role) -> bool {
280 matches!(r, Role::Toggle | Role::Switch | Role::Radio)
281}
282
283const INTERACTIVE_ROLES: &[&str] = &[
284 "button",
285 "checkbox",
286 "switch",
287 "radio",
288 "textbox",
289 "searchbox",
290 "slider",
291 "spinbutton",
292 "combobox",
293 "link",
294 "menu",
295 "menuitem",
296 "tab",
297 "listitem",
298 "treeitem",
299 "colorwell",
300];
301
302fn is_input_role(role: &str) -> bool {
303 INTERACTIVE_ROLES.contains(&role)
304}
305
306fn same_rect(a: Rect, b: Rect) -> bool {
307 (a.min - b.min).length() <= 1.0 && (a.max - b.max).length() <= 1.0
308}
309
310fn ak_rect(n: &ak::Node) -> Option<Rect> {
311 let b = n.bounds()?;
312 let r = Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32));
313 r.is_finite().then_some(r)
314}
315
316fn number_text(v: f64) -> String {
317 if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
318}
319
320impl Model {
321 pub fn build(src: &Sources<'_>, refs: &mut Refs) -> Model {
323 let mut m = Model { pass: src.pass, ..Default::default() };
324 if let Some(u) = src.update {
325 m.add_accesskit(u, src);
326 }
327 m.merge_elements(src);
328 m.nest();
329 m.link();
330 for n in &mut m.nodes {
331 n.ref_n = refs.get(n.key);
332 }
333 m
334 }
335
336 fn add_accesskit(&mut self, u: &ak::TreeUpdate, src: &Sources<'_>) {
337 let map: HashMap<u64, &ak::Node> = u.nodes.iter().map(|(id, n)| (id.0, n)).collect();
338 let root = u.tree.as_ref().map_or_else(|| egui::accesskit_root_id().value(), |t| t.root.0);
339 let focus = u.focus.0;
340 let mut stack: Vec<(u64, Option<usize>, usize)> = Vec::new();
342 if let Some(r) = map.get(&root) {
343 for c in r.children().iter().rev() {
344 stack.push((c.0, None, 1));
345 }
346 }
347 while let Some((id, parent, depth)) = stack.pop() {
348 if self.nodes.len() >= MAX_NODES {
349 break;
350 }
351 let Some(n) = map.get(&id) else { continue };
352 if n.is_hidden() {
353 continue;
354 }
355 let role = ak_role(n.role());
356 let mut name = n.label().unwrap_or_default().to_string();
357 let mut value = n.value().map(str::to_string).or_else(|| n.numeric_value().map(number_text));
358 if role == "label" && name.is_empty() {
359 name = value.take().unwrap_or_default();
360 }
361 if name.is_empty()
362 && matches!(role, "textbox" | "searchbox")
363 && let Some(p) = n.placeholder()
364 {
365 name = p.to_string();
366 }
367 let actionable = n.supports_action(Action::Click) || n.supports_action(Action::Focus);
369 let interactive = is_input_role(role) || (actionable && !matches!(role, "label" | "image"));
370 let repeats = role == "label"
372 && parent.and_then(|p| self.nodes.get(p)).is_some_and(|p| matches!(p.role, "textbox" | "searchbox") || p.name == name);
373 if repeats || src.exclude.contains(&id) {
374 continue;
375 }
376 let keep = !(matches!(role, "group" | "generic") && name.is_empty() && !interactive);
377 let mut next_parent = parent;
378 if keep {
379 let full = ak_rect(n).unwrap_or(Rect::NOTHING);
380 let clipped = src.widgets.get(&id).copied().unwrap_or(full).intersect(src.screen);
381 let visible = clipped.is_positive();
382 let (mut checked, mut selected) = (None, n.is_selected());
383 let toggled = n.toggled().map(|t| t == Toggled::True);
384 if matches!(role, "checkbox" | "radio" | "switch" | "menuitem") {
385 checked = toggled;
386 } else if selected.is_none() {
387 selected = toggled;
388 }
389 self.nodes.push(Node {
390 key: id ^ fnv(role),
393 role,
394 name,
395 value,
396 enabled: !n.is_disabled(),
397 focused: id == focus && id != root,
398 selected,
399 checked,
400 visible,
401 rect: if visible { clipped } else { full },
402 interactive,
403 egui: Some(id),
404 parent,
405 order: self.nodes.len() as f64,
406 ..Default::default()
407 });
408 next_parent = Some(self.nodes.len() - 1);
409 }
410 if depth < MAX_DEPTH {
411 for c in n.children().iter().rev() {
412 stack.push((c.0, next_parent, depth + 1));
413 }
414 }
415 }
416 }
417
418 fn merge_elements(&mut self, src: &Sources<'_>) {
421 for e in src.elements {
422 if self.nodes.len() >= MAX_NODES {
423 break;
424 }
425 let rect = e.egui_rect();
426 let found = e
427 .egui_id
428 .and_then(|g| self.nodes.iter().position(|n| n.egui == Some(g) && n.id.is_none()))
429 .or_else(|| self.nodes.iter().rposition(|n| n.id.is_none() && n.interactive && same_rect(n.rect, rect)));
430 let role = ashlar_role(e.role);
431 if let Some(n) = found.and_then(|i| self.nodes.get_mut(i)) {
432 n.key = fnv(&e.id) | (1 << 63);
433 n.id = Some(e.id.clone());
434 if let Some(r) = role {
435 n.role = r;
436 }
437 if !e.label.is_empty() {
438 n.name.clone_from(&e.label);
439 }
440 if !e.value.is_empty() {
441 n.value = Some(e.value.clone());
442 }
443 if checkable(e.role) {
444 n.checked = e.selected.or(n.checked);
445 } else if e.selected.is_some() {
446 n.selected = e.selected;
447 }
448 n.enabled &= e.enabled;
449 n.interactive |= role.is_some_and(is_input_role);
450 continue;
451 }
452 let parent =
454 self.nodes.iter().enumerate().filter(|(_, n)| n.rect.contains_rect(rect)).max_by_key(|(_, n)| n.depth_hint()).map(|(i, _)| i);
455 let idx = self.nodes.len();
456 let mut first_child: Option<f64> = None;
457 for n in &mut self.nodes {
458 if n.parent == parent && rect.contains_rect(n.rect) && n.rect.is_positive() {
459 n.parent = Some(idx);
460 first_child = Some(first_child.map_or(n.order, |o: f64| o.min(n.order)));
461 }
462 }
463 let role = role.unwrap_or("generic");
464 let order = first_child.map_or(idx as f64, |o| o - 0.5);
465 self.nodes.push(Node {
466 key: fnv(&e.id) | (1 << 63),
467 id: Some(e.id.clone()),
468 role,
469 name: e.label.clone(),
470 value: (!e.value.is_empty()).then(|| e.value.clone()),
471 enabled: e.enabled,
472 selected: if checkable(e.role) { None } else { e.selected },
473 checked: if checkable(e.role) { e.selected } else { None },
474 visible: rect.is_positive(),
475 rect,
476 interactive: is_input_role(role),
477 parent,
478 order,
479 ..Default::default()
480 });
481 }
482 }
483
484 fn nest(&mut self) {
487 const CONTAINERS: &[&str] = &[
488 "list", "listitem", "tree", "treeitem", "table", "row", "dialog", "menu", "menubar", "toolbar", "tablist", "status", "panel", "window",
489 "group",
490 ];
491 let mut by_order: Vec<usize> = (0..self.nodes.len()).collect();
492 by_order.sort_by(|a, b| {
493 let o = |i: &usize| self.nodes.get(*i).map_or(0.0, |n| n.order);
494 o(a).total_cmp(&o(b))
495 });
496 let mut budget: usize = 4_000_000;
497 for (pos, &c) in by_order.iter().enumerate() {
498 let Some(container) = self.nodes.get(c) else { continue };
499 if container.id.is_none() || !CONTAINERS.contains(&container.role) || !container.rect.is_positive() {
500 continue;
501 }
502 let (parent, rect, name) = (container.parent, container.rect, container.name.clone());
503 for &j in by_order.get(pos + 1..).unwrap_or(&[]) {
504 budget = budget.saturating_sub(1);
505 if budget == 0 {
506 return;
507 }
508 if let Some(n) = self.nodes.get_mut(j)
509 && n.parent == parent
510 && n.rect.is_positive()
511 && rect.contains_rect(n.rect)
512 {
513 n.parent = Some(c);
514 n.echo = n.role == "label" && !name.is_empty() && n.name == name;
515 }
516 }
517 }
518 }
519
520 fn link(&mut self) {
522 let n = self.nodes.len();
523 let mut kids: Vec<Vec<usize>> = vec![Vec::new(); n];
524 self.roots.clear();
525 for (i, node) in self.nodes.iter().enumerate() {
526 match node.parent.and_then(|p| kids.get_mut(p)) {
527 Some(k) => k.push(i),
528 None => self.roots.push(i),
529 }
530 }
531 let order = |i: &usize| self.nodes.get(*i).map_or(0.0, |n| n.order);
532 for k in &mut kids {
533 k.sort_by(|a, b| order(a).total_cmp(&order(b)));
534 }
535 let mut roots = std::mem::take(&mut self.roots);
536 roots.sort_by(|a, b| order(a).total_cmp(&order(b)));
537 let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|r| (*r, 0)).collect();
538 let mut seen = vec![false; n];
539 while let Some((i, d)) = stack.pop() {
540 if seen.get(i).copied().unwrap_or(true) {
541 continue;
542 }
543 if let Some(s) = seen.get_mut(i) {
544 *s = true;
545 }
546 for c in kids.get(i).into_iter().flatten().rev() {
547 stack.push((*c, d + 1));
548 }
549 if let Some(node) = self.nodes.get_mut(i) {
550 node.depth = d;
551 }
552 }
553 for (node, k) in self.nodes.iter_mut().zip(kids) {
554 node.children = k;
555 }
556 self.roots = roots;
557 }
558
559 pub fn get(&self, i: usize) -> Option<&Node> {
560 self.nodes.get(i)
561 }
562
563 pub fn preorder(&self, scope: Option<usize>) -> Vec<usize> {
565 let mut out = Vec::new();
566 let mut stack: Vec<usize> = match scope {
567 Some(s) => vec![s],
568 None => self.roots.iter().rev().copied().collect(),
569 };
570 while let Some(i) = stack.pop() {
571 if out.len() > MAX_NODES {
572 break;
573 }
574 out.push(i);
575 if let Some(n) = self.nodes.get(i) {
576 stack.extend(n.children.iter().rev());
577 }
578 }
579 out
580 }
581
582 pub fn by_key(&self, key: u64) -> Option<usize> {
583 self.nodes.iter().position(|n| n.key == key)
584 }
585
586 pub fn at(&self, p: Pos2) -> Option<usize> {
588 self.nodes
589 .iter()
590 .enumerate()
591 .filter(|(_, n)| n.visible && n.rect.contains(p))
592 .min_by(|(_, a), (_, b)| a.rect.area().total_cmp(&b.rect.area()).then(b.depth.cmp(&a.depth)))
593 .map(|(i, _)| i)
594 }
595
596 pub fn shows_text(&self, text: &str) -> bool {
598 self.nodes.iter().any(|n| n.visible && (n.name.contains(text) || n.value.as_deref().is_some_and(|v| v.contains(text))))
599 }
600
601 pub fn outline(&self, opts: &Outline) -> (String, Vec<usize>) {
603 let mut s = String::new();
604 let mut listed = Vec::new();
605 let mut stack: Vec<(usize, usize)> = match opts.scope {
607 Some(i) => vec![(i, 0)],
608 None => self.roots.iter().rev().map(|r| (*r, 0)).collect(),
609 };
610 while let Some((i, d)) = stack.pop() {
611 let Some(n) = self.nodes.get(i) else { continue };
612 if listed.len() >= MAX_NODES {
613 break;
614 }
615 let shown = opts.shows(n);
616 let child_d = if shown { d + 1 } else { d };
617 if shown {
618 listed.push(i);
619 line(&mut s, n, d);
620 }
621 if opts.depth.is_none_or(|max| child_d <= max) {
622 for c in n.children.iter().rev() {
623 stack.push((*c, child_d));
624 }
625 }
626 }
627 (s, listed)
628 }
629}
630
631impl Node {
632 fn depth_hint(&self) -> usize {
633 self.order as usize
636 }
637}
638
639#[derive(Clone, Copy, Debug, Default)]
641pub struct Outline {
642 pub interactive: bool,
644 pub compact: bool,
646 pub depth: Option<usize>,
647 pub scope: Option<usize>,
648}
649
650impl Outline {
651 fn shows(&self, n: &Node) -> bool {
652 if n.echo {
653 return false;
654 }
655 let anonymous = n.role == "generic" && n.name.is_empty() && n.id.is_none();
657 if self.interactive {
658 return n.interactive && n.visible && !anonymous;
659 }
660 if self.compact {
661 return n.visible && !anonymous && (n.interactive || !n.name.is_empty() || n.id.is_some());
662 }
663 true
664 }
665}
666
667fn quoted(s: &mut String, text: &str) {
668 s.push('"');
669 for (i, c) in text.chars().enumerate() {
670 if i >= OUTLINE_TEXT {
671 s.push('…');
672 break;
673 }
674 match c {
675 '"' => s.push_str("\\\""),
676 '\n' => s.push_str("\\n"),
677 c if c.is_control() => {}
678 c => s.push(c),
679 }
680 }
681 s.push('"');
682}
683
684fn line(s: &mut String, n: &Node, depth: usize) {
685 for _ in 0..depth.min(64) {
686 s.push_str(" ");
687 }
688 let _ = write!(s, "- {}", n.role);
689 if !n.name.is_empty() {
690 s.push(' ');
691 quoted(s, &n.name);
692 }
693 let _ = write!(s, " [ref=e{}]", n.ref_n);
694 if let Some(id) = &n.id {
695 let _ = write!(s, " [id={id}]");
696 }
697 if let Some(v) = n.value.as_deref().filter(|v| *v != n.name) {
698 s.push_str(" [value=");
699 quoted(s, v);
700 s.push(']');
701 }
702 if !n.enabled {
703 s.push_str(" [disabled]");
704 }
705 if n.focused {
706 s.push_str(" [focused]");
707 }
708 if n.selected == Some(true) {
709 s.push_str(" [selected]");
710 }
711 match n.checked {
712 Some(true) => s.push_str(" [checked]"),
713 Some(false) => s.push_str(" [unchecked]"),
714 None => {}
715 }
716 if !n.visible {
717 s.push_str(" [offscreen]");
718 }
719 s.push('\n');
720}
721
722#[derive(Clone, Debug, PartialEq)]
728pub enum Target {
729 Ref(u32),
730 Id(String),
731 Text { text: String, role: Option<String> },
732 Point(Pos2),
733}
734
735fn parse_ref(s: &str) -> Option<u32> {
736 s.trim().trim_start_matches('@').strip_prefix('e')?.parse().ok()
737}
738
739impl Target {
740 pub fn parse(v: &Value) -> Option<Target> {
742 if let Some(s) = v.as_str() {
743 return parse_ref(s).map(Target::Ref);
744 }
745 if let Some(r) = v.get("ref") {
746 return r.as_str().and_then(parse_ref).or_else(|| r.as_u64().and_then(|n| u32::try_from(n).ok())).map(Target::Ref);
747 }
748 if let Some(id) = v.get("id").and_then(Value::as_str) {
749 return Some(Target::Id(id.to_string()));
750 }
751 if let Some(t) = v.get("text").and_then(Value::as_str) {
752 return Some(Target::Text { text: t.to_string(), role: v.get("role").and_then(Value::as_str).map(str::to_string) });
753 }
754 super::input::point(v).map(Target::Point)
755 }
756
757 pub fn of(p: &Value) -> Option<Target> {
759 match p.get("target") {
760 Some(t) if !t.is_null() => Target::parse(t),
761 _ => Target::parse_flat(p),
762 }
763 }
764
765 fn parse_flat(p: &Value) -> Option<Target> {
768 if p.get("ref").is_some() || p.get("id").is_some() || p.get("x").is_some() {
769 return Target::parse(p);
770 }
771 None
772 }
773
774 pub fn describe(&self) -> String {
775 match self {
776 Target::Ref(r) => format!("@e{r}"),
777 Target::Id(id) => format!("id `{id}`"),
778 Target::Text { text, role: Some(r) } => format!("{r} \"{text}\""),
779 Target::Text { text, role: None } => format!("\"{text}\""),
780 Target::Point(p) => format!("({}, {})", p.x, p.y),
781 }
782 }
783}
784
785#[derive(Clone, Debug, PartialEq)]
787pub struct Miss {
788 pub error: String,
789 pub detail: Value,
790}
791
792#[derive(Clone, Copy, Debug, PartialEq)]
794pub struct Hit {
795 pub node: Option<usize>,
796 pub point: Pos2,
797}
798
799impl Model {
800 pub fn resolve(&self, t: &Target, refs: &Refs, generation: u64) -> Result<Hit, Miss> {
802 let hit = |i: usize| self.nodes.get(i).map(|n| Hit { node: Some(i), point: n.center() });
803 let not_found = || Miss {
804 error: format!("not_found: no element {} on screen (take a new ui.snapshot)", t.describe()),
805 detail: json!({"kind": "not_found", "generation": generation}),
806 };
807 match t {
808 Target::Ref(r) => {
809 let found = refs.key_of(*r).and_then(|k| self.by_key(k));
810 match found.and_then(hit) {
811 Some(h) => Ok(h),
812 None => Err(Miss {
813 error: format!("stale_ref: @e{r} is gone (snapshot generation {generation}; take a new ui.snapshot)"),
814 detail: json!({"kind": "stale_ref", "ref": format!("e{r}"), "generation": generation}),
815 }),
816 }
817 }
818 Target::Id(id) => {
819 let i = self.nodes.iter().position(|n| n.visible && n.id.as_deref() == Some(id.as_str()));
820 let i = i.or_else(|| self.nodes.iter().position(|n| n.id.as_deref() == Some(id.as_str())));
821 i.and_then(hit).ok_or_else(not_found)
822 }
823 Target::Text { text, role } => self.find_text(text, role.as_deref()).and_then(hit).ok_or_else(not_found),
824 Target::Point(p) => Ok(Hit { node: self.at(*p), point: *p }),
825 }
826 }
827
828 pub fn find_text(&self, text: &str, role: Option<&str>) -> Option<usize> {
831 let lower = text.to_lowercase();
832 let order = self.preorder(None);
833 let candidates =
834 || order.iter().filter_map(|i| self.nodes.get(*i).map(|n| (*i, n))).filter(|(_, n)| n.visible && role.is_none_or(|r| n.role == r));
835 let tiers: [&dyn Fn(&Node) -> bool; 3] =
836 [&|n: &Node| n.name == text, &|n: &Node| n.name.to_lowercase() == lower, &|n: &Node| n.name.to_lowercase().contains(&lower)];
837 for tier in tiers {
838 let mut first = None;
839 for (i, n) in candidates() {
840 if tier(n) {
841 if n.interactive {
842 return Some(i);
843 }
844 first = first.or(Some(i));
845 }
846 }
847 if first.is_some() {
848 return first;
849 }
850 }
851 None
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use egui::{pos2, vec2};
859
860 fn ak_node(role: ak::Role, label: Option<&str>, bounds: [f64; 4], children: &[u64]) -> ak::Node {
861 let mut n = ak::Node::new(role);
862 if let Some(l) = label {
863 n.set_label(l);
864 }
865 n.set_bounds(ak::Rect { x0: bounds[0], y0: bounds[1], x1: bounds[2], y1: bounds[3] });
866 n.set_children(children.iter().map(|c| ak::NodeId(*c)).collect::<Vec<_>>());
867 n
868 }
869
870 fn update() -> ak::TreeUpdate {
871 let root = egui::accesskit_root_id().value();
872 let mut button = ak_node(ak::Role::Button, Some("Save"), [10.0, 10.0, 60.0, 30.0], &[]);
873 button.add_action(Action::Click);
874 let mut label = ak_node(ak::Role::Label, None, [10.0, 40.0, 60.0, 60.0], &[]);
875 label.set_value("3 notes");
876 let mut row = ak_node(ak::Role::Unknown, None, [0.0, 100.0, 200.0, 122.0], &[]);
877 row.add_action(Action::Click);
878 let mut gone = ak_node(ak::Role::Button, Some("Hidden"), [0.0, 0.0, 1.0, 1.0], &[]);
879 gone.set_hidden();
880 let mut check = ak_node(ak::Role::CheckBox, Some("Wrap"), [10.0, 70.0, 80.0, 90.0], &[]);
881 check.set_toggled(Toggled::True);
882 check.set_disabled();
883 ak::TreeUpdate {
884 nodes: vec![
885 (ak::NodeId(root), ak_node(ak::Role::Window, None, [0.0, 0.0, 400.0, 300.0], &[2])),
886 (ak::NodeId(2), ak_node(ak::Role::GenericContainer, None, [0.0, 0.0, 400.0, 300.0], &[3, 4, 5, 6, 7])),
887 (ak::NodeId(3), button),
888 (ak::NodeId(4), label),
889 (ak::NodeId(5), row),
890 (ak::NodeId(6), gone),
891 (ak::NodeId(7), check),
892 ],
893 tree: Some(ak::Tree::new(ak::NodeId(root))),
894 tree_id: ak::TreeId::ROOT,
895 focus: ak::NodeId(3),
896 }
897 }
898
899 fn element(id: &str, label: &str, role: Role, rect: Rect, egui_id: Option<u64>) -> Element {
900 Element {
901 id: id.into(),
902 label: label.into(),
903 role,
904 rect: [rect.min.x, rect.min.y, rect.width(), rect.height()],
905 enabled: true,
906 egui_id,
907 ..Default::default()
908 }
909 }
910
911 fn build(refs: &mut Refs, elements: &[Element]) -> Model {
912 let u = update();
913 let widgets = HashMap::new();
914 let src = Sources {
915 update: Some(&u),
916 elements,
917 widgets: &widgets,
918 screen: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)),
919 exclude: &HashSet::new(),
920 pass: 1,
921 };
922 Model::build(&src, refs)
923 }
924
925 #[test]
926 fn accesskit_and_automation_merge_into_one_tree() {
927 let mut refs = Refs::default();
928 let row = Rect::from_min_max(pos2(0.0, 100.0), pos2(200.0, 122.0));
929 let elements = [
930 element("toolbar.save", "Save", Role::Button, Rect::from_min_max(pos2(10.0, 10.0), pos2(60.0, 30.0)), Some(3)),
931 element("list.row.0", "First", Role::Row, row, None),
932 element("status.text", "Ready", Role::Status, Rect::from_min_max(pos2(0.0, 280.0), pos2(400.0, 300.0)), None),
933 ];
934 let m = build(&mut refs, &elements);
935 let (text, listed) = m.outline(&Outline::default());
936 assert_eq!(
937 text,
938 "- button \"Save\" [ref=e1] [id=toolbar.save] [focused]\n- label \"3 notes\" [ref=e2]\n- listitem \"First\" [ref=e3] [id=list.row.0]\n- checkbox \"Wrap\" [ref=e4] [disabled] [checked]\n- status \"Ready\" [ref=e5] [id=status.text]\n"
939 );
940 assert_eq!(listed.len(), 5);
941 let (i, _) = m.outline(&Outline { interactive: true, ..Default::default() });
942 assert!(!i.contains("label"), "{i}");
943 let m2 = build(&mut refs, &elements[..2]);
945 assert_eq!(m2.outline(&Outline::default()).0.lines().next(), Some("- button \"Save\" [ref=e1] [id=toolbar.save] [focused]"));
946 let save = m2.resolve(&Target::Ref(1), &refs, 2).unwrap();
947 assert_eq!(save.point, pos2(35.0, 20.0));
948 let stale = m2.resolve(&Target::Ref(5), &refs, 2).unwrap_err();
949 assert_eq!(stale.detail["kind"], "stale_ref");
950 assert!(stale.error.contains("generation 2"), "{}", stale.error);
951 assert!(m2.resolve(&Target::Ref(999), &refs, 2).is_err());
952 let json = m2.get(0).unwrap().to_json(&m2.nodes);
953 assert_eq!(json["ref"], "e1");
954 assert_eq!(json["rect"], json!([10.0, 10.0, 50.0, 20.0]));
955 }
956
957 #[test]
958 fn targets_resolve_by_id_text_and_point() {
959 let mut refs = Refs::default();
960 let m = build(&mut refs, &[element("toolbar.save", "Save", Role::Button, Rect::from_min_max(pos2(10.0, 10.0), pos2(60.0, 30.0)), Some(3))]);
961 let r = |t: Target| m.resolve(&t, &refs, 1).map(|h| h.node.and_then(|i| m.get(i)).map(|n| n.name.clone()));
962 assert_eq!(r(Target::Id("toolbar.save".into())), Ok(Some("Save".into())));
963 assert_eq!(r(Target::Text { text: "save".into(), role: None }), Ok(Some("Save".into())));
964 assert_eq!(r(Target::Text { text: "notes".into(), role: None }), Ok(Some("3 notes".into())));
965 assert!(r(Target::Text { text: "Save".into(), role: Some("checkbox".into()) }).is_err());
966 assert_eq!(r(Target::Point(pos2(20.0, 20.0))), Ok(Some("Save".into())));
967 assert!(m.shows_text("3 notes"));
968 assert!(!m.shows_text("Hidden"));
969 }
970
971 #[test]
972 fn target_params_parse() {
973 assert_eq!(Target::parse(&json!("@e7")), Some(Target::Ref(7)));
974 assert_eq!(Target::of(&json!({"target": {"ref": "e3"}})), Some(Target::Ref(3)));
975 assert_eq!(Target::of(&json!({"ref": 4})), Some(Target::Ref(4)));
976 assert_eq!(Target::of(&json!({"id": "a.b"})), Some(Target::Id("a.b".into())));
977 assert_eq!(Target::of(&json!({"x": 1, "y": 2})), Some(Target::Point(pos2(1.0, 2.0))));
978 assert_eq!(Target::of(&json!({"text": "typed, not a target"})), None);
979 assert_eq!(
980 Target::of(&json!({"target": {"text": "Save", "role": "button"}})),
981 Some(Target::Text { text: "Save".into(), role: Some("button".into()) })
982 );
983 assert_eq!(Target::parse(&json!({"ref": "x9"})), None);
984 assert_eq!(Target::parse(&json!(5)), None);
985 }
986}