Skip to main content

ashlar_ui/devtools/
model.rs

1//! The element tree the debugger works on: egui's own AccessKit output (every egui widget: role,
2//! label, value, state, bounds) merged with ashlar's [`automation`](crate::automation) registry
3//! (stable dotted ids, component roles, values egui does not expose), with short refs (`e12`) that
4//! stay the same for an element across snapshots.
5
6use 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
15/// Most elements one model holds (a runaway UI must not make a snapshot unbounded).
16pub const MAX_NODES: usize = 50_000;
17/// Deepest nesting followed.
18const MAX_DEPTH: usize = 256;
19/// Refs remembered before numbering starts over (old refs then read as stale).
20const MAX_REFS: usize = 200_000;
21/// Longest name or value shown in the text outline.
22const OUTLINE_TEXT: usize = 80;
23
24/// One element.
25#[derive(Clone, Debug, PartialEq)]
26pub struct Node {
27    /// What the ref is keyed by: the egui `Id` value, or a hash of the automation id.
28    pub key: u64,
29    /// `e<n>`.
30    pub ref_n: u32,
31    /// The ashlar automation id, when the element registered one.
32    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    /// Some part of it is on screen.
41    pub visible: bool,
42    /// The on-screen part, in points (the whole bounds when nothing is visible).
43    pub rect: Rect,
44    /// Clickable, focusable or an input role.
45    pub interactive: bool,
46    /// The egui widget (its `Id` value), for AccessKit actions.
47    pub egui: Option<u64>,
48    pub parent: Option<usize>,
49    pub children: Vec<usize>,
50    pub depth: usize,
51    order: f64,
52    /// A label that only repeats its parent's name (left out of the outline).
53    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    /// Where a click on it lands.
88    pub fn center(&self) -> Pos2 {
89        self.rect.center()
90    }
91
92    /// What `get text` reports: a text field's content, otherwise the name.
93    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
128/// `[x, y, w, h]`, to a tenth of a point.
129pub 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/// Refs handed out so far: the same key always gets the same ref.
135#[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/// The element tree of one frame.
163#[derive(Debug, Default)]
164pub struct Model {
165    pub nodes: Vec<Node>,
166    pub roots: Vec<usize>,
167    /// egui pass it was built from.
168    pub pass: u64,
169}
170
171/// What [`Model::build`] reads.
172pub struct Sources<'a> {
173    pub update: Option<&'a ak::TreeUpdate>,
174    pub elements: &'a [Element],
175    /// Clipped (interact) rect and enabled state of each egui widget, by `Id` value.
176    pub widgets: &'a HashMap<u64, Rect>,
177    pub screen: Rect,
178    /// AccessKit subtrees to leave out (the inspector's own window).
179    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
192/// The role name of an AccessKit role.
193pub 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
239/// The role name of an ashlar component role (`None` for `Other`: keep egui's).
240pub 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
278/// Roles whose `selected` state is a checked state (a toggle, a switch, a radio option).
279fn 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    /// Build the tree; `refs` keeps refs stable across builds.
322    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        // (accesskit id, parent index, depth); children pushed in reverse to visit in order
341        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            // egui labels are selectable text (clickable); that does not make them controls
368            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            // the text run egui puts inside a text field or a label repeats its parent
371            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                    // egui's automatic ids are positional: a widget removed before this one hands
391                    // its id on, so the role is part of what keeps a ref on the same element
392                    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    /// Fold ashlar's registered elements in: onto egui's node for the same widget, or as nodes of
419    /// their own under the element that contains them.
420    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            // its own node, under the deepest node containing it; siblings inside it move under it
453            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    /// egui's tree is flat where ashlar's components draw their own rows: a list, row, dialog…
485    /// that ashlar registered takes the later siblings drawn inside its rect as children.
486    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    /// Children lists (in order) and depths from the parent links.
521    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    /// Nodes in reading order (pre-order), starting at `scope` or the roots.
564    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    /// The smallest visible element under `p`.
587    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    /// Whether any visible element shows `text` (in its name or value).
597    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    /// The text outline (`- button "Save" [ref=e3]`) and the nodes it lists.
602    pub fn outline(&self, opts: &Outline) -> (String, Vec<usize>) {
603        let mut s = String::new();
604        let mut listed = Vec::new();
605        // (node, printed depth)
606        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        // before `link`, nodes from the AccessKit walk are in pre-order: a later container that
634        // contains the rect is nested deeper (or is a later sibling, which is as good a parent)
635        self.order as usize
636    }
637}
638
639/// What a snapshot lists.
640#[derive(Clone, Copy, Debug, Default)]
641pub struct Outline {
642    /// Only clickable / focusable / input elements.
643    pub interactive: bool,
644    /// Leave out unnamed, non-interactive elements and everything off screen.
645    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        // an unnamed drag area or click catcher tells an agent nothing it can act on by name
656        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// ---------------------------------------------------------------------------------------------
723// Targets
724// ---------------------------------------------------------------------------------------------
725
726/// What a request acts on.
727#[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    /// `{ref}` | `{id}` | `{text, role?}` | `{x, y}`, or a bare `"@e3"` / `"e3"` string.
741    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    /// The target of a request: `params.target`, or the target fields given flat in `params`.
758    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    /// Like [`Target::parse`], but `text` alone is not a target here (`ui.fill {text}` is the
766    /// text to type).
767    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/// A target that matched nothing.
786#[derive(Clone, Debug, PartialEq)]
787pub struct Miss {
788    pub error: String,
789    pub detail: Value,
790}
791
792/// What a target resolved to.
793#[derive(Clone, Copy, Debug, PartialEq)]
794pub struct Hit {
795    pub node: Option<usize>,
796    pub point: Pos2,
797}
798
799impl Model {
800    /// Find `t` in this model. `generation` is the latest snapshot's, for the stale-ref error.
801    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    /// The first visible element named `text`: exact, then ignoring case, then containing it;
829    /// interactive ones first within each tier.
830    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        // refs survive a rebuild; a new element gets a new one
944        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}