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