Skip to main content

xa11y_linux/
atspi.rs

1//! Real AT-SPI2 backend implementation using zbus D-Bus bindings.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6
7use rayon::prelude::*;
8use xa11y_core::{
9    ElementData, Error, Provider, Rect, Result, Role, StateSet, Subscription, Toggled,
10};
11use zbus::blocking::{Connection, Proxy};
12
13/// Global handle counter for mapping ElementData back to AccessibleRefs.
14static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
15
16/// Format a normalized state attribute as the same string `xa11y_core::selector::resolve_attr`
17/// would produce, so fast-path matching agrees byte-for-byte with the slow path.
18fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
19    match name {
20        "enabled" => Some(s.enabled.to_string()),
21        "visible" => Some(s.visible.to_string()),
22        "focused" => Some(s.focused.to_string()),
23        "active" => Some(s.active.to_string()),
24        "focusable" => Some(s.focusable.to_string()),
25        "selected" => Some(s.selected.to_string()),
26        "editable" => Some(s.editable.to_string()),
27        "modal" => Some(s.modal.to_string()),
28        "required" => Some(s.required.to_string()),
29        "busy" => Some(s.busy.to_string()),
30        "expanded" => s.expanded.map(|b| b.to_string()),
31        "checked" => s.checked.map(|c| {
32            match c {
33                Toggled::On => "on",
34                Toggled::Off => "off",
35                Toggled::Mixed => "mixed",
36            }
37            .to_string()
38        }),
39        _ => None,
40    }
41}
42
43/// Linux accessibility provider using AT-SPI2 over D-Bus.
44pub struct LinuxProvider {
45    a11y_bus: Connection,
46    /// Cached AT-SPI accessible refs keyed by handle ID.
47    handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
48    /// Cached AT-SPI2 action indices keyed by element handle.
49    /// Maps each action name (snake_case) to the integer index used by `DoAction(i)`.
50    action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
51}
52
53/// AT-SPI2 accessible reference: (bus_name, object_path).
54#[derive(Debug, Clone)]
55pub(crate) struct AccessibleRef {
56    pub(crate) bus_name: String,
57    pub(crate) path: String,
58}
59
60impl LinuxProvider {
61    /// Create a new Linux accessibility provider.
62    ///
63    /// Connects to the AT-SPI2 bus. Falls back to the session bus
64    /// if the dedicated AT-SPI bus is unavailable.
65    pub fn new() -> Result<Self> {
66        let a11y_bus = Self::connect_a11y_bus()?;
67        Ok(Self {
68            a11y_bus,
69            handle_cache: Mutex::new(HashMap::new()),
70            action_indices: Mutex::new(HashMap::new()),
71        })
72    }
73
74    pub(crate) fn connect_a11y_bus() -> Result<Connection> {
75        // Try getting the AT-SPI bus address from the a11y bus launcher,
76        // then connect to it. If that fails, fall back to the session bus
77        // (AT-SPI2 may use the session bus directly).
78        if let Ok(session) = Connection::session() {
79            let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
80                .map_err(|e| Error::Platform {
81                    code: -1,
82                    message: format!("Failed to create a11y bus proxy: {}", e),
83                })?;
84
85            if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
86                if let Ok(address) = addr_reply.body().deserialize::<String>() {
87                    if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
88                        if let Ok(Ok(conn)) =
89                            zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
90                        {
91                            return Ok(conn);
92                        }
93                    }
94                }
95            }
96
97            // Fall back to session bus
98            return Ok(session);
99        }
100
101        Connection::session().map_err(|e| Error::Platform {
102            code: -1,
103            message: format!("Failed to connect to D-Bus session bus: {}", e),
104        })
105    }
106
107    fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
108        // Use uncached proxy to avoid GetAll calls — Qt's AT-SPI adaptor
109        // doesn't support GetAll on all objects, causing spurious errors.
110        zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
111            .destination(bus_name.to_owned())
112            .map_err(|e| Error::Platform {
113                code: -1,
114                message: format!("Failed to set proxy destination: {}", e),
115            })?
116            .path(path.to_owned())
117            .map_err(|e| Error::Platform {
118                code: -1,
119                message: format!("Failed to set proxy path: {}", e),
120            })?
121            .interface(interface.to_owned())
122            .map_err(|e| Error::Platform {
123                code: -1,
124                message: format!("Failed to set proxy interface: {}", e),
125            })?
126            .cache_properties(zbus::proxy::CacheProperties::No)
127            .build()
128            .map_err(|e| Error::Platform {
129                code: -1,
130                message: format!("Failed to create proxy: {}", e),
131            })
132    }
133
134    /// Check whether an accessible object implements a given interface.
135    /// Queries the AT-SPI GetInterfaces method on the Accessible interface.
136    fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
137        let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
138            Ok(p) => p,
139            Err(_) => return false,
140        };
141        let reply = match proxy.call_method("GetInterfaces", &()) {
142            Ok(r) => r,
143            Err(_) => return false,
144        };
145        let interfaces: Vec<String> = match reply.body().deserialize() {
146            Ok(v) => v,
147            Err(_) => return false,
148        };
149        interfaces.iter().any(|i| i.contains(iface))
150    }
151
152    /// Get the numeric AT-SPI role via GetRole method.
153    fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
154        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
155        let reply = proxy
156            .call_method("GetRole", &())
157            .map_err(|e| Error::Platform {
158                code: -1,
159                message: format!("GetRole failed: {}", e),
160            })?;
161        reply
162            .body()
163            .deserialize::<u32>()
164            .map_err(|e| Error::Platform {
165                code: -1,
166                message: format!("GetRole deserialize failed: {}", e),
167            })
168    }
169
170    /// Get the AT-SPI role name string.
171    fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
172        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
173        let reply = proxy
174            .call_method("GetRoleName", &())
175            .map_err(|e| Error::Platform {
176                code: -1,
177                message: format!("GetRoleName failed: {}", e),
178            })?;
179        reply
180            .body()
181            .deserialize::<String>()
182            .map_err(|e| Error::Platform {
183                code: -1,
184                message: format!("GetRoleName deserialize failed: {}", e),
185            })
186    }
187
188    /// Detect Chromium/Electron's "accessibility bridge disabled" signature.
189    ///
190    /// On Linux, Chromium-based apps (Electron, Chrome, VS Code, …) register
191    /// with AT-SPI but expose only an `application → frame` skeleton — the
192    /// frame's children list is empty (just the `/org/a11y/atspi/null`
193    /// sentinel) — unless the process was launched with the
194    /// `--force-renderer-accessibility` flag. Without this detection, callers
195    /// see zero results and assume their selector is wrong.
196    ///
197    /// Call this after observing an empty filtered children list. Returns an
198    /// error only when the parent is a window/frame whose AT-SPI bus reports
199    /// `Application.ToolkitName == "Chromium"`; otherwise returns `Ok(())` so
200    /// genuinely empty windows in other toolkits stay valid.
201    ///
202    /// `role_hint` lets callers that already know the role skip a `GetRole`
203    /// round-trip.
204    fn check_chromium_a11y_enabled(
205        &self,
206        parent: &AccessibleRef,
207        role_hint: Option<Role>,
208    ) -> Result<()> {
209        let app_root = AccessibleRef {
210            bus_name: parent.bus_name.clone(),
211            path: "/org/a11y/atspi/accessible/root".to_string(),
212        };
213        let toolkit = match self
214            .make_proxy(
215                &app_root.bus_name,
216                &app_root.path,
217                "org.a11y.atspi.Application",
218            )
219            .ok()
220            .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
221        {
222            Some(t) => t,
223            None => return Ok(()),
224        };
225        if !toolkit.eq_ignore_ascii_case("Chromium") {
226            return Ok(());
227        }
228        let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
229        if role != Role::Window {
230            return Ok(());
231        }
232        let app_name = self.get_name(&app_root).unwrap_or_default();
233        Err(Error::AccessibilityNotEnabled {
234            app: app_name,
235            instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
236                Relaunch with `--force-renderer-accessibility` (or set the env var \
237                `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
238                .to_string(),
239        })
240    }
241
242    /// Get the name of an accessible element.
243    fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
244        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
245        proxy
246            .get_property::<String>("Name")
247            .map_err(|e| Error::Platform {
248                code: -1,
249                message: format!("Get Name property failed: {}", e),
250            })
251    }
252
253    /// Get the description of an accessible element.
254    fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
255        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
256        proxy
257            .get_property::<String>("Description")
258            .map_err(|e| Error::Platform {
259                code: -1,
260                message: format!("Get Description property failed: {}", e),
261            })
262    }
263
264    /// Get children via the GetChildren method.
265    /// AT-SPI registryd doesn't always implement standard D-Bus Properties,
266    /// so we use GetChildren which is more reliable.
267    fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
268        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
269        let reply = proxy
270            .call_method("GetChildren", &())
271            .map_err(|e| Error::Platform {
272                code: -1,
273                message: format!("GetChildren failed: {}", e),
274            })?;
275        let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
276            reply.body().deserialize().map_err(|e| Error::Platform {
277                code: -1,
278                message: format!("GetChildren deserialize failed: {}", e),
279            })?;
280        Ok(children
281            .into_iter()
282            .map(|(bus_name, path)| AccessibleRef {
283                bus_name,
284                path: path.to_string(),
285            })
286            .collect())
287    }
288
289    /// Get the state set as raw u32 values.
290    fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
291        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
292        let reply = proxy
293            .call_method("GetState", &())
294            .map_err(|e| Error::Platform {
295                code: -1,
296                message: format!("GetState failed: {}", e),
297            })?;
298        reply
299            .body()
300            .deserialize::<Vec<u32>>()
301            .map_err(|e| Error::Platform {
302                code: -1,
303                message: format!("GetState deserialize failed: {}", e),
304            })
305    }
306
307    /// Return true if the element reports the AT-SPI MULTI_LINE state.
308    /// Used to distinguish multi-line text areas (TextArea) from single-line
309    /// text inputs (TextField), since both use the AT-SPI "text" role name.
310    /// Note: Qt's AT-SPI bridge does not reliably set SINGLE_LINE, so we
311    /// check MULTI_LINE and default to TextField when neither is set.
312    fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
313        let state_bits = self.get_state(aref).unwrap_or_default();
314        let bits: u64 = if state_bits.len() >= 2 {
315            (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
316        } else if state_bits.len() == 1 {
317            state_bits[0] as u64
318        } else {
319            0
320        };
321        // ATSPI_STATE_MULTI_LINE = 17 in AtspiStateType enum
322        const MULTI_LINE: u64 = 1 << 17;
323        (bits & MULTI_LINE) != 0
324    }
325
326    /// Resolve TextField vs TextArea using the MULTI_LINE state, regardless of
327    /// the coarse role we started with. Different toolkits expose text inputs
328    /// under different AT-SPI roles ("text", "textbox", "entry", "embedded",
329    /// numeric 61/78/79), and WebKitGTK has changed mapping between versions
330    /// (e.g. 2.50 → 2.52 swapped what role textarea reports). MULTI_LINE is
331    /// the cross-toolkit invariant, so we let it decide.
332    ///
333    /// For any other coarse role, returns it unchanged.
334    fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
335        match coarse {
336            Role::TextArea | Role::TextField => {
337                if self.is_multi_line(aref) {
338                    Role::TextArea
339                } else {
340                    Role::TextField
341                }
342            }
343            other => other,
344        }
345    }
346
347    /// Get bounds via Component interface.
348    /// Checks for Component support first to avoid GTK CRITICAL warnings
349    /// on objects (e.g. TreeView cell renderers) that don't implement it.
350    fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
351        if !self.has_interface(aref, "Component") {
352            return None;
353        }
354        let proxy = self
355            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
356            .ok()?;
357        // GetExtents(coord_type: u32) -> (x, y, width, height)
358        // coord_type 0 = screen coordinates
359        let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
360        let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
361        if w <= 0 && h <= 0 {
362            return None;
363        }
364        // AT-SPI screen extents are physical device pixels. Convert to logical
365        // coordinates (the cross-platform contract, same space as the
366        // screenshot/input layers) using the detected display scale. On the
367        // common 1.0 case this is a no-op. See `crate::scale`.
368        Some(
369            Rect {
370                x,
371                y,
372                width: w.max(0) as u32,
373                height: h.max(0) as u32,
374            }
375            .to_logical(crate::scale::coordinate_scale()),
376        )
377    }
378
379    /// Get available actions via Action interface, returning both the action list
380    /// and a map of each action name to its AT-SPI2 integer index for direct `DoAction(i)`.
381    ///
382    /// Probes the interface directly rather than relying on the Interfaces property,
383    /// which some AT-SPI adapters (e.g. AccessKit) don't expose.
384    fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
385        let mut actions = Vec::new();
386        let mut indices = HashMap::new();
387
388        // Try Action interface directly
389        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
390            // NActions may be returned as i32 or u32 depending on AT-SPI implementation.
391            let n_actions = proxy
392                .get_property::<i32>("NActions")
393                .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
394                .unwrap_or(0);
395            for i in 0..n_actions {
396                if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
397                    if let Ok(name) = reply.body().deserialize::<String>() {
398                        if let Some(action_name) = map_atspi_action_name(&name) {
399                            if !actions.contains(&action_name) {
400                                indices.insert(action_name.clone(), i);
401                                actions.push(action_name);
402                            }
403                        }
404                    }
405                }
406            }
407        }
408
409        // NOTE: We do NOT add implicit "focus" based on Component interface existence.
410        // On GTK4, GrabFocus() often returns false even when Component interface exists,
411        // violating design tenet 3 ("if action is listed, calling it must work").
412        // Only report "focus" if it's explicitly in the AT-SPI Action interface with a
413        // proper index. This ensures focus() will work when reported.
414        // Fixes GitHub issue #98.
415
416        (actions, indices)
417    }
418
419    /// Return true when the accessible's application identifies itself as GTK
420    /// via `org.a11y.atspi.Application.ToolkitName`.
421    ///
422    /// Used to scope the press-fallback heuristic for the
423    /// AdwMenuButton/GtkMenuButton wrapper pattern; other toolkits are
424    /// unaffected.
425    fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
426        let app_root = AccessibleRef {
427            bus_name: aref.bus_name.clone(),
428            path: "/org/a11y/atspi/accessible/root".to_string(),
429        };
430        self.make_proxy(
431            &app_root.bus_name,
432            &app_root.path,
433            "org.a11y.atspi.Application",
434        )
435        .ok()
436        .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
437        .map(|t| t.eq_ignore_ascii_case("GTK"))
438        .unwrap_or(false)
439    }
440
441    /// GTK press-fallback resolver.
442    ///
443    /// Walks the accessible's descendants (BFS, depth-capped) looking for a
444    /// single activatable child that shares the outer widget's name. Returns
445    /// the (ref, action_index) pair when exactly one suitable candidate
446    /// exists at the shallowest matching depth. Returns `None` when the
447    /// subtree contains nothing suitable or multiple equally plausible
448    /// candidates — refusing to guess.
449    ///
450    /// Empirically fixes the `GtkMenuButton` / `AdwMenuButton` wrappers that
451    /// ship in every stock GNOME app (Calculator, Text Editor, Logs, Clocks,
452    /// Characters, …).
453    fn find_gtk_press_fallback(
454        &self,
455        outer: &AccessibleRef,
456        outer_name: &str,
457    ) -> Option<(AccessibleRef, i32)> {
458        let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
459        queue.push_back((outer.clone(), 0));
460        let mut visited: usize = 0;
461        let mut shallowest_depth: Option<u32> = None;
462        let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
463
464        while let Some((node, depth)) = queue.pop_front() {
465            // Once we've found the shallowest level with hits, don't look deeper.
466            if let Some(best) = shallowest_depth {
467                if depth > best {
468                    continue;
469                }
470            }
471            if visited > GTK_FALLBACK_MAX_NODES {
472                break;
473            }
474
475            let role_name = if depth == 0 {
476                String::new()
477            } else {
478                visited += 1;
479                self.get_role_name(&node).unwrap_or_default().to_lowercase()
480            };
481
482            if depth > 0 && is_actionable_atspi_role(&role_name) {
483                if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
484                    match shallowest_depth {
485                        Some(d) if depth < d => {
486                            shallowest_depth = Some(depth);
487                            hits.clear();
488                            hits.push((node.clone(), idx));
489                        }
490                        Some(d) if depth == d => hits.push((node.clone(), idx)),
491                        Some(_) => {}
492                        None => {
493                            shallowest_depth = Some(depth);
494                            hits.push((node.clone(), idx));
495                        }
496                    }
497                }
498            }
499
500            // Do not descend into static/decorative roles. Descend through
501            // containers and actionable roles alike (actionable roles may
502            // themselves wrap an inner actionable — e.g. an AdwSplitButton's
503            // primary button inside a toggle-button shell).
504            let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
505                || (depth > 0 && is_never_descend_atspi_role(&role_name));
506            if stop_descending {
507                continue;
508            }
509            if let Ok(children) = self.get_atspi_children(&node) {
510                for c in children {
511                    queue.push_back((c, depth + 1));
512                }
513            }
514        }
515
516        if hits.len() == 1 {
517            Some(hits.into_iter().next().unwrap())
518        } else {
519            None
520        }
521    }
522
523    /// Per-node filter for `find_gtk_press_fallback`. Returns the AT-SPI
524    /// action index to invoke when `aref` is a valid fallback candidate.
525    fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
526        let (_, index_map) = self.get_actions(aref);
527        let idx = *index_map.get("press")?;
528        if !self.is_showing_visible_sensitive(aref) {
529            return None;
530        }
531        // If the outer has a name, require the candidate to share it. Rules
532        // out unrelated suffix widgets (e.g. a switch inside an AdwActionRow
533        // that happens to expose `click`).
534        if !outer_name.is_empty() {
535            let inner_name = self.get_name(aref).unwrap_or_default();
536            if !inner_name.is_empty() && inner_name != outer_name {
537                return None;
538            }
539        }
540        Some(idx)
541    }
542
543    /// True when the accessible's state has SHOWING, VISIBLE, and SENSITIVE
544    /// set. ENABLED is deliberately excluded: in GTK4 it reflects "has an
545    /// enabled GAction bound", which is false for the exact widgets we
546    /// rescue.
547    fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
548        let raw = self.get_state(aref).unwrap_or_default();
549        let bits: u64 = if raw.len() >= 2 {
550            (raw[0] as u64) | ((raw[1] as u64) << 32)
551        } else if raw.len() == 1 {
552            raw[0] as u64
553        } else {
554            0
555        };
556        const SENSITIVE: u64 = 1 << 24;
557        const SHOWING: u64 = 1 << 25;
558        const VISIBLE: u64 = 1 << 30;
559        (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
560    }
561
562    /// Get value via Value or Text interface.
563    /// Probes interfaces directly rather than relying on the Interfaces property.
564    fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
565        // Try Text interface first for text content (text fields, labels, combo boxes).
566        // This must come before Value because some AT-SPI adapters (e.g. AccessKit)
567        // may expose both interfaces, and Value.CurrentValue returns 0.0 for text elements.
568        let text_value = self.get_text_content(aref);
569        if text_value.is_some() {
570            return text_value;
571        }
572        // Try Value interface (sliders, progress bars, scroll bars, spinners)
573        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
574            if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
575                return Some(val.to_string());
576            }
577        }
578        None
579    }
580
581    /// Read text content via the AT-SPI Text interface.
582    fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
583        let proxy = self
584            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
585            .ok()?;
586        let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
587        if char_count > 0 {
588            let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
589            let text: String = reply.body().deserialize().ok()?;
590            if !text.is_empty() {
591                return Some(text);
592            }
593        }
594        None
595    }
596
597    /// Cache an AccessibleRef and return a new handle ID.
598    fn cache_element(&self, aref: AccessibleRef) -> u64 {
599        let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
600        self.handle_cache
601            .lock()
602            .unwrap_or_else(|e| e.into_inner())
603            .insert(handle, aref);
604        handle
605    }
606
607    /// Look up a cached AccessibleRef by handle.
608    fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
609        self.handle_cache
610            .lock()
611            .unwrap_or_else(|e| e.into_inner())
612            .get(&handle)
613            .cloned()
614            .ok_or(Error::ElementStale {
615                selector: format!("handle:{}", handle),
616            })
617    }
618
619    /// Build an ElementData from an AccessibleRef, caching the ref for later lookup.
620    ///
621    /// After resolving the role (1-3 sequential D-Bus calls), all remaining
622    /// property fetches are independent and run in parallel via rayon::join.
623    fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
624        let role_name = self.get_role_name(aref).unwrap_or_default();
625        let role_num = self.get_role_number(aref).unwrap_or(0);
626        let role = {
627            let by_name = if !role_name.is_empty() {
628                map_atspi_role(&role_name)
629            } else {
630                Role::Unknown
631            };
632            let coarse = if by_name != Role::Unknown {
633                by_name
634            } else {
635                map_atspi_role_number(role_num)
636            };
637            self.refine_text_role(coarse, aref)
638        };
639
640        // Fetch all independent properties in parallel.
641        // Left tree: (name+value, description)
642        // Right tree: ((states, bounds), (actions, numeric_values))
643        let (
644            ((mut name, value), description),
645            (
646                ((states, state_bits), bounds),
647                ((actions, action_index_map), (numeric_value, min_value, max_value)),
648            ),
649        ) = rayon::join(
650            || {
651                rayon::join(
652                    || {
653                        let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
654                        let value = if role_has_value(role) {
655                            self.get_value(aref)
656                        } else {
657                            None
658                        };
659                        (name, value)
660                    },
661                    || self.get_description(aref).ok().filter(|s| !s.is_empty()),
662                )
663            },
664            || {
665                rayon::join(
666                    || {
667                        rayon::join(
668                            || self.parse_states_with_bits(aref, role),
669                            || {
670                                if role != Role::Application {
671                                    self.get_extents(aref)
672                                } else {
673                                    None
674                                }
675                            },
676                        )
677                    },
678                    || {
679                        rayon::join(
680                            || {
681                                if role_has_actions(role) {
682                                    self.get_actions(aref)
683                                } else {
684                                    (vec![], HashMap::new())
685                                }
686                            },
687                            || {
688                                if matches!(
689                                    role,
690                                    Role::Slider
691                                        | Role::ProgressBar
692                                        | Role::ScrollBar
693                                        | Role::SpinButton
694                                ) {
695                                    if let Ok(proxy) = self.make_proxy(
696                                        &aref.bus_name,
697                                        &aref.path,
698                                        "org.a11y.atspi.Value",
699                                    ) {
700                                        (
701                                            proxy.get_property::<f64>("CurrentValue").ok(),
702                                            proxy.get_property::<f64>("MinimumValue").ok(),
703                                            proxy.get_property::<f64>("MaximumValue").ok(),
704                                        )
705                                    } else {
706                                        (None, None, None)
707                                    }
708                                } else {
709                                    (None, None, None)
710                                }
711                            },
712                        )
713                    },
714                )
715            },
716        );
717
718        // For label/static text elements, AT-SPI may put content in the Text
719        // interface (returned as value) rather than the Name property.
720        if name.is_none() && role == Role::StaticText {
721            if let Some(ref v) = value {
722                name = Some(v.clone());
723            }
724        }
725
726        let raw = {
727            let raw_role = if role_name.is_empty() {
728                format!("role_num:{}", role_num)
729            } else {
730                role_name
731            };
732            let mut raw = HashMap::new();
733            raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
734            raw.insert(
735                "bus_name".into(),
736                serde_json::Value::String(aref.bus_name.clone()),
737            );
738            raw.insert(
739                "object_path".into(),
740                serde_json::Value::String(aref.path.clone()),
741            );
742            // Preserve unstripped originals so callers who need bidi marks can
743            // recover them after the strip below.
744            if let Some(ref n) = name {
745                raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
746            }
747            if let Some(ref v) = value {
748                raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
749            }
750            if let Some(ref d) = description {
751                raw.insert(
752                    "atspi_description".into(),
753                    serde_json::Value::String(d.clone()),
754                );
755            }
756            // DEFUNCT (bit 6) marks a dead accessible — the object was
757            // destroyed provider-side but a reference still resolves. It has
758            // no cross-platform StateSet field, but it's the key diagnostic
759            // for stale-reference bugs (e.g. a churning WebKit tree), so
760            // surface it whenever set.
761            if (state_bits & (1 << 6)) != 0 {
762                raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
763            }
764            raw
765        };
766
767        // Strip Unicode bidi format controls. RTL apps on Linux embed LRM/RLM
768        // marks into reported strings; the originals are preserved in `raw`.
769        let name = xa11y_core::text::strip_bidi_opt(name);
770        let value = xa11y_core::text::strip_bidi_opt(value);
771        let description = xa11y_core::text::strip_bidi_opt(description);
772
773        let handle = self.cache_element(aref.clone());
774        if !action_index_map.is_empty() {
775            self.action_indices
776                .lock()
777                .unwrap_or_else(|e| e.into_inner())
778                .insert(handle, action_index_map);
779        }
780
781        ElementData {
782            role,
783            name,
784            value,
785            description,
786            bounds,
787            actions,
788            states,
789            numeric_value,
790            min_value,
791            max_value,
792            pid,
793            stable_id: Some(aref.path.clone()),
794            raw,
795            handle,
796        }
797    }
798
799    /// Get the AT-SPI parent of an accessible ref.
800    fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
801        // Read the Parent property via the D-Bus Properties interface.
802        let proxy = self.make_proxy(
803            &aref.bus_name,
804            &aref.path,
805            "org.freedesktop.DBus.Properties",
806        )?;
807        let reply = proxy
808            .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
809            .map_err(|e| Error::Platform {
810                code: -1,
811                message: format!("Get Parent property failed: {}", e),
812            })?;
813        // The reply is a Variant containing (so) — a struct of (bus_name, object_path)
814        let variant: zbus::zvariant::OwnedValue =
815            reply.body().deserialize().map_err(|e| Error::Platform {
816                code: -1,
817                message: format!("Parent deserialize variant failed: {}", e),
818            })?;
819        let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
820            zbus::zvariant::Value::from(variant).try_into().map_err(
821                |e: zbus::zvariant::Error| Error::Platform {
822                    code: -1,
823                    message: format!("Parent deserialize struct failed: {}", e),
824                },
825            )?;
826        let path_str = path.as_str();
827        if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
828            return Ok(None);
829        }
830        // If the parent is the registry root, this is a top-level app — no parent
831        if path_str == "/org/a11y/atspi/accessible/root" {
832            return Ok(None);
833        }
834        Ok(Some(AccessibleRef {
835            bus_name: bus,
836            path: path_str.to_string(),
837        }))
838    }
839
840    /// Fetch an accessible's AT-SPI2 state bitfield as a single `u64`.
841    ///
842    /// AT-SPI2 returns states as two `u32`s (low/high words); collapse them
843    /// into one mask so callers can test bit positions directly.
844    fn state_bits(&self, aref: &AccessibleRef) -> u64 {
845        let state_bits = self.get_state(aref).unwrap_or_default();
846        if state_bits.len() >= 2 {
847            (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
848        } else if state_bits.len() == 1 {
849            state_bits[0] as u64
850        } else {
851            0
852        }
853    }
854
855    /// Parse AT-SPI2 state bitfield into xa11y StateSet.
856    fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
857        self.parse_states_with_bits(aref, role).0
858    }
859
860    /// Like [`parse_states`], but also returns the raw AT-SPI state bitset so
861    /// callers can surface provider-only facts (notably DEFUNCT) that have no
862    /// cross-platform `StateSet` field.
863    fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
864        let bits = self.state_bits(aref);
865
866        // AT-SPI2 state bit positions (AtspiStateType enum values)
867        const ACTIVE: u64 = 1 << 1;
868        const BUSY: u64 = 1 << 3;
869        const CHECKED: u64 = 1 << 4;
870        const EDITABLE: u64 = 1 << 7;
871        const ENABLED: u64 = 1 << 8;
872        const EXPANDABLE: u64 = 1 << 9;
873        const EXPANDED: u64 = 1 << 10;
874        const FOCUSABLE: u64 = 1 << 11;
875        const FOCUSED: u64 = 1 << 12;
876        const MODAL: u64 = 1 << 16;
877        const SELECTED: u64 = 1 << 23;
878        const SENSITIVE: u64 = 1 << 24;
879        const SHOWING: u64 = 1 << 25;
880        const VISIBLE: u64 = 1 << 30;
881        const INDETERMINATE: u64 = 1 << 32;
882        const REQUIRED: u64 = 1 << 33;
883
884        let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
885        let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
886
887        let checked = match role {
888            Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
889                if (bits & INDETERMINATE) != 0 {
890                    Some(Toggled::Mixed)
891                } else if (bits & CHECKED) != 0 {
892                    Some(Toggled::On)
893                } else {
894                    Some(Toggled::Off)
895                }
896            }
897            _ => None,
898        };
899
900        let expanded = if (bits & EXPANDABLE) != 0 {
901            Some((bits & EXPANDED) != 0)
902        } else {
903            None
904        };
905
906        let mut states = StateSet::default();
907        states.enabled = enabled;
908        states.visible = visible;
909        states.focused = (bits & FOCUSED) != 0;
910        // AT-SPI `ACTIVE` marks the foreground window/frame (see
911        // `focused_app`, which relies on the same bit).
912        states.active = (bits & ACTIVE) != 0;
913        states.checked = checked;
914        states.selected = (bits & SELECTED) != 0;
915        states.expanded = expanded;
916        states.editable = (bits & EDITABLE) != 0;
917        states.focusable = (bits & FOCUSABLE) != 0;
918        states.modal = (bits & MODAL) != 0;
919        states.required = (bits & REQUIRED) != 0;
920        states.busy = (bits & BUSY) != 0;
921        (states, bits)
922    }
923
924    /// Find an application by PID.
925    ///
926    /// Used by `subscribe` to resolve the target app's D-Bus sender name so
927    /// signal match rules can be scoped to it.
928    pub(crate) fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
929        let registry = AccessibleRef {
930            bus_name: "org.a11y.atspi.Registry".to_string(),
931            path: "/org/a11y/atspi/accessible/root".to_string(),
932        };
933        let children = self.get_atspi_children(&registry)?;
934
935        for child in &children {
936            if child.path == "/org/a11y/atspi/null" {
937                continue;
938            }
939            // Prefer D-Bus connection PID — it's authoritative for the
940            // process owning the bus connection. Application.Id is *not* a
941            // process pid: AT-SPI assigns it as a registry-local index
942            // (1, 2, 3, …) for some bridges (notably GTK4), so matching
943            // against Application.Id alone misses real processes.
944            if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
945                if app_pid == pid {
946                    return Ok(child.clone());
947                }
948            }
949            // Fall back to Application.Id for adapters that do set it to pid.
950            if let Ok(proxy) =
951                self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
952            {
953                if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
954                    if app_pid as u32 == pid {
955                        return Ok(child.clone());
956                    }
957                }
958            }
959        }
960
961        Err(Error::Platform {
962            code: -1,
963            message: format!("No application found with PID {}", pid),
964        })
965    }
966
967    /// Get PID via D-Bus GetConnectionUnixProcessID.
968    fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
969        let proxy = self
970            .make_proxy(
971                "org.freedesktop.DBus",
972                "/org/freedesktop/DBus",
973                "org.freedesktop.DBus",
974            )
975            .ok()?;
976        let reply = proxy
977            .call_method("GetConnectionUnixProcessID", &(bus_name,))
978            .ok()?;
979        let pid: u32 = reply.body().deserialize().ok()?;
980        if pid > 0 {
981            Some(pid)
982        } else {
983            None
984        }
985    }
986
987    /// Perform an AT-SPI2 action by its integer index (from discovery).
988    fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
989        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
990        proxy
991            .call_method("DoAction", &(index,))
992            .map_err(|e| Error::Platform {
993                code: -1,
994                message: format!("DoAction({}) failed: {}", index, e),
995            })?;
996        Ok(())
997    }
998
999    /// Look up the stored AT-SPI2 action index for the given element and action.
1000    fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1001        self.action_indices
1002            .lock()
1003            .unwrap_or_else(|e| e.into_inner())
1004            .get(&handle)
1005            .and_then(|map| map.get(action).copied())
1006            .ok_or_else(|| Error::ActionNotSupported {
1007                action: action.to_string(),
1008                role: Role::Unknown, // caller will provide better context
1009            })
1010    }
1011
1012    /// Get the process PID for an AT-SPI accessible. Prefers the D-Bus
1013    /// connection PID over `Application.Id`: the latter is a registry-local
1014    /// index (1, 2, 3, …) on some bridges (e.g. GTK4), not a real pid, so
1015    /// reading it first produces apps whose `pid` property doesn't match the
1016    /// process the user launched.
1017    fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1018        if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1019            return Some(pid);
1020        }
1021
1022        // Fall back to Application.Id for adapters that set it to pid.
1023        // Require that a process with that id actually exists — a registry
1024        // index from a misbehaving bridge usually names a long-dead or absurd
1025        // pid. This is only a cheap sanity filter: a low index can still
1026        // collide with a real process (1 = init), which is why the D-Bus
1027        // connection pid above is authoritative whenever available.
1028        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1029        {
1030            if let Ok(pid) = proxy.get_property::<i32>("Id") {
1031                if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1032                    return Some(pid as u32);
1033                }
1034            }
1035        }
1036
1037        None
1038    }
1039
1040    /// Resolve the mapped Role for an accessible ref (1-3 D-Bus calls).
1041    fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1042        let role_name = self.get_role_name(aref).unwrap_or_default();
1043        let by_name = if !role_name.is_empty() {
1044            map_atspi_role(&role_name)
1045        } else {
1046            Role::Unknown
1047        };
1048        let coarse = if by_name != Role::Unknown {
1049            by_name
1050        } else {
1051            // Unmapped or missing role name — fall back to numeric role.
1052            let role_num = self.get_role_number(aref).unwrap_or(0);
1053            map_atspi_role_number(role_num)
1054        };
1055        self.refine_text_role(coarse, aref)
1056    }
1057
1058    /// Check if an accessible ref matches a simple selector, fetching only the
1059    /// attributes the selector actually requires.
1060    ///
1061    /// Filter routing (cheapest first):
1062    ///   * `role` / `name` / `value` / `description` — single targeted D-Bus
1063    ///     call against this accessible.
1064    ///   * Normalized state attrs (`enabled`, `checked`, `focused`, …) —
1065    ///     one shared `GetState` call, cached for the rest of this match.
1066    ///   * Anything else (custom `raw` keys, `bounds`, numeric values) — fall
1067    ///     through to building a full `ElementData` and delegating to
1068    ///     [`xa11y_core::selector::matches_simple`]. This is slower but keeps
1069    ///     selectors with rare attribute filters semantically equivalent to
1070    ///     the default tree-traversal path.
1071    fn matches_ref(
1072        &self,
1073        aref: &AccessibleRef,
1074        simple: &xa11y_core::selector::SimpleSelector,
1075    ) -> bool {
1076        // Resolve role only if the selector needs it (for either the role
1077        // segment or any role/checked filter — checked depends on role).
1078        let needs_role = simple.role.is_some()
1079            || simple
1080                .filters
1081                .iter()
1082                .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1083        let role = if needs_role {
1084            Some(self.resolve_role(aref))
1085        } else {
1086            None
1087        };
1088
1089        if let Some(ref role_match) = simple.role {
1090            match role_match {
1091                xa11y_core::selector::RoleMatch::Normalized(expected) => {
1092                    if role != Some(*expected) {
1093                        return false;
1094                    }
1095                }
1096                xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1097                    let raw_role = self.get_role_name(aref).unwrap_or_default();
1098                    if raw_role != *platform_role {
1099                        return false;
1100                    }
1101                }
1102            }
1103        }
1104
1105        let mut state_set: Option<StateSet> = None;
1106
1107        for filter in &simple.filters {
1108            let attr = filter.attr.as_str();
1109            let resolved: Option<Option<String>> = match attr {
1110                "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1111                "name" => {
1112                    let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1113                    // Mirror build_element_data: StaticText carries its name
1114                    // in the Text interface's Value when Name is empty.
1115                    let resolved = if name.is_none() && role == Some(Role::StaticText) {
1116                        self.get_value(aref)
1117                    } else {
1118                        name
1119                    };
1120                    Some(resolved)
1121                }
1122                "value" => Some(self.get_value(aref)),
1123                "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1124                "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1125                | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1126                    let s = state_set.get_or_insert_with(|| {
1127                        // `parse_states` reads `checked` based on role; pass
1128                        // whatever we already resolved (Unknown is a no-op for
1129                        // the role-gated `checked` mapping).
1130                        self.parse_states(aref, role.unwrap_or(Role::Unknown))
1131                    });
1132                    Some(state_attr_to_string(attr, s))
1133                }
1134                _ => None, // Routed through full ElementData below.
1135            };
1136
1137            match resolved {
1138                Some(value) => {
1139                    if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1140                    {
1141                        return false;
1142                    }
1143                }
1144                None => {
1145                    // Filter targets an attribute the fast path doesn't know
1146                    // (e.g. `bounds`, `numeric_value`, a custom `raw` key).
1147                    // Build the full ElementData once and let the shared
1148                    // matcher handle every remaining filter — it dispatches
1149                    // to `ElementData` fields and the `raw` map identically
1150                    // to the default tree-traversal path.
1151                    let pid = None; // pid isn't selector-addressable
1152                    let data = self.build_element_data(aref, pid);
1153                    return xa11y_core::selector::matches_simple(&data, simple);
1154                }
1155            }
1156        }
1157
1158        true
1159    }
1160
1161    /// ONE DFS over the subtree, evaluating each clause's first SimpleSelector
1162    /// against every visited node. Emits `(clause_idx, AccessibleRef)` pairs
1163    /// in document order; a node that matches multiple clauses is emitted
1164    /// once per matching clause (the merge step in `find_elements_group`
1165    /// collapses these by `(bus_name, path)` identity).
1166    ///
1167    /// This is the "push-down to the platform query per group" core: a
1168    /// selector group like `button, text_field` traverses the AT-SPI tree
1169    /// exactly once regardless of clause count, instead of N separate walks.
1170    fn collect_matching_refs_group(
1171        &self,
1172        parent: &AccessibleRef,
1173        clauses: &[&xa11y_core::selector::SimpleSelector],
1174        depth: u32,
1175        max_depth: u32,
1176        limit: Option<usize>,
1177    ) -> Result<Vec<(usize, AccessibleRef)>> {
1178        if depth > max_depth {
1179            return Ok(vec![]);
1180        }
1181
1182        let children = self.get_atspi_children(parent)?;
1183
1184        // Filter invalid refs. Application-node flattening (collapsing a
1185        // redundant `application` accessible into its grandchildren) is only
1186        // valid when the parent is itself an application — i.e. we have already
1187        // descended past the registry. At the registry root, the children *are*
1188        // the applications and must not be collapsed, otherwise selectors like
1189        // `application` (used by `App::list` / `App::by_name`) match nothing
1190        // because the app accessibles get dissolved into their windows.
1191        let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1192        let mut to_search: Vec<AccessibleRef> = Vec::new();
1193        for child in children {
1194            if child.path == "/org/a11y/atspi/null"
1195                || child.bus_name.is_empty()
1196                || child.path.is_empty()
1197            {
1198                continue;
1199            }
1200
1201            if !parent_is_registry {
1202                let child_role = self.get_role_name(&child).unwrap_or_default();
1203                if child_role == "application" {
1204                    let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1205                    for gc in grandchildren {
1206                        if gc.path == "/org/a11y/atspi/null"
1207                            || gc.bus_name.is_empty()
1208                            || gc.path.is_empty()
1209                        {
1210                            continue;
1211                        }
1212                        let gc_role = self.get_role_name(&gc).unwrap_or_default();
1213                        if gc_role == "application" {
1214                            continue;
1215                        }
1216                        to_search.push(gc);
1217                    }
1218                    continue;
1219                }
1220            }
1221            to_search.push(child);
1222        }
1223
1224        // Detect Chromium/Electron's empty-tree signature whenever we
1225        // descend into a parent and find no real children.
1226        if to_search.is_empty() {
1227            self.check_chromium_a11y_enabled(parent, None)?;
1228        }
1229
1230        // Process each child subtree in parallel: check match against every
1231        // clause + recurse. Tagging each match with `clause_idx` lets the
1232        // caller route phase-2 narrowing to the right clause without
1233        // re-running the first-segment match.
1234        //
1235        // We deliberately swallow transient sibling errors here — a single
1236        // flaky D-Bus call on one child shouldn't fail the whole locator
1237        // query (the rest of this file is similarly tolerant via
1238        // `unwrap_or_default()`). But `AccessibilityNotEnabled` is *not* a
1239        // transient error: it's the signal that a Chromium renderer bridge
1240        // isn't initialised, and callers need to see it. So we propagate
1241        // that variant specifically and keep tolerating everything else.
1242        // Per-child batches: matches collected during that child's subtree
1243        // walk, plus the first AccessibilityNotEnabled error (if any).
1244        type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1245        let per_child: Vec<ChildBatch> = to_search
1246            .par_iter()
1247            .map(|child| {
1248                let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1249                for (idx, simple) in clauses.iter().enumerate() {
1250                    if self.matches_ref(child, simple) {
1251                        child_results.push((idx, child.clone()));
1252                    }
1253                }
1254                match self.collect_matching_refs_group(child, clauses, depth + 1, max_depth, limit)
1255                {
1256                    Ok(sub) => {
1257                        child_results.extend(sub);
1258                        (child_results, None)
1259                    }
1260                    Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1261                    Err(_) => (child_results, None),
1262                }
1263            })
1264            .collect();
1265
1266        // Merge results, respecting limit. The first AccessibilityNotEnabled
1267        // error seen wins — any child subtree raising it means the whole
1268        // query is untrustworthy, so surface it rather than return partial
1269        // data.
1270        //
1271        // Note: `limit` is the *caller's* outer limit; for a multi-clause
1272        // group it must not be applied per-clause (a low-priority clause's
1273        // hit could come before a high-priority clause's in doc order, so
1274        // truncating mid-walk would drop legitimate matches). Callers pass
1275        // `None` for multi-clause queries and apply the limit after the
1276        // cross-clause merge.
1277        let mut results = Vec::new();
1278        for (batch, maybe_err) in per_child {
1279            if let Some(err) = maybe_err {
1280                return Err(err);
1281            }
1282            for r in batch {
1283                results.push(r);
1284                if let Some(limit) = limit {
1285                    if results.len() >= limit {
1286                        return Ok(results);
1287                    }
1288                }
1289            }
1290        }
1291        Ok(results)
1292    }
1293}
1294
1295impl Provider for LinuxProvider {
1296    fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1297        match element {
1298            None => {
1299                // Top-level: delegate to `list_apps()`, the canonical
1300                // discovery primitive.
1301                self.list_apps()
1302            }
1303            Some(element_data) => {
1304                let aref = self.get_cached(element_data.handle)?;
1305                let children = self.get_atspi_children(&aref).unwrap_or_default();
1306                let pid = element_data.pid;
1307
1308                // Pre-filter invalid refs and flatten nested application nodes,
1309                // collecting the concrete refs to build in parallel.
1310                let mut to_build: Vec<AccessibleRef> = Vec::new();
1311                for child_ref in &children {
1312                    if child_ref.path == "/org/a11y/atspi/null"
1313                        || child_ref.bus_name.is_empty()
1314                        || child_ref.path.is_empty()
1315                    {
1316                        continue;
1317                    }
1318                    let child_role = self.get_role_name(child_ref).unwrap_or_default();
1319                    if child_role == "application" {
1320                        let grandchildren = self.get_atspi_children(child_ref).unwrap_or_default();
1321                        for gc_ref in grandchildren {
1322                            if gc_ref.path == "/org/a11y/atspi/null"
1323                                || gc_ref.bus_name.is_empty()
1324                                || gc_ref.path.is_empty()
1325                            {
1326                                continue;
1327                            }
1328                            let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1329                            if gc_role == "application" {
1330                                continue;
1331                            }
1332                            to_build.push(gc_ref);
1333                        }
1334                        continue;
1335                    }
1336                    to_build.push(child_ref.clone());
1337                }
1338
1339                if to_build.is_empty() {
1340                    self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1341                }
1342
1343                let results: Vec<ElementData> = to_build
1344                    .par_iter()
1345                    .map(|r| self.build_element_data(r, pid))
1346                    .collect();
1347
1348                Ok(results)
1349            }
1350        }
1351    }
1352
1353    fn find_elements_group(
1354        &self,
1355        root: &ElementData,
1356        group: &xa11y_core::selector::SelectorGroup,
1357        limit: Option<usize>,
1358        max_depth: Option<u32>,
1359    ) -> Result<Vec<ElementData>> {
1360        if group.clauses.is_empty() {
1361            return Ok(vec![]);
1362        }
1363        // Reject any clause with zero segments early — `clause.segments[0]`
1364        // below would otherwise panic.
1365        if group.clauses.iter().any(|c| c.segments.is_empty()) {
1366            return Ok(vec![]);
1367        }
1368
1369        // ONE AT-SPI walk that evaluates every clause's first SimpleSelector
1370        // against each visited node, then per-clause phase-2 narrowing on
1371        // the small candidate sets that fall out. Dedup uses
1372        // `(bus_name, path)` — the only AT-SPI identifier that's stable
1373        // across the per-clause narrowings (handles are minted fresh on
1374        // each `build_element_data` call).
1375        //
1376        // App discovery is handled separately by `list_apps()` — `root` is
1377        // always present here.
1378        let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1379
1380        let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1381            .clauses
1382            .iter()
1383            .map(|c| &c.segments[0].simple)
1384            .collect();
1385
1386        let start_ref = self.get_cached(root.handle)?;
1387
1388        // ── Phase-1 limit short-circuit ───────────────────────────
1389        // When there's exactly one clause, propagate the user's `limit`
1390        // (adjusted for `:nth`) to the AT-SPI walk so e.g.
1391        // `app.locator("button").first()` stops at the first match. With
1392        // multiple clauses, phase-1 hits for clause N might come before
1393        // clause M's in doc order, so we need the union before truncating.
1394        // (Critical on Electron-scale apps where this prevents 50k-node
1395        // walks.)
1396        let phase1_limit = if group.clauses.len() == 1 {
1397            let clause = &group.clauses[0];
1398            let first = firsts[0];
1399            let outer = if clause.segments.len() == 1 {
1400                limit
1401            } else {
1402                None
1403            };
1404            match (outer, first.nth) {
1405                (Some(l), Some(n)) => Some(l.max(n)),
1406                (_, Some(n)) => Some(n),
1407                (l, None) => l,
1408            }
1409        } else {
1410            None
1411        };
1412
1413        let phase1: Vec<(usize, AccessibleRef)> =
1414            self.collect_matching_refs_group(&start_ref, &firsts, 0, max_depth_val, phase1_limit)?;
1415
1416        let pid_from_root = root.pid;
1417
1418        // Bucket phase-1 hits by clause so each clause's tail can narrow
1419        // independently. `walk_pos` preserves the doc-order rank from the
1420        // single walk for the final merge.
1421        let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1422            (0..group.clauses.len()).map(|_| Vec::new()).collect();
1423        for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1424            by_clause[clause_idx].push((walk_pos, aref));
1425        }
1426
1427        // For each clause, build ElementData for its phase-1 hits, then
1428        // narrow through any trailing segments. Single-segment clauses
1429        // skip phase 2 entirely.
1430        //
1431        // Each narrowed result keeps a (walk_pos, ElementData) pair: the
1432        // walk_pos of its phase-1 ancestor is the doc-order anchor we use
1433        // to merge across clauses at the end.
1434        let mut merged: Vec<(usize, ElementData)> = Vec::new();
1435        for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1436            if hits.is_empty() {
1437                continue;
1438            }
1439            let clause = &group.clauses[clause_idx];
1440            // Build ElementData for this clause's phase-1 hits in parallel.
1441            let phase1_data: Vec<(usize, ElementData)> = hits
1442                .par_iter()
1443                .map(|(pos, aref)| {
1444                    // `root` is always present now, so the PID comes from
1445                    // the root element. No need to re-resolve via
1446                    // `get_app_pid` / `get_dbus_pid`.
1447                    (*pos, self.build_element_data(aref, pid_from_root))
1448                })
1449                .collect();
1450
1451            if clause.segments.len() == 1 {
1452                // Apply :nth on the first segment (per-clause, then merged).
1453                let mut candidates = phase1_data;
1454                if let Some(nth) = clause.segments[0].simple.nth {
1455                    if nth <= candidates.len() {
1456                        let kept = candidates.remove(nth - 1);
1457                        candidates.clear();
1458                        candidates.push(kept);
1459                    } else {
1460                        candidates.clear();
1461                    }
1462                }
1463                merged.extend(candidates);
1464                continue;
1465            }
1466
1467            // Multi-segment narrowing: re-use the trait helper. Each
1468            // narrowed result is anchored at the phase-1 ancestor's
1469            // walk_pos so the final sort puts it in doc order against
1470            // other clauses.
1471            for (anchor_pos, head) in phase1_data {
1472                let narrowed = self.narrow_multi_segment(
1473                    vec![head],
1474                    &clause.segments[1..],
1475                    max_depth_val,
1476                    None,
1477                )?;
1478                for n in narrowed {
1479                    merged.push((anchor_pos, n));
1480                }
1481            }
1482        }
1483
1484        // Doc-order merge: stable sort by walk position. Within the same
1485        // walk position multiple clauses can have emitted the same node —
1486        // dedup by AT-SPI identity (`(bus_name, path)`) keeps the first.
1487        merged.sort_by_key(|(pos, _)| *pos);
1488        let mut seen: HashSet<(String, String)> = HashSet::new();
1489        let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1490        for (_, data) in merged {
1491            // Use the cached AccessibleRef for identity, not the (handle).
1492            // Handle is freshly minted per `build_element_data` call so it
1493            // would never collide here.
1494            if let Ok(aref) = self.get_cached(data.handle) {
1495                if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1496                    continue;
1497                }
1498            }
1499            out.push(data);
1500        }
1501        if let Some(l) = limit {
1502            out.truncate(l);
1503        }
1504        Ok(out)
1505    }
1506
1507    fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1508        let aref = self.get_cached(element.handle)?;
1509        match self.get_atspi_parent(&aref)? {
1510            Some(parent_ref) => {
1511                let data = self.build_element_data(&parent_ref, element.pid);
1512                Ok(Some(data))
1513            }
1514            None => Ok(None),
1515        }
1516    }
1517
1518    /// Enumerate top-level applications by listing direct children of the
1519    /// AT-SPI registry root — every running accessibility-enabled app
1520    /// registers a child accessible there. Apps with empty names are
1521    /// filtered out (toolkits sometimes register transient/system
1522    /// accessibles before the real app appears). PID resolution uses the
1523    /// AT-SPI `Application` interface, falling back to the D-Bus owner.
1524    fn list_apps(&self) -> Result<Vec<ElementData>> {
1525        let registry = AccessibleRef {
1526            bus_name: "org.a11y.atspi.Registry".to_string(),
1527            path: "/org/a11y/atspi/accessible/root".to_string(),
1528        };
1529        let children = self.get_atspi_children(&registry)?;
1530
1531        // Filter valid children first, then build in parallel
1532        let valid: Vec<(&AccessibleRef, String)> = children
1533            .iter()
1534            .filter(|c| c.path != "/org/a11y/atspi/null")
1535            .filter_map(|c| {
1536                let name = self.get_name(c).unwrap_or_default();
1537                if name.is_empty() {
1538                    None
1539                } else {
1540                    Some((c, name))
1541                }
1542            })
1543            .collect();
1544
1545        let results: Vec<ElementData> = valid
1546            .par_iter()
1547            .map(|(child, app_name)| {
1548                let pid = self.get_app_pid(child);
1549                let mut data = self.build_element_data(child, pid);
1550                data.name = Some(app_name.clone());
1551                data
1552            })
1553            .collect();
1554
1555        Ok(results)
1556    }
1557
1558    /// Find an application by pid directly against the AT-SPI registry.
1559    ///
1560    /// Differs from filtering `list_apps()` in two ways that matter for a
1561    /// freshly launched process:
1562    ///
1563    /// * No empty-name filter — toolkits can register on the bus before the
1564    ///   app name is set, and a pid match is precise enough on its own.
1565    /// * The not-matched error reports how many registry entries were
1566    ///   examined and how many had no resolvable pid (the misreported-pid
1567    ///   bridge quirk `get_app_pid` documents), so a timeout on CI says why
1568    ///   the app was invisible instead of just "not found".
1569    fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
1570        let registry = AccessibleRef {
1571            bus_name: "org.a11y.atspi.Registry".to_string(),
1572            path: "/org/a11y/atspi/accessible/root".to_string(),
1573        };
1574        let children = self.get_atspi_children(&registry)?;
1575
1576        let mut total = 0usize;
1577        let mut unresolved = 0usize;
1578        for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1579            total += 1;
1580            match self.get_app_pid(child) {
1581                Some(p) if p == pid => {
1582                    let mut data = self.build_element_data(child, Some(p));
1583                    // Mirror list_apps: prefer the registry-reported name.
1584                    let name = self.get_name(child).unwrap_or_default();
1585                    if !name.is_empty() {
1586                        data.name = Some(name);
1587                    }
1588                    return Ok(data);
1589                }
1590                Some(_) => {}
1591                None => unresolved += 1,
1592            }
1593        }
1594        Err(
1595            Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
1596                xa11y_core::Diagnosis {
1597                    last_observed: Some(format!(
1598                        "{total} AT-SPI registry entries examined, {unresolved} without a \
1599                         resolvable pid"
1600                    )),
1601                    ..Default::default()
1602                },
1603            ),
1604        )
1605    }
1606
1607    /// Identify the foreground application via the active top-level window.
1608    ///
1609    /// AT-SPI has no registry-level "focused application" call, and the
1610    /// `Application` accessibles themselves don't carry an active/focused
1611    /// state across toolkits. The portable signal is the `ACTIVE` state
1612    /// (`AtspiStateType::ACTIVE`) set on the foreground *window* (frame): we
1613    /// enumerate each registered application's top-level children and return
1614    /// the first application that owns a window reporting `ACTIVE`.
1615    ///
1616    /// Returns [`Error::SelectorNotMatched`] when no window is active (no app
1617    /// has the foreground — e.g. focus is on the shell or a non-AT-SPI
1618    /// surface). The core reads that as "nothing focused", not a failure.
1619    fn focused_app(&self) -> Result<ElementData> {
1620        // AtspiStateType::ACTIVE = 1 → bit position 1.
1621        const ACTIVE: u64 = 1 << 1;
1622
1623        let registry = AccessibleRef {
1624            bus_name: "org.a11y.atspi.Registry".to_string(),
1625            path: "/org/a11y/atspi/accessible/root".to_string(),
1626        };
1627        let apps = self.get_atspi_children(&registry)?;
1628
1629        for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1630            let windows = match self.get_atspi_children(app) {
1631                Ok(w) => w,
1632                // A single app failing to enumerate its windows shouldn't abort
1633                // the whole foreground probe — skip it and keep scanning.
1634                Err(_) => continue,
1635            };
1636            let has_active_window = windows
1637                .iter()
1638                .filter(|w| w.path != "/org/a11y/atspi/null")
1639                .any(|w| self.state_bits(w) & ACTIVE != 0);
1640            if has_active_window {
1641                let pid = self.get_app_pid(app);
1642                let mut data = self.build_element_data(app, pid);
1643                let name = self.get_name(app).unwrap_or_default();
1644                if !name.is_empty() {
1645                    data.name = Some(name);
1646                }
1647                return Ok(data);
1648            }
1649        }
1650
1651        Err(Error::selector_not_matched("focused application"))
1652    }
1653
1654    fn press(&self, element: &ElementData) -> Result<()> {
1655        let target = self.get_cached(element.handle)?;
1656        // Fast path: the widget exposes `press` on its own Action interface.
1657        if let Ok(index) = self.get_action_index(element.handle, "press") {
1658            return self.do_atspi_action_by_index(&target, index);
1659        }
1660        // TENET-BREAK(1): substitute target. Human approval granted for this
1661        // break (see batch B6 / PR #125 / commit daeaf59). Justification: GTK4
1662        // menu-button widgets (GtkMenuButton, AdwMenuButton, AdwSplitButton)
1663        // expose their AT-SPI Action interface on an *inner* toggle-button
1664        // child, not the outer push-button accessible the app author addresses
1665        // by name. Without this workaround, `press()` silently does nothing on
1666        // every GtkMenuButton in every stock GNOME app (Calculator, Text
1667        // Editor, Logs, …). Alternatives considered and rejected:
1668        //   1. Expose the inner widget in the tree — breaks AT-SPI tree
1669        //      fidelity and leaks a GTK implementation detail into every
1670        //      consumer.
1671        //   2. Return ActionNotSupported — every GTK consumer would have to
1672        //      reimplement the same subtree search to work around it.
1673        // The break is narrowly scoped: gated on (a) the owning toolkit being
1674        // GTK and (b) the outer role being Role::Button (push-button, which is
1675        // what all three menu-button variants present as). Non-GtkMenuButton
1676        // roles and non-GTK toolkits continue to fail-fast with
1677        // ActionNotSupported.
1678        if element.role == Role::Button && self.is_gtk_toolkit(&target) {
1679            let outer_name = self.get_name(&target).unwrap_or_default();
1680            if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
1681                return self.do_atspi_action_by_index(&inner, index);
1682            }
1683        }
1684        Err(Error::ActionNotSupported {
1685            action: "press".to_string(),
1686            role: element.role,
1687        })
1688    }
1689
1690    fn focus(&self, element: &ElementData) -> Result<()> {
1691        let target = self.get_cached(element.handle)?;
1692        // Try Component.GrabFocus first, then fall back to stored action index.
1693        // GrabFocus returns a boolean indicating success — we must check it.
1694        // Fixes GitHub issue #98.
1695        if let Ok(proxy) =
1696            self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
1697        {
1698            if let Ok(reply) = proxy.call_method("GrabFocus", &()) {
1699                // GrabFocus returns boolean: true if focus was grabbed, false otherwise
1700                if let Ok(true) = reply.body().deserialize::<bool>() {
1701                    return Ok(());
1702                }
1703                // GrabFocus returned false — fall through to action index fallback
1704            }
1705        }
1706        if let Ok(index) = self.get_action_index(element.handle, "focus") {
1707            return self.do_atspi_action_by_index(&target, index);
1708        }
1709        Err(Error::ActionNotSupported {
1710            action: "focus".to_string(),
1711            role: element.role,
1712        })
1713    }
1714
1715    fn blur(&self, element: &ElementData) -> Result<()> {
1716        let target = self.get_cached(element.handle)?;
1717        // Grab focus on parent element to blur the current one. We propagate
1718        // every failure — no silent fallbacks (tenet 1): callers need to see
1719        // when blur can't do anything useful.
1720        if let Some(parent_ref) = self.get_atspi_parent(&target)? {
1721            if parent_ref.path != "/org/a11y/atspi/null" {
1722                let p = self.make_proxy(
1723                    &parent_ref.bus_name,
1724                    &parent_ref.path,
1725                    "org.a11y.atspi.Component",
1726                )?;
1727                p.call_method("GrabFocus", &())
1728                    .map_err(|e| Error::Platform {
1729                        code: -1,
1730                        message: format!("Component.GrabFocus on parent failed: {}", e),
1731                    })?;
1732                return Ok(());
1733            }
1734        }
1735        Err(Error::ActionNotSupported {
1736            action: "blur".to_string(),
1737            role: element.role,
1738        })
1739    }
1740
1741    fn toggle(&self, element: &ElementData) -> Result<()> {
1742        let target = self.get_cached(element.handle)?;
1743        // Fast path: the widget exposes the literal AT-SPI action name "toggle"
1744        // (or one of its toolkit aliases — "check" / "uncheck" — both
1745        // canonicalised to "toggle" by map_atspi_action_name during cache
1746        // population).
1747        if let Ok(index) = self.get_action_index(element.handle, "toggle") {
1748            return self.do_atspi_action_by_index(&target, index);
1749        }
1750        // Fallback for AccessKit-backed apps. accesskit_unix's AT-SPI bridge
1751        // hard-codes the action name as "click" for every clickable node
1752        // regardless of role — see platforms/atspi-common/src/node.rs in
1753        // AccessKit. That means egui, eframe, winit-based apps, Bevy, Tauri
1754        // (when using AccessKit) and any other AccessKit consumer never
1755        // advertise "toggle" for CheckBox/RadioButton/Switch, even though
1756        // activating those widgets via "click" is functionally a toggle on
1757        // every platform. Without this fallback, xa11y.toggle() is
1758        // unreachable on every AccessKit-on-Linux widget.
1759        //
1760        // This is tenet-3-consistent: the semantic verb `toggle` on a
1761        // toggleable role means "activate this toggleable element," and on
1762        // Linux the canonical AccessKit implementation of that is the same
1763        // action node entry we'd dispatch for press(). It's the mirror image
1764        // of the Windows `press` dispatch (uia.rs) which fans out to Invoke,
1765        // Toggle, SelectionItem.Select, or ExpandCollapse depending on the
1766        // element's primary-activation pattern.
1767        //
1768        // Narrowly scoped: only fires for toggleable primary-activation
1769        // roles. Other roles still fail-fast with ActionNotSupported so a
1770        // misuse of toggle() on, e.g., a Button does not silently dispatch
1771        // press().
1772        if matches!(
1773            element.role,
1774            Role::CheckBox | Role::RadioButton | Role::Switch
1775        ) {
1776            if let Ok(index) = self.get_action_index(element.handle, "press") {
1777                return self.do_atspi_action_by_index(&target, index);
1778            }
1779        }
1780        Err(Error::ActionNotSupported {
1781            action: "toggle".to_string(),
1782            role: element.role,
1783        })
1784    }
1785
1786    fn select(&self, element: &ElementData) -> Result<()> {
1787        let target = self.get_cached(element.handle)?;
1788        let index = self
1789            .get_action_index(element.handle, "select")
1790            .map_err(|_| Error::ActionNotSupported {
1791                action: "select".to_string(),
1792                role: element.role,
1793            })?;
1794        self.do_atspi_action_by_index(&target, index)
1795    }
1796
1797    fn expand(&self, element: &ElementData) -> Result<()> {
1798        let target = self.get_cached(element.handle)?;
1799        let index = self
1800            .get_action_index(element.handle, "expand")
1801            .map_err(|_| Error::ActionNotSupported {
1802                action: "expand".to_string(),
1803                role: element.role,
1804            })?;
1805        self.do_atspi_action_by_index(&target, index)
1806    }
1807
1808    fn collapse(&self, element: &ElementData) -> Result<()> {
1809        let target = self.get_cached(element.handle)?;
1810        let index = self
1811            .get_action_index(element.handle, "collapse")
1812            .map_err(|_| Error::ActionNotSupported {
1813                action: "collapse".to_string(),
1814                role: element.role,
1815            })?;
1816        self.do_atspi_action_by_index(&target, index)
1817    }
1818
1819    fn show_menu(&self, element: &ElementData) -> Result<()> {
1820        let target = self.get_cached(element.handle)?;
1821        let index = self
1822            .get_action_index(element.handle, "show_menu")
1823            .map_err(|_| Error::ActionNotSupported {
1824                action: "show_menu".to_string(),
1825                role: element.role,
1826            })?;
1827        self.do_atspi_action_by_index(&target, index)
1828    }
1829
1830    fn increment(&self, element: &ElementData) -> Result<()> {
1831        let target = self.get_cached(element.handle)?;
1832        // Try stored AT-SPI2 action index first, fall back to Value interface
1833        if let Ok(index) = self.get_action_index(element.handle, "increment") {
1834            return self.do_atspi_action_by_index(&target, index);
1835        }
1836        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1837        let current: f64 = proxy
1838            .get_property("CurrentValue")
1839            .map_err(|e| Error::Platform {
1840                code: -1,
1841                message: format!("Value.CurrentValue failed: {}", e),
1842            })?;
1843        let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
1844        let step = if step <= 0.0 { 1.0 } else { step };
1845        proxy
1846            .set_property("CurrentValue", current + step)
1847            .map_err(|e| Error::Platform {
1848                code: -1,
1849                message: format!("Value.SetCurrentValue failed: {}", e),
1850            })
1851    }
1852
1853    fn decrement(&self, element: &ElementData) -> Result<()> {
1854        let target = self.get_cached(element.handle)?;
1855        if let Ok(index) = self.get_action_index(element.handle, "decrement") {
1856            return self.do_atspi_action_by_index(&target, index);
1857        }
1858        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1859        let current: f64 = proxy
1860            .get_property("CurrentValue")
1861            .map_err(|e| Error::Platform {
1862                code: -1,
1863                message: format!("Value.CurrentValue failed: {}", e),
1864            })?;
1865        let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
1866        let step = if step <= 0.0 { 1.0 } else { step };
1867        proxy
1868            .set_property("CurrentValue", current - step)
1869            .map_err(|e| Error::Platform {
1870                code: -1,
1871                message: format!("Value.SetCurrentValue failed: {}", e),
1872            })
1873    }
1874
1875    fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
1876        let target = self.get_cached(element.handle)?;
1877        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1878        proxy
1879            .call_method("ScrollTo", &(0u32,))
1880            .map_err(|e| Error::Platform {
1881                code: -1,
1882                message: format!("ScrollTo failed: {}", e),
1883            })?;
1884        Ok(())
1885    }
1886
1887    fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
1888        let target = self.get_cached(element.handle)?;
1889        let proxy = self
1890            .make_proxy(
1891                &target.bus_name,
1892                &target.path,
1893                "org.a11y.atspi.EditableText",
1894            )
1895            .map_err(|_| Error::TextValueNotSupported)?;
1896        // Try SetTextContents first (WebKit2GTK exposes this but not InsertText).
1897        if proxy.call_method("SetTextContents", &(value,)).is_ok() {
1898            return Ok(());
1899        }
1900        // Fall back to delete-then-insert for other AT-SPI2 implementations.
1901        // Capture the underlying D-Bus error so callers can distinguish an
1902        // absent `EditableText` interface (common on Chromium — the Chrome
1903        // URL bar only exposes read-only `Text`; see issue #101) from other
1904        // failures. Collapsing to `TextValueNotSupported` hides the reason.
1905        let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
1906            let msg = e.to_string();
1907            if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
1908                Error::TextValueNotSupported
1909            } else {
1910                Error::Platform {
1911                    code: -1,
1912                    message: format!("EditableText.{} failed: {}", op, msg),
1913                }
1914            }
1915        };
1916        if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
1917            return Err(classify_editable_text_error("DeleteText", e));
1918        }
1919        if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
1920            return Err(classify_editable_text_error("InsertText", e));
1921        }
1922        Ok(())
1923    }
1924
1925    fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
1926        let target = self.get_cached(element.handle)?;
1927        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1928        proxy
1929            .set_property("CurrentValue", value)
1930            .map_err(|e| Error::Platform {
1931                code: -1,
1932                message: format!("SetValue failed: {}", e),
1933            })
1934    }
1935
1936    fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
1937        let target = self.get_cached(element.handle)?;
1938        // Insert text via EditableText interface (accessibility API, not input simulation).
1939        // Get cursor position from Text interface, then insert at that position.
1940        let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
1941        let insert_pos = text_proxy
1942            .as_ref()
1943            .ok()
1944            .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
1945            .unwrap_or(-1); // -1 = append at end
1946
1947        let proxy = self
1948            .make_proxy(
1949                &target.bus_name,
1950                &target.path,
1951                "org.a11y.atspi.EditableText",
1952            )
1953            .map_err(|_| Error::TextValueNotSupported)?;
1954        let pos = if insert_pos >= 0 {
1955            insert_pos
1956        } else {
1957            i32::MAX
1958        };
1959        proxy
1960            .call_method("InsertText", &(pos, text, text.len() as i32))
1961            .map_err(|e| Error::Platform {
1962                code: -1,
1963                message: format!("EditableText.InsertText failed: {}", e),
1964            })?;
1965        Ok(())
1966    }
1967
1968    fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
1969        let target = self.get_cached(element.handle)?;
1970        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
1971        proxy
1972            .call_method("SetSelection", &(0i32, start as i32, end as i32))
1973            .map_err(|e| Error::Platform {
1974                code: -1,
1975                message: format!("Text.SetSelection failed: {}", e),
1976            })?;
1977        Ok(())
1978    }
1979
1980    fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
1981        match action {
1982            "press" => self.press(element),
1983            "focus" => self.focus(element),
1984            "blur" => self.blur(element),
1985            "toggle" => self.toggle(element),
1986            "select" => self.select(element),
1987            "expand" => self.expand(element),
1988            "collapse" => self.collapse(element),
1989            "show_menu" => self.show_menu(element),
1990            "increment" => self.increment(element),
1991            "decrement" => self.decrement(element),
1992            "scroll_into_view" => self.scroll_into_view(element),
1993            _ => Err(Error::ActionNotSupported {
1994                action: action.to_string(),
1995                role: element.role,
1996            }),
1997        }
1998    }
1999
2000    fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2001        let pid = element.pid.ok_or(Error::Platform {
2002            code: -1,
2003            message: "Element has no PID for subscribe".to_string(),
2004        })?;
2005        let app_name = element.name.clone().unwrap_or_default();
2006        crate::events::subscribe_for_pid(self, pid, app_name)
2007    }
2008}
2009
2010/// Whether a role typically has text or Value interface content.
2011/// Container/structural roles are skipped to save D-Bus round-trips.
2012fn role_has_value(role: Role) -> bool {
2013    !matches!(
2014        role,
2015        Role::Application
2016            | Role::Window
2017            | Role::Dialog
2018            | Role::Group
2019            | Role::MenuBar
2020            | Role::Toolbar
2021            | Role::TabGroup
2022            | Role::SplitGroup
2023            | Role::Table
2024            | Role::TableRow
2025            | Role::Separator
2026    )
2027}
2028
2029/// Whether a role typically supports actions via the Action interface.
2030/// Container and display-only roles are skipped to save D-Bus round-trips.
2031fn role_has_actions(role: Role) -> bool {
2032    matches!(
2033        role,
2034        Role::Button
2035            | Role::CheckBox
2036            | Role::RadioButton
2037            | Role::MenuItem
2038            | Role::Link
2039            | Role::ComboBox
2040            | Role::TextField
2041            | Role::TextArea
2042            | Role::SpinButton
2043            | Role::Tab
2044            | Role::TreeItem
2045            | Role::ListItem
2046            | Role::ScrollBar
2047            | Role::Slider
2048            | Role::Menu
2049            | Role::Image
2050            | Role::Unknown
2051    )
2052}
2053
2054/// Map AT-SPI2 role name to xa11y Role.
2055pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2056    match role_name.to_lowercase().as_str() {
2057        "application" => Role::Application,
2058        "window" | "frame" => Role::Window,
2059        "dialog" | "file chooser" => Role::Dialog,
2060        "alert" | "notification" | "info bar" => Role::Alert,
2061        "push button" | "push button menu" => Role::Button,
2062        "toggle button" => Role::Switch,
2063        "check box" | "check menu item" => Role::CheckBox,
2064        "radio button" | "radio menu item" => Role::RadioButton,
2065        "entry" | "password text" => Role::TextField,
2066        "spin button" | "spinbutton" => Role::SpinButton,
2067        // "textbox" is the ARIA role name returned by WebKit2GTK for both
2068        // <input type="text"> and <textarea>.  Map to TextArea here so the
2069        // multi-line refinement below can downgrade single-line ones to TextField.
2070        // "embedded" is WebKit2GTK's numeric-path equivalent (role 78);
2071        // "terminal" is VTE — text is reachable via the Text interface.
2072        "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2073        "label" | "static" | "caption" => Role::StaticText,
2074        "combo box" | "combobox" => Role::ComboBox,
2075        // "listbox" is the ARIA role name returned by WebKit2GTK for role="listbox".
2076        "list" | "list box" | "listbox" => Role::List,
2077        "list item" => Role::ListItem,
2078        "menu" | "popup menu" => Role::Menu,
2079        "menu item" | "tearoff menu item" => Role::MenuItem,
2080        "menu bar" => Role::MenuBar,
2081        "page tab" => Role::Tab,
2082        "page tab list" => Role::TabGroup,
2083        "table" | "tree table" => Role::Table,
2084        "table row" => Role::TableRow,
2085        // Bare "column header"/"row header" are WebKit's <th> and GTK
2086        // column-view headers; the "table …" pair is the classic toolkit one.
2087        "table cell"
2088        | "table column header"
2089        | "table row header"
2090        | "column header"
2091        | "row header" => Role::TableCell,
2092        "tool bar" => Role::Toolbar,
2093        "scroll bar" => Role::ScrollBar,
2094        "slider" => Role::Slider,
2095        "image" | "icon" | "desktop icon" => Role::Image,
2096        "link" => Role::Link,
2097        "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2098        | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2099        | "comment" | "title bar" | "calendar" => Role::Group,
2100        "progress bar" | "level bar" => Role::ProgressBar,
2101        "tree item" => Role::TreeItem,
2102        "document web"
2103        | "document frame"
2104        | "document spreadsheet"
2105        | "document presentation"
2106        | "document text"
2107        | "document email" => Role::WebArea,
2108        "heading" => Role::Heading,
2109        "separator" => Role::Separator,
2110        "split pane" => Role::SplitGroup,
2111        "tooltip" | "tool tip" => Role::Tooltip,
2112        "status bar" | "statusbar" => Role::Status,
2113        "landmark" | "navigation" => Role::Navigation,
2114        _ => xa11y_core::unknown_role(role_name),
2115    }
2116}
2117
2118/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
2119/// Values from atspi-common Role enum (repr(u32)).
2120pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2121    match role {
2122        2 => Role::Alert,    // Alert
2123        5 => Role::Group,    // Calendar (mirrors UIA CalendarControlType)
2124        7 => Role::CheckBox, // CheckBox
2125        8 => Role::CheckBox, // CheckMenuItem
2126        // Bare ColumnHeader/RowHeader (10/47): WebKit and GTK emit these for
2127        // <th> / column-view header cells, distinct from the
2128        // TableColumnHeader/TableRowHeader pair (57/58) below.
2129        10 => Role::TableCell,   // ColumnHeader
2130        11 => Role::ComboBox,    // ComboBox
2131        13 => Role::Image,       // DesktopIcon
2132        16 => Role::Dialog,      // Dialog
2133        19 => Role::Dialog,      // FileChooser
2134        20 => Role::Group,       // Filler
2135        23 => Role::Window,      // Frame
2136        26 => Role::Image,       // Icon
2137        27 => Role::Image,       // Image
2138        29 => Role::StaticText,  // Label
2139        31 => Role::List,        // List
2140        32 => Role::ListItem,    // ListItem
2141        33 => Role::Menu,        // Menu
2142        34 => Role::MenuBar,     // MenuBar
2143        35 => Role::MenuItem,    // MenuItem
2144        37 => Role::Tab,         // PageTab
2145        38 => Role::TabGroup,    // PageTabList
2146        39 => Role::Group,       // Panel
2147        40 => Role::TextField,   // PasswordText
2148        41 => Role::Menu,        // PopupMenu
2149        42 => Role::ProgressBar, // ProgressBar
2150        43 => Role::Button,      // Button (push button)
2151        44 => Role::RadioButton, // RadioButton
2152        45 => Role::RadioButton, // RadioMenuItem
2153        47 => Role::TableCell,   // RowHeader
2154        48 => Role::ScrollBar,   // ScrollBar
2155        49 => Role::Group,       // ScrollPane
2156        50 => Role::Separator,   // Separator
2157        51 => Role::Slider,      // Slider
2158        52 => Role::SpinButton,  // SpinButton
2159        53 => Role::SplitGroup,  // SplitPane
2160        54 => Role::Status,      // StatusBar
2161        55 => Role::Table,       // Table
2162        56 => Role::TableCell,   // TableCell
2163        57 => Role::TableCell,   // TableColumnHeader
2164        58 => Role::TableCell,   // TableRowHeader
2165        59 => Role::MenuItem,    // TearoffMenuItem
2166        60 => Role::TextArea,    // Terminal (VTE and friends — text via the Text interface)
2167        61 => Role::TextArea,    // Text
2168        62 => Role::Switch,      // ToggleButton
2169        63 => Role::Toolbar,     // ToolBar
2170        64 => Role::Tooltip,     // ToolTip
2171        65 => Role::Group,       // Tree
2172        66 => Role::Table,       // TreeTable
2173        67 => Role::Unknown,     // Unknown
2174        68 => Role::Group,       // Viewport
2175        69 => Role::Window,      // Window
2176        // Text-document containers WebKit emits for plain markup: a bare
2177        // <p> (no ARIA naming) is Paragraph; header/footer/caption/block
2178        // quote come from the matching HTML elements. Containers normalize
2179        // to Group, caption to StaticText, mirroring the by-name map.
2180        71 => Role::Group,       // Header
2181        72 => Role::Group,       // Footer
2182        73 => Role::Group,       // Paragraph
2183        75 => Role::Application, // Application
2184        78 => Role::TextArea, // Embedded — WebKit2GTK uses this for <input type="text"> and <textarea>;
2185        // multi-line refinement below downgrades single-line ones to TextField
2186        79 => Role::TextField,  // Entry
2187        81 => Role::StaticText, // Caption
2188        82 => Role::WebArea,    // DocumentFrame
2189        83 => Role::Heading,    // Heading
2190        85 => Role::Group,      // Section
2191        86 => Role::Group,      // RedundantObject
2192        87 => Role::Group,      // Form
2193        88 => Role::Link,       // Link
2194        90 => Role::TableRow,   // TableRow
2195        91 => Role::TreeItem,   // TreeItem
2196        // Document roots (92-96): LibreOffice exposes spreadsheet /
2197        // presentation / text / email documents with these; all normalize to
2198        // WebArea alongside DocumentFrame/DocumentWeb above.
2199        92 => Role::WebArea,      // DocumentSpreadsheet
2200        93 => Role::WebArea,      // DocumentPresentation
2201        94 => Role::WebArea,      // DocumentText
2202        95 => Role::WebArea,      // DocumentWeb
2203        96 => Role::WebArea,      // DocumentEmail
2204        97 => Role::Group,        // Comment (ARIA comment container)
2205        98 => Role::List,         // ListBox — WebKit2GTK emits this for <ul role="listbox">
2206        99 => Role::Group,        // Grouping
2207        101 => Role::Alert,       // Notification
2208        102 => Role::Alert,       // InfoBar (GtkInfoBar message strip)
2209        103 => Role::ProgressBar, // LevelBar (GtkLevelBar)
2210        104 => Role::Group,       // TitleBar (mirrors UIA TitleBar)
2211        105 => Role::Group,       // BlockQuote
2212        110 => Role::Navigation,  // Landmark
2213        116 => Role::StaticText,  // Static
2214        129 => Role::Button,      // PushButtonMenu
2215        _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
2216    }
2217}
2218
2219/// Depth cap for the GTK press-fallback BFS. Wrapper patterns nest at most
2220/// two levels (e.g. `AdwSplitButton` → inner toggle-button → inner label);
2221/// depth 3 covers them with headroom without letting the walk wander.
2222const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2223
2224/// Hard cap on visited accessibles per fallback resolution. Defensive — the
2225/// depth cap already bounds typical cases to ≤ 20 nodes.
2226const GTK_FALLBACK_MAX_NODES: usize = 200;
2227
2228/// Whether an AT-SPI2 role name represents an activatable widget we are
2229/// willing to invoke via the fallback path. Deliberately narrow: roles that
2230/// could carry destructive or misleading semantics (e.g. `label` with the
2231/// synthesised clipboard/selection actions) are excluded.
2232fn is_actionable_atspi_role(role: &str) -> bool {
2233    matches!(
2234        role,
2235        "push button"
2236            | "toggle button"
2237            | "check box"
2238            | "radio button"
2239            | "menu item"
2240            | "check menu item"
2241            | "radio menu item"
2242            | "link"
2243            | "page tab"
2244            | "tab"
2245            | "list item"
2246            | "tree item"
2247    )
2248}
2249
2250/// Roles the BFS refuses to descend into. Static and decorative roles never
2251/// lead anywhere useful, and `label` in particular carries a fan-out of
2252/// text-editing actions (`clipboard.copy`, `selection.delete`, …) that we
2253/// must never reach via a press heuristic.
2254fn is_never_descend_atspi_role(role: &str) -> bool {
2255    matches!(
2256        role,
2257        "label" | "separator" | "image" | "icon" | "static" | "caption"
2258    )
2259}
2260
2261/// Map an AT-SPI2 action name to its canonical `snake_case` xa11y action name.
2262///
2263/// Toolkit-specific aliases are normalised to the single canonical name:
2264///   "click" / "activate" / "press" / "invoke" / "dodefault" → "press"
2265///   "toggle" / "check" / "uncheck"            → "toggle"
2266///   "expand" / "open"                          → "expand"
2267///   "collapse" / "close"                       → "collapse"
2268///   "menu" / "showmenu" / "showcontextmenu" / "popup" / "show menu" → "show_menu"
2269///   "select"                                    → "select"
2270///   "increment"                                 → "increment"
2271///   "decrement"                                 → "decrement"
2272///
2273/// Returns `None` for unrecognised names.
2274fn map_atspi_action_name(action_name: &str) -> Option<String> {
2275    // Normalise by lowercasing and stripping underscores/spaces so that
2276    // "show_menu", "show menu", "showMenu" and "showContextMenu" all collapse
2277    // to the same canonical form. Chromium is the main motivator: it uses
2278    // `doDefault` as the default activation action on ~190 elements per
2279    // window (Back/Forward buttons, toolbar icons, menu items, sliders,
2280    // notifications) and `showContextMenu` as the context-menu action. The
2281    // previous table dropped both, leaving those elements with no `press`
2282    // mapping at all. See issue #101.
2283    let lower = action_name.to_lowercase();
2284    let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
2285    let canonical = match collapsed.as_str() {
2286        "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
2287        "toggle" | "check" | "uncheck" => "toggle",
2288        "expand" | "open" => "expand",
2289        "collapse" | "close" => "collapse",
2290        "select" => "select",
2291        "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
2292        "increment" => "increment",
2293        "decrement" => "decrement",
2294        _ => return None,
2295    };
2296    Some(canonical.to_string())
2297}
2298
2299#[cfg(test)]
2300mod tests {
2301    use super::*;
2302
2303    #[test]
2304    fn test_role_mapping() {
2305        assert_eq!(map_atspi_role("push button"), Role::Button);
2306        assert_eq!(map_atspi_role("toggle button"), Role::Switch);
2307        assert_eq!(map_atspi_role("check box"), Role::CheckBox);
2308        assert_eq!(map_atspi_role("entry"), Role::TextField);
2309        assert_eq!(map_atspi_role("label"), Role::StaticText);
2310        assert_eq!(map_atspi_role("window"), Role::Window);
2311        assert_eq!(map_atspi_role("frame"), Role::Window);
2312        assert_eq!(map_atspi_role("dialog"), Role::Dialog);
2313        assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
2314        assert_eq!(map_atspi_role("slider"), Role::Slider);
2315        assert_eq!(map_atspi_role("panel"), Role::Group);
2316        assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
2317    }
2318
2319    #[test]
2320    fn test_numeric_role_mapping() {
2321        // ToggleButton (62) must map to Switch, not Button.
2322        // GTK4's Gtk.Switch and Gtk.ToggleButton both report numeric role 62.
2323        assert_eq!(map_atspi_role_number(62), Role::Switch);
2324        // Sanity-check a few well-established numeric mappings.
2325        assert_eq!(map_atspi_role_number(43), Role::Button); // PushButton
2326        assert_eq!(map_atspi_role_number(7), Role::CheckBox);
2327        assert_eq!(map_atspi_role_number(67), Role::Unknown); // AT-SPI Unknown
2328    }
2329
2330    /// The full AtspiRole enum (atspi 2.52) as (number, GetRoleName-style
2331    /// name) pairs. The name form is the enum nick with dashes replaced by
2332    /// spaces, which is what providers hand back over D-Bus.
2333    const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
2334        (0, "invalid"),
2335        (1, "accelerator label"),
2336        (2, "alert"),
2337        (3, "animation"),
2338        (4, "arrow"),
2339        (5, "calendar"),
2340        (6, "canvas"),
2341        (7, "check box"),
2342        (8, "check menu item"),
2343        (9, "color chooser"),
2344        (10, "column header"),
2345        (11, "combo box"),
2346        (12, "date editor"),
2347        (13, "desktop icon"),
2348        (14, "desktop frame"),
2349        (15, "dial"),
2350        (16, "dialog"),
2351        (17, "directory pane"),
2352        (18, "drawing area"),
2353        (19, "file chooser"),
2354        (20, "filler"),
2355        (21, "focus traversable"),
2356        (22, "font chooser"),
2357        (23, "frame"),
2358        (24, "glass pane"),
2359        (25, "html container"),
2360        (26, "icon"),
2361        (27, "image"),
2362        (28, "internal frame"),
2363        (29, "label"),
2364        (30, "layered pane"),
2365        (31, "list"),
2366        (32, "list item"),
2367        (33, "menu"),
2368        (34, "menu bar"),
2369        (35, "menu item"),
2370        (36, "option pane"),
2371        (37, "page tab"),
2372        (38, "page tab list"),
2373        (39, "panel"),
2374        (40, "password text"),
2375        (41, "popup menu"),
2376        (42, "progress bar"),
2377        (43, "push button"),
2378        (44, "radio button"),
2379        (45, "radio menu item"),
2380        (46, "root pane"),
2381        (47, "row header"),
2382        (48, "scroll bar"),
2383        (49, "scroll pane"),
2384        (50, "separator"),
2385        (51, "slider"),
2386        (52, "spin button"),
2387        (53, "split pane"),
2388        (54, "status bar"),
2389        (55, "table"),
2390        (56, "table cell"),
2391        (57, "table column header"),
2392        (58, "table row header"),
2393        (59, "tearoff menu item"),
2394        (60, "terminal"),
2395        (61, "text"),
2396        (62, "toggle button"),
2397        (63, "tool bar"),
2398        (64, "tool tip"),
2399        (65, "tree"),
2400        (66, "tree table"),
2401        (67, "unknown"),
2402        (68, "viewport"),
2403        (69, "window"),
2404        (70, "extended"),
2405        (71, "header"),
2406        (72, "footer"),
2407        (73, "paragraph"),
2408        (74, "ruler"),
2409        (75, "application"),
2410        (76, "autocomplete"),
2411        (77, "editbar"),
2412        (78, "embedded"),
2413        (79, "entry"),
2414        (80, "chart"),
2415        (81, "caption"),
2416        (82, "document frame"),
2417        (83, "heading"),
2418        (84, "page"),
2419        (85, "section"),
2420        (86, "redundant object"),
2421        (87, "form"),
2422        (88, "link"),
2423        (89, "input method window"),
2424        (90, "table row"),
2425        (91, "tree item"),
2426        (92, "document spreadsheet"),
2427        (93, "document presentation"),
2428        (94, "document text"),
2429        (95, "document web"),
2430        (96, "document email"),
2431        (97, "comment"),
2432        (98, "list box"),
2433        (99, "grouping"),
2434        (100, "image map"),
2435        (101, "notification"),
2436        (102, "info bar"),
2437        (103, "level bar"),
2438        (104, "title bar"),
2439        (105, "block quote"),
2440        (106, "audio"),
2441        (107, "video"),
2442        (108, "definition"),
2443        (109, "article"),
2444        (110, "landmark"),
2445        (111, "log"),
2446        (112, "marquee"),
2447        (113, "math"),
2448        (114, "rating"),
2449        (115, "timer"),
2450        (116, "static"),
2451        (117, "math fraction"),
2452        (118, "math root"),
2453        (119, "subscript"),
2454        (120, "superscript"),
2455        (121, "description list"),
2456        (122, "description term"),
2457        (123, "description value"),
2458        (124, "footnote"),
2459        (125, "content deletion"),
2460        (126, "content insertion"),
2461        (127, "mark"),
2462        (128, "suggestion"),
2463        (129, "push button menu"),
2464    ];
2465
2466    #[test]
2467    fn numeric_and_name_role_maps_agree() {
2468        // The provider resolves roles by number on some paths (resolve_role)
2469        // and by name on others (build_element_data). If the two maps drift,
2470        // the same element reports different roles depending on how it was
2471        // reached — this happened for StatusBar (54, name-only), ToolTip
2472        // (64, and 93 was mis-numbered as Tooltip), and Landmark (110).
2473        // Sweep the whole enum so any future addition lands in both maps.
2474        for &(num, name) in ATSPI_ROLE_ENUM {
2475            let by_num = map_atspi_role_number(num);
2476            let by_name = map_atspi_role(name);
2477            assert_eq!(
2478                by_num, by_name,
2479                "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
2480                 {by_num:?}, name map gives {by_name:?}"
2481            );
2482        }
2483    }
2484
2485    #[test]
2486    fn test_action_name_mapping() {
2487        assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
2488        assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
2489        assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
2490        assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
2491        // Chromium uses `doDefault` for default activation on ~190 elements.
2492        assert_eq!(
2493            map_atspi_action_name("doDefault"),
2494            Some("press".to_string())
2495        );
2496        assert_eq!(
2497            map_atspi_action_name("do_default"),
2498            Some("press".to_string())
2499        );
2500        assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
2501        assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
2502        assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
2503        assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
2504        assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
2505        assert_eq!(
2506            map_atspi_action_name("collapse"),
2507            Some("collapse".to_string())
2508        );
2509        assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
2510        assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
2511        assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
2512        assert_eq!(
2513            map_atspi_action_name("showmenu"),
2514            Some("show_menu".to_string())
2515        );
2516        assert_eq!(
2517            map_atspi_action_name("popup"),
2518            Some("show_menu".to_string())
2519        );
2520        assert_eq!(
2521            map_atspi_action_name("show menu"),
2522            Some("show_menu".to_string())
2523        );
2524        // Chrome / Chromium expose the URL-bar context-menu action as
2525        // `showContextMenu`; the previous table missed both spellings.
2526        assert_eq!(
2527            map_atspi_action_name("showContextMenu"),
2528            Some("show_menu".to_string())
2529        );
2530        assert_eq!(
2531            map_atspi_action_name("show_context_menu"),
2532            Some("show_menu".to_string())
2533        );
2534        assert_eq!(
2535            map_atspi_action_name("increment"),
2536            Some("increment".to_string())
2537        );
2538        assert_eq!(
2539            map_atspi_action_name("decrement"),
2540            Some("decrement".to_string())
2541        );
2542        assert_eq!(map_atspi_action_name("foobar"), None);
2543    }
2544
2545    /// All known AT-SPI2 aliases map to one of the well-known action names,
2546    /// and re-mapping the canonical name produces the same canonical name.
2547    #[test]
2548    fn test_action_name_aliases_roundtrip() {
2549        let atspi_names = [
2550            "click",
2551            "activate",
2552            "press",
2553            "invoke",
2554            "doDefault",
2555            "do_default",
2556            "toggle",
2557            "check",
2558            "uncheck",
2559            "expand",
2560            "open",
2561            "collapse",
2562            "close",
2563            "select",
2564            "menu",
2565            "showmenu",
2566            "showContextMenu",
2567            "show_context_menu",
2568            "popup",
2569            "show menu",
2570            "increment",
2571            "decrement",
2572        ];
2573        for name in atspi_names {
2574            let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
2575                panic!("AT-SPI2 name {:?} should map to a canonical name", name)
2576            });
2577            // Re-mapping the canonical name must produce itself.
2578            let back = map_atspi_action_name(&canonical)
2579                .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
2580            assert_eq!(
2581                canonical, back,
2582                "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
2583                name, canonical, back, canonical
2584            );
2585        }
2586    }
2587
2588    /// Case-insensitive mapping works.
2589    #[test]
2590    fn test_action_name_case_insensitive() {
2591        assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
2592        assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
2593        assert_eq!(
2594            map_atspi_action_name("Increment"),
2595            Some("increment".to_string())
2596        );
2597    }
2598
2599    /// The GTK press-fallback's actionable-role set must include the inner
2600    /// toggle-button pattern used by `GtkMenuButton` / `AdwMenuButton`, plus
2601    /// the other standard activatable roles we're willing to synthesise a
2602    /// click for.
2603    #[test]
2604    fn test_gtk_fallback_actionable_roles() {
2605        for role in [
2606            "push button",
2607            "toggle button",
2608            "check box",
2609            "radio button",
2610            "menu item",
2611            "link",
2612            "tab",
2613            "list item",
2614            "tree item",
2615        ] {
2616            assert!(
2617                is_actionable_atspi_role(role),
2618                "{role:?} should be actionable"
2619            );
2620        }
2621    }
2622
2623    /// Never treat static / decorative accessibles as fallback candidates.
2624    /// Particularly important for `label`, whose synthesised text-editing
2625    /// actions (`clipboard.copy`, `selection.delete`) must never be invoked
2626    /// by a press heuristic.
2627    #[test]
2628    fn test_gtk_fallback_non_actionable_roles() {
2629        for role in [
2630            "label",
2631            "panel",
2632            "filler",
2633            "section",
2634            "group",
2635            "image",
2636            "separator",
2637            "static",
2638            "frame",
2639            "window",
2640        ] {
2641            assert!(
2642                !is_actionable_atspi_role(role),
2643                "{role:?} must not be treated as actionable"
2644            );
2645        }
2646    }
2647
2648    /// The BFS stops at static/decorative roles. `label` in particular must
2649    /// never be descended into — its children are text spans with bogus
2650    /// actions.
2651    #[test]
2652    fn test_gtk_fallback_never_descend_roles() {
2653        for role in ["label", "separator", "image", "icon", "static", "caption"] {
2654            assert!(
2655                is_never_descend_atspi_role(role),
2656                "{role:?} must block BFS descent"
2657            );
2658        }
2659        // Containers — panel / filler / section / group / frame — stay
2660        // walkable so the BFS can reach a wrapped inner actionable.
2661        for role in ["panel", "filler", "section", "group", "frame"] {
2662            assert!(
2663                !is_never_descend_atspi_role(role),
2664                "container role {role:?} must remain descendable"
2665            );
2666        }
2667    }
2668}