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::GenericContainer | R::Group => "group",
234 _ => "generic",
235 }
236}
237
238pub fn ashlar_role(r: Role) -> Option<&'static str> {
240 Some(match r {
241 Role::Button | Role::Swatch => "button",
242 Role::Toggle => "checkbox",
243 Role::Field => "textbox",
244 Role::Slider | Role::Knob => "slider",
245 Role::List => "list",
246 Role::Row => "listitem",
247 Role::Table => "table",
248 Role::Cell => "cell",
249 Role::Header => "columnheader",
250 Role::Tree => "tree",
251 Role::TreeItem => "treeitem",
252 Role::Menu => "menu",
253 Role::MenuItem => "menuitem",
254 Role::Dialog | Role::Palette => "dialog",
255 Role::Toast => "alert",
256 Role::Search => "searchbox",
257 Role::Status => "status",
258 Role::Tab => "tab",
259 Role::Other => return None,
260 })
261}
262
263const INTERACTIVE_ROLES: &[&str] = &[
264 "button",
265 "checkbox",
266 "switch",
267 "radio",
268 "textbox",
269 "searchbox",
270 "slider",
271 "spinbutton",
272 "combobox",
273 "link",
274 "menu",
275 "menuitem",
276 "tab",
277 "listitem",
278 "treeitem",
279 "colorwell",
280];
281
282fn is_input_role(role: &str) -> bool {
283 INTERACTIVE_ROLES.contains(&role)
284}
285
286fn same_rect(a: Rect, b: Rect) -> bool {
287 (a.min - b.min).length() <= 1.0 && (a.max - b.max).length() <= 1.0
288}
289
290fn ak_rect(n: &ak::Node) -> Option<Rect> {
291 let b = n.bounds()?;
292 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));
293 r.is_finite().then_some(r)
294}
295
296fn number_text(v: f64) -> String {
297 if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
298}
299
300impl Model {
301 pub fn build(src: &Sources<'_>, refs: &mut Refs) -> Model {
303 let mut m = Model { pass: src.pass, ..Default::default() };
304 if let Some(u) = src.update {
305 m.add_accesskit(u, src);
306 }
307 m.merge_elements(src);
308 m.nest();
309 m.link();
310 for n in &mut m.nodes {
311 n.ref_n = refs.get(n.key);
312 }
313 m
314 }
315
316 fn add_accesskit(&mut self, u: &ak::TreeUpdate, src: &Sources<'_>) {
317 let map: HashMap<u64, &ak::Node> = u.nodes.iter().map(|(id, n)| (id.0, n)).collect();
318 let root = u.tree.as_ref().map_or_else(|| egui::accesskit_root_id().value(), |t| t.root.0);
319 let focus = u.focus.0;
320 let mut stack: Vec<(u64, Option<usize>, usize)> = Vec::new();
322 if let Some(r) = map.get(&root) {
323 for c in r.children().iter().rev() {
324 stack.push((c.0, None, 1));
325 }
326 }
327 while let Some((id, parent, depth)) = stack.pop() {
328 if self.nodes.len() >= MAX_NODES {
329 break;
330 }
331 let Some(n) = map.get(&id) else { continue };
332 if n.is_hidden() {
333 continue;
334 }
335 let role = ak_role(n.role());
336 let mut name = n.label().unwrap_or_default().to_string();
337 let mut value = n.value().map(str::to_string).or_else(|| n.numeric_value().map(number_text));
338 if role == "label" && name.is_empty() {
339 name = value.take().unwrap_or_default();
340 }
341 if name.is_empty()
342 && matches!(role, "textbox" | "searchbox")
343 && let Some(p) = n.placeholder()
344 {
345 name = p.to_string();
346 }
347 let actionable = n.supports_action(Action::Click) || n.supports_action(Action::Focus);
349 let interactive = is_input_role(role) || (actionable && !matches!(role, "label" | "image"));
350 let repeats = role == "label"
352 && parent.and_then(|p| self.nodes.get(p)).is_some_and(|p| matches!(p.role, "textbox" | "searchbox") || p.name == name);
353 if repeats || src.exclude.contains(&id) {
354 continue;
355 }
356 let keep = !(matches!(role, "group" | "generic") && name.is_empty() && !interactive);
357 let mut next_parent = parent;
358 if keep {
359 let full = ak_rect(n).unwrap_or(Rect::NOTHING);
360 let clipped = src.widgets.get(&id).copied().unwrap_or(full).intersect(src.screen);
361 let visible = clipped.is_positive();
362 let (mut checked, mut selected) = (None, n.is_selected());
363 let toggled = n.toggled().map(|t| t == Toggled::True);
364 if matches!(role, "checkbox" | "radio" | "switch" | "menuitem") {
365 checked = toggled;
366 } else if selected.is_none() {
367 selected = toggled;
368 }
369 self.nodes.push(Node {
370 key: id ^ fnv(role),
373 role,
374 name,
375 value,
376 enabled: !n.is_disabled(),
377 focused: id == focus && id != root,
378 selected,
379 checked,
380 visible,
381 rect: if visible { clipped } else { full },
382 interactive,
383 egui: Some(id),
384 parent,
385 order: self.nodes.len() as f64,
386 ..Default::default()
387 });
388 next_parent = Some(self.nodes.len() - 1);
389 }
390 if depth < MAX_DEPTH {
391 for c in n.children().iter().rev() {
392 stack.push((c.0, next_parent, depth + 1));
393 }
394 }
395 }
396 }
397
398 fn merge_elements(&mut self, src: &Sources<'_>) {
401 for e in src.elements {
402 if self.nodes.len() >= MAX_NODES {
403 break;
404 }
405 let rect = e.egui_rect();
406 let found = e
407 .egui_id
408 .and_then(|g| self.nodes.iter().position(|n| n.egui == Some(g) && n.id.is_none()))
409 .or_else(|| self.nodes.iter().rposition(|n| n.id.is_none() && n.interactive && same_rect(n.rect, rect)));
410 let role = ashlar_role(e.role);
411 if let Some(n) = found.and_then(|i| self.nodes.get_mut(i)) {
412 n.key = fnv(&e.id) | (1 << 63);
413 n.id = Some(e.id.clone());
414 if let Some(r) = role {
415 n.role = r;
416 }
417 if !e.label.is_empty() {
418 n.name.clone_from(&e.label);
419 }
420 if !e.value.is_empty() {
421 n.value = Some(e.value.clone());
422 }
423 if e.role == Role::Toggle {
424 n.checked = e.selected.or(n.checked);
425 } else if e.selected.is_some() {
426 n.selected = e.selected;
427 }
428 n.enabled &= e.enabled;
429 n.interactive |= role.is_some_and(is_input_role);
430 continue;
431 }
432 let parent =
434 self.nodes.iter().enumerate().filter(|(_, n)| n.rect.contains_rect(rect)).max_by_key(|(_, n)| n.depth_hint()).map(|(i, _)| i);
435 let idx = self.nodes.len();
436 let mut first_child: Option<f64> = None;
437 for n in &mut self.nodes {
438 if n.parent == parent && rect.contains_rect(n.rect) && n.rect.is_positive() {
439 n.parent = Some(idx);
440 first_child = Some(first_child.map_or(n.order, |o: f64| o.min(n.order)));
441 }
442 }
443 let role = role.unwrap_or("generic");
444 let order = first_child.map_or(idx as f64, |o| o - 0.5);
445 self.nodes.push(Node {
446 key: fnv(&e.id) | (1 << 63),
447 id: Some(e.id.clone()),
448 role,
449 name: e.label.clone(),
450 value: (!e.value.is_empty()).then(|| e.value.clone()),
451 enabled: e.enabled,
452 selected: if e.role == Role::Toggle { None } else { e.selected },
453 checked: if e.role == Role::Toggle { e.selected } else { None },
454 visible: rect.is_positive(),
455 rect,
456 interactive: is_input_role(role),
457 parent,
458 order,
459 ..Default::default()
460 });
461 }
462 }
463
464 fn nest(&mut self) {
467 const CONTAINERS: &[&str] = &[
468 "list", "listitem", "tree", "treeitem", "table", "row", "dialog", "menu", "menubar", "toolbar", "tablist", "status", "panel", "window",
469 "group",
470 ];
471 let mut by_order: Vec<usize> = (0..self.nodes.len()).collect();
472 by_order.sort_by(|a, b| {
473 let o = |i: &usize| self.nodes.get(*i).map_or(0.0, |n| n.order);
474 o(a).total_cmp(&o(b))
475 });
476 let mut budget: usize = 4_000_000;
477 for (pos, &c) in by_order.iter().enumerate() {
478 let Some(container) = self.nodes.get(c) else { continue };
479 if container.id.is_none() || !CONTAINERS.contains(&container.role) || !container.rect.is_positive() {
480 continue;
481 }
482 let (parent, rect, name) = (container.parent, container.rect, container.name.clone());
483 for &j in by_order.get(pos + 1..).unwrap_or(&[]) {
484 budget = budget.saturating_sub(1);
485 if budget == 0 {
486 return;
487 }
488 if let Some(n) = self.nodes.get_mut(j)
489 && n.parent == parent
490 && n.rect.is_positive()
491 && rect.contains_rect(n.rect)
492 {
493 n.parent = Some(c);
494 n.echo = n.role == "label" && !name.is_empty() && n.name == name;
495 }
496 }
497 }
498 }
499
500 fn link(&mut self) {
502 let n = self.nodes.len();
503 let mut kids: Vec<Vec<usize>> = vec![Vec::new(); n];
504 self.roots.clear();
505 for (i, node) in self.nodes.iter().enumerate() {
506 match node.parent.and_then(|p| kids.get_mut(p)) {
507 Some(k) => k.push(i),
508 None => self.roots.push(i),
509 }
510 }
511 let order = |i: &usize| self.nodes.get(*i).map_or(0.0, |n| n.order);
512 for k in &mut kids {
513 k.sort_by(|a, b| order(a).total_cmp(&order(b)));
514 }
515 let mut roots = std::mem::take(&mut self.roots);
516 roots.sort_by(|a, b| order(a).total_cmp(&order(b)));
517 let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|r| (*r, 0)).collect();
518 let mut seen = vec![false; n];
519 while let Some((i, d)) = stack.pop() {
520 if seen.get(i).copied().unwrap_or(true) {
521 continue;
522 }
523 if let Some(s) = seen.get_mut(i) {
524 *s = true;
525 }
526 for c in kids.get(i).into_iter().flatten().rev() {
527 stack.push((*c, d + 1));
528 }
529 if let Some(node) = self.nodes.get_mut(i) {
530 node.depth = d;
531 }
532 }
533 for (node, k) in self.nodes.iter_mut().zip(kids) {
534 node.children = k;
535 }
536 self.roots = roots;
537 }
538
539 pub fn get(&self, i: usize) -> Option<&Node> {
540 self.nodes.get(i)
541 }
542
543 pub fn preorder(&self, scope: Option<usize>) -> Vec<usize> {
545 let mut out = Vec::new();
546 let mut stack: Vec<usize> = match scope {
547 Some(s) => vec![s],
548 None => self.roots.iter().rev().copied().collect(),
549 };
550 while let Some(i) = stack.pop() {
551 if out.len() > MAX_NODES {
552 break;
553 }
554 out.push(i);
555 if let Some(n) = self.nodes.get(i) {
556 stack.extend(n.children.iter().rev());
557 }
558 }
559 out
560 }
561
562 pub fn by_key(&self, key: u64) -> Option<usize> {
563 self.nodes.iter().position(|n| n.key == key)
564 }
565
566 pub fn at(&self, p: Pos2) -> Option<usize> {
568 self.nodes
569 .iter()
570 .enumerate()
571 .filter(|(_, n)| n.visible && n.rect.contains(p))
572 .min_by(|(_, a), (_, b)| a.rect.area().total_cmp(&b.rect.area()).then(b.depth.cmp(&a.depth)))
573 .map(|(i, _)| i)
574 }
575
576 pub fn shows_text(&self, text: &str) -> bool {
578 self.nodes.iter().any(|n| n.visible && (n.name.contains(text) || n.value.as_deref().is_some_and(|v| v.contains(text))))
579 }
580
581 pub fn outline(&self, opts: &Outline) -> (String, Vec<usize>) {
583 let mut s = String::new();
584 let mut listed = Vec::new();
585 let mut stack: Vec<(usize, usize)> = match opts.scope {
587 Some(i) => vec![(i, 0)],
588 None => self.roots.iter().rev().map(|r| (*r, 0)).collect(),
589 };
590 while let Some((i, d)) = stack.pop() {
591 let Some(n) = self.nodes.get(i) else { continue };
592 if listed.len() >= MAX_NODES {
593 break;
594 }
595 let shown = opts.shows(n);
596 let child_d = if shown { d + 1 } else { d };
597 if shown {
598 listed.push(i);
599 line(&mut s, n, d);
600 }
601 if opts.depth.is_none_or(|max| child_d <= max) {
602 for c in n.children.iter().rev() {
603 stack.push((*c, child_d));
604 }
605 }
606 }
607 (s, listed)
608 }
609}
610
611impl Node {
612 fn depth_hint(&self) -> usize {
613 self.order as usize
616 }
617}
618
619#[derive(Clone, Copy, Debug, Default)]
621pub struct Outline {
622 pub interactive: bool,
624 pub compact: bool,
626 pub depth: Option<usize>,
627 pub scope: Option<usize>,
628}
629
630impl Outline {
631 fn shows(&self, n: &Node) -> bool {
632 if n.echo {
633 return false;
634 }
635 let anonymous = n.role == "generic" && n.name.is_empty() && n.id.is_none();
637 if self.interactive {
638 return n.interactive && n.visible && !anonymous;
639 }
640 if self.compact {
641 return n.visible && !anonymous && (n.interactive || !n.name.is_empty() || n.id.is_some());
642 }
643 true
644 }
645}
646
647fn quoted(s: &mut String, text: &str) {
648 s.push('"');
649 for (i, c) in text.chars().enumerate() {
650 if i >= OUTLINE_TEXT {
651 s.push('…');
652 break;
653 }
654 match c {
655 '"' => s.push_str("\\\""),
656 '\n' => s.push_str("\\n"),
657 c if c.is_control() => {}
658 c => s.push(c),
659 }
660 }
661 s.push('"');
662}
663
664fn line(s: &mut String, n: &Node, depth: usize) {
665 for _ in 0..depth.min(64) {
666 s.push_str(" ");
667 }
668 let _ = write!(s, "- {}", n.role);
669 if !n.name.is_empty() {
670 s.push(' ');
671 quoted(s, &n.name);
672 }
673 let _ = write!(s, " [ref=e{}]", n.ref_n);
674 if let Some(id) = &n.id {
675 let _ = write!(s, " [id={id}]");
676 }
677 if let Some(v) = n.value.as_deref().filter(|v| *v != n.name) {
678 s.push_str(" [value=");
679 quoted(s, v);
680 s.push(']');
681 }
682 if !n.enabled {
683 s.push_str(" [disabled]");
684 }
685 if n.focused {
686 s.push_str(" [focused]");
687 }
688 if n.selected == Some(true) {
689 s.push_str(" [selected]");
690 }
691 match n.checked {
692 Some(true) => s.push_str(" [checked]"),
693 Some(false) => s.push_str(" [unchecked]"),
694 None => {}
695 }
696 if !n.visible {
697 s.push_str(" [offscreen]");
698 }
699 s.push('\n');
700}
701
702#[derive(Clone, Debug, PartialEq)]
708pub enum Target {
709 Ref(u32),
710 Id(String),
711 Text { text: String, role: Option<String> },
712 Point(Pos2),
713}
714
715fn parse_ref(s: &str) -> Option<u32> {
716 s.trim().trim_start_matches('@').strip_prefix('e')?.parse().ok()
717}
718
719impl Target {
720 pub fn parse(v: &Value) -> Option<Target> {
722 if let Some(s) = v.as_str() {
723 return parse_ref(s).map(Target::Ref);
724 }
725 if let Some(r) = v.get("ref") {
726 return r.as_str().and_then(parse_ref).or_else(|| r.as_u64().and_then(|n| u32::try_from(n).ok())).map(Target::Ref);
727 }
728 if let Some(id) = v.get("id").and_then(Value::as_str) {
729 return Some(Target::Id(id.to_string()));
730 }
731 if let Some(t) = v.get("text").and_then(Value::as_str) {
732 return Some(Target::Text { text: t.to_string(), role: v.get("role").and_then(Value::as_str).map(str::to_string) });
733 }
734 super::input::point(v).map(Target::Point)
735 }
736
737 pub fn of(p: &Value) -> Option<Target> {
739 match p.get("target") {
740 Some(t) if !t.is_null() => Target::parse(t),
741 _ => Target::parse_flat(p),
742 }
743 }
744
745 fn parse_flat(p: &Value) -> Option<Target> {
748 if p.get("ref").is_some() || p.get("id").is_some() || p.get("x").is_some() {
749 return Target::parse(p);
750 }
751 None
752 }
753
754 pub fn describe(&self) -> String {
755 match self {
756 Target::Ref(r) => format!("@e{r}"),
757 Target::Id(id) => format!("id `{id}`"),
758 Target::Text { text, role: Some(r) } => format!("{r} \"{text}\""),
759 Target::Text { text, role: None } => format!("\"{text}\""),
760 Target::Point(p) => format!("({}, {})", p.x, p.y),
761 }
762 }
763}
764
765#[derive(Clone, Debug, PartialEq)]
767pub struct Miss {
768 pub error: String,
769 pub detail: Value,
770}
771
772#[derive(Clone, Copy, Debug, PartialEq)]
774pub struct Hit {
775 pub node: Option<usize>,
776 pub point: Pos2,
777}
778
779impl Model {
780 pub fn resolve(&self, t: &Target, refs: &Refs, generation: u64) -> Result<Hit, Miss> {
782 let hit = |i: usize| self.nodes.get(i).map(|n| Hit { node: Some(i), point: n.center() });
783 let not_found = || Miss {
784 error: format!("not_found: no element {} on screen (take a new ui.snapshot)", t.describe()),
785 detail: json!({"kind": "not_found", "generation": generation}),
786 };
787 match t {
788 Target::Ref(r) => {
789 let found = refs.key_of(*r).and_then(|k| self.by_key(k));
790 match found.and_then(hit) {
791 Some(h) => Ok(h),
792 None => Err(Miss {
793 error: format!("stale_ref: @e{r} is gone (snapshot generation {generation}; take a new ui.snapshot)"),
794 detail: json!({"kind": "stale_ref", "ref": format!("e{r}"), "generation": generation}),
795 }),
796 }
797 }
798 Target::Id(id) => {
799 let i = self.nodes.iter().position(|n| n.visible && n.id.as_deref() == Some(id.as_str()));
800 let i = i.or_else(|| self.nodes.iter().position(|n| n.id.as_deref() == Some(id.as_str())));
801 i.and_then(hit).ok_or_else(not_found)
802 }
803 Target::Text { text, role } => self.find_text(text, role.as_deref()).and_then(hit).ok_or_else(not_found),
804 Target::Point(p) => Ok(Hit { node: self.at(*p), point: *p }),
805 }
806 }
807
808 pub fn find_text(&self, text: &str, role: Option<&str>) -> Option<usize> {
811 let lower = text.to_lowercase();
812 let order = self.preorder(None);
813 let candidates =
814 || 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));
815 let tiers: [&dyn Fn(&Node) -> bool; 3] =
816 [&|n: &Node| n.name == text, &|n: &Node| n.name.to_lowercase() == lower, &|n: &Node| n.name.to_lowercase().contains(&lower)];
817 for tier in tiers {
818 let mut first = None;
819 for (i, n) in candidates() {
820 if tier(n) {
821 if n.interactive {
822 return Some(i);
823 }
824 first = first.or(Some(i));
825 }
826 }
827 if first.is_some() {
828 return first;
829 }
830 }
831 None
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use egui::{pos2, vec2};
839
840 fn ak_node(role: ak::Role, label: Option<&str>, bounds: [f64; 4], children: &[u64]) -> ak::Node {
841 let mut n = ak::Node::new(role);
842 if let Some(l) = label {
843 n.set_label(l);
844 }
845 n.set_bounds(ak::Rect { x0: bounds[0], y0: bounds[1], x1: bounds[2], y1: bounds[3] });
846 n.set_children(children.iter().map(|c| ak::NodeId(*c)).collect::<Vec<_>>());
847 n
848 }
849
850 fn update() -> ak::TreeUpdate {
851 let root = egui::accesskit_root_id().value();
852 let mut button = ak_node(ak::Role::Button, Some("Save"), [10.0, 10.0, 60.0, 30.0], &[]);
853 button.add_action(Action::Click);
854 let mut label = ak_node(ak::Role::Label, None, [10.0, 40.0, 60.0, 60.0], &[]);
855 label.set_value("3 notes");
856 let mut row = ak_node(ak::Role::Unknown, None, [0.0, 100.0, 200.0, 122.0], &[]);
857 row.add_action(Action::Click);
858 let mut gone = ak_node(ak::Role::Button, Some("Hidden"), [0.0, 0.0, 1.0, 1.0], &[]);
859 gone.set_hidden();
860 let mut check = ak_node(ak::Role::CheckBox, Some("Wrap"), [10.0, 70.0, 80.0, 90.0], &[]);
861 check.set_toggled(Toggled::True);
862 check.set_disabled();
863 ak::TreeUpdate {
864 nodes: vec![
865 (ak::NodeId(root), ak_node(ak::Role::Window, None, [0.0, 0.0, 400.0, 300.0], &[2])),
866 (ak::NodeId(2), ak_node(ak::Role::GenericContainer, None, [0.0, 0.0, 400.0, 300.0], &[3, 4, 5, 6, 7])),
867 (ak::NodeId(3), button),
868 (ak::NodeId(4), label),
869 (ak::NodeId(5), row),
870 (ak::NodeId(6), gone),
871 (ak::NodeId(7), check),
872 ],
873 tree: Some(ak::Tree::new(ak::NodeId(root))),
874 tree_id: ak::TreeId::ROOT,
875 focus: ak::NodeId(3),
876 }
877 }
878
879 fn element(id: &str, label: &str, role: Role, rect: Rect, egui_id: Option<u64>) -> Element {
880 Element {
881 id: id.into(),
882 label: label.into(),
883 role,
884 rect: [rect.min.x, rect.min.y, rect.width(), rect.height()],
885 enabled: true,
886 egui_id,
887 ..Default::default()
888 }
889 }
890
891 fn build(refs: &mut Refs, elements: &[Element]) -> Model {
892 let u = update();
893 let widgets = HashMap::new();
894 let src = Sources {
895 update: Some(&u),
896 elements,
897 widgets: &widgets,
898 screen: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)),
899 exclude: &HashSet::new(),
900 pass: 1,
901 };
902 Model::build(&src, refs)
903 }
904
905 #[test]
906 fn accesskit_and_automation_merge_into_one_tree() {
907 let mut refs = Refs::default();
908 let row = Rect::from_min_max(pos2(0.0, 100.0), pos2(200.0, 122.0));
909 let elements = [
910 element("toolbar.save", "Save", Role::Button, Rect::from_min_max(pos2(10.0, 10.0), pos2(60.0, 30.0)), Some(3)),
911 element("list.row.0", "First", Role::Row, row, None),
912 element("status.text", "Ready", Role::Status, Rect::from_min_max(pos2(0.0, 280.0), pos2(400.0, 300.0)), None),
913 ];
914 let m = build(&mut refs, &elements);
915 let (text, listed) = m.outline(&Outline::default());
916 assert_eq!(
917 text,
918 "- 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"
919 );
920 assert_eq!(listed.len(), 5);
921 let (i, _) = m.outline(&Outline { interactive: true, ..Default::default() });
922 assert!(!i.contains("label"), "{i}");
923 let m2 = build(&mut refs, &elements[..2]);
925 assert_eq!(m2.outline(&Outline::default()).0.lines().next(), Some("- button \"Save\" [ref=e1] [id=toolbar.save] [focused]"));
926 let save = m2.resolve(&Target::Ref(1), &refs, 2).unwrap();
927 assert_eq!(save.point, pos2(35.0, 20.0));
928 let stale = m2.resolve(&Target::Ref(5), &refs, 2).unwrap_err();
929 assert_eq!(stale.detail["kind"], "stale_ref");
930 assert!(stale.error.contains("generation 2"), "{}", stale.error);
931 assert!(m2.resolve(&Target::Ref(999), &refs, 2).is_err());
932 let json = m2.get(0).unwrap().to_json(&m2.nodes);
933 assert_eq!(json["ref"], "e1");
934 assert_eq!(json["rect"], json!([10.0, 10.0, 50.0, 20.0]));
935 }
936
937 #[test]
938 fn targets_resolve_by_id_text_and_point() {
939 let mut refs = Refs::default();
940 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))]);
941 let r = |t: Target| m.resolve(&t, &refs, 1).map(|h| h.node.and_then(|i| m.get(i)).map(|n| n.name.clone()));
942 assert_eq!(r(Target::Id("toolbar.save".into())), Ok(Some("Save".into())));
943 assert_eq!(r(Target::Text { text: "save".into(), role: None }), Ok(Some("Save".into())));
944 assert_eq!(r(Target::Text { text: "notes".into(), role: None }), Ok(Some("3 notes".into())));
945 assert!(r(Target::Text { text: "Save".into(), role: Some("checkbox".into()) }).is_err());
946 assert_eq!(r(Target::Point(pos2(20.0, 20.0))), Ok(Some("Save".into())));
947 assert!(m.shows_text("3 notes"));
948 assert!(!m.shows_text("Hidden"));
949 }
950
951 #[test]
952 fn target_params_parse() {
953 assert_eq!(Target::parse(&json!("@e7")), Some(Target::Ref(7)));
954 assert_eq!(Target::of(&json!({"target": {"ref": "e3"}})), Some(Target::Ref(3)));
955 assert_eq!(Target::of(&json!({"ref": 4})), Some(Target::Ref(4)));
956 assert_eq!(Target::of(&json!({"id": "a.b"})), Some(Target::Id("a.b".into())));
957 assert_eq!(Target::of(&json!({"x": 1, "y": 2})), Some(Target::Point(pos2(1.0, 2.0))));
958 assert_eq!(Target::of(&json!({"text": "typed, not a target"})), None);
959 assert_eq!(
960 Target::of(&json!({"target": {"text": "Save", "role": "button"}})),
961 Some(Target::Text { text: "Save".into(), role: Some("button".into()) })
962 );
963 assert_eq!(Target::parse(&json!({"ref": "x9"})), None);
964 assert_eq!(Target::parse(&json!(5)), None);
965 }
966}