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                let proxy =
1473                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1474                // BOTTOM_EDGE = 3
1475                proxy
1476                    .call_method("ScrollTo", &(3u32,))
1477                    .map_err(|e| Error::Platform {
1478                        code: -1,
1479                        message: format!("ScrollTo failed: {}", e),
1480                    })?;
1481                return Ok(());
1482            }
1483        }
1484        Ok(())
1485    }
1486
1487    fn scroll_up(&self, element: &ElementData, amount: f64) -> Result<()> {
1488        let target = self.get_cached(element.handle)?;
1489        let count = (amount.abs() as u32).max(1);
1490        for _ in 0..count {
1491            if self.do_atspi_action_by_name(&target, "scroll up").is_err() {
1492                // Fall back to Component.ScrollTo (single call, not repeatable)
1493                let proxy =
1494                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1495                // TOP_EDGE = 2
1496                proxy
1497                    .call_method("ScrollTo", &(2u32,))
1498                    .map_err(|e| Error::Platform {
1499                        code: -1,
1500                        message: format!("ScrollTo failed: {}", e),
1501                    })?;
1502                return Ok(());
1503            }
1504        }
1505        Ok(())
1506    }
1507
1508    fn scroll_right(&self, element: &ElementData, amount: f64) -> Result<()> {
1509        let target = self.get_cached(element.handle)?;
1510        let count = (amount.abs() as u32).max(1);
1511        for _ in 0..count {
1512            if self
1513                .do_atspi_action_by_name(&target, "scroll right")
1514                .is_err()
1515            {
1516                // Fall back to Component.ScrollTo (single call, not repeatable)
1517                let proxy =
1518                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1519                // RIGHT_EDGE = 5
1520                proxy
1521                    .call_method("ScrollTo", &(5u32,))
1522                    .map_err(|e| Error::Platform {
1523                        code: -1,
1524                        message: format!("ScrollTo failed: {}", e),
1525                    })?;
1526                return Ok(());
1527            }
1528        }
1529        Ok(())
1530    }
1531
1532    fn scroll_left(&self, element: &ElementData, amount: f64) -> Result<()> {
1533        let target = self.get_cached(element.handle)?;
1534        let count = (amount.abs() as u32).max(1);
1535        for _ in 0..count {
1536            if self
1537                .do_atspi_action_by_name(&target, "scroll left")
1538                .is_err()
1539            {
1540                // Fall back to Component.ScrollTo (single call, not repeatable)
1541                let proxy =
1542                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1543                // LEFT_EDGE = 4
1544                proxy
1545                    .call_method("ScrollTo", &(4u32,))
1546                    .map_err(|e| Error::Platform {
1547                        code: -1,
1548                        message: format!("ScrollTo failed: {}", e),
1549                    })?;
1550                return Ok(());
1551            }
1552        }
1553        Ok(())
1554    }
1555
1556    fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
1557        match action {
1558            "press" => self.press(element),
1559            "focus" => self.focus(element),
1560            "blur" => self.blur(element),
1561            "toggle" => self.toggle(element),
1562            "select" => self.select(element),
1563            "expand" => self.expand(element),
1564            "collapse" => self.collapse(element),
1565            "show_menu" => self.show_menu(element),
1566            "increment" => self.increment(element),
1567            "decrement" => self.decrement(element),
1568            "scroll_into_view" => self.scroll_into_view(element),
1569            _ => Err(Error::ActionNotSupported {
1570                action: action.to_string(),
1571                role: element.role,
1572            }),
1573        }
1574    }
1575
1576    fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
1577        let pid = element.pid.ok_or(Error::Platform {
1578            code: -1,
1579            message: "Element has no PID for subscribe".to_string(),
1580        })?;
1581        let app_name = element.name.clone().unwrap_or_default();
1582        self.subscribe_impl(app_name, pid, pid)
1583    }
1584}
1585
1586// ── Event subscription ──────────────────────────────────────────────────────
1587
1588impl LinuxProvider {
1589    /// Spawn a polling thread that detects focus and structure changes.
1590    fn subscribe_impl(&self, app_name: String, app_pid: u32, pid: u32) -> Result<Subscription> {
1591        let (tx, rx) = std::sync::mpsc::channel();
1592        let poll_provider = LinuxProvider::new()?;
1593        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1594        let stop_clone = stop.clone();
1595
1596        let handle = std::thread::spawn(move || {
1597            let mut prev_focused: Option<String> = None;
1598            let mut prev_element_count: usize = 0;
1599
1600            while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
1601                std::thread::sleep(Duration::from_millis(100));
1602
1603                // Find the app element by PID
1604                let app_ref = match poll_provider.find_app_by_pid(pid) {
1605                    Ok(r) => r,
1606                    Err(_) => continue,
1607                };
1608                let app_data = poll_provider.build_element_data(&app_ref, Some(pid));
1609
1610                // Walk the tree lazily to find focused element and count
1611                let mut stack = vec![app_data];
1612                let mut element_count: usize = 0;
1613                let mut focused_element: Option<ElementData> = None;
1614                let mut visited = HashSet::new();
1615
1616                while let Some(el) = stack.pop() {
1617                    let path_key = format!("{:?}:{}", el.raw, el.handle);
1618                    if !visited.insert(path_key) {
1619                        continue;
1620                    }
1621                    element_count += 1;
1622                    if el.states.focused && focused_element.is_none() {
1623                        focused_element = Some(el.clone());
1624                    }
1625                    if let Ok(children) = poll_provider.get_children(Some(&el)) {
1626                        stack.extend(children);
1627                    }
1628                }
1629
1630                let focused_name = focused_element.as_ref().and_then(|e| e.name.clone());
1631                if focused_name != prev_focused {
1632                    if prev_focused.is_some() {
1633                        let _ = tx.send(Event {
1634                            event_type: EventType::FocusChanged,
1635                            app_name: app_name.clone(),
1636                            app_pid,
1637                            target: focused_element,
1638                            state_flag: None,
1639                            state_value: None,
1640                            text_change: None,
1641                            timestamp: std::time::Instant::now(),
1642                        });
1643                    }
1644                    prev_focused = focused_name;
1645                }
1646
1647                if element_count != prev_element_count && prev_element_count > 0 {
1648                    let _ = tx.send(Event {
1649                        event_type: EventType::StructureChanged,
1650                        app_name: app_name.clone(),
1651                        app_pid,
1652                        target: None,
1653                        state_flag: None,
1654                        state_value: None,
1655                        text_change: None,
1656                        timestamp: std::time::Instant::now(),
1657                    });
1658                }
1659                prev_element_count = element_count;
1660            }
1661        });
1662
1663        let cancel = CancelHandle::new(move || {
1664            stop.store(true, std::sync::atomic::Ordering::Relaxed);
1665            let _ = handle.join();
1666        });
1667
1668        Ok(Subscription::new(EventReceiver::new(rx), cancel))
1669    }
1670}
1671
1672/// Whether a role typically has text or Value interface content.
1673/// Container/structural roles are skipped to save D-Bus round-trips.
1674fn role_has_value(role: Role) -> bool {
1675    !matches!(
1676        role,
1677        Role::Application
1678            | Role::Window
1679            | Role::Dialog
1680            | Role::Group
1681            | Role::MenuBar
1682            | Role::Toolbar
1683            | Role::TabGroup
1684            | Role::SplitGroup
1685            | Role::Table
1686            | Role::TableRow
1687            | Role::Separator
1688    )
1689}
1690
1691/// Whether a role typically supports actions via the Action interface.
1692/// Container and display-only roles are skipped to save D-Bus round-trips.
1693fn role_has_actions(role: Role) -> bool {
1694    matches!(
1695        role,
1696        Role::Button
1697            | Role::CheckBox
1698            | Role::RadioButton
1699            | Role::MenuItem
1700            | Role::Link
1701            | Role::ComboBox
1702            | Role::TextField
1703            | Role::TextArea
1704            | Role::SpinButton
1705            | Role::Tab
1706            | Role::TreeItem
1707            | Role::ListItem
1708            | Role::ScrollBar
1709            | Role::Slider
1710            | Role::Menu
1711            | Role::Image
1712            | Role::Unknown
1713    )
1714}
1715
1716/// Map AT-SPI2 role name to xa11y Role.
1717fn map_atspi_role(role_name: &str) -> Role {
1718    match role_name.to_lowercase().as_str() {
1719        "application" => Role::Application,
1720        "window" | "frame" => Role::Window,
1721        "dialog" | "file chooser" => Role::Dialog,
1722        "alert" | "notification" => Role::Alert,
1723        "push button" | "push button menu" => Role::Button,
1724        "toggle button" => Role::Switch,
1725        "check box" | "check menu item" => Role::CheckBox,
1726        "radio button" | "radio menu item" => Role::RadioButton,
1727        "entry" | "password text" => Role::TextField,
1728        "spin button" | "spinbutton" => Role::SpinButton,
1729        // "textbox" is the ARIA role name returned by WebKit2GTK for both
1730        // <input type="text"> and <textarea>.  Map to TextArea here so the
1731        // multi-line refinement below can downgrade single-line ones to TextField.
1732        "text" | "textbox" => Role::TextArea,
1733        "label" | "static" | "caption" => Role::StaticText,
1734        "combo box" | "combobox" => Role::ComboBox,
1735        // "listbox" is the ARIA role name returned by WebKit2GTK for role="listbox".
1736        "list" | "list box" | "listbox" => Role::List,
1737        "list item" => Role::ListItem,
1738        "menu" => Role::Menu,
1739        "menu item" | "tearoff menu item" => Role::MenuItem,
1740        "menu bar" => Role::MenuBar,
1741        "page tab" => Role::Tab,
1742        "page tab list" => Role::TabGroup,
1743        "table" | "tree table" => Role::Table,
1744        "table row" => Role::TableRow,
1745        "table cell" | "table column header" | "table row header" => Role::TableCell,
1746        "tool bar" => Role::Toolbar,
1747        "scroll bar" => Role::ScrollBar,
1748        "slider" => Role::Slider,
1749        "image" | "icon" | "desktop icon" => Role::Image,
1750        "link" => Role::Link,
1751        "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" => Role::Group,
1752        "progress bar" => Role::ProgressBar,
1753        "tree item" => Role::TreeItem,
1754        "document web" | "document frame" => Role::WebArea,
1755        "heading" => Role::Heading,
1756        "separator" => Role::Separator,
1757        "split pane" => Role::SplitGroup,
1758        "tooltip" | "tool tip" => Role::Tooltip,
1759        "status bar" | "statusbar" => Role::Status,
1760        "landmark" | "navigation" => Role::Navigation,
1761        _ => xa11y_core::unknown_role(role_name),
1762    }
1763}
1764
1765/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
1766/// Values from atspi-common Role enum (repr(u32)).
1767fn map_atspi_role_number(role: u32) -> Role {
1768    match role {
1769        2 => Role::Alert,        // Alert
1770        7 => Role::CheckBox,     // CheckBox
1771        8 => Role::CheckBox,     // CheckMenuItem
1772        11 => Role::ComboBox,    // ComboBox
1773        16 => Role::Dialog,      // Dialog
1774        19 => Role::Dialog,      // FileChooser
1775        20 => Role::Group,       // Filler
1776        23 => Role::Window,      // Frame
1777        26 => Role::Image,       // Icon
1778        27 => Role::Image,       // Image
1779        29 => Role::StaticText,  // Label
1780        31 => Role::List,        // List
1781        32 => Role::ListItem,    // ListItem
1782        33 => Role::Menu,        // Menu
1783        34 => Role::MenuBar,     // MenuBar
1784        35 => Role::MenuItem,    // MenuItem
1785        37 => Role::Tab,         // PageTab
1786        38 => Role::TabGroup,    // PageTabList
1787        39 => Role::Group,       // Panel
1788        40 => Role::TextField,   // PasswordText
1789        42 => Role::ProgressBar, // ProgressBar
1790        43 => Role::Button,      // Button (push button)
1791        44 => Role::RadioButton, // RadioButton
1792        45 => Role::RadioButton, // RadioMenuItem
1793        48 => Role::ScrollBar,   // ScrollBar
1794        49 => Role::Group,       // ScrollPane
1795        50 => Role::Separator,   // Separator
1796        51 => Role::Slider,      // Slider
1797        52 => Role::SpinButton,  // SpinButton
1798        53 => Role::SplitGroup,  // SplitPane
1799        55 => Role::Table,       // Table
1800        56 => Role::TableCell,   // TableCell
1801        57 => Role::TableCell,   // TableColumnHeader
1802        58 => Role::TableCell,   // TableRowHeader
1803        61 => Role::TextArea,    // Text
1804        62 => Role::Switch,      // ToggleButton
1805        63 => Role::Toolbar,     // ToolBar
1806        65 => Role::Group,       // Tree
1807        66 => Role::Table,       // TreeTable
1808        67 => Role::Unknown,     // Unknown
1809        68 => Role::Group,       // Viewport
1810        69 => Role::Window,      // Window
1811        75 => Role::Application, // Application
1812        78 => Role::TextArea, // Embedded — WebKit2GTK uses this for <input type="text"> and <textarea>;
1813        // multi-line refinement below downgrades single-line ones to TextField
1814        79 => Role::TextField,   // Entry
1815        82 => Role::WebArea,     // DocumentFrame
1816        83 => Role::Heading,     // Heading
1817        85 => Role::Group,       // Section
1818        86 => Role::Group,       // RedundantObject
1819        87 => Role::Group,       // Form
1820        88 => Role::Link,        // Link
1821        90 => Role::TableRow,    // TableRow
1822        91 => Role::TreeItem,    // TreeItem
1823        95 => Role::WebArea,     // DocumentWeb
1824        97 => Role::List,        // WebKit2GTK uses this for <ul role="listbox">
1825        98 => Role::List,        // ListBox
1826        93 => Role::Tooltip,     // Tooltip
1827        101 => Role::Alert,      // Notification
1828        116 => Role::StaticText, // Static
1829        129 => Role::Button,     // PushButtonMenu
1830        _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
1831    }
1832}
1833
1834/// Map an AT-SPI2 action name to its canonical `snake_case` xa11y action name.
1835///
1836/// Toolkit-specific aliases are normalised to the single canonical name:
1837///   "click" / "activate" / "press" / "invoke" → "press"
1838///   "toggle" / "check" / "uncheck"            → "toggle"
1839///   "expand" / "open"                          → "expand"
1840///   "collapse" / "close"                       → "collapse"
1841///   "menu" / "showmenu" / "popup" / "show menu" → "show_menu"
1842///   "select"                                    → "select"
1843///   "increment"                                 → "increment"
1844///   "decrement"                                 → "decrement"
1845///
1846/// Returns `None` for unrecognised names.
1847fn map_atspi_action_name(action_name: &str) -> Option<String> {
1848    let lower = action_name.to_lowercase();
1849    let canonical = match lower.as_str() {
1850        "click" | "activate" | "press" | "invoke" => "press",
1851        "toggle" | "check" | "uncheck" => "toggle",
1852        "expand" | "open" => "expand",
1853        "collapse" | "close" => "collapse",
1854        "select" => "select",
1855        "menu" | "showmenu" | "show_menu" | "popup" | "show menu" => "show_menu",
1856        "increment" => "increment",
1857        "decrement" => "decrement",
1858        _ => return None,
1859    };
1860    Some(canonical.to_string())
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866
1867    #[test]
1868    fn test_role_mapping() {
1869        assert_eq!(map_atspi_role("push button"), Role::Button);
1870        assert_eq!(map_atspi_role("toggle button"), Role::Switch);
1871        assert_eq!(map_atspi_role("check box"), Role::CheckBox);
1872        assert_eq!(map_atspi_role("entry"), Role::TextField);
1873        assert_eq!(map_atspi_role("label"), Role::StaticText);
1874        assert_eq!(map_atspi_role("window"), Role::Window);
1875        assert_eq!(map_atspi_role("frame"), Role::Window);
1876        assert_eq!(map_atspi_role("dialog"), Role::Dialog);
1877        assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
1878        assert_eq!(map_atspi_role("slider"), Role::Slider);
1879        assert_eq!(map_atspi_role("panel"), Role::Group);
1880        assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
1881    }
1882
1883    #[test]
1884    fn test_numeric_role_mapping() {
1885        // ToggleButton (62) must map to Switch, not Button.
1886        // GTK4's Gtk.Switch and Gtk.ToggleButton both report numeric role 62.
1887        assert_eq!(map_atspi_role_number(62), Role::Switch);
1888        // Sanity-check a few well-established numeric mappings.
1889        assert_eq!(map_atspi_role_number(43), Role::Button); // PushButton
1890        assert_eq!(map_atspi_role_number(7), Role::CheckBox);
1891        assert_eq!(map_atspi_role_number(67), Role::Unknown); // AT-SPI Unknown
1892    }
1893
1894    #[test]
1895    fn test_action_name_mapping() {
1896        assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
1897        assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
1898        assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
1899        assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
1900        assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
1901        assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
1902        assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
1903        assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
1904        assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
1905        assert_eq!(
1906            map_atspi_action_name("collapse"),
1907            Some("collapse".to_string())
1908        );
1909        assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
1910        assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
1911        assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
1912        assert_eq!(
1913            map_atspi_action_name("showmenu"),
1914            Some("show_menu".to_string())
1915        );
1916        assert_eq!(
1917            map_atspi_action_name("popup"),
1918            Some("show_menu".to_string())
1919        );
1920        assert_eq!(
1921            map_atspi_action_name("show menu"),
1922            Some("show_menu".to_string())
1923        );
1924        assert_eq!(
1925            map_atspi_action_name("increment"),
1926            Some("increment".to_string())
1927        );
1928        assert_eq!(
1929            map_atspi_action_name("decrement"),
1930            Some("decrement".to_string())
1931        );
1932        assert_eq!(map_atspi_action_name("foobar"), None);
1933    }
1934
1935    /// All known AT-SPI2 aliases map to one of the well-known action names,
1936    /// and re-mapping the canonical name produces the same canonical name.
1937    #[test]
1938    fn test_action_name_aliases_roundtrip() {
1939        let atspi_names = [
1940            "click",
1941            "activate",
1942            "press",
1943            "invoke",
1944            "toggle",
1945            "check",
1946            "uncheck",
1947            "expand",
1948            "open",
1949            "collapse",
1950            "close",
1951            "select",
1952            "menu",
1953            "showmenu",
1954            "popup",
1955            "show menu",
1956            "increment",
1957            "decrement",
1958        ];
1959        for name in atspi_names {
1960            let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
1961                panic!("AT-SPI2 name {:?} should map to a canonical name", name)
1962            });
1963            // Re-mapping the canonical name must produce itself.
1964            let back = map_atspi_action_name(&canonical)
1965                .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
1966            assert_eq!(
1967                canonical, back,
1968                "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
1969                name, canonical, back, canonical
1970            );
1971        }
1972    }
1973
1974    /// Case-insensitive mapping works.
1975    #[test]
1976    fn test_action_name_case_insensitive() {
1977        assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
1978        assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
1979        assert_eq!(
1980            map_atspi_action_name("Increment"),
1981            Some("increment".to_string())
1982        );
1983    }
1984}