Skip to main content

xa11y_linux/
atspi.rs

1//! Real AT-SPI2 backend implementation using zbus D-Bus bindings.
2
3use std::sync::Mutex;
4use std::time::Duration;
5
6use xa11y_core::{
7    Action, ActionData, AppInfo, AppTarget, CancelHandle, ElementState, Error, Event, EventFilter,
8    EventKind, EventProvider, EventReceiver, Node, PermissionStatus, Provider, QueryOptions, Rect,
9    Result, Role, ScrollDirection, StateSet, Subscription, Toggled, Tree,
10};
11use zbus::blocking::{Connection, Proxy};
12
13/// Linux accessibility provider using AT-SPI2 over D-Bus.
14pub struct LinuxProvider {
15    a11y_bus: Connection,
16    /// Cached AT-SPI accessible refs for action dispatch (keyed by node index).
17    cached_refs: Mutex<Vec<AccessibleRef>>,
18}
19
20/// AT-SPI2 accessible reference: (bus_name, object_path).
21#[derive(Debug, Clone)]
22struct AccessibleRef {
23    bus_name: String,
24    path: String,
25}
26
27impl LinuxProvider {
28    /// Create a new Linux accessibility provider.
29    ///
30    /// Connects to the AT-SPI2 bus. Falls back to the session bus
31    /// if the dedicated AT-SPI bus is unavailable.
32    pub fn new() -> Result<Self> {
33        let a11y_bus = Self::connect_a11y_bus()?;
34        Ok(Self {
35            a11y_bus,
36            cached_refs: Mutex::new(Vec::new()),
37        })
38    }
39
40    fn connect_a11y_bus() -> Result<Connection> {
41        // Try getting the AT-SPI bus address from the a11y bus launcher,
42        // then connect to it. If that fails, fall back to the session bus
43        // (AT-SPI2 may use the session bus directly).
44        if let Ok(session) = Connection::session() {
45            let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
46                .map_err(|e| Error::Platform {
47                    code: -1,
48                    message: format!("Failed to create a11y bus proxy: {}", e),
49                })?;
50
51            if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
52                if let Ok(address) = addr_reply.body().deserialize::<String>() {
53                    if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
54                        if let Ok(Ok(conn)) =
55                            zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
56                        {
57                            return Ok(conn);
58                        }
59                    }
60                }
61            }
62
63            // Fall back to session bus
64            return Ok(session);
65        }
66
67        Connection::session().map_err(|e| Error::Platform {
68            code: -1,
69            message: format!("Failed to connect to D-Bus session bus: {}", e),
70        })
71    }
72
73    fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
74        Proxy::new(
75            &self.a11y_bus,
76            bus_name.to_owned(),
77            path.to_owned(),
78            interface.to_owned(),
79        )
80        .map_err(|e| Error::Platform {
81            code: -1,
82            message: format!("Failed to create proxy: {}", e),
83        })
84    }
85
86    /// Check whether an accessible object implements a given interface.
87    /// Queries the AT-SPI GetInterfaces method on the Accessible interface.
88    fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
89        let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
90            Ok(p) => p,
91            Err(_) => return false,
92        };
93        let reply = match proxy.call_method("GetInterfaces", &()) {
94            Ok(r) => r,
95            Err(_) => return false,
96        };
97        let interfaces: Vec<String> = match reply.body().deserialize() {
98            Ok(v) => v,
99            Err(_) => return false,
100        };
101        interfaces.iter().any(|i| i.contains(iface))
102    }
103
104    /// Get the numeric AT-SPI role via GetRole method.
105    fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
106        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
107        let reply = proxy
108            .call_method("GetRole", &())
109            .map_err(|e| Error::Platform {
110                code: -1,
111                message: format!("GetRole failed: {}", e),
112            })?;
113        reply
114            .body()
115            .deserialize::<u32>()
116            .map_err(|e| Error::Platform {
117                code: -1,
118                message: format!("GetRole deserialize failed: {}", e),
119            })
120    }
121
122    /// Get the AT-SPI role name string.
123    fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
124        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
125        let reply = proxy
126            .call_method("GetRoleName", &())
127            .map_err(|e| Error::Platform {
128                code: -1,
129                message: format!("GetRoleName failed: {}", e),
130            })?;
131        reply
132            .body()
133            .deserialize::<String>()
134            .map_err(|e| Error::Platform {
135                code: -1,
136                message: format!("GetRoleName deserialize failed: {}", e),
137            })
138    }
139
140    /// Get the name of an accessible element.
141    fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
142        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
143        proxy
144            .get_property::<String>("Name")
145            .map_err(|e| Error::Platform {
146                code: -1,
147                message: format!("Get Name property failed: {}", e),
148            })
149    }
150
151    /// Get the description of an accessible element.
152    fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
153        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
154        proxy
155            .get_property::<String>("Description")
156            .map_err(|e| Error::Platform {
157                code: -1,
158                message: format!("Get Description property failed: {}", e),
159            })
160    }
161
162    /// Get children via the GetChildren method.
163    /// AT-SPI registryd doesn't always implement standard D-Bus Properties,
164    /// so we use GetChildren which is more reliable.
165    fn get_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
166        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
167        let reply = proxy
168            .call_method("GetChildren", &())
169            .map_err(|e| Error::Platform {
170                code: -1,
171                message: format!("GetChildren failed: {}", e),
172            })?;
173        let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
174            reply.body().deserialize().map_err(|e| Error::Platform {
175                code: -1,
176                message: format!("GetChildren deserialize failed: {}", e),
177            })?;
178        Ok(children
179            .into_iter()
180            .map(|(bus_name, path)| AccessibleRef {
181                bus_name,
182                path: path.to_string(),
183            })
184            .collect())
185    }
186
187    /// Get the state set as raw u32 values.
188    fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
189        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
190        let reply = proxy
191            .call_method("GetState", &())
192            .map_err(|e| Error::Platform {
193                code: -1,
194                message: format!("GetState failed: {}", e),
195            })?;
196        reply
197            .body()
198            .deserialize::<Vec<u32>>()
199            .map_err(|e| Error::Platform {
200                code: -1,
201                message: format!("GetState deserialize failed: {}", e),
202            })
203    }
204
205    /// Get bounds via Component interface.
206    /// Checks for Component support first to avoid GTK CRITICAL warnings
207    /// on objects (e.g. TreeView cell renderers) that don't implement it.
208    fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
209        if !self.has_interface(aref, "Component") {
210            return None;
211        }
212        let proxy = self
213            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
214            .ok()?;
215        // GetExtents(coord_type: u32) -> (x, y, width, height)
216        // coord_type 0 = screen coordinates
217        let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
218        let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
219        if w <= 0 && h <= 0 {
220            return None;
221        }
222        Some(Rect {
223            x,
224            y,
225            width: w.max(0) as u32,
226            height: h.max(0) as u32,
227        })
228    }
229
230    /// Get available actions via Action interface.
231    /// Probes the interface directly rather than relying on the Interfaces property,
232    /// which some AT-SPI adapters (e.g. AccessKit) don't expose.
233    fn get_actions(&self, aref: &AccessibleRef) -> Vec<Action> {
234        let mut actions = Vec::new();
235
236        // Try Action interface directly
237        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
238            if let Ok(n_actions) = proxy.get_property::<i32>("NActions") {
239                for i in 0..n_actions {
240                    if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
241                        if let Ok(name) = reply.body().deserialize::<String>() {
242                            if let Some(action) = map_atspi_action(&name) {
243                                if !actions.contains(&action) {
244                                    actions.push(action);
245                                }
246                            }
247                        }
248                    }
249                }
250            }
251        }
252
253        // Try Component interface for Focus
254        if !actions.contains(&Action::Focus) {
255            if let Ok(proxy) =
256                self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
257            {
258                // Verify the interface exists by trying a method
259                if proxy.call_method("GetExtents", &(0u32,)).is_ok() {
260                    actions.push(Action::Focus);
261                }
262            }
263        }
264
265        actions
266    }
267
268    /// Get value via Value or Text interface.
269    /// Probes interfaces directly rather than relying on the Interfaces property.
270    fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
271        // Try Text interface first for text content (text fields, labels, combo boxes).
272        // This must come before Value because some AT-SPI adapters (e.g. AccessKit)
273        // may expose both interfaces, and Value.CurrentValue returns 0.0 for text nodes.
274        let text_value = self.get_text_content(aref);
275        if text_value.is_some() {
276            return text_value;
277        }
278        // Try Value interface (sliders, progress bars, scroll bars, spinners)
279        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
280            if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
281                return Some(val.to_string());
282            }
283        }
284        None
285    }
286
287    /// Read text content via the AT-SPI Text interface.
288    fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
289        let proxy = self
290            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
291            .ok()?;
292        let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
293        if char_count > 0 {
294            let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
295            let text: String = reply.body().deserialize().ok()?;
296            if !text.is_empty() {
297                return Some(text);
298            }
299        }
300        None
301    }
302
303    /// Traverse the accessibility tree rooted at `aref`, building nodes.
304    #[allow(clippy::too_many_arguments)]
305    #[allow(clippy::only_used_in_recursion)]
306    fn traverse(
307        &self,
308        aref: &AccessibleRef,
309        opts: &QueryOptions,
310        nodes: &mut Vec<Node>,
311        refs: &mut Vec<AccessibleRef>,
312        parent_idx: Option<u32>,
313        depth: u32,
314        screen_size: (u32, u32),
315    ) {
316        if let Some(max_depth) = opts.max_depth {
317            if depth > max_depth {
318                return;
319            }
320        }
321        if let Some(max_elements) = opts.max_elements {
322            if nodes.len() >= max_elements as usize {
323                return;
324            }
325        }
326
327        let role_name = self.get_role_name(aref).unwrap_or_default();
328        let role_num = self.get_role_number(aref).unwrap_or(0);
329        let role = if !role_name.is_empty() {
330            map_atspi_role(&role_name)
331        } else {
332            map_atspi_role_number(role_num)
333        };
334
335        // Don't apply role/visibility filters to the root node (depth 0)
336        // so the tree always has at least the application node.
337        let is_root = depth == 0;
338
339        // For role filtering, skip adding this node but still traverse children
340        // so descendant nodes matching the filter can be found.
341        let skip_for_role = if !is_root {
342            if let Some(ref filter_roles) = opts.roles {
343                !filter_roles.contains(&role)
344            } else {
345                false
346            }
347        } else {
348            false
349        };
350
351        // If role-filtered, skip this node but still traverse children
352        // so that descendant nodes matching the filter can be found.
353        if skip_for_role {
354            let children = self.get_children(aref).unwrap_or_default();
355            for child_ref in &children {
356                if let Some(max_elements) = opts.max_elements {
357                    if nodes.len() >= max_elements as usize {
358                        break;
359                    }
360                }
361                if child_ref.path == "/org/a11y/atspi/null"
362                    || child_ref.bus_name.is_empty()
363                    || child_ref.path.is_empty()
364                {
365                    continue;
366                }
367                self.traverse(
368                    child_ref,
369                    opts,
370                    nodes,
371                    refs,
372                    parent_idx,
373                    depth + 1,
374                    screen_size,
375                );
376            }
377            return;
378        }
379
380        let mut name = self.get_name(aref).ok().filter(|s| !s.is_empty());
381        let description = self.get_description(aref).ok().filter(|s| !s.is_empty());
382        let value = self.get_value(aref);
383
384        // For label/static text nodes, AT-SPI may put content in the Text interface
385        // (returned as value) rather than the Name property. Use it as the name.
386        if name.is_none() && role == Role::StaticText {
387            if let Some(ref v) = value {
388                name = Some(v.clone());
389            }
390        }
391        let bounds = self.get_extents(aref);
392        let states = self.parse_states(aref, role);
393        let actions = self.get_actions(aref);
394
395        if !is_root && opts.visible_only && !states.visible {
396            return;
397        }
398
399        let raw = {
400            let raw_role = if role_name.is_empty() {
401                format!("role_num:{}", role_num)
402            } else {
403                role_name
404            };
405            xa11y_core::RawPlatformData::Linux {
406                atspi_role: raw_role,
407                bus_name: aref.bus_name.clone(),
408                object_path: aref.path.clone(),
409            }
410        };
411
412        let (numeric_value, min_value, max_value) = if matches!(
413            role,
414            Role::Slider | Role::ProgressBar | Role::ScrollBar | Role::SpinButton
415        ) {
416            if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
417                (
418                    proxy.get_property::<f64>("CurrentValue").ok(),
419                    proxy.get_property::<f64>("MinimumValue").ok(),
420                    proxy.get_property::<f64>("MaximumValue").ok(),
421                )
422            } else {
423                (None, None, None)
424            }
425        } else {
426            (None, None, None)
427        };
428
429        let node_idx = nodes.len() as u32;
430        nodes.push(Node {
431            role,
432            name,
433            value,
434            description,
435            bounds,
436            actions,
437            states,
438            numeric_value,
439            min_value,
440            max_value,
441            stable_id: Some(aref.path.clone()),
442            raw,
443            index: node_idx,
444            children_indices: vec![], // filled in below
445            parent_index: parent_idx,
446        });
447        refs.push(aref.clone());
448
449        // Get children
450        let children = self.get_children(aref).unwrap_or_default();
451        let mut child_ids = Vec::new();
452
453        for child_ref in &children {
454            if let Some(max_elements) = opts.max_elements {
455                if nodes.len() >= max_elements as usize {
456                    break;
457                }
458            }
459            // Skip invalid refs
460            if child_ref.path == "/org/a11y/atspi/null"
461                || child_ref.bus_name.is_empty()
462                || child_ref.path.is_empty()
463            {
464                continue;
465            }
466            let child_idx = nodes.len() as u32;
467            child_ids.push(child_idx);
468            self.traverse(
469                child_ref,
470                opts,
471                nodes,
472                refs,
473                Some(node_idx),
474                depth + 1,
475                screen_size,
476            );
477        }
478
479        // Update children list
480        nodes[node_idx as usize].children_indices = child_ids;
481    }
482
483    /// Parse AT-SPI2 state bitfield into xa11y StateSet.
484    fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
485        let state_bits = self.get_state(aref).unwrap_or_default();
486
487        // AT-SPI2 states are a bitfield across two u32s
488        let bits: u64 = if state_bits.len() >= 2 {
489            (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
490        } else if state_bits.len() == 1 {
491            state_bits[0] as u64
492        } else {
493            0
494        };
495
496        // AT-SPI2 state bit positions (AtspiStateType enum values)
497        const BUSY: u64 = 1 << 3;
498        const CHECKED: u64 = 1 << 4;
499        const EDITABLE: u64 = 1 << 7;
500        const ENABLED: u64 = 1 << 8;
501        const EXPANDABLE: u64 = 1 << 9;
502        const EXPANDED: u64 = 1 << 10;
503        const FOCUSABLE: u64 = 1 << 11;
504        const FOCUSED: u64 = 1 << 12;
505        const MODAL: u64 = 1 << 16;
506        const SELECTED: u64 = 1 << 23;
507        const SENSITIVE: u64 = 1 << 24;
508        const SHOWING: u64 = 1 << 25;
509        const VISIBLE: u64 = 1 << 30;
510        const INDETERMINATE: u64 = 1 << 32;
511        const REQUIRED: u64 = 1 << 33;
512
513        let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
514        let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
515
516        let checked = match role {
517            Role::CheckBox | Role::RadioButton | Role::MenuItem => {
518                if (bits & INDETERMINATE) != 0 {
519                    Some(Toggled::Mixed)
520                } else if (bits & CHECKED) != 0 {
521                    Some(Toggled::On)
522                } else {
523                    Some(Toggled::Off)
524                }
525            }
526            _ => None,
527        };
528
529        let expanded = if (bits & EXPANDABLE) != 0 {
530            Some((bits & EXPANDED) != 0)
531        } else {
532            None
533        };
534
535        StateSet {
536            enabled,
537            visible,
538            focused: (bits & FOCUSED) != 0,
539            checked,
540            selected: (bits & SELECTED) != 0,
541            expanded,
542            editable: (bits & EDITABLE) != 0,
543            focusable: (bits & FOCUSABLE) != 0,
544            modal: (bits & MODAL) != 0,
545            required: (bits & REQUIRED) != 0,
546            busy: (bits & BUSY) != 0,
547        }
548    }
549
550    /// Get screen size.
551    fn detect_screen_size() -> (u32, u32) {
552        if let Ok(output) = std::process::Command::new("xdpyinfo").output() {
553            let stdout = String::from_utf8_lossy(&output.stdout);
554            for line in stdout.lines() {
555                let trimmed = line.trim();
556                if trimmed.starts_with("dimensions:") {
557                    if let Some(dims) = trimmed.split_whitespace().nth(1) {
558                        let parts: Vec<&str> = dims.split('x').collect();
559                        if parts.len() == 2 {
560                            if let (Ok(w), Ok(h)) = (parts[0].parse(), parts[1].parse()) {
561                                return (w, h);
562                            }
563                        }
564                    }
565                }
566            }
567        }
568        (1920, 1080)
569    }
570
571    /// Find an application by name.
572    fn find_app_by_name(&self, name: &str) -> Result<AccessibleRef> {
573        let registry = AccessibleRef {
574            bus_name: "org.a11y.atspi.Registry".to_string(),
575            path: "/org/a11y/atspi/accessible/root".to_string(),
576        };
577        let children = self.get_children(&registry)?;
578        let name_lower = name.to_lowercase();
579
580        for child in &children {
581            if child.path == "/org/a11y/atspi/null" {
582                continue;
583            }
584            if let Ok(app_name) = self.get_name(child) {
585                if app_name.to_lowercase().contains(&name_lower) {
586                    return Ok(child.clone());
587                }
588            }
589        }
590
591        Err(Error::AppNotFound {
592            target: name.to_string(),
593        })
594    }
595
596    /// Find an application by PID.
597    fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
598        let registry = AccessibleRef {
599            bus_name: "org.a11y.atspi.Registry".to_string(),
600            path: "/org/a11y/atspi/accessible/root".to_string(),
601        };
602        let children = self.get_children(&registry)?;
603
604        for child in &children {
605            if child.path == "/org/a11y/atspi/null" {
606                continue;
607            }
608            // Try Application.Id first
609            if let Ok(proxy) =
610                self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
611            {
612                if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
613                    if app_pid as u32 == pid {
614                        return Ok(child.clone());
615                    }
616                }
617            }
618            // Fall back to D-Bus connection PID
619            if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
620                if app_pid == pid {
621                    return Ok(child.clone());
622                }
623            }
624        }
625
626        Err(Error::AppNotFound {
627            target: format!("PID {}", pid),
628        })
629    }
630
631    /// Get PID via D-Bus GetConnectionUnixProcessID.
632    fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
633        let proxy = self
634            .make_proxy(
635                "org.freedesktop.DBus",
636                "/org/freedesktop/DBus",
637                "org.freedesktop.DBus",
638            )
639            .ok()?;
640        let reply = proxy
641            .call_method("GetConnectionUnixProcessID", &(bus_name,))
642            .ok()?;
643        let pid: u32 = reply.body().deserialize().ok()?;
644        if pid > 0 {
645            Some(pid)
646        } else {
647            None
648        }
649    }
650
651    /// Perform an AT-SPI action by name.
652    fn do_atspi_action(&self, aref: &AccessibleRef, action_name: &str) -> Result<()> {
653        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
654        let n_actions: i32 = proxy.get_property("NActions").unwrap_or(0);
655
656        for i in 0..n_actions {
657            if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
658                if let Ok(name) = reply.body().deserialize::<String>() {
659                    if name == action_name {
660                        let _ =
661                            proxy
662                                .call_method("DoAction", &(i,))
663                                .map_err(|e| Error::Platform {
664                                    code: -1,
665                                    message: format!("DoAction failed: {}", e),
666                                })?;
667                        return Ok(());
668                    }
669                }
670            }
671        }
672
673        Err(Error::Platform {
674            code: -1,
675            message: format!("Action '{}' not found", action_name),
676        })
677    }
678
679    /// Get PID from Application interface, falling back to D-Bus connection PID.
680    fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
681        // Try Application.Id first
682        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
683        {
684            if let Ok(pid) = proxy.get_property::<i32>("Id") {
685                if pid > 0 {
686                    return Some(pid as u32);
687                }
688            }
689        }
690
691        // Fall back to D-Bus GetConnectionUnixProcessID
692        if let Ok(proxy) = self.make_proxy(
693            "org.freedesktop.DBus",
694            "/org/freedesktop/DBus",
695            "org.freedesktop.DBus",
696        ) {
697            if let Ok(reply) =
698                proxy.call_method("GetConnectionUnixProcessID", &(aref.bus_name.as_str(),))
699            {
700                if let Ok(pid) = reply.body().deserialize::<u32>() {
701                    if pid > 0 {
702                        return Some(pid);
703                    }
704                }
705            }
706        }
707
708        None
709    }
710}
711
712impl Provider for LinuxProvider {
713    fn get_app_tree(&self, target: &AppTarget, opts: &QueryOptions) -> Result<Tree> {
714        let app_ref = match target {
715            AppTarget::ByName(name) => self.find_app_by_name(name)?,
716            AppTarget::ByPid(pid) => self.find_app_by_pid(*pid)?,
717            AppTarget::ByWindow(_) => {
718                return Err(Error::Platform {
719                    code: -1,
720                    message: "ByWindow not supported on Linux AT-SPI2".to_string(),
721                });
722            }
723        };
724
725        let app_name = self.get_name(&app_ref).unwrap_or_default();
726        let screen_size = Self::detect_screen_size();
727        let mut nodes = Vec::new();
728        let mut refs = Vec::new();
729
730        self.traverse(&app_ref, opts, &mut nodes, &mut refs, None, 0, screen_size);
731
732        if nodes.is_empty() {
733            return Err(Error::AppNotFound {
734                target: format!("{:?}", target),
735            });
736        }
737
738        // Cache refs for action dispatch
739        *self.cached_refs.lock().unwrap() = refs;
740
741        let pid = self.get_app_pid(&app_ref);
742
743        Ok(Tree::new(app_name, pid, screen_size, nodes))
744    }
745
746    fn get_all_apps(&self, opts: &QueryOptions) -> Result<Tree> {
747        let screen_size = Self::detect_screen_size();
748        let mut nodes = Vec::new();
749
750        nodes.push(Node {
751            role: Role::Application,
752            name: Some("Desktop".to_string()),
753            value: None,
754            description: None,
755            bounds: Some(Rect {
756                x: 0,
757                y: 0,
758                width: screen_size.0,
759                height: screen_size.1,
760            }),
761            actions: vec![],
762            states: StateSet::default(),
763            numeric_value: None,
764            min_value: None,
765            max_value: None,
766            stable_id: None,
767            raw: xa11y_core::RawPlatformData::Synthetic,
768            index: 0,
769            children_indices: vec![],
770            parent_index: None,
771        });
772
773        let mut refs = Vec::new();
774        refs.push(AccessibleRef {
775            bus_name: String::new(),
776            path: String::new(),
777        }); // placeholder for desktop root
778
779        let registry = AccessibleRef {
780            bus_name: "org.a11y.atspi.Registry".to_string(),
781            path: "/org/a11y/atspi/accessible/root".to_string(),
782        };
783        let children = self.get_children(&registry).unwrap_or_default();
784        let mut root_children = Vec::new();
785
786        for child in &children {
787            if child.path == "/org/a11y/atspi/null" {
788                continue;
789            }
790            let app_name = self.get_name(child).unwrap_or_default();
791            if app_name.is_empty() {
792                continue;
793            }
794            let child_idx = nodes.len() as u32;
795            root_children.push(child_idx);
796            self.traverse(child, opts, &mut nodes, &mut refs, Some(0), 1, screen_size);
797        }
798
799        nodes[0].children_indices = root_children;
800
801        *self.cached_refs.lock().unwrap() = refs;
802
803        Ok(Tree::new("Desktop".to_string(), None, screen_size, nodes))
804    }
805
806    fn perform_action(
807        &self,
808        tree: &Tree,
809        node: &Node,
810        action: Action,
811        data: Option<ActionData>,
812    ) -> Result<()> {
813        let node_idx = tree.node_index(node);
814
815        // Look up cached accessible ref for action dispatch
816        let cache = self.cached_refs.lock().unwrap();
817        let target = cache
818            .get(node_idx as usize)
819            .ok_or(Error::ElementStale {
820                selector: format!("index:{}", node_idx),
821            })?
822            .clone();
823        drop(cache);
824
825        match action {
826            Action::Press => self
827                .do_atspi_action(&target, "click")
828                .or_else(|_| self.do_atspi_action(&target, "activate"))
829                .or_else(|_| self.do_atspi_action(&target, "press")),
830            Action::Focus => {
831                // Try Component.GrabFocus first, then fall back to Action interface
832                if let Ok(proxy) =
833                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
834                {
835                    if proxy.call_method("GrabFocus", &()).is_ok() {
836                        return Ok(());
837                    }
838                }
839                self.do_atspi_action(&target, "focus")
840                    .or_else(|_| self.do_atspi_action(&target, "setFocus"))
841            }
842            Action::SetValue => match data {
843                Some(ActionData::NumericValue(v)) => {
844                    let proxy =
845                        self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
846                    proxy
847                        .set_property("CurrentValue", v)
848                        .map_err(|e| Error::Platform {
849                            code: -1,
850                            message: format!("SetValue failed: {}", e),
851                        })
852                }
853                Some(ActionData::Value(text)) => {
854                    let proxy = self
855                        .make_proxy(
856                            &target.bus_name,
857                            &target.path,
858                            "org.a11y.atspi.EditableText",
859                        )
860                        .map_err(|_| Error::TextValueNotSupported)?;
861                    let _ = proxy.call_method("DeleteText", &(0i32, i32::MAX));
862                    proxy
863                        .call_method("InsertText", &(0i32, &*text, text.len() as i32))
864                        .map_err(|_| Error::TextValueNotSupported)?;
865                    Ok(())
866                }
867                _ => Err(Error::Platform {
868                    code: -1,
869                    message: "SetValue requires ActionData".to_string(),
870                }),
871            },
872            Action::Toggle => self
873                .do_atspi_action(&target, "toggle")
874                .or_else(|_| self.do_atspi_action(&target, "click"))
875                .or_else(|_| self.do_atspi_action(&target, "activate")),
876            Action::Expand => self
877                .do_atspi_action(&target, "expand")
878                .or_else(|_| self.do_atspi_action(&target, "open")),
879            Action::Collapse => self
880                .do_atspi_action(&target, "collapse")
881                .or_else(|_| self.do_atspi_action(&target, "close")),
882            Action::Select => self.do_atspi_action(&target, "select"),
883            Action::ShowMenu => self
884                .do_atspi_action(&target, "menu")
885                .or_else(|_| self.do_atspi_action(&target, "showmenu")),
886            Action::ScrollIntoView => {
887                let proxy =
888                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
889                proxy
890                    .call_method("ScrollTo", &(0u32,))
891                    .map_err(|e| Error::Platform {
892                        code: -1,
893                        message: format!("ScrollTo failed: {}", e),
894                    })?;
895                Ok(())
896            }
897            Action::Increment => self.do_atspi_action(&target, "increment").or_else(|_| {
898                // Fall back to Value interface: current + step (or +1)
899                let proxy =
900                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
901                let current: f64 =
902                    proxy
903                        .get_property("CurrentValue")
904                        .map_err(|e| Error::Platform {
905                            code: -1,
906                            message: format!("Value.CurrentValue failed: {}", e),
907                        })?;
908                let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
909                let step = if step <= 0.0 { 1.0 } else { step };
910                proxy
911                    .set_property("CurrentValue", current + step)
912                    .map_err(|e| Error::Platform {
913                        code: -1,
914                        message: format!("Value.SetCurrentValue failed: {}", e),
915                    })
916            }),
917            Action::Decrement => self.do_atspi_action(&target, "decrement").or_else(|_| {
918                let proxy =
919                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
920                let current: f64 =
921                    proxy
922                        .get_property("CurrentValue")
923                        .map_err(|e| Error::Platform {
924                            code: -1,
925                            message: format!("Value.CurrentValue failed: {}", e),
926                        })?;
927                let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
928                let step = if step <= 0.0 { 1.0 } else { step };
929                proxy
930                    .set_property("CurrentValue", current - step)
931                    .map_err(|e| Error::Platform {
932                        code: -1,
933                        message: format!("Value.SetCurrentValue failed: {}", e),
934                    })
935            }),
936            Action::Blur => {
937                // Grab focus on parent element to blur the current one
938                let proxy =
939                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Accessible")?;
940                if let Ok(reply) = proxy.call_method("GetParent", &()) {
941                    if let Ok((bus, path)) = reply
942                        .body()
943                        .deserialize::<(String, zbus::zvariant::OwnedObjectPath)>()
944                    {
945                        let path_str = path.as_str();
946                        if path_str != "/org/a11y/atspi/null" {
947                            if let Ok(p) =
948                                self.make_proxy(&bus, path_str, "org.a11y.atspi.Component")
949                            {
950                                let _ = p.call_method("GrabFocus", &());
951                                return Ok(());
952                            }
953                        }
954                    }
955                }
956                Ok(())
957            }
958
959            Action::Scroll => {
960                let (direction, amount) = match data {
961                    Some(ActionData::ScrollAmount { direction, amount }) => (direction, amount),
962                    _ => {
963                        return Err(Error::Platform {
964                            code: -1,
965                            message: "Scroll requires ActionData::ScrollAmount".to_string(),
966                        })
967                    }
968                };
969                // Repeat scroll action for each logical unit (AT-SPI has no scroll-by-amount)
970                let count = (amount.abs() as u32).max(1);
971                let action_name = match direction {
972                    ScrollDirection::Up => "scroll up",
973                    ScrollDirection::Down => "scroll down",
974                    ScrollDirection::Left => "scroll left",
975                    ScrollDirection::Right => "scroll right",
976                };
977                for _ in 0..count {
978                    if self.do_atspi_action(&target, action_name).is_err() {
979                        // Fall back to Component.ScrollTo (single call, not repeatable)
980                        let proxy = self.make_proxy(
981                            &target.bus_name,
982                            &target.path,
983                            "org.a11y.atspi.Component",
984                        )?;
985                        let scroll_type: u32 = match direction {
986                            ScrollDirection::Up => 2,    // TOP_EDGE
987                            ScrollDirection::Down => 3,  // BOTTOM_EDGE
988                            ScrollDirection::Left => 4,  // LEFT_EDGE
989                            ScrollDirection::Right => 5, // RIGHT_EDGE
990                        };
991                        proxy
992                            .call_method("ScrollTo", &(scroll_type,))
993                            .map_err(|e| Error::Platform {
994                                code: -1,
995                                message: format!("ScrollTo failed: {}", e),
996                            })?;
997                        return Ok(());
998                    }
999                }
1000                Ok(())
1001            }
1002
1003            Action::SetTextSelection => {
1004                let (start, end) = match data {
1005                    Some(ActionData::TextSelection { start, end }) => (start, end),
1006                    _ => {
1007                        return Err(Error::Platform {
1008                            code: -1,
1009                            message: "SetTextSelection requires ActionData::TextSelection"
1010                                .to_string(),
1011                        })
1012                    }
1013                };
1014                let proxy =
1015                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
1016                // Try SetSelection first, fall back to AddSelection
1017                if proxy
1018                    .call_method("SetSelection", &(0i32, start as i32, end as i32))
1019                    .is_err()
1020                {
1021                    proxy
1022                        .call_method("AddSelection", &(start as i32, end as i32))
1023                        .map_err(|e| Error::Platform {
1024                            code: -1,
1025                            message: format!("Text.AddSelection failed: {}", e),
1026                        })?;
1027                }
1028                Ok(())
1029            }
1030
1031            Action::TypeText => {
1032                let text = match data {
1033                    Some(ActionData::Value(text)) => text,
1034                    _ => {
1035                        return Err(Error::Platform {
1036                            code: -1,
1037                            message: "TypeText requires ActionData::Value".to_string(),
1038                        })
1039                    }
1040                };
1041                // Insert text via EditableText interface (accessibility API, not input simulation).
1042                // Get cursor position from Text interface, then insert at that position.
1043                let text_proxy =
1044                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
1045                let insert_pos = text_proxy
1046                    .as_ref()
1047                    .ok()
1048                    .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
1049                    .unwrap_or(-1); // -1 = append at end
1050
1051                let proxy = self
1052                    .make_proxy(
1053                        &target.bus_name,
1054                        &target.path,
1055                        "org.a11y.atspi.EditableText",
1056                    )
1057                    .map_err(|_| Error::TextValueNotSupported)?;
1058                let pos = if insert_pos >= 0 {
1059                    insert_pos
1060                } else {
1061                    i32::MAX
1062                };
1063                proxy
1064                    .call_method("InsertText", &(pos, &*text, text.len() as i32))
1065                    .map_err(|e| Error::Platform {
1066                        code: -1,
1067                        message: format!("EditableText.InsertText failed: {}", e),
1068                    })?;
1069                Ok(())
1070            }
1071        }
1072    }
1073
1074    fn check_permissions(&self) -> Result<PermissionStatus> {
1075        let registry = AccessibleRef {
1076            bus_name: "org.a11y.atspi.Registry".to_string(),
1077            path: "/org/a11y/atspi/accessible/root".to_string(),
1078        };
1079        match self.get_children(&registry) {
1080            Ok(_) => Ok(PermissionStatus::Granted),
1081            Err(_) => Ok(PermissionStatus::Denied {
1082                instructions:
1083                    "Enable accessibility: gsettings set org.gnome.desktop.interface toolkit-accessibility true\nEnsure at-spi2-core is installed."
1084                        .to_string(),
1085            }),
1086        }
1087    }
1088
1089    fn list_apps(&self) -> Result<Vec<AppInfo>> {
1090        let registry = AccessibleRef {
1091            bus_name: "org.a11y.atspi.Registry".to_string(),
1092            path: "/org/a11y/atspi/accessible/root".to_string(),
1093        };
1094        let children = self.get_children(&registry)?;
1095        let mut apps = Vec::new();
1096
1097        for child in &children {
1098            if child.path == "/org/a11y/atspi/null" {
1099                continue;
1100            }
1101            let name = self.get_name(child).unwrap_or_default();
1102            if name.is_empty() {
1103                continue;
1104            }
1105            let pid = self.get_app_pid(child);
1106            apps.push(AppInfo {
1107                name,
1108                pid: pid.unwrap_or(0),
1109                bundle_id: None,
1110            });
1111        }
1112
1113        Ok(apps)
1114    }
1115}
1116
1117// ── EventProvider ────────────────────────────────────────────────────────────
1118
1119impl EventProvider for LinuxProvider {
1120    fn subscribe(&self, target: &AppTarget, filter: EventFilter) -> Result<Subscription> {
1121        let (tx, rx) = std::sync::mpsc::channel();
1122
1123        let app_info = match target {
1124            AppTarget::ByName(name) => {
1125                let app_ref = self.find_app_by_name(name)?;
1126                let pid = self.get_app_pid(&app_ref).unwrap_or(0);
1127                AppInfo {
1128                    name: self.get_name(&app_ref).unwrap_or_default(),
1129                    pid,
1130                    bundle_id: None,
1131                }
1132            }
1133            AppTarget::ByPid(pid) => {
1134                let app_ref = self.find_app_by_pid(*pid)?;
1135                AppInfo {
1136                    name: self.get_name(&app_ref).unwrap_or_default(),
1137                    pid: *pid,
1138                    bundle_id: None,
1139                }
1140            }
1141            AppTarget::ByWindow(_) => {
1142                return Err(Error::Platform {
1143                    code: -1,
1144                    message: "ByWindow not supported for event subscription".to_string(),
1145                })
1146            }
1147        };
1148
1149        // Create a separate provider for polling on the background thread
1150        let poll_provider = LinuxProvider::new()?;
1151        let target_clone = target.clone();
1152        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1153        let stop_clone = stop.clone();
1154
1155        // Poll for tree changes on a background thread, emitting events for diffs
1156        let handle = std::thread::spawn(move || {
1157            let mut prev_focused: Option<String> = None;
1158            let mut prev_node_count: usize = 0;
1159
1160            while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
1161                std::thread::sleep(Duration::from_millis(100));
1162
1163                let tree = match poll_provider.get_app_tree(&target_clone, &QueryOptions::default())
1164                {
1165                    Ok(t) => t,
1166                    Err(_) => continue,
1167                };
1168
1169                // Detect focus changes
1170                let focused_name = tree
1171                    .iter()
1172                    .find(|n| n.states.focused)
1173                    .and_then(|n| n.name.clone());
1174                if focused_name != prev_focused {
1175                    if prev_focused.is_some() {
1176                        let kind = EventKind::FocusChanged;
1177                        if filter.kinds.is_empty() || filter.kinds.contains(&kind) {
1178                            let _ = tx.send(Event {
1179                                kind,
1180                                app: app_info.clone(),
1181                                target: tree.iter().find(|n| n.states.focused).cloned(),
1182                                state_flag: None,
1183                                state_value: None,
1184                                text_change: None,
1185                                timestamp: std::time::Instant::now(),
1186                            });
1187                        }
1188                    }
1189                    prev_focused = focused_name;
1190                }
1191
1192                // Detect structure changes (node count changed)
1193                let node_count = tree.len();
1194                if node_count != prev_node_count && prev_node_count > 0 {
1195                    let kind = EventKind::StructureChanged;
1196                    if filter.kinds.is_empty() || filter.kinds.contains(&kind) {
1197                        let _ = tx.send(Event {
1198                            kind,
1199                            app: app_info.clone(),
1200                            target: None,
1201                            state_flag: None,
1202                            state_value: None,
1203                            text_change: None,
1204                            timestamp: std::time::Instant::now(),
1205                        });
1206                    }
1207                }
1208                prev_node_count = node_count;
1209            }
1210        });
1211
1212        let cancel = CancelHandle::new(move || {
1213            stop.store(true, std::sync::atomic::Ordering::Relaxed);
1214            let _ = handle.join();
1215        });
1216
1217        Ok(Subscription::new(EventReceiver::new(rx), cancel))
1218    }
1219
1220    fn wait_for_event(
1221        &self,
1222        target: &AppTarget,
1223        filter: EventFilter,
1224        timeout: Duration,
1225    ) -> Result<Event> {
1226        let sub = self.subscribe(target, filter)?;
1227        let start = std::time::Instant::now();
1228        loop {
1229            if let Some(event) = sub.try_recv() {
1230                return Ok(event);
1231            }
1232            let elapsed = start.elapsed();
1233            if elapsed >= timeout {
1234                return Err(Error::Timeout { elapsed });
1235            }
1236            std::thread::sleep(Duration::from_millis(10));
1237        }
1238    }
1239
1240    fn wait_for(
1241        &self,
1242        target: &AppTarget,
1243        selector: &str,
1244        state: ElementState,
1245        timeout: Duration,
1246    ) -> Result<Node> {
1247        let start = std::time::Instant::now();
1248        let poll_interval = Duration::from_millis(100);
1249
1250        loop {
1251            let elapsed = start.elapsed();
1252            if elapsed >= timeout {
1253                return Err(Error::Timeout { elapsed });
1254            }
1255
1256            let tree = self.get_app_tree(target, &QueryOptions::default())?;
1257            let matches = tree.query(selector).ok();
1258            let node = matches.as_ref().and_then(|m| m.first().copied());
1259
1260            if state.is_met(node) {
1261                return Ok(node.cloned().unwrap_or_else(Node::synthetic_empty));
1262            }
1263
1264            std::thread::sleep(poll_interval);
1265        }
1266    }
1267}
1268
1269/// Map AT-SPI2 role name to xa11y Role.
1270fn map_atspi_role(role_name: &str) -> Role {
1271    match role_name.to_lowercase().as_str() {
1272        "application" => Role::Application,
1273        "window" | "frame" => Role::Window,
1274        "dialog" | "file chooser" => Role::Dialog,
1275        "alert" | "notification" => Role::Alert,
1276        "push button" | "push button menu" => Role::Button,
1277        "check box" | "check menu item" => Role::CheckBox,
1278        "radio button" | "radio menu item" => Role::RadioButton,
1279        "entry" | "password text" => Role::TextField,
1280        "spin button" => Role::SpinButton,
1281        "text" => Role::TextArea,
1282        "label" | "static" | "caption" => Role::StaticText,
1283        "combo box" => Role::ComboBox,
1284        "list" | "list box" => Role::List,
1285        "list item" => Role::ListItem,
1286        "menu" => Role::Menu,
1287        "menu item" | "tearoff menu item" => Role::MenuItem,
1288        "menu bar" => Role::MenuBar,
1289        "page tab" => Role::Tab,
1290        "page tab list" => Role::TabGroup,
1291        "table" | "tree table" => Role::Table,
1292        "table row" => Role::TableRow,
1293        "table cell" | "table column header" | "table row header" => Role::TableCell,
1294        "tool bar" => Role::Toolbar,
1295        "scroll bar" => Role::ScrollBar,
1296        "slider" => Role::Slider,
1297        "image" | "icon" | "desktop icon" => Role::Image,
1298        "link" => Role::Link,
1299        "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" => Role::Group,
1300        "progress bar" => Role::ProgressBar,
1301        "tree item" => Role::TreeItem,
1302        "document web" | "document frame" => Role::WebArea,
1303        "heading" => Role::Heading,
1304        "separator" => Role::Separator,
1305        "split pane" => Role::SplitGroup,
1306        "tooltip" | "tool tip" => Role::Tooltip,
1307        "status bar" | "statusbar" => Role::Status,
1308        "landmark" | "navigation" => Role::Navigation,
1309        _ => Role::Unknown,
1310    }
1311}
1312
1313/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
1314/// Values from atspi-common Role enum (repr(u32)).
1315fn map_atspi_role_number(role: u32) -> Role {
1316    match role {
1317        2 => Role::Alert,        // Alert
1318        7 => Role::CheckBox,     // CheckBox
1319        8 => Role::CheckBox,     // CheckMenuItem
1320        11 => Role::ComboBox,    // ComboBox
1321        16 => Role::Dialog,      // Dialog
1322        19 => Role::Dialog,      // FileChooser
1323        20 => Role::Group,       // Filler
1324        23 => Role::Window,      // Frame
1325        26 => Role::Image,       // Icon
1326        27 => Role::Image,       // Image
1327        29 => Role::StaticText,  // Label
1328        31 => Role::List,        // List
1329        32 => Role::ListItem,    // ListItem
1330        33 => Role::Menu,        // Menu
1331        34 => Role::MenuBar,     // MenuBar
1332        35 => Role::MenuItem,    // MenuItem
1333        37 => Role::Tab,         // PageTab
1334        38 => Role::TabGroup,    // PageTabList
1335        39 => Role::Group,       // Panel
1336        40 => Role::TextField,   // PasswordText
1337        42 => Role::ProgressBar, // ProgressBar
1338        43 => Role::Button,      // Button (push button)
1339        44 => Role::RadioButton, // RadioButton
1340        45 => Role::RadioButton, // RadioMenuItem
1341        48 => Role::ScrollBar,   // ScrollBar
1342        49 => Role::Group,       // ScrollPane
1343        50 => Role::Separator,   // Separator
1344        51 => Role::Slider,      // Slider
1345        52 => Role::SpinButton,  // SpinButton
1346        53 => Role::SplitGroup,  // SplitPane
1347        55 => Role::Table,       // Table
1348        56 => Role::TableCell,   // TableCell
1349        57 => Role::TableCell,   // TableColumnHeader
1350        58 => Role::TableCell,   // TableRowHeader
1351        61 => Role::TextArea,    // Text
1352        62 => Role::Button,      // ToggleButton
1353        63 => Role::Toolbar,     // ToolBar
1354        65 => Role::Group,       // Tree
1355        66 => Role::Table,       // TreeTable
1356        67 => Role::Unknown,     // Unknown
1357        68 => Role::Group,       // Viewport
1358        69 => Role::Window,      // Window
1359        75 => Role::Application, // Application
1360        79 => Role::TextField,   // Entry
1361        82 => Role::WebArea,     // DocumentFrame
1362        83 => Role::Heading,     // Heading
1363        85 => Role::Group,       // Section
1364        86 => Role::Group,       // RedundantObject
1365        87 => Role::Group,       // Form
1366        88 => Role::Link,        // Link
1367        90 => Role::TableRow,    // TableRow
1368        91 => Role::TreeItem,    // TreeItem
1369        95 => Role::WebArea,     // DocumentWeb
1370        98 => Role::List,        // ListBox
1371        93 => Role::Tooltip,     // Tooltip
1372        97 => Role::Status,      // StatusBar
1373        101 => Role::Alert,      // Notification
1374        116 => Role::StaticText, // Static
1375        129 => Role::Button,     // PushButtonMenu
1376        _ => Role::Unknown,
1377    }
1378}
1379
1380/// Map AT-SPI2 action name to xa11y Action.
1381fn map_atspi_action(action_name: &str) -> Option<Action> {
1382    match action_name.to_lowercase().as_str() {
1383        "click" | "activate" | "press" | "invoke" => Some(Action::Press),
1384        "toggle" | "check" | "uncheck" => Some(Action::Toggle),
1385        "expand" | "open" => Some(Action::Expand),
1386        "collapse" | "close" => Some(Action::Collapse),
1387        "select" => Some(Action::Select),
1388        "menu" | "showmenu" | "popup" | "show menu" => Some(Action::ShowMenu),
1389        "increment" => Some(Action::Increment),
1390        "decrement" => Some(Action::Decrement),
1391        _ => None,
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398
1399    #[test]
1400    fn test_role_mapping() {
1401        assert_eq!(map_atspi_role("push button"), Role::Button);
1402        assert_eq!(map_atspi_role("check box"), Role::CheckBox);
1403        assert_eq!(map_atspi_role("entry"), Role::TextField);
1404        assert_eq!(map_atspi_role("label"), Role::StaticText);
1405        assert_eq!(map_atspi_role("window"), Role::Window);
1406        assert_eq!(map_atspi_role("frame"), Role::Window);
1407        assert_eq!(map_atspi_role("dialog"), Role::Dialog);
1408        assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
1409        assert_eq!(map_atspi_role("slider"), Role::Slider);
1410        assert_eq!(map_atspi_role("panel"), Role::Group);
1411        assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
1412    }
1413
1414    #[test]
1415    fn test_action_mapping() {
1416        assert_eq!(map_atspi_action("click"), Some(Action::Press));
1417        assert_eq!(map_atspi_action("activate"), Some(Action::Press));
1418        assert_eq!(map_atspi_action("toggle"), Some(Action::Toggle));
1419        assert_eq!(map_atspi_action("expand"), Some(Action::Expand));
1420        assert_eq!(map_atspi_action("collapse"), Some(Action::Collapse));
1421        assert_eq!(map_atspi_action("select"), Some(Action::Select));
1422        assert_eq!(map_atspi_action("increment"), Some(Action::Increment));
1423        assert_eq!(map_atspi_action("foobar"), None);
1424    }
1425}