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, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6
7use rayon::prelude::*;
8use xa11y_core::{
9    ElementData, ElementParts, Error, Provider, Rect, Result, Role, ShellSurfaceKind, StateParts,
10    StateSet, Subscription, Toggled,
11};
12use zbus::blocking::{Connection, Proxy};
13
14use crate::window::WindowManager;
15
16/// Global handle counter for mapping ElementData back to AccessibleRefs.
17static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
18
19/// Format a normalized state attribute as the same string `xa11y_core::selector::resolve_attr`
20/// would produce, so fast-path matching agrees byte-for-byte with the slow path.
21fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
22    match name {
23        "enabled" => Some(s.enabled.to_string()),
24        "visible" => Some(s.visible.to_string()),
25        "focused" => Some(s.focused.to_string()),
26        "active" => Some(s.active.to_string()),
27        "focusable" => Some(s.focusable.to_string()),
28        "selected" => Some(s.selected.to_string()),
29        "editable" => Some(s.editable.to_string()),
30        "modal" => Some(s.modal.to_string()),
31        "required" => Some(s.required.to_string()),
32        "busy" => Some(s.busy.to_string()),
33        "expanded" => s.expanded.map(|b| b.to_string()),
34        "checked" => s.checked.map(|c| {
35            match c {
36                Toggled::On => "on",
37                Toggled::Off => "off",
38                Toggled::Mixed => "mixed",
39            }
40            .to_string()
41        }),
42        _ => None,
43    }
44}
45
46/// Linux accessibility provider using AT-SPI2 over D-Bus.
47pub struct LinuxProvider {
48    a11y_bus: Connection,
49    /// Cached AT-SPI accessible refs keyed by handle ID.
50    handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
51    /// Cached AT-SPI2 action indices keyed by element handle.
52    /// Maps each action name (snake_case) to the integer index used by `DoAction(i)`.
53    action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
54    /// Native X11/Wayland window-manager channel, selected independently of
55    /// the AT-SPI accessibility connection.
56    window_manager: WindowManager,
57}
58
59/// AT-SPI2 accessible reference: (bus_name, object_path).
60#[derive(Debug, Clone)]
61pub(crate) struct AccessibleRef {
62    pub(crate) bus_name: String,
63    pub(crate) path: String,
64}
65
66/// The optional AT-SPI interfaces an object reports via
67/// `Accessible.GetInterfaces`, read once per node so every property fetch
68/// that depends on one can be gated without another round trip.
69///
70/// Gating matters beyond the round trips: atk-bridge answers `Text`,
71/// `Value` and `Component` reads on *every* object and its handlers log a
72/// `CRITICAL` assertion for each one whose `AtkObject` lacks the interface
73/// (issue #465 for Text/Value; GTK TreeView cell renderers for Component).
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub(crate) struct Interfaces {
76    pub(crate) component: bool,
77    pub(crate) text: bool,
78    pub(crate) value: bool,
79}
80
81impl Interfaces {
82    pub(crate) fn from_names<S: AsRef<str>>(names: &[S]) -> Self {
83        let has = |iface: &str| names.iter().any(|n| n.as_ref() == iface);
84        Self {
85            component: has("org.a11y.atspi.Component"),
86            text: has("org.a11y.atspi.Text"),
87            value: has("org.a11y.atspi.Value"),
88        }
89    }
90}
91
92/// Call `GetInterfaces` on a proxy for an object's
93/// `org.a11y.atspi.Accessible` interface.
94pub(crate) fn read_interfaces(accessible: &Proxy<'_>) -> Result<Interfaces> {
95    let reply = accessible
96        .call_method("GetInterfaces", &())
97        .map_err(|e| Error::Platform {
98            code: -1,
99            message: format!("GetInterfaces failed: {}", e),
100        })?;
101    let names: Vec<String> = reply.body().deserialize().map_err(|e| Error::Platform {
102        code: -1,
103        message: format!("GetInterfaces deserialize failed: {}", e),
104    })?;
105    Ok(Interfaces::from_names(&names))
106}
107
108impl LinuxProvider {
109    /// Create a new Linux accessibility provider.
110    ///
111    /// Connects to the AT-SPI2 bus. Falls back to the session bus
112    /// if the dedicated AT-SPI bus is unavailable.
113    pub fn new() -> Result<Self> {
114        let a11y_bus = Self::connect_a11y_bus()?;
115        Ok(Self {
116            a11y_bus,
117            handle_cache: Mutex::new(HashMap::new()),
118            action_indices: Mutex::new(HashMap::new()),
119            window_manager: WindowManager::new(),
120        })
121    }
122
123    pub(crate) fn connect_a11y_bus() -> Result<Connection> {
124        // Try getting the AT-SPI bus address from the a11y bus launcher,
125        // then connect to it. If that fails, fall back to the session bus
126        // (AT-SPI2 may use the session bus directly).
127        if let Ok(session) = Connection::session() {
128            let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
129                .map_err(|e| Error::Platform {
130                    code: -1,
131                    message: format!("Failed to create a11y bus proxy: {}", e),
132                })?;
133
134            if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
135                if let Ok(address) = addr_reply.body().deserialize::<String>() {
136                    if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
137                        if let Ok(Ok(conn)) =
138                            zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
139                        {
140                            return Ok(conn);
141                        }
142                    }
143                }
144            }
145
146            // Fall back to session bus
147            return Ok(session);
148        }
149
150        Connection::session().map_err(|e| Error::Platform {
151            code: -1,
152            message: format!("Failed to connect to D-Bus session bus: {}", e),
153        })
154    }
155
156    fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
157        // Use uncached proxy to avoid GetAll calls — Qt's AT-SPI adaptor
158        // doesn't support GetAll on all objects, causing spurious errors.
159        zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
160            .destination(bus_name.to_owned())
161            .map_err(|e| Error::Platform {
162                code: -1,
163                message: format!("Failed to set proxy destination: {}", e),
164            })?
165            .path(path.to_owned())
166            .map_err(|e| Error::Platform {
167                code: -1,
168                message: format!("Failed to set proxy path: {}", e),
169            })?
170            .interface(interface.to_owned())
171            .map_err(|e| Error::Platform {
172                code: -1,
173                message: format!("Failed to set proxy interface: {}", e),
174            })?
175            .cache_properties(zbus::proxy::CacheProperties::No)
176            .build()
177            .map_err(|e| Error::Platform {
178                code: -1,
179                message: format!("Failed to create proxy: {}", e),
180            })
181    }
182
183    /// Read the interfaces an accessible object reports via
184    /// `Accessible.GetInterfaces`.
185    fn get_interfaces(&self, aref: &AccessibleRef) -> Result<Interfaces> {
186        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
187        read_interfaces(&proxy)
188    }
189
190    /// Get the numeric AT-SPI role via GetRole method.
191    fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
192        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
193        let reply = proxy
194            .call_method("GetRole", &())
195            .map_err(|e| Error::Platform {
196                code: -1,
197                message: format!("GetRole failed: {}", e),
198            })?;
199        reply
200            .body()
201            .deserialize::<u32>()
202            .map_err(|e| Error::Platform {
203                code: -1,
204                message: format!("GetRole deserialize failed: {}", e),
205            })
206    }
207
208    /// Get the AT-SPI role name string.
209    fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
210        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
211        let reply = proxy
212            .call_method("GetRoleName", &())
213            .map_err(|e| Error::Platform {
214                code: -1,
215                message: format!("GetRoleName failed: {}", e),
216            })?;
217        reply
218            .body()
219            .deserialize::<String>()
220            .map_err(|e| Error::Platform {
221                code: -1,
222                message: format!("GetRoleName deserialize failed: {}", e),
223            })
224    }
225
226    /// Detect Chromium/Electron's "accessibility bridge disabled" signature.
227    ///
228    /// On Linux, Chromium-based apps (Electron, Chrome, VS Code, …) register
229    /// with AT-SPI but expose only an `application → frame` skeleton — the
230    /// frame's children list is empty (just the `/org/a11y/atspi/null`
231    /// sentinel) — unless the process was launched with the
232    /// `--force-renderer-accessibility` flag. Without this detection, callers
233    /// see zero results and assume their selector is wrong.
234    ///
235    /// Call this after observing an empty filtered children list. Returns an
236    /// error only when the parent is a window/frame whose AT-SPI bus reports
237    /// `Application.ToolkitName == "Chromium"`; otherwise returns `Ok(())` so
238    /// genuinely empty windows in other toolkits stay valid.
239    ///
240    /// `role_hint` lets callers that already know the role skip a `GetRole`
241    /// round-trip.
242    fn check_chromium_a11y_enabled(
243        &self,
244        parent: &AccessibleRef,
245        role_hint: Option<Role>,
246    ) -> Result<()> {
247        let app_root = AccessibleRef {
248            bus_name: parent.bus_name.clone(),
249            path: "/org/a11y/atspi/accessible/root".to_string(),
250        };
251        let toolkit = match self
252            .make_proxy(
253                &app_root.bus_name,
254                &app_root.path,
255                "org.a11y.atspi.Application",
256            )
257            .ok()
258            .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
259        {
260            Some(t) => t,
261            None => return Ok(()),
262        };
263        if !toolkit.eq_ignore_ascii_case("Chromium") {
264            return Ok(());
265        }
266        let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
267        if role != Role::Window {
268            return Ok(());
269        }
270        let app_name = self.get_name(&app_root).unwrap_or_default();
271        Err(Error::AccessibilityNotEnabled {
272            app: app_name,
273            instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
274                Relaunch with `--force-renderer-accessibility` (or set the env var \
275                `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
276                .to_string(),
277        })
278    }
279
280    /// Get the name of an accessible element.
281    fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
282        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
283        proxy
284            .get_property::<String>("Name")
285            .map_err(|e| Error::Platform {
286                code: -1,
287                message: format!("Get Name property failed: {}", e),
288            })
289    }
290
291    /// Get the description of an accessible element.
292    fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
293        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
294        proxy
295            .get_property::<String>("Description")
296            .map_err(|e| Error::Platform {
297                code: -1,
298                message: format!("Get Description property failed: {}", e),
299            })
300    }
301
302    /// Get children via the GetChildren method.
303    /// AT-SPI registryd doesn't always implement standard D-Bus Properties,
304    /// so we use GetChildren which is more reliable.
305    fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
306        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
307        let reply = proxy
308            .call_method("GetChildren", &())
309            .map_err(|e| children_call_error(e, aref))?;
310        let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
311            reply.body().deserialize().map_err(|e| Error::Platform {
312                code: -1,
313                message: format!("GetChildren deserialize failed: {}", e),
314            })?;
315        Ok(children
316            .into_iter()
317            .map(|(bus_name, path)| AccessibleRef {
318                bus_name,
319                path: path.to_string(),
320            })
321            .collect())
322    }
323
324    /// Read a live subtree, distinguishing a destroyed object from an empty
325    /// child list. Only `UnknownObject` is absence; broken peers still fail.
326    fn live_atspi_children(&self, aref: &AccessibleRef) -> Result<Option<Vec<AccessibleRef>>> {
327        match self.get_atspi_children(aref) {
328            Ok(children) => Ok(Some(children)),
329            Err(Error::ElementStale { .. }) => Ok(None),
330            Err(err) => Err(err),
331        }
332    }
333
334    /// Get the state set as raw u32 values.
335    fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
336        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
337        let reply = proxy
338            .call_method("GetState", &())
339            .map_err(|e| Error::Platform {
340                code: -1,
341                message: format!("GetState failed: {}", e),
342            })?;
343        reply
344            .body()
345            .deserialize::<Vec<u32>>()
346            .map_err(|e| Error::Platform {
347                code: -1,
348                message: format!("GetState deserialize failed: {}", e),
349            })
350    }
351
352    /// `GetRole` for the shell scan, separating "the peer left the bus" from
353    /// a genuine failure.
354    ///
355    /// `Ok(None)` means the owning application exited mid-scan; see
356    /// [`PEER_GONE_ERRORS`].
357    fn scan_role_number(&self, aref: &AccessibleRef) -> Result<Option<u32>> {
358        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
359        let reply = match proxy.call_method("GetRole", &()) {
360            Ok(reply) => reply,
361            Err(e) if is_peer_gone(&e) => return Ok(None),
362            Err(e) => {
363                return Err(Error::Platform {
364                    code: -1,
365                    message: format!("GetRole failed: {}", e),
366                })
367            }
368        };
369        reply
370            .body()
371            .deserialize::<u32>()
372            .map(Some)
373            .map_err(|e| Error::Platform {
374                code: -1,
375                message: format!("GetRole deserialize failed: {}", e),
376            })
377    }
378
379    /// `GetAttributes` for the shell scan.
380    ///
381    /// Three outcomes rather than two: `Ok(None)` is the peer having left the
382    /// bus, `Ok(Some(None))` is an object that does not implement
383    /// `GetAttributes` (an honest "not a panel"), and `Ok(Some(Some(_)))` is a
384    /// reply.
385    #[allow(
386        clippy::option_option,
387        reason = "The two Nones mean different things \
388        — peer gone versus member absent — and collapsing them would erase \
389        exactly the distinction this function exists to make."
390    )]
391    fn scan_attributes(
392        &self,
393        aref: &AccessibleRef,
394    ) -> Result<Option<Option<HashMap<String, String>>>> {
395        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
396        let reply = match proxy.call_method("GetAttributes", &()) {
397            Ok(reply) => reply,
398            Err(e) if is_peer_gone(&e) => return Ok(None),
399            Err(e) if is_absent_member(&e) => return Ok(Some(None)),
400            Err(e) => {
401                return Err(Error::Platform {
402                    code: -1,
403                    message: format!("GetAttributes failed: {}", e),
404                })
405            }
406        };
407        reply
408            .body()
409            .deserialize::<HashMap<String, String>>()
410            .map(|a| Some(Some(a)))
411            .map_err(|e| Error::Platform {
412                code: -1,
413                message: format!("GetAttributes deserialize failed: {}", e),
414            })
415    }
416
417    /// Return true if the element reports the AT-SPI MULTI_LINE state.
418    /// Used to distinguish multi-line text areas (TextArea) from single-line
419    /// text inputs (TextField), since both use the AT-SPI "text" role name.
420    /// Note: Qt's AT-SPI bridge does not reliably set SINGLE_LINE, so we
421    /// check MULTI_LINE and default to TextField when neither is set.
422    fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
423        let state_bits = self.get_state(aref).unwrap_or_default();
424        let bits: u64 = if state_bits.len() >= 2 {
425            (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
426        } else if state_bits.len() == 1 {
427            state_bits[0] as u64
428        } else {
429            0
430        };
431        // ATSPI_STATE_MULTI_LINE = 17 in AtspiStateType enum
432        const MULTI_LINE: u64 = 1 << 17;
433        (bits & MULTI_LINE) != 0
434    }
435
436    /// Resolve TextField vs TextArea using the MULTI_LINE state, regardless of
437    /// the coarse role we started with. Different toolkits expose text inputs
438    /// under different AT-SPI roles ("text", "textbox", "entry", "embedded",
439    /// numeric 61/78/79), and WebKitGTK has changed mapping between versions
440    /// (e.g. 2.50 → 2.52 swapped what role textarea reports). MULTI_LINE is
441    /// the cross-toolkit invariant, so we let it decide.
442    ///
443    /// For any other coarse role, returns it unchanged.
444    fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
445        match coarse {
446            Role::TextArea | Role::TextField => {
447                if self.is_multi_line(aref) {
448                    Role::TextArea
449                } else {
450                    Role::TextField
451                }
452            }
453            other => other,
454        }
455    }
456
457    /// Get bounds via Component interface.
458    /// Gated on the node reporting Component, to avoid GTK CRITICAL warnings
459    /// on objects (e.g. TreeView cell renderers) that don't implement it.
460    fn get_extents(&self, aref: &AccessibleRef, ifaces: Interfaces) -> Option<Rect> {
461        if !ifaces.component {
462            return None;
463        }
464        let proxy = self
465            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
466            .ok()?;
467        // GetExtents(coord_type: u32) -> (x, y, width, height)
468        // coord_type 0 = screen coordinates
469        let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
470        let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
471        if w <= 0 && h <= 0 {
472            return None;
473        }
474        // AT-SPI screen extents are physical device pixels. Convert to logical
475        // coordinates (the cross-platform contract, same space as the
476        // screenshot/input layers) using the detected display scale. On the
477        // common 1.0 case this is a no-op. See `crate::scale`.
478        Some(
479            Rect {
480                x,
481                y,
482                width: w.max(0) as u32,
483                height: h.max(0) as u32,
484            }
485            .to_logical(crate::scale::coordinate_scale()),
486        )
487    }
488
489    /// Get available actions via Action interface, returning both the action list
490    /// and a map of each action name to its AT-SPI2 integer index for direct `DoAction(i)`.
491    ///
492    /// Probes the interface directly rather than relying on the Interfaces property,
493    /// which some AT-SPI adapters (e.g. AccessKit) don't expose.
494    fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
495        let mut actions = Vec::new();
496        let mut indices = HashMap::new();
497
498        // Try Action interface directly
499        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
500            // NActions may be returned as i32 or u32 depending on AT-SPI implementation.
501            let n_actions = proxy
502                .get_property::<i32>("NActions")
503                .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
504                .unwrap_or(0);
505            for i in 0..n_actions {
506                if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
507                    if let Ok(name) = reply.body().deserialize::<String>() {
508                        if let Some(action_name) = map_atspi_action_name(&name) {
509                            if !actions.contains(&action_name) {
510                                indices.insert(action_name.clone(), i);
511                                actions.push(action_name);
512                            }
513                        }
514                    }
515                }
516            }
517        }
518
519        // NOTE: We do NOT add implicit "focus" based on Component interface existence.
520        // On GTK4, GrabFocus() often returns false even when Component interface exists,
521        // violating design tenet 3 ("if action is listed, calling it must work").
522        // Only report "focus" if it's explicitly in the AT-SPI Action interface with a
523        // proper index. This ensures focus() will work when reported.
524        // Fixes GitHub issue #98.
525
526        (actions, indices)
527    }
528
529    /// Return true when the accessible's application identifies itself as GTK
530    /// via `org.a11y.atspi.Application.ToolkitName`.
531    ///
532    /// Used to scope the press-fallback heuristic for the
533    /// AdwMenuButton/GtkMenuButton wrapper pattern; other toolkits are
534    /// unaffected. Note that WebKitGTK also reports "GTK", so the answer is
535    /// not a "GtkWidget app" discriminator.
536    fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
537        let app_root = AccessibleRef {
538            bus_name: aref.bus_name.clone(),
539            path: "/org/a11y/atspi/accessible/root".to_string(),
540        };
541        self.make_proxy(
542            &app_root.bus_name,
543            &app_root.path,
544            "org.a11y.atspi.Application",
545        )
546        .ok()
547        .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
548        .map(|t| t.eq_ignore_ascii_case("GTK"))
549        .unwrap_or(false)
550    }
551
552    /// GTK press-fallback resolver.
553    ///
554    /// Walks the accessible's descendants (BFS, depth-capped) looking for a
555    /// single activatable child that shares the outer widget's name. Returns
556    /// the (ref, action_index) pair when exactly one suitable candidate
557    /// exists at the shallowest matching depth. Returns `None` when the
558    /// subtree contains nothing suitable or multiple equally plausible
559    /// candidates — refusing to guess.
560    ///
561    /// Empirically fixes the `GtkMenuButton` / `AdwMenuButton` wrappers that
562    /// ship in every stock GNOME app (Calculator, Text Editor, Logs, Clocks,
563    /// Characters, …).
564    fn find_gtk_press_fallback(
565        &self,
566        outer: &AccessibleRef,
567        outer_name: &str,
568    ) -> Option<(AccessibleRef, i32)> {
569        let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
570        queue.push_back((outer.clone(), 0));
571        let mut visited: usize = 0;
572        let mut shallowest_depth: Option<u32> = None;
573        let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
574
575        while let Some((node, depth)) = queue.pop_front() {
576            // Once we've found the shallowest level with hits, don't look deeper.
577            if let Some(best) = shallowest_depth {
578                if depth > best {
579                    continue;
580                }
581            }
582            if visited > GTK_FALLBACK_MAX_NODES {
583                break;
584            }
585
586            let role_name = if depth == 0 {
587                String::new()
588            } else {
589                visited += 1;
590                self.get_role_name(&node).unwrap_or_default().to_lowercase()
591            };
592
593            if depth > 0 && is_actionable_atspi_role(&role_name) {
594                if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
595                    match shallowest_depth {
596                        Some(d) if depth < d => {
597                            shallowest_depth = Some(depth);
598                            hits.clear();
599                            hits.push((node.clone(), idx));
600                        }
601                        Some(d) if depth == d => hits.push((node.clone(), idx)),
602                        Some(_) => {}
603                        None => {
604                            shallowest_depth = Some(depth);
605                            hits.push((node.clone(), idx));
606                        }
607                    }
608                }
609            }
610
611            // Do not descend into static/decorative roles. Descend through
612            // containers and actionable roles alike (actionable roles may
613            // themselves wrap an inner actionable — e.g. an AdwSplitButton's
614            // primary button inside a toggle-button shell).
615            let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
616                || (depth > 0 && is_never_descend_atspi_role(&role_name));
617            if stop_descending {
618                continue;
619            }
620            if let Ok(children) = self.get_atspi_children(&node) {
621                for c in children {
622                    queue.push_back((c, depth + 1));
623                }
624            }
625        }
626
627        if hits.len() == 1 {
628            Some(hits.into_iter().next().unwrap())
629        } else {
630            None
631        }
632    }
633
634    /// Per-node filter for `find_gtk_press_fallback`. Returns the AT-SPI
635    /// action index to invoke when `aref` is a valid fallback candidate.
636    fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
637        let (_, index_map) = self.get_actions(aref);
638        let idx = *index_map.get("press")?;
639        if !self.is_showing_visible_sensitive(aref) {
640            return None;
641        }
642        // If the outer has a name, require the candidate to share it. Rules
643        // out unrelated suffix widgets (e.g. a switch inside an AdwActionRow
644        // that happens to expose `click`).
645        if !outer_name.is_empty() {
646            let inner_name = self.get_name(aref).unwrap_or_default();
647            if !inner_name.is_empty() && inner_name != outer_name {
648                return None;
649            }
650        }
651        Some(idx)
652    }
653
654    /// True when the accessible's state has SHOWING, VISIBLE, and SENSITIVE
655    /// set. ENABLED is deliberately excluded: in GTK4 it reflects "has an
656    /// enabled GAction bound", which is false for the exact widgets we
657    /// rescue.
658    fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
659        let raw = self.get_state(aref).unwrap_or_default();
660        let bits: u64 = if raw.len() >= 2 {
661            (raw[0] as u64) | ((raw[1] as u64) << 32)
662        } else if raw.len() == 1 {
663            raw[0] as u64
664        } else {
665            0
666        };
667        const SENSITIVE: u64 = 1 << 24;
668        const SHOWING: u64 = 1 << 25;
669        const VISIBLE: u64 = 1 << 30;
670        (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
671    }
672
673    /// Get value via the Text or Value interface, asking only for the ones
674    /// the node reports (see [`Interfaces`] for why an unreported interface
675    /// must not be read).
676    fn get_value(&self, aref: &AccessibleRef, ifaces: Interfaces) -> Option<String> {
677        // Try Text interface first for text content (text fields, labels, combo boxes).
678        // This must come before Value because some AT-SPI adapters (e.g. AccessKit)
679        // may expose both interfaces, and Value.CurrentValue returns 0.0 for text elements.
680        if ifaces.text {
681            let text_value = self.get_text_content(aref);
682            if text_value.is_some() {
683                return text_value;
684            }
685        }
686        // Try Value interface (sliders, progress bars, scroll bars, spinners)
687        if ifaces.value {
688            if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
689                if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
690                    return Some(val.to_string());
691                }
692            }
693        }
694        None
695    }
696
697    /// Read text content via the AT-SPI Text interface.
698    fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
699        let proxy = self
700            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
701            .ok()?;
702        let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
703        if char_count > 0 {
704            let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
705            let text: String = reply.body().deserialize().ok()?;
706            if !text.is_empty() {
707                return Some(text);
708            }
709        }
710        None
711    }
712
713    /// Cache an AccessibleRef and return a new handle ID.
714    fn cache_element(&self, aref: AccessibleRef) -> u64 {
715        let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
716        self.handle_cache
717            .lock()
718            .unwrap_or_else(|e| e.into_inner())
719            .insert(handle, aref);
720        handle
721    }
722
723    /// Look up a cached AccessibleRef by handle.
724    fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
725        self.handle_cache
726            .lock()
727            .unwrap_or_else(|e| e.into_inner())
728            .get(&handle)
729            .cloned()
730            .ok_or(Error::ElementStale {
731                selector: format!("handle:{}", handle),
732            })
733    }
734
735    /// Build an ElementData from an AccessibleRef, caching the ref for later lookup.
736    ///
737    /// After resolving the role (1-3 sequential D-Bus calls), all remaining
738    /// property fetches are independent and run in parallel via rayon::join.
739    fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
740        // GetInterfaces runs alongside the role reads so it costs no extra
741        // latency, and its result gates every Text / Value / Component read
742        // below. A failed read gates them all off: like the other per-field
743        // reads in this snapshot, an unreadable object degrades to absent
744        // fields, and probing the interfaces anyway is exactly the unguarded
745        // read issue #465 removed.
746        let ((role_name, role_num), ifaces) = rayon::join(
747            || {
748                (
749                    self.get_role_name(aref).unwrap_or_default(),
750                    self.get_role_number(aref).unwrap_or(0),
751                )
752            },
753            || self.get_interfaces(aref).unwrap_or_default(),
754        );
755        let coarse_role = {
756            let by_name = if !role_name.is_empty() {
757                map_atspi_role(&role_name)
758            } else {
759                Role::Unknown
760            };
761            let coarse = if by_name != Role::Unknown {
762                by_name
763            } else {
764                map_atspi_role_number(role_num)
765            };
766            self.refine_text_role(coarse, aref)
767        };
768        // GTK 3 overloads AT-SPI's `menu` role: both a popup menu container
769        // and the MenuItem that opens a submenu report `menu`. The entry is
770        // distinguishable by its native activation action (`click`, normalized
771        // to `press`); GtkMenu itself has no Action interface. Fetch once here
772        // so role refinement and the advertised action list use the same
773        // evidence without a second D-Bus round trip.
774        let prefetched_menu_actions = if coarse_role == Role::Menu {
775            Some(self.get_actions(aref))
776        } else {
777            None
778        };
779        let role = refine_menu_role(
780            coarse_role,
781            prefetched_menu_actions
782                .as_ref()
783                .map(|(actions, _)| actions.as_slice())
784                .unwrap_or(&[]),
785        );
786
787        // Fetch all independent properties in parallel.
788        // Left tree: (name+value, description)
789        // Right tree: ((states, bounds), (actions, numeric_values))
790        let (
791            ((mut name, value), description),
792            (
793                ((states, state_bits), bounds),
794                ((actions, action_index_map), (numeric_value, min_value, max_value)),
795            ),
796        ) = rayon::join(
797            || {
798                rayon::join(
799                    || {
800                        let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
801                        let value = if role_has_value(role) {
802                            self.get_value(aref, ifaces)
803                        } else {
804                            None
805                        };
806                        (name, value)
807                    },
808                    || self.get_description(aref).ok().filter(|s| !s.is_empty()),
809                )
810            },
811            || {
812                rayon::join(
813                    || {
814                        rayon::join(
815                            || self.parse_states_with_bits(aref, role),
816                            || {
817                                if role != Role::Application {
818                                    self.get_extents(aref, ifaces)
819                                } else {
820                                    None
821                                }
822                            },
823                        )
824                    },
825                    || {
826                        rayon::join(
827                            || {
828                                if let Some(actions) = prefetched_menu_actions.as_ref() {
829                                    actions.clone()
830                                } else if role_has_actions(role) {
831                                    self.get_actions(aref)
832                                } else {
833                                    (vec![], HashMap::new())
834                                }
835                            },
836                            || {
837                                if ifaces.value
838                                    && matches!(
839                                        role,
840                                        Role::Slider
841                                            | Role::ProgressBar
842                                            | Role::ScrollBar
843                                            | Role::SpinButton
844                                    )
845                                {
846                                    if let Ok(proxy) = self.make_proxy(
847                                        &aref.bus_name,
848                                        &aref.path,
849                                        "org.a11y.atspi.Value",
850                                    ) {
851                                        (
852                                            proxy.get_property::<f64>("CurrentValue").ok(),
853                                            proxy.get_property::<f64>("MinimumValue").ok(),
854                                            proxy.get_property::<f64>("MaximumValue").ok(),
855                                        )
856                                    } else {
857                                        (None, None, None)
858                                    }
859                                } else {
860                                    (None, None, None)
861                                }
862                            },
863                        )
864                    },
865                )
866            },
867        );
868
869        // For label/static text elements, AT-SPI may put content in the Text
870        // interface (returned as value) rather than the Name property.
871        if name.is_none() && role == Role::StaticText {
872            if let Some(ref v) = value {
873                name = Some(v.clone());
874            }
875        }
876
877        // `activate` is deliberately not advertised from AT-SPI alone. A real
878        // native window backend may add it below after proving identity and
879        // checking capabilities. The AT-SPI-only path is Component.GrabFocus
880        // on the top-level frame, and Windows/macOS advertise theirs unconditionally,
881        // but the AT-SPI adapters disagree about implementing a *frame*
882        // GrabFocus: the GTK widget adapter answers true, while Chromium
883        // (Electron), WebKitGTK (Tauri — whose bridge reports itself as
884        // ToolkitName "GTK", so even a toolkit gate cannot separate it from
885        // the GTK widget app) and Qt answer false. No interface or toolkit
886        // probe discriminates them, and a snapshot-side GrabFocus probe
887        // would activate windows as a side effect. Advertising anyway would
888        // promise an action the call then declines — the false-promise shape
889        // the integration suites exist to catch (round-4 CI: it failed the
890        // activate suites on electron, tauri and qt; only the GTK cell passed).
891        // The action stays callable on every toolkit, and the call reports a
892        // `false` reply or a D-Bus NotSupported error honestly as
893        // ActionNotSupported (tenet 3; tenet 1 — no silent fallback — is
894        // why there is no substitute mechanism to advertise instead).
895
896        let raw = {
897            let raw_role = if role_name.is_empty() {
898                format!("role_num:{}", role_num)
899            } else {
900                role_name
901            };
902            let mut raw = HashMap::new();
903            raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
904            raw.insert(
905                "bus_name".into(),
906                serde_json::Value::String(aref.bus_name.clone()),
907            );
908            raw.insert(
909                "object_path".into(),
910                serde_json::Value::String(aref.path.clone()),
911            );
912            // Preserve unstripped originals so callers who need bidi marks can
913            // recover them after the strip below.
914            if let Some(ref n) = name {
915                raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
916            }
917            if let Some(ref v) = value {
918                raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
919            }
920            if let Some(ref d) = description {
921                raw.insert(
922                    "atspi_description".into(),
923                    serde_json::Value::String(d.clone()),
924                );
925            }
926            // DEFUNCT (bit 6) marks a dead accessible — the object was
927            // destroyed provider-side but a reference still resolves. It has
928            // no cross-platform StateSet field, but it's the key diagnostic
929            // for stale-reference bugs (e.g. a churning WebKit tree), so
930            // surface it whenever set.
931            if (state_bits & (1 << 6)) != 0 {
932                raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
933            }
934            raw
935        };
936
937        // Strip Unicode bidi format controls. RTL apps on Linux embed LRM/RLM
938        // marks into reported strings; the originals are preserved in `raw`.
939        let name = xa11y_core::text::strip_bidi_opt(name);
940        let value = xa11y_core::text::strip_bidi_opt(value);
941        let description = xa11y_core::text::strip_bidi_opt(description);
942
943        let handle = self.cache_element(aref.clone());
944        if !action_index_map.is_empty() {
945            self.action_indices
946                .lock()
947                .unwrap_or_else(|e| e.into_inner())
948                .insert(handle, action_index_map);
949        }
950
951        let mut data: ElementData = ElementParts {
952            role,
953            name,
954            value,
955            description,
956            bounds,
957            actions,
958            states,
959            numeric_value,
960            min_value,
961            max_value,
962            stable_id: Some(aref.path.clone()),
963            pid,
964            raw,
965            handle,
966        }
967        .into();
968
969        if matches!(role, Role::Window | Role::Dialog) && self.is_top_level_window_ref(aref) {
970            let facts = self.window_manager.facts(&data);
971            if facts.bounds.is_some() {
972                data.bounds = facts.bounds;
973            }
974            for action in &facts.actions {
975                if !data.actions.iter().any(|existing| existing == action) {
976                    data.actions.push((*action).to_string());
977                }
978            }
979            if facts.minimized.is_some() {
980                data.states.minimized = facts.minimized;
981            }
982            if facts.maximized.is_some() {
983                data.states.maximized = facts.maximized;
984            }
985            if facts.fullscreen.is_some() {
986                data.states.fullscreen = facts.fullscreen;
987            }
988            if let Some(backend) = facts.backend {
989                data.raw.insert(
990                    "window_backend".to_string(),
991                    serde_json::Value::String(backend.to_string()),
992                );
993            }
994            if let Some(version) = facts.protocol_version {
995                data.raw.insert(
996                    "window_protocol_version".to_string(),
997                    serde_json::Value::Number(version.into()),
998                );
999            }
1000            let capabilities = [
1001                "activate",
1002                "minimize",
1003                "maximize",
1004                "enter_fullscreen",
1005                "restore",
1006                "close",
1007                "move_to",
1008                "resize_to",
1009            ]
1010            .into_iter()
1011            .map(|action| {
1012                let status = if facts.backend.is_none() {
1013                    "unknown"
1014                } else if facts.actions.contains(&action) {
1015                    "supported"
1016                } else {
1017                    "unsupported"
1018                };
1019                (
1020                    action.to_string(),
1021                    serde_json::Value::String(status.to_string()),
1022                )
1023            })
1024            .collect();
1025            data.raw.insert(
1026                "window_capabilities".to_string(),
1027                serde_json::Value::Object(capabilities),
1028            );
1029        }
1030
1031        data
1032    }
1033
1034    /// Get the AT-SPI parent of an accessible ref.
1035    fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
1036        // Read the Parent property via the D-Bus Properties interface.
1037        let proxy = self.make_proxy(
1038            &aref.bus_name,
1039            &aref.path,
1040            "org.freedesktop.DBus.Properties",
1041        )?;
1042        let reply = proxy
1043            .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
1044            .map_err(|e| Error::Platform {
1045                code: -1,
1046                message: format!("Get Parent property failed: {}", e),
1047            })?;
1048        // The reply is a Variant containing (so) — a struct of (bus_name, object_path)
1049        let variant: zbus::zvariant::OwnedValue =
1050            reply.body().deserialize().map_err(|e| Error::Platform {
1051                code: -1,
1052                message: format!("Parent deserialize variant failed: {}", e),
1053            })?;
1054        let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
1055            zbus::zvariant::Value::from(variant).try_into().map_err(
1056                |e: zbus::zvariant::Error| Error::Platform {
1057                    code: -1,
1058                    message: format!("Parent deserialize struct failed: {}", e),
1059                },
1060            )?;
1061        let path_str = path.as_str();
1062        if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
1063            return Ok(None);
1064        }
1065        // If the parent is the registry root, this is a top-level app — no parent
1066        if path_str == "/org/a11y/atspi/accessible/root" {
1067            return Ok(None);
1068        }
1069        Ok(Some(AccessibleRef {
1070            bus_name: bus,
1071            path: path_str.to_string(),
1072        }))
1073    }
1074
1075    /// Fetch an accessible's AT-SPI2 state bitfield as a single `u64`.
1076    ///
1077    /// AT-SPI2 returns states as two `u32`s (low/high words); collapse them
1078    /// into one mask so callers can test bit positions directly.
1079    ///
1080    /// The `bool` reports whether `GetState` itself succeeded. A failed read
1081    /// is not "all states false": window-state optionals (`minimized`) must
1082    /// stay unknown rather than guess `false` (tenet 1), just as the rest of
1083    /// the window contract treats an unread state as `None`.
1084    fn state_bits(&self, aref: &AccessibleRef) -> (u64, bool) {
1085        match self.get_state(aref) {
1086            Ok(state_bits) if state_bits.len() >= 2 => (
1087                (state_bits[0] as u64) | ((state_bits[1] as u64) << 32),
1088                true,
1089            ),
1090            Ok(state_bits) if state_bits.len() == 1 => (state_bits[0] as u64, true),
1091            Ok(_) => (0, true),
1092            Err(_) => (0, false),
1093        }
1094    }
1095
1096    /// Parse AT-SPI2 state bitfield into xa11y StateSet.
1097    fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
1098        self.parse_states_with_bits(aref, role).0
1099    }
1100
1101    /// Like [`parse_states`], but also returns the raw AT-SPI state bitset so
1102    /// callers can surface provider-only facts (notably DEFUNCT) that have no
1103    /// cross-platform `StateSet` field.
1104    fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
1105        let (bits, state_read_ok) = self.state_bits(aref);
1106
1107        // AT-SPI2 state bit positions: each is `1 << ordinal` of the
1108        // `AtspiStateType` enum in at-spi2-core's `atspi/atspi-constants.h`
1109        // (ICONIFIED=15, MODAL=16, SELECTED=23, VISIBLE=30, ...). Kept in
1110        // sync with `states_from_bits` in events.rs.
1111        const ACTIVE: u64 = 1 << 1;
1112        const BUSY: u64 = 1 << 3;
1113        const CHECKED: u64 = 1 << 4;
1114        const EDITABLE: u64 = 1 << 7;
1115        const ENABLED: u64 = 1 << 8;
1116        const EXPANDABLE: u64 = 1 << 9;
1117        const EXPANDED: u64 = 1 << 10;
1118        const FOCUSABLE: u64 = 1 << 11;
1119        const FOCUSED: u64 = 1 << 12;
1120        const ICONIFIED: u64 = 1 << 15;
1121        const MODAL: u64 = 1 << 16;
1122        const SELECTED: u64 = 1 << 23;
1123        const SENSITIVE: u64 = 1 << 24;
1124        const SHOWING: u64 = 1 << 25;
1125        const VISIBLE: u64 = 1 << 30;
1126        const INDETERMINATE: u64 = 1 << 32;
1127        const REQUIRED: u64 = 1 << 33;
1128
1129        let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
1130        let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
1131
1132        let checked = match role {
1133            Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
1134                if (bits & INDETERMINATE) != 0 {
1135                    Some(Toggled::Mixed)
1136                } else if (bits & CHECKED) != 0 {
1137                    Some(Toggled::On)
1138                } else {
1139                    Some(Toggled::Off)
1140                }
1141            }
1142            _ => None,
1143        };
1144
1145        let expanded = if (bits & EXPANDABLE) != 0 {
1146            Some((bits & EXPANDED) != 0)
1147        } else {
1148            None
1149        };
1150
1151        let states = StateParts {
1152            enabled,
1153            visible,
1154            focused: (bits & FOCUSED) != 0,
1155            // AT-SPI `ACTIVE` marks the foreground window/frame (see
1156            // `focused_app`, which relies on the same bit).
1157            active: (bits & ACTIVE) != 0,
1158            checked,
1159            selected: (bits & SELECTED) != 0,
1160            expanded,
1161            editable: (bits & EDITABLE) != 0,
1162            focusable: (bits & FOCUSABLE) != 0,
1163            modal: (bits & MODAL) != 0,
1164            required: (bits & REQUIRED) != 0,
1165            busy: (bits & BUSY) != 0,
1166            // `ICONIFIED` is AT-SPI's minimized state, reported on window
1167            // frames. Maximized/fullscreen are not reported by AT-SPI at all
1168            // (no state bit), so they stay `None` — unknown, not guessed
1169            // `false` (tenet 1). macOS reads the fullscreen state from
1170            // AXFullScreen, but no platform can raise a
1171            // StateChanged{fullscreen} event: the AX API has no fullscreen
1172            // notification and UIA has no fullscreen state either. A failed
1173            // `GetState` must likewise leave `minimized` unknown — an unread
1174            // state is not a state that was read as absent.
1175            minimized: if matches!(role, Role::Window | Role::Dialog) {
1176                if state_read_ok {
1177                    Some((bits & ICONIFIED) != 0)
1178                } else {
1179                    None
1180                }
1181            } else {
1182                None
1183            },
1184            maximized: None,
1185            fullscreen: None,
1186        }
1187        .into();
1188        (states, bits)
1189    }
1190
1191    /// Find an application by PID.
1192    ///
1193    /// Used by `subscribe` to resolve the target app's D-Bus sender name so
1194    /// signal match rules can be scoped to it.
1195    pub(crate) fn find_apps_by_pid(&self, pid: u32) -> Result<Vec<AccessibleRef>> {
1196        let mut matches = Vec::new();
1197        let registry = AccessibleRef {
1198            bus_name: "org.a11y.atspi.Registry".to_string(),
1199            path: "/org/a11y/atspi/accessible/root".to_string(),
1200        };
1201        let children = self.get_atspi_children(&registry)?;
1202
1203        for child in &children {
1204            if child.path == "/org/a11y/atspi/null" {
1205                continue;
1206            }
1207            // Prefer D-Bus connection PID — it's authoritative for the
1208            // process owning the bus connection. Application.Id is *not* a
1209            // process pid: AT-SPI assigns it as a registry-local index
1210            // (1, 2, 3, …) for some bridges (notably GTK4), so matching
1211            // against Application.Id alone misses real processes.
1212            if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
1213                if app_pid == pid {
1214                    matches.push(child.clone());
1215                    continue;
1216                }
1217            }
1218            // Fall back to Application.Id for adapters that do set it to pid.
1219            if let Ok(proxy) =
1220                self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
1221            {
1222                if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
1223                    if app_pid as u32 == pid {
1224                        matches.push(child.clone());
1225                    }
1226                }
1227            }
1228        }
1229
1230        Ok(matches)
1231    }
1232
1233    /// Get PID via D-Bus GetConnectionUnixProcessID.
1234    fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
1235        let proxy = self
1236            .make_proxy(
1237                "org.freedesktop.DBus",
1238                "/org/freedesktop/DBus",
1239                "org.freedesktop.DBus",
1240            )
1241            .ok()?;
1242        let reply = proxy
1243            .call_method("GetConnectionUnixProcessID", &(bus_name,))
1244            .ok()?;
1245        let pid: u32 = reply.body().deserialize().ok()?;
1246        if pid > 0 {
1247            Some(pid)
1248        } else {
1249            None
1250        }
1251    }
1252
1253    /// Perform an AT-SPI2 action by its integer index (from discovery).
1254    fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
1255        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
1256        let reply = proxy
1257            .call_method("DoAction", &(index,))
1258            .map_err(|e| Error::Platform {
1259                code: -1,
1260                message: format!("DoAction({}) failed: {}", index, e),
1261            })?;
1262        // AT-SPI's DoAction returns a boolean: true means the action ran. A
1263        // false reply means the provider declined — reporting Ok would let
1264        // activate()/focus() claim success while the window was never activated.
1265        let performed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
1266            code: -1,
1267            message: format!("DoAction({}) reply decode failed: {}", index, e),
1268        })?;
1269        if !performed {
1270            return Err(Error::Platform {
1271                code: -1,
1272                message: format!(
1273                    "DoAction({}) returned false; the accessibility provider declined the action",
1274                    index
1275                ),
1276            });
1277        }
1278        Ok(())
1279    }
1280
1281    /// Look up the stored AT-SPI2 action index for the given element and action.
1282    fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1283        self.action_indices
1284            .lock()
1285            .unwrap_or_else(|e| e.into_inner())
1286            .get(&handle)
1287            .and_then(|map| map.get(action).copied())
1288            .ok_or_else(|| Error::ActionNotSupported {
1289                action: action.to_string(),
1290                role: Role::Unknown, // caller will provide better context
1291            })
1292    }
1293
1294    /// Get the process PID for an AT-SPI accessible. Prefers the D-Bus
1295    /// connection PID over `Application.Id`: the latter is a registry-local
1296    /// index (1, 2, 3, …) on some bridges (e.g. GTK4), not a real pid, so
1297    /// reading it first produces apps whose `pid` property doesn't match the
1298    /// process the user launched.
1299    fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1300        if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1301            return Some(pid);
1302        }
1303
1304        // Fall back to Application.Id for adapters that set it to pid.
1305        // Require that a process with that id actually exists — a registry
1306        // index from a misbehaving bridge usually names a long-dead or absurd
1307        // pid. This is only a cheap sanity filter: a low index can still
1308        // collide with a real process (1 = init), which is why the D-Bus
1309        // connection pid above is authoritative whenever available.
1310        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1311        {
1312            if let Ok(pid) = proxy.get_property::<i32>("Id") {
1313                if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1314                    return Some(pid as u32);
1315                }
1316            }
1317        }
1318
1319        None
1320    }
1321
1322    /// Resolve the mapped Role for an accessible ref (1-3 D-Bus calls, plus
1323    /// an Action probe for AT-SPI `menu`, whose meaning is ambiguous on GTK 3).
1324    fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1325        let role_name = self.get_role_name(aref).unwrap_or_default();
1326        let by_name = if !role_name.is_empty() {
1327            map_atspi_role(&role_name)
1328        } else {
1329            Role::Unknown
1330        };
1331        let coarse = if by_name != Role::Unknown {
1332            by_name
1333        } else {
1334            // Unmapped or missing role name — fall back to numeric role.
1335            let role_num = self.get_role_number(aref).unwrap_or(0);
1336            map_atspi_role_number(role_num)
1337        };
1338        let role = self.refine_text_role(coarse, aref);
1339        if role == Role::Menu {
1340            let (actions, _) = self.get_actions(aref);
1341            refine_menu_role(role, &actions)
1342        } else {
1343            role
1344        }
1345    }
1346
1347    /// AT-SPI role-name probe for top-level decisions: is `parent` the
1348    /// application node?
1349    ///
1350    /// AccessKit's AT-SPI bridge reports role names as empty strings and
1351    /// carries the actual role as the numeric one (see `resolve_role`); an
1352    /// empty name fed to `map_atspi_role` would hit the strict mapper's panic
1353    /// arm — the exact failure the integration suite surfaced (every element's
1354    /// snapshot went through the round-4 top-level probe and died). The empty
1355    /// name is resolved numerically, exactly like `resolve_role`, so the
1356    /// probe agrees with the snapshot path, and a D-Bus failure is the
1357    /// caller's decision to make (propagated for the runtime guard).
1358    fn parent_role_is_application(&self, parent: &AccessibleRef) -> Result<bool> {
1359        let role_name = self.get_role_name(parent)?;
1360        let role = if role_name.is_empty() {
1361            map_atspi_role_number(self.get_role_number(parent)?)
1362        } else {
1363            map_atspi_role(&role_name)
1364        };
1365        Ok(role == Role::Application)
1366    }
1367
1368    fn ensure_top_level_window_target(&self, element: &ElementData, action: &str) -> Result<()> {
1369        // AT-SPI window discovery is "direct child of the application node";
1370        // descendants with role=dialog are document structure, not OS windows.
1371        //
1372        // A Parent of the registry root (or the null address) is the AT-SPI
1373        // top-level shape: some adapters — AccessKit's, per the
1374        // `app_node_contract` integ test — report a top-level frame's Parent
1375        // there instead of on the application, and `get_atspi_parent` maps
1376        // it to `None` (see `get_parent`). `None` is therefore a
1377        // top-level-window answer, not a rejection: only an explicit
1378        // non-application parent (an in-page ARIA dialog's document ancestor)
1379        // means "not a real OS window".
1380        let target = self.get_cached(element.handle)?;
1381        let Some(parent) = self.get_atspi_parent(&target)? else {
1382            return Ok(());
1383        };
1384        // Same resolution as the advertisement probe (`is_atspi_top_level`),
1385        // so a window the snapshot advertised and the runtime guard can never
1386        // disagree; empty role names resolve numerically (AccessKit's bridge)
1387        // instead of feeding the strict mapper's panic arm.
1388        if self.parent_role_is_application(&parent)? {
1389            Ok(())
1390        } else {
1391            Err(Error::ActionNotSupported {
1392                action: action.to_string(),
1393                role: element.role,
1394            })
1395        }
1396    }
1397
1398    fn is_top_level_window_ref(&self, target: &AccessibleRef) -> bool {
1399        match self.get_atspi_parent(target) {
1400            Ok(None) => true,
1401            Ok(Some(parent)) => self.parent_role_is_application(&parent).unwrap_or(false),
1402            Err(_) => false,
1403        }
1404    }
1405
1406    /// Check if an accessible ref matches a simple selector, fetching only the
1407    /// attributes the selector actually requires.
1408    ///
1409    /// Filter routing (cheapest first):
1410    ///   * `role` / `name` / `value` / `description` — single targeted D-Bus
1411    ///     call against this accessible.
1412    ///   * Normalized state attrs (`enabled`, `checked`, `focused`, …) —
1413    ///     one shared `GetState` call, cached for the rest of this match.
1414    ///   * Anything else (custom `raw` keys, `bounds`, numeric values) — fall
1415    ///     through to building a full `ElementData` and delegating to
1416    ///     [`xa11y_core::selector::matches_simple`]. This is slower but keeps
1417    ///     selectors with rare attribute filters semantically equivalent to
1418    ///     the default tree-traversal path.
1419    fn matches_ref(
1420        &self,
1421        aref: &AccessibleRef,
1422        simple: &xa11y_core::selector::SimpleSelector,
1423        pid: Option<u32>,
1424    ) -> bool {
1425        // Resolve role only if the selector needs it (for either the role
1426        // segment or any role/checked filter — checked depends on role).
1427        let needs_role = simple.role.is_some()
1428            || simple
1429                .filters
1430                .iter()
1431                .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1432        let role = if needs_role {
1433            Some(self.resolve_role(aref))
1434        } else {
1435            None
1436        };
1437
1438        if let Some(ref role_match) = simple.role {
1439            match role_match {
1440                xa11y_core::selector::RoleMatch::Normalized(expected) => {
1441                    if role != Some(*expected) {
1442                        return false;
1443                    }
1444                }
1445                xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1446                    let raw_role = self.get_role_name(aref).unwrap_or_default();
1447                    if raw_role != *platform_role {
1448                        return false;
1449                    }
1450                }
1451            }
1452        }
1453
1454        let mut state_set: Option<StateSet> = None;
1455        // Fetched at most once per match, and only if a filter reads Text /
1456        // Value; a failed read gates both off, as in build_element_data.
1457        let mut ifaces: Option<Interfaces> = None;
1458        let mut value_of = |aref: &AccessibleRef| {
1459            let ifaces =
1460                *ifaces.get_or_insert_with(|| self.get_interfaces(aref).unwrap_or_default());
1461            self.get_value(aref, ifaces)
1462        };
1463
1464        for filter in &simple.filters {
1465            let attr = filter.attr.as_str();
1466            let resolved: Option<Option<String>> = match attr {
1467                "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1468                "name" => {
1469                    let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1470                    // Mirror build_element_data: StaticText carries its name
1471                    // in the Text interface's Value when Name is empty.
1472                    let resolved = if name.is_none() && role == Some(Role::StaticText) {
1473                        value_of(aref)
1474                    } else {
1475                        name
1476                    };
1477                    Some(resolved)
1478                }
1479                "value" => Some(value_of(aref)),
1480                "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1481                "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1482                | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1483                    let s = state_set.get_or_insert_with(|| {
1484                        // `parse_states` reads `checked` based on role; pass
1485                        // whatever we already resolved (Unknown is a no-op for
1486                        // the role-gated `checked` mapping).
1487                        self.parse_states(aref, role.unwrap_or(Role::Unknown))
1488                    });
1489                    Some(state_attr_to_string(attr, s))
1490                }
1491                _ => None, // Routed through full ElementData below.
1492            };
1493
1494            match resolved {
1495                Some(value) => {
1496                    if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1497                    {
1498                        return false;
1499                    }
1500                }
1501                None => {
1502                    // Filter targets an attribute the fast path doesn't know
1503                    // (e.g. `bounds`, `numeric_value`, a custom `raw` key).
1504                    // Build the full ElementData once and let the shared
1505                    // matcher handle every remaining filter — it dispatches
1506                    // to `ElementData` fields and the `raw` map identically
1507                    // to the default tree-traversal path.
1508                    let data = self.build_element_data(aref, pid);
1509                    return xa11y_core::selector::matches_simple(&data, simple);
1510                }
1511            }
1512        }
1513
1514        true
1515    }
1516
1517    /// ONE DFS over the subtree, evaluating each clause's first SimpleSelector
1518    /// against every visited node. Emits `(clause_idx, AccessibleRef)` pairs
1519    /// in document order; a node that matches multiple clauses is emitted
1520    /// once per matching clause (the merge step in `find_elements_group`
1521    /// collapses these by `(bus_name, path)` identity).
1522    ///
1523    /// This is the "push-down to the platform query per group" core: a
1524    /// selector group like `button, text_field` traverses the AT-SPI tree
1525    /// exactly once regardless of clause count, instead of N separate walks.
1526    fn collect_matching_refs_group(
1527        &self,
1528        parent: &AccessibleRef,
1529        clauses: &[&xa11y_core::selector::SimpleSelector],
1530        depth: u32,
1531        max_depth: u32,
1532        limit: Option<usize>,
1533        pid: Option<u32>,
1534    ) -> Result<Vec<(usize, AccessibleRef)>> {
1535        if depth > max_depth {
1536            return Ok(vec![]);
1537        }
1538
1539        let Some(children) = self.live_atspi_children(parent)? else {
1540            return Ok(Vec::new());
1541        };
1542
1543        // Filter invalid refs. Application-node flattening (collapsing a
1544        // redundant `application` accessible into its grandchildren) is only
1545        // valid when the parent is itself an application — i.e. we have already
1546        // descended past the registry. At the registry root, the children *are*
1547        // the applications and must not be collapsed, otherwise selectors like
1548        // `application` (used by `App::list` / `App::by_name`) match nothing
1549        // because the app accessibles get dissolved into their windows.
1550        let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1551        let mut to_search: Vec<AccessibleRef> = Vec::new();
1552        for child in children {
1553            if child.path == "/org/a11y/atspi/null"
1554                || child.bus_name.is_empty()
1555                || child.path.is_empty()
1556            {
1557                continue;
1558            }
1559
1560            if !parent_is_registry {
1561                let child_role = self.get_role_name(&child).unwrap_or_default();
1562                if child_role == "application" {
1563                    let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1564                    for gc in grandchildren {
1565                        if gc.path == "/org/a11y/atspi/null"
1566                            || gc.bus_name.is_empty()
1567                            || gc.path.is_empty()
1568                        {
1569                            continue;
1570                        }
1571                        let gc_role = self.get_role_name(&gc).unwrap_or_default();
1572                        if gc_role == "application" {
1573                            continue;
1574                        }
1575                        to_search.push(gc);
1576                    }
1577                    continue;
1578                }
1579            }
1580            to_search.push(child);
1581        }
1582
1583        // Detect Chromium/Electron's empty-tree signature whenever we
1584        // descend into a parent and find no real children.
1585        if to_search.is_empty() {
1586            self.check_chromium_a11y_enabled(parent, None)?;
1587        }
1588
1589        // Process each child subtree in parallel: check match against every
1590        // clause + recurse. Tagging each match with `clause_idx` lets the
1591        // caller route phase-2 narrowing to the right clause without
1592        // re-running the first-segment match.
1593        //
1594        // We deliberately swallow transient sibling errors here — a single
1595        // flaky D-Bus call on one child shouldn't fail the whole locator
1596        // query (the rest of this file is similarly tolerant via
1597        // `unwrap_or_default()`). But `AccessibilityNotEnabled` is *not* a
1598        // transient error: it's the signal that a Chromium renderer bridge
1599        // isn't initialised, and callers need to see it. So we propagate
1600        // that variant specifically and keep tolerating everything else.
1601        // Per-child batches: matches collected during that child's subtree
1602        // walk, plus the first AccessibilityNotEnabled error (if any).
1603        type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1604        let per_child: Vec<ChildBatch> = to_search
1605            .par_iter()
1606            .map(|child| {
1607                let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1608                for (idx, simple) in clauses.iter().enumerate() {
1609                    if self.matches_ref(child, simple, pid) {
1610                        child_results.push((idx, child.clone()));
1611                    }
1612                }
1613                match self.collect_matching_refs_group(
1614                    child,
1615                    clauses,
1616                    depth + 1,
1617                    max_depth,
1618                    limit,
1619                    pid,
1620                ) {
1621                    Ok(sub) => {
1622                        child_results.extend(sub);
1623                        (child_results, None)
1624                    }
1625                    Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1626                    Err(_) => (child_results, None),
1627                }
1628            })
1629            .collect();
1630
1631        // Merge results, respecting limit. The first AccessibilityNotEnabled
1632        // error seen wins — any child subtree raising it means the whole
1633        // query is untrustworthy, so surface it rather than return partial
1634        // data.
1635        //
1636        // Note: `limit` is the *caller's* outer limit; for a multi-clause
1637        // group it must not be applied per-clause (a low-priority clause's
1638        // hit could come before a high-priority clause's in doc order, so
1639        // truncating mid-walk would drop legitimate matches). Callers pass
1640        // `None` for multi-clause queries and apply the limit after the
1641        // cross-clause merge.
1642        let mut results = Vec::new();
1643        for (batch, maybe_err) in per_child {
1644            if let Some(err) = maybe_err {
1645                return Err(err);
1646            }
1647            for r in batch {
1648                results.push(r);
1649                if let Some(limit) = limit {
1650                    if results.len() >= limit {
1651                        return Ok(results);
1652                    }
1653                }
1654            }
1655        }
1656        Ok(results)
1657    }
1658}
1659
1660impl Provider for LinuxProvider {
1661    fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1662        match element {
1663            None => {
1664                // Top-level: delegate to `list_apps()`, the canonical
1665                // discovery primitive.
1666                self.list_apps()
1667            }
1668            Some(element_data) => {
1669                let aref = self.get_cached(element_data.handle)?;
1670                // A node destroyed after its snapshot has no live descendants.
1671                // Keep genuine D-Bus failures visible; do not run the Chromium
1672                // empty-tree diagnostic on a vanished window.
1673                let Some(children) = self.live_atspi_children(&aref)? else {
1674                    return Ok(Vec::new());
1675                };
1676                let pid = element_data.pid;
1677
1678                // Pre-filter invalid refs and flatten nested application nodes,
1679                // collecting the concrete refs to build in parallel.
1680                let mut to_build: Vec<AccessibleRef> = Vec::new();
1681                for child_ref in &children {
1682                    if child_ref.path == "/org/a11y/atspi/null"
1683                        || child_ref.bus_name.is_empty()
1684                        || child_ref.path.is_empty()
1685                    {
1686                        continue;
1687                    }
1688                    let child_role = self.get_role_name(child_ref).unwrap_or_default();
1689                    if child_role == "application" {
1690                        // Nested application objects can also disappear between
1691                        // enumeration and this read. Prune only confirmed absence.
1692                        let Some(grandchildren) = self.live_atspi_children(child_ref)? else {
1693                            continue;
1694                        };
1695                        for gc_ref in grandchildren {
1696                            if gc_ref.path == "/org/a11y/atspi/null"
1697                                || gc_ref.bus_name.is_empty()
1698                                || gc_ref.path.is_empty()
1699                            {
1700                                continue;
1701                            }
1702                            let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1703                            if gc_role == "application" {
1704                                continue;
1705                            }
1706                            to_build.push(gc_ref);
1707                        }
1708                        continue;
1709                    }
1710                    to_build.push(child_ref.clone());
1711                }
1712
1713                if to_build.is_empty() {
1714                    self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1715                }
1716
1717                let results: Vec<ElementData> = to_build
1718                    .par_iter()
1719                    .map(|r| self.build_element_data(r, pid))
1720                    .collect();
1721
1722                Ok(results)
1723            }
1724        }
1725    }
1726
1727    fn find_elements_group(
1728        &self,
1729        root: &ElementData,
1730        group: &xa11y_core::selector::SelectorGroup,
1731        limit: Option<usize>,
1732        max_depth: Option<u32>,
1733    ) -> Result<Vec<ElementData>> {
1734        if group.clauses.is_empty() {
1735            return Ok(vec![]);
1736        }
1737        // Reject any clause with zero segments early — `clause.segments[0]`
1738        // below would otherwise panic.
1739        if group.clauses.iter().any(|c| c.segments.is_empty()) {
1740            return Ok(vec![]);
1741        }
1742
1743        // ONE AT-SPI walk that evaluates every clause's first SimpleSelector
1744        // against each visited node, then per-clause phase-2 narrowing on
1745        // the small candidate sets that fall out. Dedup uses
1746        // `(bus_name, path)` — the only AT-SPI identifier that's stable
1747        // across the per-clause narrowings (handles are minted fresh on
1748        // each `build_element_data` call).
1749        //
1750        // App discovery is handled separately by `list_apps()` — `root` is
1751        // always present here.
1752        let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1753
1754        let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1755            .clauses
1756            .iter()
1757            .map(|c| &c.segments[0].simple)
1758            .collect();
1759
1760        let start_ref = self.get_cached(root.handle)?;
1761
1762        // ── Phase-1 limit short-circuit ───────────────────────────
1763        // When there's exactly one clause, propagate the user's `limit`
1764        // (adjusted for `:nth`) to the AT-SPI walk so e.g.
1765        // `app.locator("button").first()` stops at the first match. With
1766        // multiple clauses, phase-1 hits for clause N might come before
1767        // clause M's in doc order, so we need the union before truncating.
1768        // (Critical on Electron-scale apps where this prevents 50k-node
1769        // walks.)
1770        let phase1_limit = if group.clauses.len() == 1 {
1771            let clause = &group.clauses[0];
1772            let first = firsts[0];
1773            let outer = if clause.segments.len() == 1 {
1774                limit
1775            } else {
1776                None
1777            };
1778            match (outer, first.nth) {
1779                (Some(l), Some(n)) => Some(l.max(n)),
1780                (_, Some(n)) => Some(n),
1781                (l, None) => l,
1782            }
1783        } else {
1784            None
1785        };
1786
1787        let pid_from_root = root.pid;
1788        let phase1: Vec<(usize, AccessibleRef)> = self.collect_matching_refs_group(
1789            &start_ref,
1790            &firsts,
1791            0,
1792            max_depth_val,
1793            phase1_limit,
1794            pid_from_root,
1795        )?;
1796
1797        // Bucket phase-1 hits by clause so each clause's tail can narrow
1798        // independently. `walk_pos` preserves the doc-order rank from the
1799        // single walk for the final merge.
1800        let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1801            (0..group.clauses.len()).map(|_| Vec::new()).collect();
1802        for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1803            by_clause[clause_idx].push((walk_pos, aref));
1804        }
1805
1806        // For each clause, build ElementData for its phase-1 hits, then
1807        // narrow through any trailing segments. Single-segment clauses
1808        // skip phase 2 entirely.
1809        //
1810        // Each narrowed result keeps a (walk_pos, ElementData) pair: the
1811        // walk_pos of its phase-1 ancestor is the doc-order anchor we use
1812        // to merge across clauses at the end.
1813        let mut merged: Vec<(usize, ElementData)> = Vec::new();
1814        for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1815            if hits.is_empty() {
1816                continue;
1817            }
1818            let clause = &group.clauses[clause_idx];
1819            // Build ElementData for this clause's phase-1 hits in parallel.
1820            let phase1_data: Vec<(usize, ElementData)> = hits
1821                .par_iter()
1822                .map(|(pos, aref)| {
1823                    // `root` is always present now, so the PID comes from
1824                    // the root element. No need to re-resolve via
1825                    // `get_app_pid` / `get_dbus_pid`.
1826                    (*pos, self.build_element_data(aref, pid_from_root))
1827                })
1828                .collect();
1829
1830            if clause.segments.len() == 1 {
1831                // Apply :nth on the first segment (per-clause, then merged).
1832                let mut candidates = phase1_data;
1833                if let Some(nth) = clause.segments[0].simple.nth {
1834                    if nth <= candidates.len() {
1835                        let kept = candidates.remove(nth - 1);
1836                        candidates.clear();
1837                        candidates.push(kept);
1838                    } else {
1839                        candidates.clear();
1840                    }
1841                }
1842                merged.extend(candidates);
1843                continue;
1844            }
1845
1846            // Multi-segment narrowing: re-use the trait helper. Each
1847            // narrowed result is anchored at the phase-1 ancestor's
1848            // walk_pos so the final sort puts it in doc order against
1849            // other clauses.
1850            for (anchor_pos, head) in phase1_data {
1851                let narrowed = self.narrow_multi_segment(
1852                    vec![head],
1853                    &clause.segments[1..],
1854                    max_depth_val,
1855                    None,
1856                )?;
1857                for n in narrowed {
1858                    merged.push((anchor_pos, n));
1859                }
1860            }
1861        }
1862
1863        // Doc-order merge: stable sort by walk position. Within the same
1864        // walk position multiple clauses can have emitted the same node —
1865        // dedup by AT-SPI identity (`(bus_name, path)`) keeps the first.
1866        merged.sort_by_key(|(pos, _)| *pos);
1867        let mut seen: HashSet<(String, String)> = HashSet::new();
1868        let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1869        for (_, data) in merged {
1870            // Use the cached AccessibleRef for identity, not the (handle).
1871            // Handle is freshly minted per `build_element_data` call so it
1872            // would never collide here.
1873            if let Ok(aref) = self.get_cached(data.handle) {
1874                if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1875                    continue;
1876                }
1877            }
1878            out.push(data);
1879        }
1880        if let Some(l) = limit {
1881            out.truncate(l);
1882        }
1883        Ok(out)
1884    }
1885
1886    fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1887        let aref = self.get_cached(element.handle)?;
1888        match self.get_atspi_parent(&aref)? {
1889            Some(parent_ref) => {
1890                let data = self.build_element_data(&parent_ref, element.pid);
1891                Ok(Some(data))
1892            }
1893            None => Ok(None),
1894        }
1895    }
1896
1897    /// Enumerate desktop panels and docks.
1898    ///
1899    /// A Linux shell surface is a top-level AT-SPI frame whose attribute set
1900    /// carries `window-type:dock` — how panels self-identify, from
1901    /// xfce4-panel's panel and dock rows to GNOME's and KDE's shells. One
1902    /// surface per such frame, so a panel process that owns two rows yields
1903    /// two [`ShellSurfaceKind::Panel`] entries, each with the panel process's
1904    /// pid. Panels are commonly unnamed; the frame's `name` is then `None` and
1905    /// the core falls back to the kind.
1906    ///
1907    /// `Panel` is the only kind Linux vends: the desktop has no menu bar,
1908    /// taskbar, or dock object of its own, and StatusNotifierItem *menus* live
1909    /// on `com.canonical.dbusmenu`, a different subsystem from AT-SPI. Their
1910    /// absence from the listing is honest scope, not failure.
1911    ///
1912    /// The scan runs over [`list_apps`](Self::list_apps) rather than its own
1913    /// registry walk, so it inherits that enumeration's empty-name filter: a
1914    /// panel that registers on the bus without a name is invisible here for
1915    /// the same reason it is invisible to `App::list`.
1916    ///
1917    /// # Errors
1918    ///
1919    /// A top-level that was just enumerated but will not answer `GetRole` or
1920    /// `GetAttributes` fails the listing (tenet 1). Those two calls are how a
1921    /// frame is classified, and `Panel` is the only kind Linux vends — so
1922    /// treating a D-Bus failure as "not a panel" turns one broken process into
1923    /// an empty listing that reads exactly like a desktop with no panels. The
1924    /// error names the app and object path that would not answer.
1925    ///
1926    /// An object that answers `GetAttributes` with `UnknownMethod` is the one
1927    /// exception, and it is absence rather than failure — see the call site.
1928    ///
1929    /// Enumerating one app's top-levels is the one step that still skips:
1930    /// [`get_atspi_children`](Self::get_atspi_children) failing is how a
1931    /// process that has left the bus presents, and `focused_app` already
1932    /// treats it that way.
1933    fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
1934        let mut surfaces = Vec::new();
1935        'apps: for app in self.list_apps()? {
1936            let app_ref = self.get_cached(app.handle)?;
1937            let top_levels = match self.get_atspi_children(&app_ref) {
1938                Ok(t) => t,
1939                // Same policy as `focused_app`: a single app failing to
1940                // enumerate its windows shouldn't abort the whole scan — skip
1941                // it and keep going.
1942                Err(_) => continue,
1943            };
1944            for top in top_levels
1945                .iter()
1946                .filter(|t| t.path != "/org/a11y/atspi/null")
1947            {
1948                // Both answer for a child the app has *just* enumerated, so a
1949                // D-Bus failure here is a failure, not absence — and "not a
1950                // panel" is the wrong reading of it (tenet 1). Say which
1951                // top-level of which app would not answer (tenet 6).
1952                //
1953                // `GetRole` rather than `GetRoleName` because every AT-SPI
1954                // object implements the numeric one, while AccessKit's bridge
1955                // (egui, and the other AccessKit-backed toolkits) implements
1956                // no `GetRoleName` at all — propagating that reply failed the
1957                // whole listing for a desktop running one such app.
1958                let Some(role_number) = self
1959                    .scan_role_number(top)
1960                    .map_err(|e| classify_failure(&app, top, e))?
1961                else {
1962                    // The application exited between `list_apps()` and this
1963                    // read. Its remaining top-levels are gone too, so move to
1964                    // the next application rather than re-asking a bus name
1965                    // that no longer has an owner.
1966                    continue 'apps;
1967                };
1968                // The same absence/failure split on the second read: an object
1969                // that does not implement `GetAttributes` cannot advertise
1970                // `window-type`, which is an honest "not a panel" — the answer
1971                // is in the reply, not missing from it. (AccessKit does serve
1972                // this one; the bridges that skip it are the reason the split
1973                // is here rather than only on the role.) Every other D-Bus
1974                // outcome — ServiceUnknown, NoReply, a timeout, a dropped
1975                // connection — still fails the listing.
1976                let Some(attributes) = self
1977                    .scan_attributes(top)
1978                    .map_err(|e| classify_failure(&app, top, e))?
1979                else {
1980                    continue 'apps;
1981                };
1982                let Some(attributes) = attributes else {
1983                    continue;
1984                };
1985                if is_dock_frame(role_number, &attributes) {
1986                    surfaces.push((
1987                        ShellSurfaceKind::Panel,
1988                        self.build_element_data(top, app.pid),
1989                    ));
1990                }
1991            }
1992        }
1993        Ok(surfaces)
1994    }
1995
1996    /// A single process can register several AT-SPI Application entries
1997    /// (per-process instance plus per-event-loop pieces), so `App::windows`
1998    /// must merge their window children by pid. The same-pid merge and the
1999    /// stable identities it deduplicates by live in `xa11y-core`.
2000    fn splits_app_across_entries(&self) -> bool {
2001        true
2002    }
2003
2004    fn app_roots(&self, app: &ElementData) -> Result<Vec<ElementData>> {
2005        let Some(pid) = app.pid else {
2006            return Ok(vec![app.clone()]);
2007        };
2008        let mut roots: Vec<_> = self
2009            .list_apps()?
2010            .into_iter()
2011            .filter(|candidate| candidate.pid == Some(pid))
2012            .collect();
2013        if roots.is_empty() {
2014            roots.push(app.clone());
2015        }
2016        Ok(roots)
2017    }
2018
2019    /// Enumerate top-level applications by listing direct children of the
2020    /// AT-SPI registry root — every running accessibility-enabled app
2021    /// registers a child accessible there. Apps with empty names are
2022    /// filtered out (toolkits sometimes register transient/system
2023    /// accessibles before the real app appears). PID resolution uses the
2024    /// AT-SPI `Application` interface, falling back to the D-Bus owner.
2025    fn list_apps(&self) -> Result<Vec<ElementData>> {
2026        let registry = AccessibleRef {
2027            bus_name: "org.a11y.atspi.Registry".to_string(),
2028            path: "/org/a11y/atspi/accessible/root".to_string(),
2029        };
2030        let children = self.get_atspi_children(&registry)?;
2031
2032        // Filter valid children first, then build in parallel
2033        let valid: Vec<(&AccessibleRef, String)> = children
2034            .iter()
2035            .filter(|c| c.path != "/org/a11y/atspi/null")
2036            .filter_map(|c| {
2037                let name = self.get_name(c).unwrap_or_default();
2038                if name.is_empty() {
2039                    None
2040                } else {
2041                    Some((c, name))
2042                }
2043            })
2044            .collect();
2045
2046        let results: Vec<ElementData> = valid
2047            .par_iter()
2048            .map(|(child, app_name)| {
2049                let pid = self.get_app_pid(child);
2050                let mut data = self.build_element_data(child, pid);
2051                data.name = Some(app_name.clone());
2052                data
2053            })
2054            .collect();
2055
2056        Ok(results)
2057    }
2058
2059    /// Find an application by pid directly against the AT-SPI registry.
2060    ///
2061    /// Differs from filtering `list_apps()` in two ways that matter for a
2062    /// freshly launched process:
2063    ///
2064    /// * No empty-name filter — toolkits can register on the bus before the
2065    ///   app name is set, and a pid match is precise enough on its own.
2066    /// * The not-matched error reports how many registry entries were
2067    ///   examined and how many had no resolvable pid (the misreported-pid
2068    ///   bridge quirk `get_app_pid` documents), so a timeout on CI says why
2069    ///   the app was invisible instead of just "not found".
2070    fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
2071        let registry = AccessibleRef {
2072            bus_name: "org.a11y.atspi.Registry".to_string(),
2073            path: "/org/a11y/atspi/accessible/root".to_string(),
2074        };
2075        let children = self.get_atspi_children(&registry)?;
2076
2077        let mut total = 0usize;
2078        let mut unresolved = 0usize;
2079        for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2080            total += 1;
2081            match self.get_app_pid(child) {
2082                Some(p) if p == pid => {
2083                    let mut data = self.build_element_data(child, Some(p));
2084                    // Mirror list_apps: prefer the registry-reported name.
2085                    let name = self.get_name(child).unwrap_or_default();
2086                    if !name.is_empty() {
2087                        data.name = Some(name);
2088                    }
2089                    return Ok(data);
2090                }
2091                Some(_) => {}
2092                None => unresolved += 1,
2093            }
2094        }
2095        Err(
2096            Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
2097                xa11y_core::Diagnosis::new().last_observed(format!(
2098                    "{total} AT-SPI registry entries examined, {unresolved} without a \
2099                     resolvable pid"
2100                )),
2101            ),
2102        )
2103    }
2104
2105    /// Identify the foreground application via the active top-level window.
2106    ///
2107    /// AT-SPI has no registry-level "focused application" call, and the
2108    /// `Application` accessibles themselves don't carry an active/focused
2109    /// state across toolkits. The portable signal is the `ACTIVE` state
2110    /// (`AtspiStateType::ACTIVE`) set on the foreground *window* (frame): we
2111    /// enumerate each registered application's top-level children and return
2112    /// the first application that owns a window reporting `ACTIVE`.
2113    ///
2114    /// Returns [`Error::SelectorNotMatched`] when no window is active (no app
2115    /// has the foreground — e.g. focus is on the shell or a non-AT-SPI
2116    /// surface). The core reads that as "nothing focused", not a failure.
2117    fn focused_app(&self) -> Result<ElementData> {
2118        // AtspiStateType::ACTIVE = 1 → bit position 1.
2119        const ACTIVE: u64 = 1 << 1;
2120
2121        let registry = AccessibleRef {
2122            bus_name: "org.a11y.atspi.Registry".to_string(),
2123            path: "/org/a11y/atspi/accessible/root".to_string(),
2124        };
2125        let apps = self.get_atspi_children(&registry)?;
2126
2127        for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2128            let windows = match self.get_atspi_children(app) {
2129                Ok(w) => w,
2130                // A single app failing to enumerate its windows shouldn't abort
2131                // the whole foreground probe — skip it and keep scanning.
2132                Err(_) => continue,
2133            };
2134            let has_active_window = windows
2135                .iter()
2136                .filter(|w| w.path != "/org/a11y/atspi/null")
2137                .any(|w| self.state_bits(w).0 & ACTIVE != 0);
2138            if has_active_window {
2139                let pid = self.get_app_pid(app);
2140                let mut data = self.build_element_data(app, pid);
2141                let name = self.get_name(app).unwrap_or_default();
2142                if !name.is_empty() {
2143                    data.name = Some(name);
2144                }
2145                return Ok(data);
2146            }
2147        }
2148
2149        Err(Error::selector_not_matched("focused application"))
2150    }
2151
2152    fn press(&self, element: &ElementData) -> Result<()> {
2153        let target = self.get_cached(element.handle)?;
2154        // Fast path: the widget exposes `press` on its own Action interface.
2155        if let Ok(index) = self.get_action_index(element.handle, "press") {
2156            return self.do_atspi_action_by_index(&target, index);
2157        }
2158        // TENET-BREAK(1): substitute target. Human approval granted for this
2159        // break (see batch B6 / PR #125 / commit daeaf59). Justification: GTK4
2160        // menu-button widgets (GtkMenuButton, AdwMenuButton, AdwSplitButton)
2161        // expose their AT-SPI Action interface on an *inner* toggle-button
2162        // child, not the outer push-button accessible the app author addresses
2163        // by name. Without this workaround, `press()` silently does nothing on
2164        // every GtkMenuButton in every stock GNOME app (Calculator, Text
2165        // Editor, Logs, …). Alternatives considered and rejected:
2166        //   1. Expose the inner widget in the tree — breaks AT-SPI tree
2167        //      fidelity and leaks a GTK implementation detail into every
2168        //      consumer.
2169        //   2. Return ActionNotSupported — every GTK consumer would have to
2170        //      reimplement the same subtree search to work around it.
2171        // The break is narrowly scoped: gated on (a) the owning toolkit being
2172        // GTK and (b) the outer role being Role::Button (push-button, which is
2173        // what all three menu-button variants present as). Non-GtkMenuButton
2174        // roles and non-GTK toolkits continue to fail-fast with
2175        // ActionNotSupported.
2176        if element.role == Role::Button && self.is_gtk_toolkit(&target) {
2177            let outer_name = self.get_name(&target).unwrap_or_default();
2178            if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
2179                return self.do_atspi_action_by_index(&inner, index);
2180            }
2181        }
2182        Err(Error::ActionNotSupported {
2183            action: "press".to_string(),
2184            role: element.role,
2185        })
2186    }
2187
2188    fn focus(&self, element: &ElementData) -> Result<()> {
2189        let target = self.get_cached(element.handle)?;
2190        // The only mechanism is Component.GrabFocus, which returns a boolean
2191        // indicating success — we must check it. Fixes GitHub issue #98.
2192        //
2193        // There is deliberately no stored-action-index fallback: one cannot
2194        // fire. `action_indices` is populated only for roles in
2195        // `role_has_actions` (Window/Dialog excluded) from names
2196        // `map_atspi_action_name` produces, and no mapping yields "focus" (see
2197        // `get_actions`'s note: "focus" is only ever reported when explicitly
2198        // in the AT-SPI Action interface). Keeping the dead lookup here would
2199        // pretend to a second mechanism that never succeeds — the same
2200        // decision `activate` documents above (tenet 1).
2201        let proxy = self
2202            .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2203            .map_err(|e| Error::Platform {
2204                code: -1,
2205                message: format!("Component proxy while focusing failed: {}", e),
2206            })?;
2207        let reply = grab_focus_result(
2208            proxy.call_method("GrabFocus", &()),
2209            "focusing",
2210            "focus",
2211            element.role,
2212        )?;
2213        let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2214            code: -1,
2215            message: format!("Component.GrabFocus reply while focusing failed: {}", e),
2216        })?;
2217        if grabbed {
2218            return Ok(());
2219        }
2220        // GrabFocus(false) is an honest "cannot focus this element" — the
2221        // AT-SPI adapter declined, not a platform failure.
2222        Err(Error::ActionNotSupported {
2223            action: "focus".to_string(),
2224            role: element.role,
2225        })
2226    }
2227
2228    fn blur(&self, element: &ElementData) -> Result<()> {
2229        let target = self.get_cached(element.handle)?;
2230        // Grab focus on parent element to blur the current one. We propagate
2231        // every failure — no silent fallbacks (tenet 1): callers need to see
2232        // when blur can't do anything useful.
2233        if let Some(parent_ref) = self.get_atspi_parent(&target)? {
2234            if parent_ref.path != "/org/a11y/atspi/null" {
2235                let p = self.make_proxy(
2236                    &parent_ref.bus_name,
2237                    &parent_ref.path,
2238                    "org.a11y.atspi.Component",
2239                )?;
2240                p.call_method("GrabFocus", &())
2241                    .map_err(|e| Error::Platform {
2242                        code: -1,
2243                        message: format!("Component.GrabFocus on parent failed: {}", e),
2244                    })?;
2245                return Ok(());
2246            }
2247        }
2248        Err(Error::ActionNotSupported {
2249            action: "blur".to_string(),
2250            role: element.role,
2251        })
2252    }
2253
2254    fn toggle(&self, element: &ElementData) -> Result<()> {
2255        let target = self.get_cached(element.handle)?;
2256        // Fast path: the widget exposes the literal AT-SPI action name "toggle"
2257        // (or one of its toolkit aliases — "check" / "uncheck" — both
2258        // canonicalised to "toggle" by map_atspi_action_name during cache
2259        // population).
2260        if let Ok(index) = self.get_action_index(element.handle, "toggle") {
2261            return self.do_atspi_action_by_index(&target, index);
2262        }
2263        // Fallback for AccessKit-backed apps. accesskit_unix's AT-SPI bridge
2264        // hard-codes the action name as "click" for every clickable node
2265        // regardless of role — see platforms/atspi-common/src/node.rs in
2266        // AccessKit. That means egui, eframe, winit-based apps, Bevy, Tauri
2267        // (when using AccessKit) and any other AccessKit consumer never
2268        // advertise "toggle" for CheckBox/RadioButton/Switch, even though
2269        // activating those widgets via "click" is functionally a toggle on
2270        // every platform. Without this fallback, xa11y.toggle() is
2271        // unreachable on every AccessKit-on-Linux widget.
2272        //
2273        // This is tenet-3-consistent: the semantic verb `toggle` on a
2274        // toggleable role means "activate this toggleable element," and on
2275        // Linux the canonical AccessKit implementation of that is the same
2276        // action node entry we'd dispatch for press(). It's the mirror image
2277        // of the Windows `press` dispatch (uia.rs) which fans out to Invoke,
2278        // Toggle, SelectionItem.Select, or ExpandCollapse depending on the
2279        // element's primary-activation pattern.
2280        //
2281        // Narrowly scoped: only fires for toggleable primary-activation
2282        // roles. Other roles still fail-fast with ActionNotSupported so a
2283        // misuse of toggle() on, e.g., a Button does not silently dispatch
2284        // press().
2285        if matches!(
2286            element.role,
2287            Role::CheckBox | Role::RadioButton | Role::Switch
2288        ) {
2289            if let Ok(index) = self.get_action_index(element.handle, "press") {
2290                return self.do_atspi_action_by_index(&target, index);
2291            }
2292        }
2293        Err(Error::ActionNotSupported {
2294            action: "toggle".to_string(),
2295            role: element.role,
2296        })
2297    }
2298
2299    fn select(&self, element: &ElementData) -> Result<()> {
2300        let target = self.get_cached(element.handle)?;
2301        let index = self
2302            .get_action_index(element.handle, "select")
2303            .map_err(|_| Error::ActionNotSupported {
2304                action: "select".to_string(),
2305                role: element.role,
2306            })?;
2307        self.do_atspi_action_by_index(&target, index)
2308    }
2309
2310    fn expand(&self, element: &ElementData) -> Result<()> {
2311        let target = self.get_cached(element.handle)?;
2312        let index = self
2313            .get_action_index(element.handle, "expand")
2314            .map_err(|_| Error::ActionNotSupported {
2315                action: "expand".to_string(),
2316                role: element.role,
2317            })?;
2318        self.do_atspi_action_by_index(&target, index)
2319    }
2320
2321    fn collapse(&self, element: &ElementData) -> Result<()> {
2322        let target = self.get_cached(element.handle)?;
2323        let index = self
2324            .get_action_index(element.handle, "collapse")
2325            .map_err(|_| Error::ActionNotSupported {
2326                action: "collapse".to_string(),
2327                role: element.role,
2328            })?;
2329        self.do_atspi_action_by_index(&target, index)
2330    }
2331
2332    fn show_menu(&self, element: &ElementData) -> Result<()> {
2333        let target = self.get_cached(element.handle)?;
2334        let index = self
2335            .get_action_index(element.handle, "show_menu")
2336            .map_err(|_| Error::ActionNotSupported {
2337                action: "show_menu".to_string(),
2338                role: element.role,
2339            })?;
2340        self.do_atspi_action_by_index(&target, index)
2341    }
2342
2343    fn increment(&self, element: &ElementData) -> Result<()> {
2344        let target = self.get_cached(element.handle)?;
2345        // Try stored AT-SPI2 action index first, fall back to Value interface
2346        if let Ok(index) = self.get_action_index(element.handle, "increment") {
2347            return self.do_atspi_action_by_index(&target, index);
2348        }
2349        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2350        let current: f64 = proxy
2351            .get_property("CurrentValue")
2352            .map_err(|e| Error::Platform {
2353                code: -1,
2354                message: format!("Value.CurrentValue failed: {}", e),
2355            })?;
2356        let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2357        let step = if step <= 0.0 { 1.0 } else { step };
2358        proxy
2359            .set_property("CurrentValue", current + step)
2360            .map_err(|e| Error::Platform {
2361                code: -1,
2362                message: format!("Value.SetCurrentValue failed: {}", e),
2363            })
2364    }
2365
2366    fn decrement(&self, element: &ElementData) -> Result<()> {
2367        let target = self.get_cached(element.handle)?;
2368        if let Ok(index) = self.get_action_index(element.handle, "decrement") {
2369            return self.do_atspi_action_by_index(&target, index);
2370        }
2371        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2372        let current: f64 = proxy
2373            .get_property("CurrentValue")
2374            .map_err(|e| Error::Platform {
2375                code: -1,
2376                message: format!("Value.CurrentValue failed: {}", e),
2377            })?;
2378        let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2379        let step = if step <= 0.0 { 1.0 } else { step };
2380        proxy
2381            .set_property("CurrentValue", current - step)
2382            .map_err(|e| Error::Platform {
2383                code: -1,
2384                message: format!("Value.SetCurrentValue failed: {}", e),
2385            })
2386    }
2387
2388    fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
2389        let target = self.get_cached(element.handle)?;
2390        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2391        proxy
2392            .call_method("ScrollTo", &(0u32,))
2393            .map_err(|e| Error::Platform {
2394                code: -1,
2395                message: format!("ScrollTo failed: {}", e),
2396            })?;
2397        Ok(())
2398    }
2399
2400    // ── Window management ──────────────────────────────────────────
2401    //
2402    // Native X11/Sway window-manager requests handle the verbs they advertise.
2403    // On Wayland compositors without a native provider, AT-SPI still exposes
2404    // `activate` (Component.GrabFocus on the frame) and two geometry setters:
2405    // SetPosition and SetSize. There is no simulated-shortcut path.
2406    //
2407    // Window discovery is `App::windows` — `get_children(app)` filtered to
2408    // Window|Dialog — as on every platform.
2409
2410    fn activate(&self, element: &ElementData) -> Result<()> {
2411        // The AT-SPI implementation of "activate this window" is the same
2412        // Component.GrabFocus path `focus` uses: the frame that receives
2413        // focus becomes the active window. No separate activate API exists.
2414        //
2415        // Implemented here rather than delegating to `focus` so a failure
2416        // reports the verb the caller requested — `activate` — not `focus`'s
2417        // name (the two share a mechanism, not an identity).
2418        //
2419        // A GrabFocus(false) reply is an honest "cannot activate this window",
2420        // reported as ActionNotSupported: the action-index fallback that
2421        // `focus` carries cannot fire for window-like roles, because
2422        // `action_indices` is populated only for roles in `role_has_actions`
2423        // (Window/Dialog excluded) and `map_atspi_action_name` maps no
2424        // "focus" action name. Unlike Windows/macOS, `activate` is deliberately
2425        // NOT advertised on AT-SPI — the adapters disagree about a frame
2426        // GrabFocus and no interface probe discriminates them (see the
2427        // reasons in `build_element_data`); advertised or not, the call here
2428        // and the `focus` call use the same mechanism, which is why the
2429        // deleted `focus` lookup would only ever pretend to a second path
2430        // (tenet 1). If GTK4's GrabFocus(false) is ever reproduced with an
2431        // actual focus-named Action interface entry (issue #98), the right
2432        // fix is a direct Action probe on this failure path, not a cache
2433        // lookup the role/name mapping rules can never fill.
2434        self.ensure_top_level_window_target(element, "activate")?;
2435        if self.window_manager.uses_native() {
2436            return self.window_manager.activate(element);
2437        }
2438        let target = self.get_cached(element.handle)?;
2439        let proxy = self
2440            .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2441            .map_err(|e| Error::Platform {
2442                code: -1,
2443                message: format!("Component proxy while activating failed: {}", e),
2444            })?;
2445        let reply = grab_focus_result(
2446            proxy.call_method("GrabFocus", &()),
2447            "activating",
2448            "activate",
2449            element.role,
2450        )?;
2451        let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2452            code: -1,
2453            message: format!("Component.GrabFocus reply while activating failed: {}", e),
2454        })?;
2455        if grabbed {
2456            return Ok(());
2457        }
2458        Err(Error::ActionNotSupported {
2459            action: "activate".to_string(),
2460            role: element.role,
2461        })
2462    }
2463
2464    fn minimize(&self, element: &ElementData) -> Result<()> {
2465        self.ensure_top_level_window_target(element, "minimize")?;
2466        self.window_manager.minimize(element)
2467    }
2468
2469    fn maximize(&self, element: &ElementData) -> Result<()> {
2470        self.ensure_top_level_window_target(element, "maximize")?;
2471        self.window_manager.maximize(element)
2472    }
2473
2474    fn enter_fullscreen(&self, element: &ElementData) -> Result<()> {
2475        self.ensure_top_level_window_target(element, "enter_fullscreen")?;
2476        self.window_manager.enter_fullscreen(element)
2477    }
2478
2479    fn restore(&self, element: &ElementData) -> Result<()> {
2480        self.ensure_top_level_window_target(element, "restore")?;
2481        self.window_manager.restore(element)
2482    }
2483
2484    fn close(&self, element: &ElementData) -> Result<()> {
2485        self.ensure_top_level_window_target(element, "close")?;
2486        self.window_manager.close(element)
2487    }
2488
2489    fn move_to(&self, element: &ElementData, x: i32, y: i32) -> Result<()> {
2490        self.ensure_top_level_window_target(element, "move_to")?;
2491        if self.window_manager.uses_native() {
2492            return self.window_manager.move_to(element, x, y);
2493        }
2494        let target = self.get_cached(element.handle)?;
2495        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2496        let scale = crate::scale::coordinate_scale();
2497        let point = xa11y_core::Point::new(x, y).to_physical(scale);
2498        // coord_type 0 is screen coordinates, matching GetExtents above.
2499        let reply = proxy
2500            .call_method("SetPosition", &(point.x, point.y, 0u32))
2501            .map_err(|e| {
2502                if is_absent_member(&e) {
2503                    Error::ActionNotSupported {
2504                        action: "move_to".to_string(),
2505                        role: element.role,
2506                    }
2507                } else {
2508                    Error::Platform {
2509                        code: -1,
2510                        message: format!("Component.SetPosition while moving window failed: {e}"),
2511                    }
2512                }
2513            })?;
2514        let moved: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2515            code: -1,
2516            message: format!("Component.SetPosition reply while moving window failed: {e}"),
2517        })?;
2518        if moved {
2519            Ok(())
2520        } else {
2521            Err(Error::ActionNotSupported {
2522                action: "move_to".to_string(),
2523                role: element.role,
2524            })
2525        }
2526    }
2527
2528    fn resize_to(&self, element: &ElementData, w: u32, h: u32) -> Result<()> {
2529        self.ensure_top_level_window_target(element, "resize_to")?;
2530        if self.window_manager.uses_native() {
2531            return self.window_manager.resize_to(element, w, h);
2532        }
2533        let target = self.get_cached(element.handle)?;
2534        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2535        let physical = Rect {
2536            x: 0,
2537            y: 0,
2538            width: w,
2539            height: h,
2540        }
2541        .to_physical(crate::scale::coordinate_scale());
2542        let width = i32::try_from(physical.width).map_err(|_| Error::InvalidActionData {
2543            message: format!("resize width {w} is too large for AT-SPI"),
2544        })?;
2545        let height = i32::try_from(physical.height).map_err(|_| Error::InvalidActionData {
2546            message: format!("resize height {h} is too large for AT-SPI"),
2547        })?;
2548        let reply = proxy
2549            .call_method("SetSize", &(width, height))
2550            .map_err(|e| {
2551                if is_absent_member(&e) {
2552                    Error::ActionNotSupported {
2553                        action: "resize_to".to_string(),
2554                        role: element.role,
2555                    }
2556                } else {
2557                    Error::Platform {
2558                        code: -1,
2559                        message: format!("Component.SetSize while resizing window failed: {e}"),
2560                    }
2561                }
2562            })?;
2563        let resized: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2564            code: -1,
2565            message: format!("Component.SetSize reply while resizing window failed: {e}"),
2566        })?;
2567        if resized {
2568            Ok(())
2569        } else {
2570            Err(Error::ActionNotSupported {
2571                action: "resize_to".to_string(),
2572                role: element.role,
2573            })
2574        }
2575    }
2576
2577    fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
2578        let target = self.get_cached(element.handle)?;
2579        let proxy = self
2580            .make_proxy(
2581                &target.bus_name,
2582                &target.path,
2583                "org.a11y.atspi.EditableText",
2584            )
2585            .map_err(|_| Error::TextValueNotSupported)?;
2586        // Try SetTextContents first (WebKit2GTK exposes this but not InsertText).
2587        if proxy.call_method("SetTextContents", &(value,)).is_ok() {
2588            return Ok(());
2589        }
2590        // Fall back to delete-then-insert for other AT-SPI2 implementations.
2591        // Capture the underlying D-Bus error so callers can distinguish an
2592        // absent `EditableText` interface (common on Chromium — the Chrome
2593        // URL bar only exposes read-only `Text`; see issue #101) from other
2594        // failures. Collapsing to `TextValueNotSupported` hides the reason.
2595        let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
2596            let msg = e.to_string();
2597            if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
2598                Error::TextValueNotSupported
2599            } else {
2600                Error::Platform {
2601                    code: -1,
2602                    message: format!("EditableText.{} failed: {}", op, msg),
2603                }
2604            }
2605        };
2606        if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
2607            return Err(classify_editable_text_error("DeleteText", e));
2608        }
2609        if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
2610            return Err(classify_editable_text_error("InsertText", e));
2611        }
2612        Ok(())
2613    }
2614
2615    fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
2616        let target = self.get_cached(element.handle)?;
2617        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2618        proxy
2619            .set_property("CurrentValue", value)
2620            .map_err(|e| Error::Platform {
2621                code: -1,
2622                message: format!("SetValue failed: {}", e),
2623            })
2624    }
2625
2626    fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
2627        let target = self.get_cached(element.handle)?;
2628        // Insert text via EditableText interface (accessibility API, not input simulation).
2629        // Get cursor position from Text interface, then insert at that position.
2630        let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
2631        let insert_pos = text_proxy
2632            .as_ref()
2633            .ok()
2634            .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
2635            .unwrap_or(-1); // -1 = append at end
2636
2637        let proxy = self
2638            .make_proxy(
2639                &target.bus_name,
2640                &target.path,
2641                "org.a11y.atspi.EditableText",
2642            )
2643            .map_err(|_| Error::TextValueNotSupported)?;
2644        let pos = if insert_pos >= 0 {
2645            insert_pos
2646        } else {
2647            i32::MAX
2648        };
2649        proxy
2650            .call_method("InsertText", &(pos, text, text.len() as i32))
2651            .map_err(|e| Error::Platform {
2652                code: -1,
2653                message: format!("EditableText.InsertText failed: {}", e),
2654            })?;
2655        Ok(())
2656    }
2657
2658    fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
2659        let target = self.get_cached(element.handle)?;
2660        let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
2661        proxy
2662            .call_method("SetSelection", &(0i32, start as i32, end as i32))
2663            .map_err(|e| Error::Platform {
2664                code: -1,
2665                message: format!("Text.SetSelection failed: {}", e),
2666            })?;
2667        Ok(())
2668    }
2669
2670    fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
2671        match action {
2672            "press" => self.press(element),
2673            "focus" => self.focus(element),
2674            "blur" => self.blur(element),
2675            "toggle" => self.toggle(element),
2676            "select" => self.select(element),
2677            "expand" => self.expand(element),
2678            "collapse" => self.collapse(element),
2679            "show_menu" => self.show_menu(element),
2680            "increment" => self.increment(element),
2681            "decrement" => self.decrement(element),
2682            "scroll_into_view" => self.scroll_into_view(element),
2683            "activate" => self.activate(element),
2684            "minimize" => self.minimize(element),
2685            "maximize" => self.maximize(element),
2686            "enter_fullscreen" => self.enter_fullscreen(element),
2687            "restore" => self.restore(element),
2688            "close" => self.close(element),
2689            // Payload verbs have no arguments on the generic escape hatch;
2690            // fail surfaceably with how to call them instead of guessing
2691            // (tenet 1: no silent fallback).
2692            "move_to" => Err(Error::InvalidActionData {
2693                message: "perform_action(\"move_to\") requires coordinates; call move_to(x, y)"
2694                    .to_string(),
2695            }),
2696            "resize_to" => Err(Error::InvalidActionData {
2697                message: "perform_action(\"resize_to\") requires dimensions; call \
2698                           resize_to(width, height)"
2699                    .to_string(),
2700            }),
2701            _ => Err(Error::ActionNotSupported {
2702                action: action.to_string(),
2703                role: element.role,
2704            }),
2705        }
2706    }
2707
2708    fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2709        let pid = element.pid.ok_or(Error::Platform {
2710            code: -1,
2711            message: "Element has no PID for subscribe".to_string(),
2712        })?;
2713        let app_name = element.name.clone().unwrap_or_default();
2714        crate::events::subscribe_for_pid(self, pid, app_name)
2715    }
2716}
2717
2718/// Whether a role typically has text or Value interface content.
2719/// Container/structural roles are skipped to save D-Bus round-trips.
2720fn role_has_value(role: Role) -> bool {
2721    !matches!(
2722        role,
2723        Role::Application
2724            | Role::Window
2725            | Role::Dialog
2726            | Role::Group
2727            | Role::MenuBar
2728            | Role::Toolbar
2729            | Role::TabGroup
2730            | Role::SplitGroup
2731            | Role::Table
2732            | Role::TableRow
2733            | Role::Separator
2734    )
2735}
2736
2737/// Whether a role typically supports actions via the Action interface.
2738/// Container and display-only roles are skipped to save D-Bus round-trips.
2739///
2740/// Window and Dialog are deliberately excluded: their semantic verbs do not
2741/// come from the Action interface. `activate` (the one Linux supports) is also
2742/// deliberately NOT advertised — see the note in `build_element_data` on why
2743/// (the AT-SPI adapters disagree about implementing a frame GrabFocus, so
2744/// advertising it would promise an action the call then declines); it stays
2745/// callable, and the state-mutating verbs are `Unsupported` on AT-SPI — so
2746/// admitting them here would only earn a pointless GetActions probe
2747/// returning no usable entries.
2748fn role_has_actions(role: Role) -> bool {
2749    matches!(
2750        role,
2751        Role::Button
2752            | Role::CheckBox
2753            | Role::RadioButton
2754            | Role::MenuItem
2755            | Role::Link
2756            | Role::ComboBox
2757            | Role::TextField
2758            | Role::TextArea
2759            | Role::SpinButton
2760            | Role::Tab
2761            | Role::TreeItem
2762            | Role::ListItem
2763            | Role::ScrollBar
2764            | Role::Slider
2765            | Role::Menu
2766            | Role::Image
2767            | Role::Unknown
2768    )
2769}
2770
2771/// Classify a `Component.GrabFocus` call result, used by
2772/// [`focus`](LinuxProvider::focus) and [`activate`](LinuxProvider::activate).
2773///
2774/// The two verbs share the AT-SPI mechanism (there is no separate activate API),
2775/// and an adapter signals "this element cannot take focus" in one of two
2776/// honest shapes: a `false` reply (checked by both callers) or the D-Bus
2777/// `NotSupported` error, which is what the GTK4 adapter raises for
2778/// non-focusable controls instead of answering false (issue #98). Both mean
2779/// the adapter declined — `ActionNotSupported`, the documented contract that
2780/// the python/JS suites treat as xfail — and every other failure stays a
2781/// `Platform` error (tenet 1: no silent swallowing of unexpected failures).
2782fn grab_focus_result(
2783    call: std::result::Result<zbus::Message, zbus::Error>,
2784    verb: &str,
2785    action: &str,
2786    role: Role,
2787) -> Result<zbus::Message> {
2788    call.map_err(|e| match e {
2789        zbus::Error::MethodError(name, _, _) => {
2790            grab_focus_error(name.as_str().to_string(), verb, action, role)
2791        }
2792        other => Error::Platform {
2793            code: -1,
2794            message: format!("Component.GrabFocus while {verb} failed: {other}"),
2795        },
2796    })
2797}
2798
2799/// Pure classification half of `grab_focus_result`, unit-testable without
2800/// constructing zbus internals.
2801fn grab_focus_error(name: String, verb: &str, action: &str, role: Role) -> Error {
2802    if name == "org.freedesktop.DBus.Error.NotSupported" {
2803        Error::ActionNotSupported {
2804            action: action.to_string(),
2805            role,
2806        }
2807    } else {
2808        Error::Platform {
2809            code: -1,
2810            message: format!("Component.GrabFocus while {verb} failed: {name}"),
2811        }
2812    }
2813}
2814
2815/// Map AT-SPI2 role name to xa11y Role.
2816pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2817    match role_name.to_lowercase().as_str() {
2818        "application" => Role::Application,
2819        "window" | "frame" => Role::Window,
2820        "dialog" | "file chooser" => Role::Dialog,
2821        "alert" | "notification" | "info bar" => Role::Alert,
2822        "push button" | "push button menu" => Role::Button,
2823        "toggle button" => Role::Switch,
2824        "check box" | "check menu item" => Role::CheckBox,
2825        "radio button" | "radio menu item" => Role::RadioButton,
2826        "entry" | "password text" => Role::TextField,
2827        "spin button" | "spinbutton" => Role::SpinButton,
2828        // "textbox" is the ARIA role name returned by WebKit2GTK for both
2829        // <input type="text"> and <textarea>.  Map to TextArea here so the
2830        // multi-line refinement below can downgrade single-line ones to TextField.
2831        // "embedded" is WebKit2GTK's numeric-path equivalent (role 78);
2832        // "terminal" is VTE — text is reachable via the Text interface.
2833        "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2834        "label" | "static" | "caption" => Role::StaticText,
2835        "combo box" | "combobox" => Role::ComboBox,
2836        // "listbox" is the ARIA role name returned by WebKit2GTK for role="listbox".
2837        "list" | "list box" | "listbox" => Role::List,
2838        "list item" => Role::ListItem,
2839        "menu" | "popup menu" => Role::Menu,
2840        "menu item" | "tearoff menu item" => Role::MenuItem,
2841        "menu bar" => Role::MenuBar,
2842        "page tab" => Role::Tab,
2843        "page tab list" => Role::TabGroup,
2844        "table" | "tree table" => Role::Table,
2845        "table row" => Role::TableRow,
2846        // Bare "column header"/"row header" are WebKit's <th> and GTK
2847        // column-view headers; the "table …" pair is the classic toolkit one.
2848        "table cell"
2849        | "table column header"
2850        | "table row header"
2851        | "column header"
2852        | "row header" => Role::TableCell,
2853        "tool bar" => Role::Toolbar,
2854        "scroll bar" => Role::ScrollBar,
2855        "slider" => Role::Slider,
2856        "image" | "icon" | "desktop icon" => Role::Image,
2857        "link" => Role::Link,
2858        "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2859        | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2860        | "comment" | "title bar" | "calendar" => Role::Group,
2861        "progress bar" | "level bar" => Role::ProgressBar,
2862        "tree item" => Role::TreeItem,
2863        "document web"
2864        | "document frame"
2865        | "document spreadsheet"
2866        | "document presentation"
2867        | "document text"
2868        | "document email" => Role::WebArea,
2869        "heading" => Role::Heading,
2870        "separator" => Role::Separator,
2871        "split pane" => Role::SplitGroup,
2872        "tooltip" | "tool tip" => Role::Tooltip,
2873        "status bar" | "statusbar" => Role::Status,
2874        "landmark" | "navigation" => Role::Navigation,
2875        _ => xa11y_core::unknown_role(role_name),
2876    }
2877}
2878
2879/// `AtspiRole::Frame` — the numeric role a top-level window frame reports.
2880///
2881/// Named because `is_dock_frame` needs the number itself: the table below
2882/// folds `Frame` and `Window` (69) into the same `Role::Window`, and a panel
2883/// registers only as the former. `test_frame_role_number_is_the_atspi_frame_role`
2884/// pins it against the AtspiRole enum the tests carry.
2885const ATSPI_ROLE_FRAME: u32 = 23;
2886
2887/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
2888/// Values from atspi-common Role enum (repr(u32)).
2889pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2890    match role {
2891        2 => Role::Alert,    // Alert
2892        5 => Role::Group,    // Calendar (mirrors UIA CalendarControlType)
2893        7 => Role::CheckBox, // CheckBox
2894        8 => Role::CheckBox, // CheckMenuItem
2895        // Bare ColumnHeader/RowHeader (10/47): WebKit and GTK emit these for
2896        // <th> / column-view header cells, distinct from the
2897        // TableColumnHeader/TableRowHeader pair (57/58) below.
2898        10 => Role::TableCell,   // ColumnHeader
2899        11 => Role::ComboBox,    // ComboBox
2900        13 => Role::Image,       // DesktopIcon
2901        16 => Role::Dialog,      // Dialog
2902        19 => Role::Dialog,      // FileChooser
2903        20 => Role::Group,       // Filler
2904        23 => Role::Window,      // Frame
2905        26 => Role::Image,       // Icon
2906        27 => Role::Image,       // Image
2907        29 => Role::StaticText,  // Label
2908        31 => Role::List,        // List
2909        32 => Role::ListItem,    // ListItem
2910        33 => Role::Menu,        // Menu
2911        34 => Role::MenuBar,     // MenuBar
2912        35 => Role::MenuItem,    // MenuItem
2913        37 => Role::Tab,         // PageTab
2914        38 => Role::TabGroup,    // PageTabList
2915        39 => Role::Group,       // Panel
2916        40 => Role::TextField,   // PasswordText
2917        41 => Role::Menu,        // PopupMenu
2918        42 => Role::ProgressBar, // ProgressBar
2919        43 => Role::Button,      // Button (push button)
2920        44 => Role::RadioButton, // RadioButton
2921        45 => Role::RadioButton, // RadioMenuItem
2922        47 => Role::TableCell,   // RowHeader
2923        48 => Role::ScrollBar,   // ScrollBar
2924        49 => Role::Group,       // ScrollPane
2925        50 => Role::Separator,   // Separator
2926        51 => Role::Slider,      // Slider
2927        52 => Role::SpinButton,  // SpinButton
2928        53 => Role::SplitGroup,  // SplitPane
2929        54 => Role::Status,      // StatusBar
2930        55 => Role::Table,       // Table
2931        56 => Role::TableCell,   // TableCell
2932        57 => Role::TableCell,   // TableColumnHeader
2933        58 => Role::TableCell,   // TableRowHeader
2934        59 => Role::MenuItem,    // TearoffMenuItem
2935        60 => Role::TextArea,    // Terminal (VTE and friends — text via the Text interface)
2936        61 => Role::TextArea,    // Text
2937        62 => Role::Switch,      // ToggleButton
2938        63 => Role::Toolbar,     // ToolBar
2939        64 => Role::Tooltip,     // ToolTip
2940        65 => Role::Group,       // Tree
2941        66 => Role::Table,       // TreeTable
2942        67 => Role::Unknown,     // Unknown
2943        68 => Role::Group,       // Viewport
2944        69 => Role::Window,      // Window
2945        // Text-document containers WebKit emits for plain markup: a bare
2946        // <p> (no ARIA naming) is Paragraph; header/footer/caption/block
2947        // quote come from the matching HTML elements. Containers normalize
2948        // to Group, caption to StaticText, mirroring the by-name map.
2949        71 => Role::Group,       // Header
2950        72 => Role::Group,       // Footer
2951        73 => Role::Group,       // Paragraph
2952        75 => Role::Application, // Application
2953        78 => Role::TextArea, // Embedded — WebKit2GTK uses this for <input type="text"> and <textarea>;
2954        // multi-line refinement below downgrades single-line ones to TextField
2955        79 => Role::TextField,  // Entry
2956        81 => Role::StaticText, // Caption
2957        82 => Role::WebArea,    // DocumentFrame
2958        83 => Role::Heading,    // Heading
2959        85 => Role::Group,      // Section
2960        86 => Role::Group,      // RedundantObject
2961        87 => Role::Group,      // Form
2962        88 => Role::Link,       // Link
2963        90 => Role::TableRow,   // TableRow
2964        91 => Role::TreeItem,   // TreeItem
2965        // Document roots (92-96): LibreOffice exposes spreadsheet /
2966        // presentation / text / email documents with these; all normalize to
2967        // WebArea alongside DocumentFrame/DocumentWeb above.
2968        92 => Role::WebArea,      // DocumentSpreadsheet
2969        93 => Role::WebArea,      // DocumentPresentation
2970        94 => Role::WebArea,      // DocumentText
2971        95 => Role::WebArea,      // DocumentWeb
2972        96 => Role::WebArea,      // DocumentEmail
2973        97 => Role::Group,        // Comment (ARIA comment container)
2974        98 => Role::List,         // ListBox — WebKit2GTK emits this for <ul role="listbox">
2975        99 => Role::Group,        // Grouping
2976        101 => Role::Alert,       // Notification
2977        102 => Role::Alert,       // InfoBar (GtkInfoBar message strip)
2978        103 => Role::ProgressBar, // LevelBar (GtkLevelBar)
2979        104 => Role::Group,       // TitleBar (mirrors UIA TitleBar)
2980        105 => Role::Group,       // BlockQuote
2981        110 => Role::Navigation,  // Landmark
2982        116 => Role::StaticText,  // Static
2983        129 => Role::Button,      // PushButtonMenu
2984        _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
2985    }
2986}
2987
2988/// Depth cap for the GTK press-fallback BFS. Wrapper patterns nest at most
2989/// two levels (e.g. `AdwSplitButton` → inner toggle-button → inner label);
2990/// depth 3 covers them with headroom without letting the walk wander.
2991const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2992
2993/// Hard cap on visited accessibles per fallback resolution. Defensive — the
2994/// depth cap already bounds typical cases to ≤ 20 nodes.
2995const GTK_FALLBACK_MAX_NODES: usize = 200;
2996
2997/// Whether an AT-SPI2 role name represents an activatable widget we are
2998/// willing to invoke via the fallback path. Deliberately narrow: roles that
2999/// could carry destructive or misleading semantics (e.g. `label` with the
3000/// synthesised clipboard/selection actions) are excluded.
3001fn is_actionable_atspi_role(role: &str) -> bool {
3002    matches!(
3003        role,
3004        "push button"
3005            | "toggle button"
3006            | "check box"
3007            | "radio button"
3008            | "menu item"
3009            | "check menu item"
3010            | "radio menu item"
3011            | "link"
3012            | "page tab"
3013            | "tab"
3014            | "list item"
3015            | "tree item"
3016    )
3017}
3018
3019/// Roles the BFS refuses to descend into. Static and decorative roles never
3020/// lead anywhere useful, and `label` in particular carries a fan-out of
3021/// text-editing actions (`clipboard.copy`, `selection.delete`, …) that we
3022/// must never reach via a press heuristic.
3023fn is_never_descend_atspi_role(role: &str) -> bool {
3024    matches!(
3025        role,
3026        "label" | "separator" | "image" | "icon" | "static" | "caption"
3027    )
3028}
3029
3030/// Whether a top-level accessible is a desktop panel or dock: an AT-SPI2
3031/// `frame` (numeric role [`ATSPI_ROLE_FRAME`]) whose attribute set carries
3032/// `window-type:dock`.
3033///
3034/// The role arrives as the number `GetRole` reports rather than the
3035/// `GetRoleName` string: the numeric method is the one every AT-SPI object
3036/// implements. `map_atspi_role_number` is no help here — `Frame` (23) and
3037/// `Window` (69) both map to `Role::Window`, and only the former is what a
3038/// panel registers as.
3039///
3040/// `window-type` mirrors the window manager's `_NET_WM_WINDOW_TYPE` hint, so
3041/// it is set by the panel itself rather than inferred — an ordinary
3042/// application window carries `normal`, or no `window-type` at all.
3043fn is_dock_frame(role_number: u32, attributes: &HashMap<String, String>) -> bool {
3044    role_number == ATSPI_ROLE_FRAME && attributes.get("window-type").is_some_and(|t| t == "dock")
3045}
3046
3047/// Classify the structured reply before rendering it. `UnknownObject` means
3048/// this exact accessible was destroyed, not that the peer timed out or that
3049/// `GetChildren` is unimplemented. Direct reads retain the stale-element error;
3050/// live traversals prune it through `live_atspi_children`.
3051fn children_call_error(err: zbus::Error, aref: &AccessibleRef) -> Error {
3052    match &err {
3053        zbus::Error::MethodError(name, ..)
3054            if name.as_str() == "org.freedesktop.DBus.Error.UnknownObject" =>
3055        {
3056            Error::ElementStale {
3057                selector: format!("{}{}", aref.bus_name, aref.path),
3058            }
3059        }
3060        _ => Error::Platform {
3061            code: -1,
3062            message: format!("GetChildren failed: {err}"),
3063        },
3064    }
3065}
3066
3067/// D-Bus error names that mean the member was never there, as opposed to the
3068/// call having failed.
3069///
3070/// A reply of `UnknownMethod` / `UnknownInterface` / `UnknownObject` is the
3071/// bus answering the question: this object does not implement that. AT-SPI
3072/// bridges implement the interface partially — AccessKit's serves `GetRole`
3073/// and `GetLocalizedRoleName` but no `GetRoleName` at all — so a caller that
3074/// reads absence as failure fails on a perfectly healthy desktop.
3075const ABSENT_MEMBER_ERRORS: [&str; 3] = [
3076    "org.freedesktop.DBus.Error.UnknownMethod",
3077    "org.freedesktop.DBus.Error.UnknownInterface",
3078    "org.freedesktop.DBus.Error.UnknownObject",
3079];
3080
3081/// D-Bus error names that mean the *peer* is gone, as opposed to the call
3082/// having failed.
3083///
3084/// The shell scan reads every top-level of every application on the bus, and an
3085/// application that quits between `list_apps()` and those reads answers with
3086/// one of these. That is genuine absence — the process and its surfaces are
3087/// gone — not a failure to hide (tenet 1): there is nothing left to report and
3088/// nothing a caller could do about it. A hung or broken application is a
3089/// different outcome and stays a failure: it answers `NoReply` or `Timeout`, or
3090/// does not answer at all.
3091///
3092/// Kept separate from [`ABSENT_MEMBER_ERRORS`] because the two are different
3093/// axes — "this object does not implement that member" versus "this bus name
3094/// has no owner any more".
3095const PEER_GONE_ERRORS: [&str; 2] = [
3096    "org.freedesktop.DBus.Error.ServiceUnknown",
3097    "org.freedesktop.DBus.Error.NameHasNoOwner",
3098];
3099
3100/// Whether a failed D-Bus call means the peer left the bus (see
3101/// [`PEER_GONE_ERRORS`]).
3102///
3103/// Matches the structured error name for the same reason
3104/// [`is_absent_member`] does, never a substring of a formatted message.
3105fn is_peer_gone(err: &zbus::Error) -> bool {
3106    match err {
3107        zbus::Error::MethodError(name, ..) => PEER_GONE_ERRORS.contains(&name.as_str()),
3108        _ => false,
3109    }
3110}
3111
3112/// Whether a failed D-Bus call means the member is absent (see
3113/// [`ABSENT_MEMBER_ERRORS`]) rather than that the call failed.
3114///
3115/// `Proxy::call_method` surfaces an error *reply* as
3116/// `zbus::Error::MethodError`, which keeps the error name structured — so
3117/// this matches the name itself, never a substring of a formatted message.
3118/// Every other `zbus::Error` (no reply, a timeout, a service that left the
3119/// bus, a broken connection) is a genuine failure and stays one.
3120fn is_absent_member(err: &zbus::Error) -> bool {
3121    match err {
3122        zbus::Error::MethodError(name, ..) => ABSENT_MEMBER_ERRORS.contains(&name.as_str()),
3123        _ => false,
3124    }
3125}
3126
3127/// Re-word a classification failure so it names the top-level that would not
3128/// answer and the application that had just listed it.
3129///
3130/// The shell scan classifies a frame by two D-Bus reads, and either failing
3131/// aborts the listing rather than being read as "not a panel" (tenet 1). What
3132/// the raw `GetRole failed: …` does not say is *whose* window it was — which
3133/// is the whole of what a consumer needs to know to go look (tenet 6).
3134fn classify_failure(app: &ElementData, top: &AccessibleRef, err: Error) -> Error {
3135    let name = app.name.as_deref().unwrap_or("<unnamed>");
3136    let pid = app.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
3137    Error::Platform {
3138        code: match &err {
3139            Error::Platform { code, .. } => *code,
3140            _ => -1,
3141        },
3142        message: format!(
3143            "could not classify top-level {} of application \"{name}\"{pid} as a shell \
3144             surface: {err}",
3145            top.path
3146        ),
3147    }
3148}
3149
3150/// Map an AT-SPI2 action name to its canonical `snake_case` xa11y action name.
3151///
3152/// Toolkit-specific aliases are normalised to the single canonical name:
3153///   "click" / "activate" / "press" / "invoke" / "dodefault" → "press"
3154///   "toggle" / "check" / "uncheck"            → "toggle"
3155///   "expand" / "open"                          → "expand"
3156///   "collapse" / "close"                       → "collapse"
3157///   "menu" / "showmenu" / "showcontextmenu" / "popup" / "show menu" → "show_menu"
3158///   "select"                                    → "select"
3159///   "increment"                                 → "increment"
3160///   "decrement"                                 → "decrement"
3161///
3162/// Returns `None` for unrecognised names.
3163fn map_atspi_action_name(action_name: &str) -> Option<String> {
3164    // Normalise by lowercasing and stripping underscores/spaces so that
3165    // "show_menu", "show menu", "showMenu" and "showContextMenu" all collapse
3166    // to the same canonical form. Chromium is the main motivator: it uses
3167    // `doDefault` as the default activation action on ~190 elements per
3168    // window (Back/Forward buttons, toolbar icons, menu items, sliders,
3169    // notifications) and `showContextMenu` as the context-menu action. The
3170    // previous table dropped both, leaving those elements with no `press`
3171    // mapping at all. See issue #101.
3172    let lower = action_name.to_lowercase();
3173    let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
3174    let canonical = match collapsed.as_str() {
3175        "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
3176        "toggle" | "check" | "uncheck" => "toggle",
3177        "expand" | "open" => "expand",
3178        "collapse" | "close" => "collapse",
3179        "select" => "select",
3180        "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
3181        "increment" => "increment",
3182        "decrement" => "decrement",
3183        _ => return None,
3184    };
3185    Some(canonical.to_string())
3186}
3187
3188/// Resolve AT-SPI's overloaded `menu` role using positive activation evidence.
3189///
3190/// GTK 3 reports both popup menu containers and submenu-bearing entries as
3191/// `menu`. Its entries implement Action with `click`, while the GtkMenu
3192/// container does not implement Action. Parentage and children cannot separate
3193/// the two: GTK reparents the popup under its owning entry and exposes submenu
3194/// items directly as children of that same entry.
3195fn refine_menu_role(role: Role, actions: &[String]) -> Role {
3196    if role == Role::Menu && actions.iter().any(|action| action == "press") {
3197        Role::MenuItem
3198    } else {
3199        role
3200    }
3201}
3202
3203#[cfg(test)]
3204mod tests {
3205    use super::*;
3206
3207    #[test]
3208    fn test_role_mapping() {
3209        assert_eq!(map_atspi_role("push button"), Role::Button);
3210        assert_eq!(map_atspi_role("toggle button"), Role::Switch);
3211        assert_eq!(map_atspi_role("check box"), Role::CheckBox);
3212        assert_eq!(map_atspi_role("entry"), Role::TextField);
3213        assert_eq!(map_atspi_role("label"), Role::StaticText);
3214        assert_eq!(map_atspi_role("window"), Role::Window);
3215        assert_eq!(map_atspi_role("frame"), Role::Window);
3216        assert_eq!(map_atspi_role("dialog"), Role::Dialog);
3217        assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
3218        assert_eq!(map_atspi_role("slider"), Role::Slider);
3219        assert_eq!(map_atspi_role("panel"), Role::Group);
3220        assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
3221    }
3222
3223    #[test]
3224    fn menu_with_native_activation_is_a_menu_item() {
3225        assert_eq!(
3226            refine_menu_role(Role::Menu, &["press".to_string()]),
3227            Role::MenuItem
3228        );
3229    }
3230
3231    #[test]
3232    fn non_activatable_menu_stays_a_container() {
3233        assert_eq!(refine_menu_role(Role::Menu, &[]), Role::Menu);
3234        assert_eq!(
3235            refine_menu_role(Role::Menu, &["show_menu".to_string()]),
3236            Role::Menu
3237        );
3238    }
3239
3240    #[test]
3241    fn activation_does_not_change_other_roles() {
3242        assert_eq!(
3243            refine_menu_role(Role::MenuBar, &["press".to_string()]),
3244            Role::MenuBar
3245        );
3246        assert_eq!(
3247            refine_menu_role(Role::MenuItem, &["press".to_string()]),
3248            Role::MenuItem
3249        );
3250    }
3251
3252    #[test]
3253    fn test_numeric_role_mapping() {
3254        // ToggleButton (62) must map to Switch, not Button.
3255        // GTK4's Gtk.Switch and Gtk.ToggleButton both report numeric role 62.
3256        assert_eq!(map_atspi_role_number(62), Role::Switch);
3257        // Sanity-check a few well-established numeric mappings.
3258        assert_eq!(map_atspi_role_number(43), Role::Button); // PushButton
3259        assert_eq!(map_atspi_role_number(7), Role::CheckBox);
3260        assert_eq!(map_atspi_role_number(67), Role::Unknown); // AT-SPI Unknown
3261    }
3262
3263    #[test]
3264    fn grab_focus_notsupported_is_action_not_supported() {
3265        // The GTK4 adapter answers a button GrabFocus with the D-Bus
3266        // NotSupported error instead of a false reply (issue #98); the
3267        // classifier must surface it as ActionNotSupported so the suites'
3268        // xfail contract holds, and keep everything else a platform error
3269        // (tenet 1).
3270        let declined = grab_focus_error(
3271            "org.freedesktop.DBus.Error.NotSupported".into(),
3272            "focusing",
3273            "focus",
3274            Role::Button,
3275        );
3276        match declined {
3277            Error::ActionNotSupported { action, role } => {
3278                assert_eq!(action, "focus");
3279                assert_eq!(role, Role::Button);
3280            }
3281            other => panic!("expected ActionNotSupported, got {other:?}"),
3282        }
3283
3284        let broken = grab_focus_error(
3285            "org.freedesktop.DBus.Error.UnknownMethod".into(),
3286            "activating",
3287            "activate",
3288            Role::Window,
3289        );
3290        match broken {
3291            Error::Platform { code, .. } => assert_eq!(code, -1),
3292            other => panic!("expected Platform error, got {other:?}"),
3293        }
3294    }
3295
3296    /// The full AtspiRole enum (atspi 2.52) as (number, GetRoleName-style
3297    /// name) pairs. The name form is the enum nick with dashes replaced by
3298    /// spaces, which is what providers hand back over D-Bus.
3299    const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
3300        (0, "invalid"),
3301        (1, "accelerator label"),
3302        (2, "alert"),
3303        (3, "animation"),
3304        (4, "arrow"),
3305        (5, "calendar"),
3306        (6, "canvas"),
3307        (7, "check box"),
3308        (8, "check menu item"),
3309        (9, "color chooser"),
3310        (10, "column header"),
3311        (11, "combo box"),
3312        (12, "date editor"),
3313        (13, "desktop icon"),
3314        (14, "desktop frame"),
3315        (15, "dial"),
3316        (16, "dialog"),
3317        (17, "directory pane"),
3318        (18, "drawing area"),
3319        (19, "file chooser"),
3320        (20, "filler"),
3321        (21, "focus traversable"),
3322        (22, "font chooser"),
3323        (23, "frame"),
3324        (24, "glass pane"),
3325        (25, "html container"),
3326        (26, "icon"),
3327        (27, "image"),
3328        (28, "internal frame"),
3329        (29, "label"),
3330        (30, "layered pane"),
3331        (31, "list"),
3332        (32, "list item"),
3333        (33, "menu"),
3334        (34, "menu bar"),
3335        (35, "menu item"),
3336        (36, "option pane"),
3337        (37, "page tab"),
3338        (38, "page tab list"),
3339        (39, "panel"),
3340        (40, "password text"),
3341        (41, "popup menu"),
3342        (42, "progress bar"),
3343        (43, "push button"),
3344        (44, "radio button"),
3345        (45, "radio menu item"),
3346        (46, "root pane"),
3347        (47, "row header"),
3348        (48, "scroll bar"),
3349        (49, "scroll pane"),
3350        (50, "separator"),
3351        (51, "slider"),
3352        (52, "spin button"),
3353        (53, "split pane"),
3354        (54, "status bar"),
3355        (55, "table"),
3356        (56, "table cell"),
3357        (57, "table column header"),
3358        (58, "table row header"),
3359        (59, "tearoff menu item"),
3360        (60, "terminal"),
3361        (61, "text"),
3362        (62, "toggle button"),
3363        (63, "tool bar"),
3364        (64, "tool tip"),
3365        (65, "tree"),
3366        (66, "tree table"),
3367        (67, "unknown"),
3368        (68, "viewport"),
3369        (69, "window"),
3370        (70, "extended"),
3371        (71, "header"),
3372        (72, "footer"),
3373        (73, "paragraph"),
3374        (74, "ruler"),
3375        (75, "application"),
3376        (76, "autocomplete"),
3377        (77, "editbar"),
3378        (78, "embedded"),
3379        (79, "entry"),
3380        (80, "chart"),
3381        (81, "caption"),
3382        (82, "document frame"),
3383        (83, "heading"),
3384        (84, "page"),
3385        (85, "section"),
3386        (86, "redundant object"),
3387        (87, "form"),
3388        (88, "link"),
3389        (89, "input method window"),
3390        (90, "table row"),
3391        (91, "tree item"),
3392        (92, "document spreadsheet"),
3393        (93, "document presentation"),
3394        (94, "document text"),
3395        (95, "document web"),
3396        (96, "document email"),
3397        (97, "comment"),
3398        (98, "list box"),
3399        (99, "grouping"),
3400        (100, "image map"),
3401        (101, "notification"),
3402        (102, "info bar"),
3403        (103, "level bar"),
3404        (104, "title bar"),
3405        (105, "block quote"),
3406        (106, "audio"),
3407        (107, "video"),
3408        (108, "definition"),
3409        (109, "article"),
3410        (110, "landmark"),
3411        (111, "log"),
3412        (112, "marquee"),
3413        (113, "math"),
3414        (114, "rating"),
3415        (115, "timer"),
3416        (116, "static"),
3417        (117, "math fraction"),
3418        (118, "math root"),
3419        (119, "subscript"),
3420        (120, "superscript"),
3421        (121, "description list"),
3422        (122, "description term"),
3423        (123, "description value"),
3424        (124, "footnote"),
3425        (125, "content deletion"),
3426        (126, "content insertion"),
3427        (127, "mark"),
3428        (128, "suggestion"),
3429        (129, "push button menu"),
3430    ];
3431
3432    #[test]
3433    fn numeric_and_name_role_maps_agree() {
3434        // The provider resolves roles by number on some paths (resolve_role)
3435        // and by name on others (build_element_data). If the two maps drift,
3436        // the same element reports different roles depending on how it was
3437        // reached — this happened for StatusBar (54, name-only), ToolTip
3438        // (64, and 93 was mis-numbered as Tooltip), and Landmark (110).
3439        // Sweep the whole enum so any future addition lands in both maps.
3440        for &(num, name) in ATSPI_ROLE_ENUM {
3441            let by_num = map_atspi_role_number(num);
3442            let by_name = map_atspi_role(name);
3443            assert_eq!(
3444                by_num, by_name,
3445                "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
3446                 {by_num:?}, name map gives {by_name:?}"
3447            );
3448        }
3449    }
3450
3451    #[test]
3452    fn test_action_name_mapping() {
3453        assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
3454        assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
3455        assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
3456        assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
3457        // Chromium uses `doDefault` for default activation on ~190 elements.
3458        assert_eq!(
3459            map_atspi_action_name("doDefault"),
3460            Some("press".to_string())
3461        );
3462        assert_eq!(
3463            map_atspi_action_name("do_default"),
3464            Some("press".to_string())
3465        );
3466        assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
3467        assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
3468        assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
3469        assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
3470        assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
3471        assert_eq!(
3472            map_atspi_action_name("collapse"),
3473            Some("collapse".to_string())
3474        );
3475        assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
3476        assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
3477        assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
3478        assert_eq!(
3479            map_atspi_action_name("showmenu"),
3480            Some("show_menu".to_string())
3481        );
3482        assert_eq!(
3483            map_atspi_action_name("popup"),
3484            Some("show_menu".to_string())
3485        );
3486        assert_eq!(
3487            map_atspi_action_name("show menu"),
3488            Some("show_menu".to_string())
3489        );
3490        // Chrome / Chromium expose the URL-bar context-menu action as
3491        // `showContextMenu`; the previous table missed both spellings.
3492        assert_eq!(
3493            map_atspi_action_name("showContextMenu"),
3494            Some("show_menu".to_string())
3495        );
3496        assert_eq!(
3497            map_atspi_action_name("show_context_menu"),
3498            Some("show_menu".to_string())
3499        );
3500        assert_eq!(
3501            map_atspi_action_name("increment"),
3502            Some("increment".to_string())
3503        );
3504        assert_eq!(
3505            map_atspi_action_name("decrement"),
3506            Some("decrement".to_string())
3507        );
3508        assert_eq!(map_atspi_action_name("foobar"), None);
3509    }
3510
3511    /// All known AT-SPI2 aliases map to one of the well-known action names,
3512    /// and re-mapping the canonical name produces the same canonical name.
3513    #[test]
3514    fn test_action_name_aliases_roundtrip() {
3515        let atspi_names = [
3516            "click",
3517            "activate",
3518            "press",
3519            "invoke",
3520            "doDefault",
3521            "do_default",
3522            "toggle",
3523            "check",
3524            "uncheck",
3525            "expand",
3526            "open",
3527            "collapse",
3528            "close",
3529            "select",
3530            "menu",
3531            "showmenu",
3532            "showContextMenu",
3533            "show_context_menu",
3534            "popup",
3535            "show menu",
3536            "increment",
3537            "decrement",
3538        ];
3539        for name in atspi_names {
3540            let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
3541                panic!("AT-SPI2 name {:?} should map to a canonical name", name)
3542            });
3543            // Re-mapping the canonical name must produce itself.
3544            let back = map_atspi_action_name(&canonical)
3545                .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
3546            assert_eq!(
3547                canonical, back,
3548                "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
3549                name, canonical, back, canonical
3550            );
3551        }
3552    }
3553
3554    /// Case-insensitive mapping works.
3555    #[test]
3556    fn test_action_name_case_insensitive() {
3557        assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
3558        assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
3559        assert_eq!(
3560            map_atspi_action_name("Increment"),
3561            Some("increment".to_string())
3562        );
3563    }
3564
3565    /// The GTK press-fallback's actionable-role set must include the inner
3566    /// toggle-button pattern used by `GtkMenuButton` / `AdwMenuButton`, plus
3567    /// the other standard activatable roles we're willing to synthesise a
3568    /// click for.
3569    #[test]
3570    fn test_gtk_fallback_actionable_roles() {
3571        for role in [
3572            "push button",
3573            "toggle button",
3574            "check box",
3575            "radio button",
3576            "menu item",
3577            "link",
3578            "tab",
3579            "list item",
3580            "tree item",
3581        ] {
3582            assert!(
3583                is_actionable_atspi_role(role),
3584                "{role:?} should be actionable"
3585            );
3586        }
3587    }
3588
3589    /// Never treat static / decorative accessibles as fallback candidates.
3590    /// Particularly important for `label`, whose synthesised text-editing
3591    /// actions (`clipboard.copy`, `selection.delete`) must never be invoked
3592    /// by a press heuristic.
3593    #[test]
3594    fn test_gtk_fallback_non_actionable_roles() {
3595        for role in [
3596            "label",
3597            "panel",
3598            "filler",
3599            "section",
3600            "group",
3601            "image",
3602            "separator",
3603            "static",
3604            "frame",
3605            "window",
3606        ] {
3607            assert!(
3608                !is_actionable_atspi_role(role),
3609                "{role:?} must not be treated as actionable"
3610            );
3611        }
3612    }
3613
3614    /// The BFS stops at static/decorative roles. `label` in particular must
3615    /// never be descended into — its children are text spans with bogus
3616    /// actions.
3617    #[test]
3618    fn test_gtk_fallback_never_descend_roles() {
3619        for role in ["label", "separator", "image", "icon", "static", "caption"] {
3620            assert!(
3621                is_never_descend_atspi_role(role),
3622                "{role:?} must block BFS descent"
3623            );
3624        }
3625        // Containers — panel / filler / section / group / frame — stay
3626        // walkable so the BFS can reach a wrapped inner actionable.
3627        for role in ["panel", "filler", "section", "group", "frame"] {
3628            assert!(
3629                !is_never_descend_atspi_role(role),
3630                "container role {role:?} must remain descendable"
3631            );
3632        }
3633    }
3634
3635    fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3636        pairs
3637            .iter()
3638            .map(|(k, v)| (k.to_string(), v.to_string()))
3639            .collect()
3640    }
3641
3642    /// A panel frame is the `window-type:dock` attribute and nothing else —
3643    /// xfce4-panel's rows are unnamed frames, so no name test can stand in
3644    /// for it.
3645    #[test]
3646    fn test_dock_frame_detection() {
3647        assert!(is_dock_frame(
3648            ATSPI_ROLE_FRAME,
3649            &attrs(&[("toolkit", "GTK"), ("window-type", "dock")])
3650        ));
3651        // An ordinary application window: same role, different window type.
3652        assert!(!is_dock_frame(
3653            ATSPI_ROLE_FRAME,
3654            &attrs(&[("window-type", "normal")])
3655        ));
3656        // Most toolkits set no `window-type` at all.
3657        assert!(!is_dock_frame(
3658            ATSPI_ROLE_FRAME,
3659            &attrs(&[("toolkit", "GTK")])
3660        ));
3661        assert!(!is_dock_frame(ATSPI_ROLE_FRAME, &HashMap::new()));
3662    }
3663
3664    /// The frame role number is the one `GetRoleName` would spell "frame" —
3665    /// the classification moved to `GetRole` because AccessKit's bridge
3666    /// implements only the numeric method.
3667    #[test]
3668    fn test_frame_role_number_is_the_atspi_frame_role() {
3669        let (number, name) = ATSPI_ROLE_ENUM
3670            .iter()
3671            .find(|(_, name)| *name == "frame")
3672            .copied()
3673            .expect("the AtspiRole table must carry `frame`");
3674        assert_eq!(number, ATSPI_ROLE_FRAME, "{name} moved");
3675    }
3676
3677    /// The dock attribute only makes a surface on a top-level frame. `panel`
3678    /// (39) is the AT-SPI role for an ordinary grouping container (it maps to
3679    /// `Role::Group`), and `window` (69) / `dialog` (16) are not what panels
3680    /// register as — `Window` in particular shares `Role::Window` with
3681    /// `Frame`, so only the raw number tells them apart.
3682    #[test]
3683    fn test_dock_frame_requires_frame_role() {
3684        let dock = attrs(&[("window-type", "dock")]);
3685        for (number, name) in ATSPI_ROLE_ENUM
3686            .iter()
3687            .filter(|(number, _)| *number != ATSPI_ROLE_FRAME)
3688        {
3689            assert!(
3690                !is_dock_frame(*number, &dock),
3691                "role {number} ({name:?}) must not be reported as a shell surface"
3692            );
3693        }
3694    }
3695
3696    /// Build the `zbus::Error` a D-Bus error reply of `name` arrives as, the
3697    /// way `Proxy::call_method` hands it back.
3698    fn method_error(name: &str) -> zbus::Error {
3699        let call = zbus::Message::method_call("/org/a11y/atspi/accessible/7", "GetAttributes")
3700            .expect("method-call builder")
3701            .serial(std::num::NonZeroU32::new(1).expect("nonzero"))
3702            .build(&())
3703            .expect("build method call");
3704        let reply = zbus::Message::error(&call.header(), name)
3705            .expect("error builder")
3706            .serial(std::num::NonZeroU32::new(2).expect("nonzero"))
3707            .build(&"boom")
3708            .expect("build error reply");
3709        zbus::Error::from(reply)
3710    }
3711
3712    #[test]
3713    fn children_errors_classify_only_unknown_object_as_stale() {
3714        let aref = AccessibleRef {
3715            bus_name: ":1.42".into(),
3716            path: "/org/a11y/atspi/accessible/7".into(),
3717        };
3718        let err = children_call_error(
3719            method_error("org.freedesktop.DBus.Error.UnknownObject"),
3720            &aref,
3721        );
3722        assert!(matches!(err, Error::ElementStale { selector }
3723            if selector.contains(&aref.bus_name) && selector.contains(&aref.path)));
3724        for name in [
3725            "org.freedesktop.DBus.Error.UnknownMethod",
3726            "org.freedesktop.DBus.Error.UnknownInterface",
3727            "org.freedesktop.DBus.Error.NoReply",
3728            "org.freedesktop.DBus.Error.Timeout",
3729            "org.freedesktop.DBus.Error.AccessDenied",
3730            "org.freedesktop.DBus.Error.ServiceUnknown",
3731            "org.freedesktop.DBus.Error.NameHasNoOwner",
3732        ] {
3733            assert!(
3734                matches!(children_call_error(method_error(name), &aref),
3735                Error::Platform { message, .. } if message.contains(name)),
3736                "{name}"
3737            );
3738        }
3739        // Text resembling an error name is never enough to prune a subtree.
3740        assert!(matches!(
3741            children_call_error(
3742                zbus::Error::Failure("org.freedesktop.DBus.Error.UnknownObject".into()),
3743                &aref
3744            ),
3745            Error::Platform { .. }
3746        ));
3747    }
3748
3749    /// Minimal AT-SPI peer: the first child disappears when its children are
3750    /// read, while a later sibling stays live. Exercises the real D-Bus reply
3751    /// path deterministically instead of relying on a scheduling race in Qt.
3752    struct LiveTreePeer {
3753        name: &'static str,
3754        role: &'static str,
3755        children: Vec<&'static str>,
3756        children_error: Option<&'static str>,
3757    }
3758
3759    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
3760    impl LiveTreePeer {
3761        fn get_children(
3762            &self,
3763        ) -> zbus::fdo::Result<Vec<(String, zbus::zvariant::OwnedObjectPath)>> {
3764            if let Some(error) = self.children_error {
3765                return Err(match error {
3766                    "UnknownObject" => zbus::fdo::Error::UnknownObject("destroyed".into()),
3767                    "NoReply" => zbus::fdo::Error::NoReply("unresponsive".into()),
3768                    _ => panic!("unsupported test error"),
3769                });
3770            }
3771            Ok(self
3772                .children
3773                .iter()
3774                .map(|path| {
3775                    (
3776                        "org.test.LiveTree".into(),
3777                        zbus::zvariant::OwnedObjectPath::try_from(*path).expect("test path"),
3778                    )
3779                })
3780                .collect())
3781        }
3782
3783        fn get_role_name(&self) -> &str {
3784            self.role
3785        }
3786
3787        #[zbus(property)]
3788        fn name(&self) -> &str {
3789            self.name
3790        }
3791    }
3792
3793    fn live_tree_peer(
3794        error: &'static str,
3795        vanished_role: &'static str,
3796    ) -> (Connection, std::sync::Arc<LinuxProvider>, ElementData) {
3797        let (server_socket, client_socket) =
3798            std::os::unix::net::UnixStream::pair().expect("socket pair");
3799        let server = std::thread::spawn(move || {
3800            zbus::blocking::connection::Builder::async_io_unix_stream(server_socket)
3801                .server(zbus::Guid::generate())
3802                .expect("server GUID")
3803                .p2p()
3804                .serve_at(
3805                    "/root",
3806                    LiveTreePeer {
3807                        name: "App",
3808                        role: "application",
3809                        children: vec!["/vanished", "/survivor"],
3810                        children_error: None,
3811                    },
3812                )
3813                .expect("root interface")
3814                .serve_at(
3815                    "/vanished",
3816                    LiveTreePeer {
3817                        name: "Vanished",
3818                        role: vanished_role,
3819                        children: vec![],
3820                        children_error: Some(error),
3821                    },
3822                )
3823                .expect("vanished interface")
3824                .serve_at(
3825                    "/survivor",
3826                    LiveTreePeer {
3827                        name: "Survivor",
3828                        role: "push button",
3829                        children: vec![],
3830                        children_error: None,
3831                    },
3832                )
3833                .expect("survivor interface")
3834                .build()
3835                .expect("server connection")
3836        });
3837        let connection = zbus::blocking::connection::Builder::async_io_unix_stream(client_socket)
3838            .p2p()
3839            .build()
3840            .expect("client connection");
3841        let server = server.join().expect("server thread");
3842        let provider = std::sync::Arc::new(LinuxProvider {
3843            a11y_bus: connection,
3844            handle_cache: Mutex::new(HashMap::new()),
3845            action_indices: Mutex::new(HashMap::new()),
3846            window_manager: WindowManager::new(),
3847        });
3848        let root_ref = AccessibleRef {
3849            bus_name: "org.test.LiveTree".into(),
3850            path: "/root".into(),
3851        };
3852        let root = provider.build_element_data(&root_ref, None);
3853        (server, provider, root)
3854    }
3855
3856    #[test]
3857    fn dump_tree_and_locators_survive_a_vanished_subtree() {
3858        let (_server, provider, root) = live_tree_peer("UnknownObject", "panel");
3859        let app = xa11y_core::App::from_data(provider.clone(), root);
3860        let tree = app.tree(None).expect("tree prunes vanished descendants");
3861        assert!(tree
3862            .children
3863            .iter()
3864            .any(|child| child.name.as_deref() == Some("Survivor")));
3865        let dump = app.dump(None).expect("dump survives UnknownObject");
3866        assert!(dump.contains("Survivor"));
3867        assert_eq!(
3868            app.locator("button")
3869                .elements()
3870                .expect("locator survives")
3871                .len(),
3872            1
3873        );
3874        let vanished = app
3875            .children()
3876            .expect("children")
3877            .into_iter()
3878            .find(|child| child.name.as_deref() == Some("Vanished"))
3879            .expect("vanished snapshot");
3880        assert!(vanished
3881            .children()
3882            .expect("destroyed node has no live children")
3883            .is_empty());
3884        assert!(matches!(
3885            provider.get_atspi_children(&AccessibleRef {
3886                bus_name: "org.test.LiveTree".into(),
3887                path: "/vanished".into(),
3888            }),
3889            Err(Error::ElementStale { .. })
3890        ));
3891    }
3892
3893    #[test]
3894    fn nested_application_disappearance_does_not_drop_live_siblings() {
3895        let (_server, provider, root) = live_tree_peer("UnknownObject", "application");
3896        let app = xa11y_core::App::from_data(provider, root);
3897        let dump = app.dump(None).expect("nested application pruned");
3898        assert!(dump.contains("Survivor"));
3899        assert!(!dump.contains("Vanished"));
3900    }
3901
3902    #[test]
3903    fn dump_and_tree_preserve_real_children_failures() {
3904        let (_server, provider, root) = live_tree_peer("NoReply", "panel");
3905        let app = xa11y_core::App::from_data(provider, root);
3906        for result in [app.tree(None).map(|_| ()), app.dump(None).map(|_| ())] {
3907            assert!(matches!(result, Err(Error::Platform { message, .. })
3908                if message.contains("NoReply")));
3909        }
3910    }
3911
3912    /// An object that does not implement the method it was asked for is
3913    /// absence, not failure: AccessKit's AT-SPI bridge (egui and friends)
3914    /// answers `GetRole` but has no `GetRoleName`, and reading that reply as a
3915    /// platform error failed the whole shell listing for every desktop running
3916    /// one such app.
3917    #[test]
3918    fn an_unimplemented_member_reads_as_absence() {
3919        for name in ABSENT_MEMBER_ERRORS {
3920            assert!(
3921                is_absent_member(&method_error(name)),
3922                "{name} must read as absence"
3923            );
3924        }
3925    }
3926
3927    /// Everything else stays a failure — including the D-Bus errors that look
3928    /// superficially similar but mean the far end is broken or gone.
3929    /// An application that quits between `list_apps()` and the shell scan's
3930    /// reads answers `ServiceUnknown`. That is the process being gone, not a
3931    /// failure worth aborting a whole-desktop listing for — and it used to
3932    /// abort it, making `ShellSurface::list()` nondeterministic on a busy
3933    /// desktop. A hung application is a different outcome and still fails.
3934    #[test]
3935    fn a_departed_peer_is_absence_not_failure() {
3936        for name in [
3937            "org.freedesktop.DBus.Error.ServiceUnknown",
3938            "org.freedesktop.DBus.Error.NameHasNoOwner",
3939        ] {
3940            assert!(
3941                is_peer_gone(&method_error(name)),
3942                "{name} means the peer left"
3943            );
3944            // The two axes stay separate: a departed peer is not a statement
3945            // about which members an object implements.
3946            assert!(!is_absent_member(&method_error(name)), "{name}");
3947        }
3948    }
3949
3950    #[test]
3951    fn a_hung_or_broken_peer_is_still_a_failure() {
3952        for name in [
3953            "org.freedesktop.DBus.Error.NoReply",
3954            "org.freedesktop.DBus.Error.Timeout",
3955            "org.freedesktop.DBus.Error.Disconnected",
3956            "org.freedesktop.DBus.Error.AccessDenied",
3957            "org.freedesktop.DBus.Error.UnknownMethod",
3958        ] {
3959            assert!(
3960                !is_peer_gone(&method_error(name)),
3961                "{name} must not be read as the peer having left"
3962            );
3963        }
3964        assert!(!is_peer_gone(&zbus::Error::InvalidReply));
3965    }
3966
3967    #[test]
3968    fn other_dbus_failures_are_not_absence() {
3969        for name in [
3970            "org.freedesktop.DBus.Error.ServiceUnknown",
3971            "org.freedesktop.DBus.Error.NoReply",
3972            "org.freedesktop.DBus.Error.Timeout",
3973            "org.freedesktop.DBus.Error.Disconnected",
3974            "org.freedesktop.DBus.Error.AccessDenied",
3975            "org.freedesktop.DBus.Error.UnknownProperty",
3976        ] {
3977            assert!(
3978                !is_absent_member(&method_error(name)),
3979                "{name} must stay a failure"
3980            );
3981        }
3982        // A transport-level failure carries no error name at all.
3983        assert!(!is_absent_member(&zbus::Error::InvalidReply));
3984    }
3985
3986    /// A frame the scan could not classify must fail the listing, naming the
3987    /// app and the object path. `Panel` is the only kind Linux vends, so
3988    /// swallowing this would render a broken panel process as a desktop with
3989    /// no panels at all — indistinguishable from the honest empty answer.
3990    #[test]
3991    fn a_classification_failure_names_the_app_and_the_top_level() {
3992        let mut app = ElementData::for_role(Role::Application);
3993        app.name = Some("xfce4-panel".to_string());
3994        app.pid = Some(4242);
3995        let top = AccessibleRef {
3996            bus_name: ":1.42".to_string(),
3997            path: "/org/a11y/atspi/accessible/7".to_string(),
3998        };
3999        let err = classify_failure(
4000            &app,
4001            &top,
4002            Error::Platform {
4003                code: -1,
4004                message: "GetRole failed: connection closed".to_string(),
4005            },
4006        );
4007        let Error::Platform { code, message } = err else {
4008            panic!("a D-Bus failure must stay a platform error");
4009        };
4010        assert_eq!(code, -1);
4011        assert!(message.contains("xfce4-panel"), "{message}");
4012        assert!(message.contains("pid=4242"), "{message}");
4013        assert!(
4014            message.contains("/org/a11y/atspi/accessible/7"),
4015            "{message}"
4016        );
4017        // Never masks the underlying D-Bus error (tenet 1).
4018        assert!(message.contains("GetRole failed"), "{message}");
4019    }
4020
4021    /// An unnamed application still produces a usable message — the object
4022    /// path is what a consumer actually goes looking with.
4023    #[test]
4024    fn a_classification_failure_survives_an_unnamed_app() {
4025        let app = ElementData::for_role(Role::Application);
4026        let top = AccessibleRef {
4027            bus_name: ":1.9".to_string(),
4028            path: "/org/a11y/atspi/accessible/3".to_string(),
4029        };
4030        let err = classify_failure(
4031            &app,
4032            &top,
4033            Error::Platform {
4034                code: -1,
4035                message: "GetAttributes failed".to_string(),
4036            },
4037        );
4038        let msg = err.to_string();
4039        assert!(msg.contains("<unnamed>"), "{msg}");
4040        assert!(msg.contains("/org/a11y/atspi/accessible/3"), "{msg}");
4041    }
4042
4043    /// Per-object counters for the Text / Value reads a fake atk-bridge
4044    /// object answers even when it does not report those interfaces.
4045    #[derive(Default)]
4046    struct InterfaceProbes {
4047        text: std::sync::atomic::AtomicUsize,
4048        value: std::sync::atomic::AtomicUsize,
4049    }
4050
4051    impl InterfaceProbes {
4052        fn text(&self) -> usize {
4053            self.text.load(Ordering::SeqCst)
4054        }
4055        fn value(&self) -> usize {
4056            self.value.load(Ordering::SeqCst)
4057        }
4058    }
4059
4060    /// AT-SPI peer whose `GetInterfaces` reply is configurable.
4061    struct InterfacePeer {
4062        name: &'static str,
4063        role: &'static str,
4064        children: Vec<&'static str>,
4065        interfaces: Vec<&'static str>,
4066    }
4067
4068    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
4069    impl InterfacePeer {
4070        fn get_children(&self) -> Vec<(String, zbus::zvariant::OwnedObjectPath)> {
4071            self.children
4072                .iter()
4073                .map(|path| {
4074                    (
4075                        "org.test.Ifaces".into(),
4076                        zbus::zvariant::OwnedObjectPath::try_from(*path).expect("test path"),
4077                    )
4078                })
4079                .collect()
4080        }
4081
4082        fn get_role_name(&self) -> &str {
4083            self.role
4084        }
4085
4086        fn get_interfaces(&self) -> Vec<String> {
4087            self.interfaces.iter().map(|s| s.to_string()).collect()
4088        }
4089
4090        #[zbus(property)]
4091        fn name(&self) -> &str {
4092            self.name
4093        }
4094    }
4095
4096    /// Text interface served on every object, the way atk-bridge registers
4097    /// its handlers regardless of what the underlying AtkObject implements.
4098    /// Each read is counted so a test can prove it was never issued.
4099    struct FakeText {
4100        text: &'static str,
4101        probes: std::sync::Arc<InterfaceProbes>,
4102    }
4103
4104    #[zbus::interface(name = "org.a11y.atspi.Text")]
4105    impl FakeText {
4106        fn get_text(&self, start: i32, end: i32) -> String {
4107            self.probes.text.fetch_add(1, Ordering::SeqCst);
4108            self.text
4109                .chars()
4110                .skip(start.max(0) as usize)
4111                .take((end - start).max(0) as usize)
4112                .collect()
4113        }
4114
4115        #[zbus(property)]
4116        fn character_count(&self) -> i32 {
4117            self.probes.text.fetch_add(1, Ordering::SeqCst);
4118            self.text.chars().count() as i32
4119        }
4120    }
4121
4122    struct FakeValue {
4123        value: f64,
4124        probes: std::sync::Arc<InterfaceProbes>,
4125    }
4126
4127    #[zbus::interface(name = "org.a11y.atspi.Value")]
4128    impl FakeValue {
4129        #[zbus(property)]
4130        fn current_value(&self) -> f64 {
4131            self.probes.value.fetch_add(1, Ordering::SeqCst);
4132            self.value
4133        }
4134
4135        #[zbus(property)]
4136        fn minimum_value(&self) -> f64 {
4137            self.probes.value.fetch_add(1, Ordering::SeqCst);
4138            0.0
4139        }
4140
4141        #[zbus(property)]
4142        fn maximum_value(&self) -> f64 {
4143            self.probes.value.fetch_add(1, Ordering::SeqCst);
4144            100.0
4145        }
4146    }
4147
4148    struct InterfaceFixture {
4149        _server: Connection,
4150        provider: std::sync::Arc<LinuxProvider>,
4151        root: ElementData,
4152        /// Push button: reports neither Text nor Value.
4153        plain: std::sync::Arc<InterfaceProbes>,
4154        /// Text field: reports Text *and* Value, like AccessKit, whose
4155        /// CurrentValue is a meaningless 0.0 on text.
4156        entry: std::sync::Arc<InterfaceProbes>,
4157        /// Slider: reports Value only.
4158        slider: std::sync::Arc<InterfaceProbes>,
4159    }
4160
4161    fn interface_peer() -> InterfaceFixture {
4162        const ACC: &str = "org.a11y.atspi.Accessible";
4163        const TEXT: &str = "org.a11y.atspi.Text";
4164        const VALUE: &str = "org.a11y.atspi.Value";
4165        let plain = std::sync::Arc::new(InterfaceProbes::default());
4166        let entry = std::sync::Arc::new(InterfaceProbes::default());
4167        let slider = std::sync::Arc::new(InterfaceProbes::default());
4168        let (server_socket, client_socket) =
4169            std::os::unix::net::UnixStream::pair().expect("socket pair");
4170        let objects = [
4171            (
4172                "/plain",
4173                "push button",
4174                "OK",
4175                vec![ACC],
4176                "",
4177                7.0,
4178                plain.clone(),
4179            ),
4180            (
4181                "/entry",
4182                "entry",
4183                "Entry",
4184                vec![ACC, TEXT, VALUE],
4185                "hello",
4186                0.0,
4187                entry.clone(),
4188            ),
4189            (
4190                "/slider",
4191                "slider",
4192                "Volume",
4193                vec![ACC, VALUE],
4194                "",
4195                42.0,
4196                slider.clone(),
4197            ),
4198        ];
4199        let server = std::thread::spawn(move || {
4200            let mut builder =
4201                zbus::blocking::connection::Builder::async_io_unix_stream(server_socket)
4202                    .server(zbus::Guid::generate())
4203                    .expect("server GUID")
4204                    .p2p()
4205                    .serve_at(
4206                        "/root",
4207                        InterfacePeer {
4208                            name: "App",
4209                            role: "application",
4210                            children: vec!["/plain", "/entry", "/slider"],
4211                            interfaces: vec![ACC],
4212                        },
4213                    )
4214                    .expect("root interface");
4215            for (path, role, name, interfaces, text, value, probes) in objects {
4216                builder = builder
4217                    .serve_at(
4218                        path,
4219                        InterfacePeer {
4220                            name,
4221                            role,
4222                            children: vec![],
4223                            interfaces,
4224                        },
4225                    )
4226                    .expect("accessible")
4227                    .serve_at(
4228                        path,
4229                        FakeText {
4230                            text,
4231                            probes: probes.clone(),
4232                        },
4233                    )
4234                    .expect("text")
4235                    .serve_at(path, FakeValue { value, probes })
4236                    .expect("value");
4237            }
4238            builder.build().expect("server connection")
4239        });
4240        let connection = zbus::blocking::connection::Builder::async_io_unix_stream(client_socket)
4241            .p2p()
4242            .build()
4243            .expect("client connection");
4244        let server = server.join().expect("server thread");
4245        let provider = std::sync::Arc::new(LinuxProvider {
4246            a11y_bus: connection,
4247            handle_cache: Mutex::new(HashMap::new()),
4248            action_indices: Mutex::new(HashMap::new()),
4249            window_manager: WindowManager::new(),
4250        });
4251        let root = provider.build_element_data(
4252            &AccessibleRef {
4253                bus_name: "org.test.Ifaces".into(),
4254                path: "/root".into(),
4255            },
4256            None,
4257        );
4258        InterfaceFixture {
4259            _server: server,
4260            provider,
4261            root,
4262            plain,
4263            entry,
4264            slider,
4265        }
4266    }
4267
4268    /// Issue #465: atk-bridge answers Text / Value reads on every object and
4269    /// logs a CRITICAL for each one whose AtkObject lacks the interface. A
4270    /// tree read must only ask a node for the interfaces it reports, while
4271    /// still reading values from the nodes that do.
4272    #[test]
4273    fn tree_reads_only_probe_interfaces_the_node_reports() {
4274        let fx = interface_peer();
4275        let app = xa11y_core::App::from_data(fx.provider.clone(), fx.root.clone());
4276        let tree = app.tree(None).expect("tree");
4277        let child = |name: &str| {
4278            tree.children
4279                .iter()
4280                .find(|c| c.name.as_deref() == Some(name))
4281                .unwrap_or_else(|| panic!("{name} in tree"))
4282        };
4283
4284        assert_eq!(child("OK").value, None);
4285        assert_eq!(fx.plain.text(), 0, "Text read on a node without Text");
4286        assert_eq!(fx.plain.value(), 0, "Value read on a node without Value");
4287
4288        // Text is still preferred over Value where both are reported.
4289        assert_eq!(child("Entry").value.as_deref(), Some("hello"));
4290        assert!(fx.entry.text() > 0);
4291
4292        assert_eq!(child("Volume").value.as_deref(), Some("42"));
4293        let slider = app
4294            .children()
4295            .expect("children")
4296            .into_iter()
4297            .find(|c| c.name.as_deref() == Some("Volume"))
4298            .expect("slider element");
4299        assert_eq!(slider.numeric_value, Some(42.0));
4300        assert_eq!(slider.max_value, Some(100.0));
4301        assert_eq!(fx.slider.text(), 0, "Text read on a Value-only node");
4302
4303        // The selector fast path resolves `value` without building the
4304        // whole element, and must obey the same gate.
4305        assert_eq!(
4306            app.locator(r#"[value="hello"]"#)
4307                .elements()
4308                .expect("value locator")
4309                .len(),
4310            1
4311        );
4312        assert_eq!(
4313            fx.plain.text(),
4314            0,
4315            "matcher read Text on a node without Text"
4316        );
4317        assert_eq!(
4318            fx.plain.value(),
4319            0,
4320            "matcher read Value on a node without Value"
4321        );
4322    }
4323}