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