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::GenericContainer | R::Group => "group",
234        _ => "generic",
235    }
236}
237
238/// The role name of an ashlar component role (`None` for `Other`: keep egui's).
239pub 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    /// Build the tree; `refs` keeps refs stable across builds.
302    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        // (accesskit id, parent index, depth); children pushed in reverse to visit in order
321        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            // egui labels are selectable text (clickable); that does not make them controls
348            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            // the text run egui puts inside a text field or a label repeats its parent
351            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                    // egui's automatic ids are positional: a widget removed before this one hands
371                    // its id on, so the role is part of what keeps a ref on the same element
372                    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    /// Fold ashlar's registered elements in: onto egui's node for the same widget, or as nodes of
399    /// their own under the element that contains them.
400    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            // its own node, under the deepest node containing it; siblings inside it move under it
433            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    /// egui's tree is flat where ashlar's components draw their own rows: a list, row, dialog…
465    /// that ashlar registered takes the later siblings drawn inside its rect as children.
466    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    /// Children lists (in order) and depths from the parent links.
501    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    /// Nodes in reading order (pre-order), starting at `scope` or the roots.
544    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    /// The smallest visible element under `p`.
567    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    /// Whether any visible element shows `text` (in its name or value).
577    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    /// The text outline (`- button "Save" [ref=e3]`) and the nodes it lists.
582    pub fn outline(&self, opts: &Outline) -> (String, Vec<usize>) {
583        let mut s = String::new();
584        let mut listed = Vec::new();
585        // (node, printed depth)
586        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        // before `link`, nodes from the AccessKit walk are in pre-order: a later container that
614        // contains the rect is nested deeper (or is a later sibling, which is as good a parent)
615        self.order as usize
616    }
617}
618
619/// What a snapshot lists.
620#[derive(Clone, Copy, Debug, Default)]
621pub struct Outline {
622    /// Only clickable / focusable / input elements.
623    pub interactive: bool,
624    /// Leave out unnamed, non-interactive elements and everything off screen.
625    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        // an unnamed drag area or click catcher tells an agent nothing it can act on by name
636        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// ---------------------------------------------------------------------------------------------
703// Targets
704// ---------------------------------------------------------------------------------------------
705
706/// What a request acts on.
707#[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    /// `{ref}` | `{id}` | `{text, role?}` | `{x, y}`, or a bare `"@e3"` / `"e3"` string.
721    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    /// The target of a request: `params.target`, or the target fields given flat in `params`.
738    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    /// Like [`Target::parse`], but `text` alone is not a target here (`ui.fill {text}` is the
746    /// text to type).
747    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/// A target that matched nothing.
766#[derive(Clone, Debug, PartialEq)]
767pub struct Miss {
768    pub error: String,
769    pub detail: Value,
770}
771
772/// What a target resolved to.
773#[derive(Clone, Copy, Debug, PartialEq)]
774pub struct Hit {
775    pub node: Option<usize>,
776    pub point: Pos2,
777}
778
779impl Model {
780    /// Find `t` in this model. `generation` is the latest snapshot's, for the stale-ref error.
781    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    /// The first visible element named `text`: exact, then ignoring case, then containing it;
809    /// interactive ones first within each tier.
810    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        // refs survive a rebuild; a new element gets a new one
924        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}