Skip to main content

xa11y_linux/
atspi.rs

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