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