Skip to main content

xa11y_linux/
atspi.rs

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