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