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