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