1use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6
7use rayon::prelude::*;
8use xa11y_core::{
9 ElementData, Error, Provider, Rect, Result, Role, StateSet, Subscription, Toggled,
10};
11use zbus::blocking::{Connection, Proxy};
12
13static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
15
16fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
19 match name {
20 "enabled" => Some(s.enabled.to_string()),
21 "visible" => Some(s.visible.to_string()),
22 "focused" => Some(s.focused.to_string()),
23 "active" => Some(s.active.to_string()),
24 "focusable" => Some(s.focusable.to_string()),
25 "selected" => Some(s.selected.to_string()),
26 "editable" => Some(s.editable.to_string()),
27 "modal" => Some(s.modal.to_string()),
28 "required" => Some(s.required.to_string()),
29 "busy" => Some(s.busy.to_string()),
30 "expanded" => s.expanded.map(|b| b.to_string()),
31 "checked" => s.checked.map(|c| {
32 match c {
33 Toggled::On => "on",
34 Toggled::Off => "off",
35 Toggled::Mixed => "mixed",
36 }
37 .to_string()
38 }),
39 _ => None,
40 }
41}
42
43pub struct LinuxProvider {
45 a11y_bus: Connection,
46 handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
48 action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
51}
52
53#[derive(Debug, Clone)]
55pub(crate) struct AccessibleRef {
56 pub(crate) bus_name: String,
57 pub(crate) path: String,
58}
59
60impl LinuxProvider {
61 pub fn new() -> Result<Self> {
66 let a11y_bus = Self::connect_a11y_bus()?;
67 Ok(Self {
68 a11y_bus,
69 handle_cache: Mutex::new(HashMap::new()),
70 action_indices: Mutex::new(HashMap::new()),
71 })
72 }
73
74 pub(crate) fn connect_a11y_bus() -> Result<Connection> {
75 if let Ok(session) = Connection::session() {
79 let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
80 .map_err(|e| Error::Platform {
81 code: -1,
82 message: format!("Failed to create a11y bus proxy: {}", e),
83 })?;
84
85 if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
86 if let Ok(address) = addr_reply.body().deserialize::<String>() {
87 if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
88 if let Ok(Ok(conn)) =
89 zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
90 {
91 return Ok(conn);
92 }
93 }
94 }
95 }
96
97 return Ok(session);
99 }
100
101 Connection::session().map_err(|e| Error::Platform {
102 code: -1,
103 message: format!("Failed to connect to D-Bus session bus: {}", e),
104 })
105 }
106
107 fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
108 zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
111 .destination(bus_name.to_owned())
112 .map_err(|e| Error::Platform {
113 code: -1,
114 message: format!("Failed to set proxy destination: {}", e),
115 })?
116 .path(path.to_owned())
117 .map_err(|e| Error::Platform {
118 code: -1,
119 message: format!("Failed to set proxy path: {}", e),
120 })?
121 .interface(interface.to_owned())
122 .map_err(|e| Error::Platform {
123 code: -1,
124 message: format!("Failed to set proxy interface: {}", e),
125 })?
126 .cache_properties(zbus::proxy::CacheProperties::No)
127 .build()
128 .map_err(|e| Error::Platform {
129 code: -1,
130 message: format!("Failed to create proxy: {}", e),
131 })
132 }
133
134 fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
137 let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
138 Ok(p) => p,
139 Err(_) => return false,
140 };
141 let reply = match proxy.call_method("GetInterfaces", &()) {
142 Ok(r) => r,
143 Err(_) => return false,
144 };
145 let interfaces: Vec<String> = match reply.body().deserialize() {
146 Ok(v) => v,
147 Err(_) => return false,
148 };
149 interfaces.iter().any(|i| i.contains(iface))
150 }
151
152 fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
154 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
155 let reply = proxy
156 .call_method("GetRole", &())
157 .map_err(|e| Error::Platform {
158 code: -1,
159 message: format!("GetRole failed: {}", e),
160 })?;
161 reply
162 .body()
163 .deserialize::<u32>()
164 .map_err(|e| Error::Platform {
165 code: -1,
166 message: format!("GetRole deserialize failed: {}", e),
167 })
168 }
169
170 fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
172 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
173 let reply = proxy
174 .call_method("GetRoleName", &())
175 .map_err(|e| Error::Platform {
176 code: -1,
177 message: format!("GetRoleName failed: {}", e),
178 })?;
179 reply
180 .body()
181 .deserialize::<String>()
182 .map_err(|e| Error::Platform {
183 code: -1,
184 message: format!("GetRoleName deserialize failed: {}", e),
185 })
186 }
187
188 fn check_chromium_a11y_enabled(
205 &self,
206 parent: &AccessibleRef,
207 role_hint: Option<Role>,
208 ) -> Result<()> {
209 let app_root = AccessibleRef {
210 bus_name: parent.bus_name.clone(),
211 path: "/org/a11y/atspi/accessible/root".to_string(),
212 };
213 let toolkit = match self
214 .make_proxy(
215 &app_root.bus_name,
216 &app_root.path,
217 "org.a11y.atspi.Application",
218 )
219 .ok()
220 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
221 {
222 Some(t) => t,
223 None => return Ok(()),
224 };
225 if !toolkit.eq_ignore_ascii_case("Chromium") {
226 return Ok(());
227 }
228 let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
229 if role != Role::Window {
230 return Ok(());
231 }
232 let app_name = self.get_name(&app_root).unwrap_or_default();
233 Err(Error::AccessibilityNotEnabled {
234 app: app_name,
235 instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
236 Relaunch with `--force-renderer-accessibility` (or set the env var \
237 `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
238 .to_string(),
239 })
240 }
241
242 fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
244 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
245 proxy
246 .get_property::<String>("Name")
247 .map_err(|e| Error::Platform {
248 code: -1,
249 message: format!("Get Name property failed: {}", e),
250 })
251 }
252
253 fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
255 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
256 proxy
257 .get_property::<String>("Description")
258 .map_err(|e| Error::Platform {
259 code: -1,
260 message: format!("Get Description property failed: {}", e),
261 })
262 }
263
264 fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
268 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
269 let reply = proxy
270 .call_method("GetChildren", &())
271 .map_err(|e| Error::Platform {
272 code: -1,
273 message: format!("GetChildren failed: {}", e),
274 })?;
275 let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
276 reply.body().deserialize().map_err(|e| Error::Platform {
277 code: -1,
278 message: format!("GetChildren deserialize failed: {}", e),
279 })?;
280 Ok(children
281 .into_iter()
282 .map(|(bus_name, path)| AccessibleRef {
283 bus_name,
284 path: path.to_string(),
285 })
286 .collect())
287 }
288
289 fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
291 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
292 let reply = proxy
293 .call_method("GetState", &())
294 .map_err(|e| Error::Platform {
295 code: -1,
296 message: format!("GetState failed: {}", e),
297 })?;
298 reply
299 .body()
300 .deserialize::<Vec<u32>>()
301 .map_err(|e| Error::Platform {
302 code: -1,
303 message: format!("GetState deserialize failed: {}", e),
304 })
305 }
306
307 fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
313 let state_bits = self.get_state(aref).unwrap_or_default();
314 let bits: u64 = if state_bits.len() >= 2 {
315 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
316 } else if state_bits.len() == 1 {
317 state_bits[0] as u64
318 } else {
319 0
320 };
321 const MULTI_LINE: u64 = 1 << 17;
323 (bits & MULTI_LINE) != 0
324 }
325
326 fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
335 match coarse {
336 Role::TextArea | Role::TextField => {
337 if self.is_multi_line(aref) {
338 Role::TextArea
339 } else {
340 Role::TextField
341 }
342 }
343 other => other,
344 }
345 }
346
347 fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
351 if !self.has_interface(aref, "Component") {
352 return None;
353 }
354 let proxy = self
355 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
356 .ok()?;
357 let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
360 let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
361 if w <= 0 && h <= 0 {
362 return None;
363 }
364 Some(
369 Rect {
370 x,
371 y,
372 width: w.max(0) as u32,
373 height: h.max(0) as u32,
374 }
375 .to_logical(crate::scale::coordinate_scale()),
376 )
377 }
378
379 fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
385 let mut actions = Vec::new();
386 let mut indices = HashMap::new();
387
388 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
390 let n_actions = proxy
392 .get_property::<i32>("NActions")
393 .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
394 .unwrap_or(0);
395 for i in 0..n_actions {
396 if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
397 if let Ok(name) = reply.body().deserialize::<String>() {
398 if let Some(action_name) = map_atspi_action_name(&name) {
399 if !actions.contains(&action_name) {
400 indices.insert(action_name.clone(), i);
401 actions.push(action_name);
402 }
403 }
404 }
405 }
406 }
407 }
408
409 (actions, indices)
417 }
418
419 fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
426 let app_root = AccessibleRef {
427 bus_name: aref.bus_name.clone(),
428 path: "/org/a11y/atspi/accessible/root".to_string(),
429 };
430 self.make_proxy(
431 &app_root.bus_name,
432 &app_root.path,
433 "org.a11y.atspi.Application",
434 )
435 .ok()
436 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
437 .map(|t| t.eq_ignore_ascii_case("GTK"))
438 .unwrap_or(false)
439 }
440
441 fn find_gtk_press_fallback(
454 &self,
455 outer: &AccessibleRef,
456 outer_name: &str,
457 ) -> Option<(AccessibleRef, i32)> {
458 let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
459 queue.push_back((outer.clone(), 0));
460 let mut visited: usize = 0;
461 let mut shallowest_depth: Option<u32> = None;
462 let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
463
464 while let Some((node, depth)) = queue.pop_front() {
465 if let Some(best) = shallowest_depth {
467 if depth > best {
468 continue;
469 }
470 }
471 if visited > GTK_FALLBACK_MAX_NODES {
472 break;
473 }
474
475 let role_name = if depth == 0 {
476 String::new()
477 } else {
478 visited += 1;
479 self.get_role_name(&node).unwrap_or_default().to_lowercase()
480 };
481
482 if depth > 0 && is_actionable_atspi_role(&role_name) {
483 if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
484 match shallowest_depth {
485 Some(d) if depth < d => {
486 shallowest_depth = Some(depth);
487 hits.clear();
488 hits.push((node.clone(), idx));
489 }
490 Some(d) if depth == d => hits.push((node.clone(), idx)),
491 Some(_) => {}
492 None => {
493 shallowest_depth = Some(depth);
494 hits.push((node.clone(), idx));
495 }
496 }
497 }
498 }
499
500 let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
505 || (depth > 0 && is_never_descend_atspi_role(&role_name));
506 if stop_descending {
507 continue;
508 }
509 if let Ok(children) = self.get_atspi_children(&node) {
510 for c in children {
511 queue.push_back((c, depth + 1));
512 }
513 }
514 }
515
516 if hits.len() == 1 {
517 Some(hits.into_iter().next().unwrap())
518 } else {
519 None
520 }
521 }
522
523 fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
526 let (_, index_map) = self.get_actions(aref);
527 let idx = *index_map.get("press")?;
528 if !self.is_showing_visible_sensitive(aref) {
529 return None;
530 }
531 if !outer_name.is_empty() {
535 let inner_name = self.get_name(aref).unwrap_or_default();
536 if !inner_name.is_empty() && inner_name != outer_name {
537 return None;
538 }
539 }
540 Some(idx)
541 }
542
543 fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
548 let raw = self.get_state(aref).unwrap_or_default();
549 let bits: u64 = if raw.len() >= 2 {
550 (raw[0] as u64) | ((raw[1] as u64) << 32)
551 } else if raw.len() == 1 {
552 raw[0] as u64
553 } else {
554 0
555 };
556 const SENSITIVE: u64 = 1 << 24;
557 const SHOWING: u64 = 1 << 25;
558 const VISIBLE: u64 = 1 << 30;
559 (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
560 }
561
562 fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
565 let text_value = self.get_text_content(aref);
569 if text_value.is_some() {
570 return text_value;
571 }
572 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
574 if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
575 return Some(val.to_string());
576 }
577 }
578 None
579 }
580
581 fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
583 let proxy = self
584 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
585 .ok()?;
586 let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
587 if char_count > 0 {
588 let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
589 let text: String = reply.body().deserialize().ok()?;
590 if !text.is_empty() {
591 return Some(text);
592 }
593 }
594 None
595 }
596
597 fn cache_element(&self, aref: AccessibleRef) -> u64 {
599 let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
600 self.handle_cache
601 .lock()
602 .unwrap_or_else(|e| e.into_inner())
603 .insert(handle, aref);
604 handle
605 }
606
607 fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
609 self.handle_cache
610 .lock()
611 .unwrap_or_else(|e| e.into_inner())
612 .get(&handle)
613 .cloned()
614 .ok_or(Error::ElementStale {
615 selector: format!("handle:{}", handle),
616 })
617 }
618
619 fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
624 let role_name = self.get_role_name(aref).unwrap_or_default();
625 let role_num = self.get_role_number(aref).unwrap_or(0);
626 let role = {
627 let by_name = if !role_name.is_empty() {
628 map_atspi_role(&role_name)
629 } else {
630 Role::Unknown
631 };
632 let coarse = if by_name != Role::Unknown {
633 by_name
634 } else {
635 map_atspi_role_number(role_num)
636 };
637 self.refine_text_role(coarse, aref)
638 };
639
640 let (
644 ((mut name, value), description),
645 (
646 ((states, state_bits), bounds),
647 ((actions, action_index_map), (numeric_value, min_value, max_value)),
648 ),
649 ) = rayon::join(
650 || {
651 rayon::join(
652 || {
653 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
654 let value = if role_has_value(role) {
655 self.get_value(aref)
656 } else {
657 None
658 };
659 (name, value)
660 },
661 || self.get_description(aref).ok().filter(|s| !s.is_empty()),
662 )
663 },
664 || {
665 rayon::join(
666 || {
667 rayon::join(
668 || self.parse_states_with_bits(aref, role),
669 || {
670 if role != Role::Application {
671 self.get_extents(aref)
672 } else {
673 None
674 }
675 },
676 )
677 },
678 || {
679 rayon::join(
680 || {
681 if role_has_actions(role) {
682 self.get_actions(aref)
683 } else {
684 (vec![], HashMap::new())
685 }
686 },
687 || {
688 if matches!(
689 role,
690 Role::Slider
691 | Role::ProgressBar
692 | Role::ScrollBar
693 | Role::SpinButton
694 ) {
695 if let Ok(proxy) = self.make_proxy(
696 &aref.bus_name,
697 &aref.path,
698 "org.a11y.atspi.Value",
699 ) {
700 (
701 proxy.get_property::<f64>("CurrentValue").ok(),
702 proxy.get_property::<f64>("MinimumValue").ok(),
703 proxy.get_property::<f64>("MaximumValue").ok(),
704 )
705 } else {
706 (None, None, None)
707 }
708 } else {
709 (None, None, None)
710 }
711 },
712 )
713 },
714 )
715 },
716 );
717
718 if name.is_none() && role == Role::StaticText {
721 if let Some(ref v) = value {
722 name = Some(v.clone());
723 }
724 }
725
726 let raw = {
727 let raw_role = if role_name.is_empty() {
728 format!("role_num:{}", role_num)
729 } else {
730 role_name
731 };
732 let mut raw = HashMap::new();
733 raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
734 raw.insert(
735 "bus_name".into(),
736 serde_json::Value::String(aref.bus_name.clone()),
737 );
738 raw.insert(
739 "object_path".into(),
740 serde_json::Value::String(aref.path.clone()),
741 );
742 if let Some(ref n) = name {
745 raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
746 }
747 if let Some(ref v) = value {
748 raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
749 }
750 if let Some(ref d) = description {
751 raw.insert(
752 "atspi_description".into(),
753 serde_json::Value::String(d.clone()),
754 );
755 }
756 if (state_bits & (1 << 6)) != 0 {
762 raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
763 }
764 raw
765 };
766
767 let name = xa11y_core::text::strip_bidi_opt(name);
770 let value = xa11y_core::text::strip_bidi_opt(value);
771 let description = xa11y_core::text::strip_bidi_opt(description);
772
773 let handle = self.cache_element(aref.clone());
774 if !action_index_map.is_empty() {
775 self.action_indices
776 .lock()
777 .unwrap_or_else(|e| e.into_inner())
778 .insert(handle, action_index_map);
779 }
780
781 ElementData {
782 role,
783 name,
784 value,
785 description,
786 bounds,
787 actions,
788 states,
789 numeric_value,
790 min_value,
791 max_value,
792 pid,
793 stable_id: Some(aref.path.clone()),
794 raw,
795 handle,
796 }
797 }
798
799 fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
801 let proxy = self.make_proxy(
803 &aref.bus_name,
804 &aref.path,
805 "org.freedesktop.DBus.Properties",
806 )?;
807 let reply = proxy
808 .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
809 .map_err(|e| Error::Platform {
810 code: -1,
811 message: format!("Get Parent property failed: {}", e),
812 })?;
813 let variant: zbus::zvariant::OwnedValue =
815 reply.body().deserialize().map_err(|e| Error::Platform {
816 code: -1,
817 message: format!("Parent deserialize variant failed: {}", e),
818 })?;
819 let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
820 zbus::zvariant::Value::from(variant).try_into().map_err(
821 |e: zbus::zvariant::Error| Error::Platform {
822 code: -1,
823 message: format!("Parent deserialize struct failed: {}", e),
824 },
825 )?;
826 let path_str = path.as_str();
827 if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
828 return Ok(None);
829 }
830 if path_str == "/org/a11y/atspi/accessible/root" {
832 return Ok(None);
833 }
834 Ok(Some(AccessibleRef {
835 bus_name: bus,
836 path: path_str.to_string(),
837 }))
838 }
839
840 fn state_bits(&self, aref: &AccessibleRef) -> u64 {
845 let state_bits = self.get_state(aref).unwrap_or_default();
846 if state_bits.len() >= 2 {
847 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
848 } else if state_bits.len() == 1 {
849 state_bits[0] as u64
850 } else {
851 0
852 }
853 }
854
855 fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
857 self.parse_states_with_bits(aref, role).0
858 }
859
860 fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
864 let bits = self.state_bits(aref);
865
866 const ACTIVE: u64 = 1 << 1;
868 const BUSY: u64 = 1 << 3;
869 const CHECKED: u64 = 1 << 4;
870 const EDITABLE: u64 = 1 << 7;
871 const ENABLED: u64 = 1 << 8;
872 const EXPANDABLE: u64 = 1 << 9;
873 const EXPANDED: u64 = 1 << 10;
874 const FOCUSABLE: u64 = 1 << 11;
875 const FOCUSED: u64 = 1 << 12;
876 const MODAL: u64 = 1 << 16;
877 const SELECTED: u64 = 1 << 23;
878 const SENSITIVE: u64 = 1 << 24;
879 const SHOWING: u64 = 1 << 25;
880 const VISIBLE: u64 = 1 << 30;
881 const INDETERMINATE: u64 = 1 << 32;
882 const REQUIRED: u64 = 1 << 33;
883
884 let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
885 let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
886
887 let checked = match role {
888 Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
889 if (bits & INDETERMINATE) != 0 {
890 Some(Toggled::Mixed)
891 } else if (bits & CHECKED) != 0 {
892 Some(Toggled::On)
893 } else {
894 Some(Toggled::Off)
895 }
896 }
897 _ => None,
898 };
899
900 let expanded = if (bits & EXPANDABLE) != 0 {
901 Some((bits & EXPANDED) != 0)
902 } else {
903 None
904 };
905
906 let mut states = StateSet::default();
907 states.enabled = enabled;
908 states.visible = visible;
909 states.focused = (bits & FOCUSED) != 0;
910 states.active = (bits & ACTIVE) != 0;
913 states.checked = checked;
914 states.selected = (bits & SELECTED) != 0;
915 states.expanded = expanded;
916 states.editable = (bits & EDITABLE) != 0;
917 states.focusable = (bits & FOCUSABLE) != 0;
918 states.modal = (bits & MODAL) != 0;
919 states.required = (bits & REQUIRED) != 0;
920 states.busy = (bits & BUSY) != 0;
921 (states, bits)
922 }
923
924 pub(crate) fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
929 let registry = AccessibleRef {
930 bus_name: "org.a11y.atspi.Registry".to_string(),
931 path: "/org/a11y/atspi/accessible/root".to_string(),
932 };
933 let children = self.get_atspi_children(®istry)?;
934
935 for child in &children {
936 if child.path == "/org/a11y/atspi/null" {
937 continue;
938 }
939 if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
945 if app_pid == pid {
946 return Ok(child.clone());
947 }
948 }
949 if let Ok(proxy) =
951 self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
952 {
953 if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
954 if app_pid as u32 == pid {
955 return Ok(child.clone());
956 }
957 }
958 }
959 }
960
961 Err(Error::Platform {
962 code: -1,
963 message: format!("No application found with PID {}", pid),
964 })
965 }
966
967 fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
969 let proxy = self
970 .make_proxy(
971 "org.freedesktop.DBus",
972 "/org/freedesktop/DBus",
973 "org.freedesktop.DBus",
974 )
975 .ok()?;
976 let reply = proxy
977 .call_method("GetConnectionUnixProcessID", &(bus_name,))
978 .ok()?;
979 let pid: u32 = reply.body().deserialize().ok()?;
980 if pid > 0 {
981 Some(pid)
982 } else {
983 None
984 }
985 }
986
987 fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
989 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
990 proxy
991 .call_method("DoAction", &(index,))
992 .map_err(|e| Error::Platform {
993 code: -1,
994 message: format!("DoAction({}) failed: {}", index, e),
995 })?;
996 Ok(())
997 }
998
999 fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1001 self.action_indices
1002 .lock()
1003 .unwrap_or_else(|e| e.into_inner())
1004 .get(&handle)
1005 .and_then(|map| map.get(action).copied())
1006 .ok_or_else(|| Error::ActionNotSupported {
1007 action: action.to_string(),
1008 role: Role::Unknown, })
1010 }
1011
1012 fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1018 if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1019 return Some(pid);
1020 }
1021
1022 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1029 {
1030 if let Ok(pid) = proxy.get_property::<i32>("Id") {
1031 if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1032 return Some(pid as u32);
1033 }
1034 }
1035 }
1036
1037 None
1038 }
1039
1040 fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1042 let role_name = self.get_role_name(aref).unwrap_or_default();
1043 let by_name = if !role_name.is_empty() {
1044 map_atspi_role(&role_name)
1045 } else {
1046 Role::Unknown
1047 };
1048 let coarse = if by_name != Role::Unknown {
1049 by_name
1050 } else {
1051 let role_num = self.get_role_number(aref).unwrap_or(0);
1053 map_atspi_role_number(role_num)
1054 };
1055 self.refine_text_role(coarse, aref)
1056 }
1057
1058 fn matches_ref(
1072 &self,
1073 aref: &AccessibleRef,
1074 simple: &xa11y_core::selector::SimpleSelector,
1075 ) -> bool {
1076 let needs_role = simple.role.is_some()
1079 || simple
1080 .filters
1081 .iter()
1082 .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1083 let role = if needs_role {
1084 Some(self.resolve_role(aref))
1085 } else {
1086 None
1087 };
1088
1089 if let Some(ref role_match) = simple.role {
1090 match role_match {
1091 xa11y_core::selector::RoleMatch::Normalized(expected) => {
1092 if role != Some(*expected) {
1093 return false;
1094 }
1095 }
1096 xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1097 let raw_role = self.get_role_name(aref).unwrap_or_default();
1098 if raw_role != *platform_role {
1099 return false;
1100 }
1101 }
1102 }
1103 }
1104
1105 let mut state_set: Option<StateSet> = None;
1106
1107 for filter in &simple.filters {
1108 let attr = filter.attr.as_str();
1109 let resolved: Option<Option<String>> = match attr {
1110 "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1111 "name" => {
1112 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1113 let resolved = if name.is_none() && role == Some(Role::StaticText) {
1116 self.get_value(aref)
1117 } else {
1118 name
1119 };
1120 Some(resolved)
1121 }
1122 "value" => Some(self.get_value(aref)),
1123 "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1124 "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1125 | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1126 let s = state_set.get_or_insert_with(|| {
1127 self.parse_states(aref, role.unwrap_or(Role::Unknown))
1131 });
1132 Some(state_attr_to_string(attr, s))
1133 }
1134 _ => None, };
1136
1137 match resolved {
1138 Some(value) => {
1139 if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1140 {
1141 return false;
1142 }
1143 }
1144 None => {
1145 let pid = None; let data = self.build_element_data(aref, pid);
1153 return xa11y_core::selector::matches_simple(&data, simple);
1154 }
1155 }
1156 }
1157
1158 true
1159 }
1160
1161 fn collect_matching_refs_group(
1171 &self,
1172 parent: &AccessibleRef,
1173 clauses: &[&xa11y_core::selector::SimpleSelector],
1174 depth: u32,
1175 max_depth: u32,
1176 limit: Option<usize>,
1177 ) -> Result<Vec<(usize, AccessibleRef)>> {
1178 if depth > max_depth {
1179 return Ok(vec![]);
1180 }
1181
1182 let children = self.get_atspi_children(parent)?;
1183
1184 let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1192 let mut to_search: Vec<AccessibleRef> = Vec::new();
1193 for child in children {
1194 if child.path == "/org/a11y/atspi/null"
1195 || child.bus_name.is_empty()
1196 || child.path.is_empty()
1197 {
1198 continue;
1199 }
1200
1201 if !parent_is_registry {
1202 let child_role = self.get_role_name(&child).unwrap_or_default();
1203 if child_role == "application" {
1204 let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1205 for gc in grandchildren {
1206 if gc.path == "/org/a11y/atspi/null"
1207 || gc.bus_name.is_empty()
1208 || gc.path.is_empty()
1209 {
1210 continue;
1211 }
1212 let gc_role = self.get_role_name(&gc).unwrap_or_default();
1213 if gc_role == "application" {
1214 continue;
1215 }
1216 to_search.push(gc);
1217 }
1218 continue;
1219 }
1220 }
1221 to_search.push(child);
1222 }
1223
1224 if to_search.is_empty() {
1227 self.check_chromium_a11y_enabled(parent, None)?;
1228 }
1229
1230 type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1245 let per_child: Vec<ChildBatch> = to_search
1246 .par_iter()
1247 .map(|child| {
1248 let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1249 for (idx, simple) in clauses.iter().enumerate() {
1250 if self.matches_ref(child, simple) {
1251 child_results.push((idx, child.clone()));
1252 }
1253 }
1254 match self.collect_matching_refs_group(child, clauses, depth + 1, max_depth, limit)
1255 {
1256 Ok(sub) => {
1257 child_results.extend(sub);
1258 (child_results, None)
1259 }
1260 Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1261 Err(_) => (child_results, None),
1262 }
1263 })
1264 .collect();
1265
1266 let mut results = Vec::new();
1278 for (batch, maybe_err) in per_child {
1279 if let Some(err) = maybe_err {
1280 return Err(err);
1281 }
1282 for r in batch {
1283 results.push(r);
1284 if let Some(limit) = limit {
1285 if results.len() >= limit {
1286 return Ok(results);
1287 }
1288 }
1289 }
1290 }
1291 Ok(results)
1292 }
1293}
1294
1295impl Provider for LinuxProvider {
1296 fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1297 match element {
1298 None => {
1299 self.list_apps()
1302 }
1303 Some(element_data) => {
1304 let aref = self.get_cached(element_data.handle)?;
1305 let children = self.get_atspi_children(&aref).unwrap_or_default();
1306 let pid = element_data.pid;
1307
1308 let mut to_build: Vec<AccessibleRef> = Vec::new();
1311 for child_ref in &children {
1312 if child_ref.path == "/org/a11y/atspi/null"
1313 || child_ref.bus_name.is_empty()
1314 || child_ref.path.is_empty()
1315 {
1316 continue;
1317 }
1318 let child_role = self.get_role_name(child_ref).unwrap_or_default();
1319 if child_role == "application" {
1320 let grandchildren = self.get_atspi_children(child_ref).unwrap_or_default();
1321 for gc_ref in grandchildren {
1322 if gc_ref.path == "/org/a11y/atspi/null"
1323 || gc_ref.bus_name.is_empty()
1324 || gc_ref.path.is_empty()
1325 {
1326 continue;
1327 }
1328 let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1329 if gc_role == "application" {
1330 continue;
1331 }
1332 to_build.push(gc_ref);
1333 }
1334 continue;
1335 }
1336 to_build.push(child_ref.clone());
1337 }
1338
1339 if to_build.is_empty() {
1340 self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1341 }
1342
1343 let results: Vec<ElementData> = to_build
1344 .par_iter()
1345 .map(|r| self.build_element_data(r, pid))
1346 .collect();
1347
1348 Ok(results)
1349 }
1350 }
1351 }
1352
1353 fn find_elements_group(
1354 &self,
1355 root: &ElementData,
1356 group: &xa11y_core::selector::SelectorGroup,
1357 limit: Option<usize>,
1358 max_depth: Option<u32>,
1359 ) -> Result<Vec<ElementData>> {
1360 if group.clauses.is_empty() {
1361 return Ok(vec![]);
1362 }
1363 if group.clauses.iter().any(|c| c.segments.is_empty()) {
1366 return Ok(vec![]);
1367 }
1368
1369 let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1379
1380 let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1381 .clauses
1382 .iter()
1383 .map(|c| &c.segments[0].simple)
1384 .collect();
1385
1386 let start_ref = self.get_cached(root.handle)?;
1387
1388 let phase1_limit = if group.clauses.len() == 1 {
1397 let clause = &group.clauses[0];
1398 let first = firsts[0];
1399 let outer = if clause.segments.len() == 1 {
1400 limit
1401 } else {
1402 None
1403 };
1404 match (outer, first.nth) {
1405 (Some(l), Some(n)) => Some(l.max(n)),
1406 (_, Some(n)) => Some(n),
1407 (l, None) => l,
1408 }
1409 } else {
1410 None
1411 };
1412
1413 let phase1: Vec<(usize, AccessibleRef)> =
1414 self.collect_matching_refs_group(&start_ref, &firsts, 0, max_depth_val, phase1_limit)?;
1415
1416 let pid_from_root = root.pid;
1417
1418 let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1422 (0..group.clauses.len()).map(|_| Vec::new()).collect();
1423 for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1424 by_clause[clause_idx].push((walk_pos, aref));
1425 }
1426
1427 let mut merged: Vec<(usize, ElementData)> = Vec::new();
1435 for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1436 if hits.is_empty() {
1437 continue;
1438 }
1439 let clause = &group.clauses[clause_idx];
1440 let phase1_data: Vec<(usize, ElementData)> = hits
1442 .par_iter()
1443 .map(|(pos, aref)| {
1444 (*pos, self.build_element_data(aref, pid_from_root))
1448 })
1449 .collect();
1450
1451 if clause.segments.len() == 1 {
1452 let mut candidates = phase1_data;
1454 if let Some(nth) = clause.segments[0].simple.nth {
1455 if nth <= candidates.len() {
1456 let kept = candidates.remove(nth - 1);
1457 candidates.clear();
1458 candidates.push(kept);
1459 } else {
1460 candidates.clear();
1461 }
1462 }
1463 merged.extend(candidates);
1464 continue;
1465 }
1466
1467 for (anchor_pos, head) in phase1_data {
1472 let narrowed = self.narrow_multi_segment(
1473 vec![head],
1474 &clause.segments[1..],
1475 max_depth_val,
1476 None,
1477 )?;
1478 for n in narrowed {
1479 merged.push((anchor_pos, n));
1480 }
1481 }
1482 }
1483
1484 merged.sort_by_key(|(pos, _)| *pos);
1488 let mut seen: HashSet<(String, String)> = HashSet::new();
1489 let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1490 for (_, data) in merged {
1491 if let Ok(aref) = self.get_cached(data.handle) {
1495 if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1496 continue;
1497 }
1498 }
1499 out.push(data);
1500 }
1501 if let Some(l) = limit {
1502 out.truncate(l);
1503 }
1504 Ok(out)
1505 }
1506
1507 fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1508 let aref = self.get_cached(element.handle)?;
1509 match self.get_atspi_parent(&aref)? {
1510 Some(parent_ref) => {
1511 let data = self.build_element_data(&parent_ref, element.pid);
1512 Ok(Some(data))
1513 }
1514 None => Ok(None),
1515 }
1516 }
1517
1518 fn list_apps(&self) -> Result<Vec<ElementData>> {
1525 let registry = AccessibleRef {
1526 bus_name: "org.a11y.atspi.Registry".to_string(),
1527 path: "/org/a11y/atspi/accessible/root".to_string(),
1528 };
1529 let children = self.get_atspi_children(®istry)?;
1530
1531 let valid: Vec<(&AccessibleRef, String)> = children
1533 .iter()
1534 .filter(|c| c.path != "/org/a11y/atspi/null")
1535 .filter_map(|c| {
1536 let name = self.get_name(c).unwrap_or_default();
1537 if name.is_empty() {
1538 None
1539 } else {
1540 Some((c, name))
1541 }
1542 })
1543 .collect();
1544
1545 let results: Vec<ElementData> = valid
1546 .par_iter()
1547 .map(|(child, app_name)| {
1548 let pid = self.get_app_pid(child);
1549 let mut data = self.build_element_data(child, pid);
1550 data.name = Some(app_name.clone());
1551 data
1552 })
1553 .collect();
1554
1555 Ok(results)
1556 }
1557
1558 fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
1570 let registry = AccessibleRef {
1571 bus_name: "org.a11y.atspi.Registry".to_string(),
1572 path: "/org/a11y/atspi/accessible/root".to_string(),
1573 };
1574 let children = self.get_atspi_children(®istry)?;
1575
1576 let mut total = 0usize;
1577 let mut unresolved = 0usize;
1578 for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1579 total += 1;
1580 match self.get_app_pid(child) {
1581 Some(p) if p == pid => {
1582 let mut data = self.build_element_data(child, Some(p));
1583 let name = self.get_name(child).unwrap_or_default();
1585 if !name.is_empty() {
1586 data.name = Some(name);
1587 }
1588 return Ok(data);
1589 }
1590 Some(_) => {}
1591 None => unresolved += 1,
1592 }
1593 }
1594 Err(
1595 Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
1596 xa11y_core::Diagnosis {
1597 last_observed: Some(format!(
1598 "{total} AT-SPI registry entries examined, {unresolved} without a \
1599 resolvable pid"
1600 )),
1601 ..Default::default()
1602 },
1603 ),
1604 )
1605 }
1606
1607 fn focused_app(&self) -> Result<ElementData> {
1620 const ACTIVE: u64 = 1 << 1;
1622
1623 let registry = AccessibleRef {
1624 bus_name: "org.a11y.atspi.Registry".to_string(),
1625 path: "/org/a11y/atspi/accessible/root".to_string(),
1626 };
1627 let apps = self.get_atspi_children(®istry)?;
1628
1629 for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1630 let windows = match self.get_atspi_children(app) {
1631 Ok(w) => w,
1632 Err(_) => continue,
1635 };
1636 let has_active_window = windows
1637 .iter()
1638 .filter(|w| w.path != "/org/a11y/atspi/null")
1639 .any(|w| self.state_bits(w) & ACTIVE != 0);
1640 if has_active_window {
1641 let pid = self.get_app_pid(app);
1642 let mut data = self.build_element_data(app, pid);
1643 let name = self.get_name(app).unwrap_or_default();
1644 if !name.is_empty() {
1645 data.name = Some(name);
1646 }
1647 return Ok(data);
1648 }
1649 }
1650
1651 Err(Error::selector_not_matched("focused application"))
1652 }
1653
1654 fn press(&self, element: &ElementData) -> Result<()> {
1655 let target = self.get_cached(element.handle)?;
1656 if let Ok(index) = self.get_action_index(element.handle, "press") {
1658 return self.do_atspi_action_by_index(&target, index);
1659 }
1660 if element.role == Role::Button && self.is_gtk_toolkit(&target) {
1679 let outer_name = self.get_name(&target).unwrap_or_default();
1680 if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
1681 return self.do_atspi_action_by_index(&inner, index);
1682 }
1683 }
1684 Err(Error::ActionNotSupported {
1685 action: "press".to_string(),
1686 role: element.role,
1687 })
1688 }
1689
1690 fn focus(&self, element: &ElementData) -> Result<()> {
1691 let target = self.get_cached(element.handle)?;
1692 if let Ok(proxy) =
1696 self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
1697 {
1698 if let Ok(reply) = proxy.call_method("GrabFocus", &()) {
1699 if let Ok(true) = reply.body().deserialize::<bool>() {
1701 return Ok(());
1702 }
1703 }
1705 }
1706 if let Ok(index) = self.get_action_index(element.handle, "focus") {
1707 return self.do_atspi_action_by_index(&target, index);
1708 }
1709 Err(Error::ActionNotSupported {
1710 action: "focus".to_string(),
1711 role: element.role,
1712 })
1713 }
1714
1715 fn blur(&self, element: &ElementData) -> Result<()> {
1716 let target = self.get_cached(element.handle)?;
1717 if let Some(parent_ref) = self.get_atspi_parent(&target)? {
1721 if parent_ref.path != "/org/a11y/atspi/null" {
1722 let p = self.make_proxy(
1723 &parent_ref.bus_name,
1724 &parent_ref.path,
1725 "org.a11y.atspi.Component",
1726 )?;
1727 p.call_method("GrabFocus", &())
1728 .map_err(|e| Error::Platform {
1729 code: -1,
1730 message: format!("Component.GrabFocus on parent failed: {}", e),
1731 })?;
1732 return Ok(());
1733 }
1734 }
1735 Err(Error::ActionNotSupported {
1736 action: "blur".to_string(),
1737 role: element.role,
1738 })
1739 }
1740
1741 fn toggle(&self, element: &ElementData) -> Result<()> {
1742 let target = self.get_cached(element.handle)?;
1743 if let Ok(index) = self.get_action_index(element.handle, "toggle") {
1748 return self.do_atspi_action_by_index(&target, index);
1749 }
1750 if matches!(
1773 element.role,
1774 Role::CheckBox | Role::RadioButton | Role::Switch
1775 ) {
1776 if let Ok(index) = self.get_action_index(element.handle, "press") {
1777 return self.do_atspi_action_by_index(&target, index);
1778 }
1779 }
1780 Err(Error::ActionNotSupported {
1781 action: "toggle".to_string(),
1782 role: element.role,
1783 })
1784 }
1785
1786 fn select(&self, element: &ElementData) -> Result<()> {
1787 let target = self.get_cached(element.handle)?;
1788 let index = self
1789 .get_action_index(element.handle, "select")
1790 .map_err(|_| Error::ActionNotSupported {
1791 action: "select".to_string(),
1792 role: element.role,
1793 })?;
1794 self.do_atspi_action_by_index(&target, index)
1795 }
1796
1797 fn expand(&self, element: &ElementData) -> Result<()> {
1798 let target = self.get_cached(element.handle)?;
1799 let index = self
1800 .get_action_index(element.handle, "expand")
1801 .map_err(|_| Error::ActionNotSupported {
1802 action: "expand".to_string(),
1803 role: element.role,
1804 })?;
1805 self.do_atspi_action_by_index(&target, index)
1806 }
1807
1808 fn collapse(&self, element: &ElementData) -> Result<()> {
1809 let target = self.get_cached(element.handle)?;
1810 let index = self
1811 .get_action_index(element.handle, "collapse")
1812 .map_err(|_| Error::ActionNotSupported {
1813 action: "collapse".to_string(),
1814 role: element.role,
1815 })?;
1816 self.do_atspi_action_by_index(&target, index)
1817 }
1818
1819 fn show_menu(&self, element: &ElementData) -> Result<()> {
1820 let target = self.get_cached(element.handle)?;
1821 let index = self
1822 .get_action_index(element.handle, "show_menu")
1823 .map_err(|_| Error::ActionNotSupported {
1824 action: "show_menu".to_string(),
1825 role: element.role,
1826 })?;
1827 self.do_atspi_action_by_index(&target, index)
1828 }
1829
1830 fn increment(&self, element: &ElementData) -> Result<()> {
1831 let target = self.get_cached(element.handle)?;
1832 if let Ok(index) = self.get_action_index(element.handle, "increment") {
1834 return self.do_atspi_action_by_index(&target, index);
1835 }
1836 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1837 let current: f64 = proxy
1838 .get_property("CurrentValue")
1839 .map_err(|e| Error::Platform {
1840 code: -1,
1841 message: format!("Value.CurrentValue failed: {}", e),
1842 })?;
1843 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
1844 let step = if step <= 0.0 { 1.0 } else { step };
1845 proxy
1846 .set_property("CurrentValue", current + step)
1847 .map_err(|e| Error::Platform {
1848 code: -1,
1849 message: format!("Value.SetCurrentValue failed: {}", e),
1850 })
1851 }
1852
1853 fn decrement(&self, element: &ElementData) -> Result<()> {
1854 let target = self.get_cached(element.handle)?;
1855 if let Ok(index) = self.get_action_index(element.handle, "decrement") {
1856 return self.do_atspi_action_by_index(&target, index);
1857 }
1858 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1859 let current: f64 = proxy
1860 .get_property("CurrentValue")
1861 .map_err(|e| Error::Platform {
1862 code: -1,
1863 message: format!("Value.CurrentValue failed: {}", e),
1864 })?;
1865 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
1866 let step = if step <= 0.0 { 1.0 } else { step };
1867 proxy
1868 .set_property("CurrentValue", current - step)
1869 .map_err(|e| Error::Platform {
1870 code: -1,
1871 message: format!("Value.SetCurrentValue failed: {}", e),
1872 })
1873 }
1874
1875 fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
1876 let target = self.get_cached(element.handle)?;
1877 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
1878 proxy
1879 .call_method("ScrollTo", &(0u32,))
1880 .map_err(|e| Error::Platform {
1881 code: -1,
1882 message: format!("ScrollTo failed: {}", e),
1883 })?;
1884 Ok(())
1885 }
1886
1887 fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
1888 let target = self.get_cached(element.handle)?;
1889 let proxy = self
1890 .make_proxy(
1891 &target.bus_name,
1892 &target.path,
1893 "org.a11y.atspi.EditableText",
1894 )
1895 .map_err(|_| Error::TextValueNotSupported)?;
1896 if proxy.call_method("SetTextContents", &(value,)).is_ok() {
1898 return Ok(());
1899 }
1900 let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
1906 let msg = e.to_string();
1907 if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
1908 Error::TextValueNotSupported
1909 } else {
1910 Error::Platform {
1911 code: -1,
1912 message: format!("EditableText.{} failed: {}", op, msg),
1913 }
1914 }
1915 };
1916 if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
1917 return Err(classify_editable_text_error("DeleteText", e));
1918 }
1919 if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
1920 return Err(classify_editable_text_error("InsertText", e));
1921 }
1922 Ok(())
1923 }
1924
1925 fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
1926 let target = self.get_cached(element.handle)?;
1927 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
1928 proxy
1929 .set_property("CurrentValue", value)
1930 .map_err(|e| Error::Platform {
1931 code: -1,
1932 message: format!("SetValue failed: {}", e),
1933 })
1934 }
1935
1936 fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
1937 let target = self.get_cached(element.handle)?;
1938 let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
1941 let insert_pos = text_proxy
1942 .as_ref()
1943 .ok()
1944 .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
1945 .unwrap_or(-1); let proxy = self
1948 .make_proxy(
1949 &target.bus_name,
1950 &target.path,
1951 "org.a11y.atspi.EditableText",
1952 )
1953 .map_err(|_| Error::TextValueNotSupported)?;
1954 let pos = if insert_pos >= 0 {
1955 insert_pos
1956 } else {
1957 i32::MAX
1958 };
1959 proxy
1960 .call_method("InsertText", &(pos, text, text.len() as i32))
1961 .map_err(|e| Error::Platform {
1962 code: -1,
1963 message: format!("EditableText.InsertText failed: {}", e),
1964 })?;
1965 Ok(())
1966 }
1967
1968 fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
1969 let target = self.get_cached(element.handle)?;
1970 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
1971 proxy
1972 .call_method("SetSelection", &(0i32, start as i32, end as i32))
1973 .map_err(|e| Error::Platform {
1974 code: -1,
1975 message: format!("Text.SetSelection failed: {}", e),
1976 })?;
1977 Ok(())
1978 }
1979
1980 fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
1981 match action {
1982 "press" => self.press(element),
1983 "focus" => self.focus(element),
1984 "blur" => self.blur(element),
1985 "toggle" => self.toggle(element),
1986 "select" => self.select(element),
1987 "expand" => self.expand(element),
1988 "collapse" => self.collapse(element),
1989 "show_menu" => self.show_menu(element),
1990 "increment" => self.increment(element),
1991 "decrement" => self.decrement(element),
1992 "scroll_into_view" => self.scroll_into_view(element),
1993 _ => Err(Error::ActionNotSupported {
1994 action: action.to_string(),
1995 role: element.role,
1996 }),
1997 }
1998 }
1999
2000 fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2001 let pid = element.pid.ok_or(Error::Platform {
2002 code: -1,
2003 message: "Element has no PID for subscribe".to_string(),
2004 })?;
2005 let app_name = element.name.clone().unwrap_or_default();
2006 crate::events::subscribe_for_pid(self, pid, app_name)
2007 }
2008}
2009
2010fn role_has_value(role: Role) -> bool {
2013 !matches!(
2014 role,
2015 Role::Application
2016 | Role::Window
2017 | Role::Dialog
2018 | Role::Group
2019 | Role::MenuBar
2020 | Role::Toolbar
2021 | Role::TabGroup
2022 | Role::SplitGroup
2023 | Role::Table
2024 | Role::TableRow
2025 | Role::Separator
2026 )
2027}
2028
2029fn role_has_actions(role: Role) -> bool {
2032 matches!(
2033 role,
2034 Role::Button
2035 | Role::CheckBox
2036 | Role::RadioButton
2037 | Role::MenuItem
2038 | Role::Link
2039 | Role::ComboBox
2040 | Role::TextField
2041 | Role::TextArea
2042 | Role::SpinButton
2043 | Role::Tab
2044 | Role::TreeItem
2045 | Role::ListItem
2046 | Role::ScrollBar
2047 | Role::Slider
2048 | Role::Menu
2049 | Role::Image
2050 | Role::Unknown
2051 )
2052}
2053
2054pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2056 match role_name.to_lowercase().as_str() {
2057 "application" => Role::Application,
2058 "window" | "frame" => Role::Window,
2059 "dialog" | "file chooser" => Role::Dialog,
2060 "alert" | "notification" | "info bar" => Role::Alert,
2061 "push button" | "push button menu" => Role::Button,
2062 "toggle button" => Role::Switch,
2063 "check box" | "check menu item" => Role::CheckBox,
2064 "radio button" | "radio menu item" => Role::RadioButton,
2065 "entry" | "password text" => Role::TextField,
2066 "spin button" | "spinbutton" => Role::SpinButton,
2067 "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2073 "label" | "static" | "caption" => Role::StaticText,
2074 "combo box" | "combobox" => Role::ComboBox,
2075 "list" | "list box" | "listbox" => Role::List,
2077 "list item" => Role::ListItem,
2078 "menu" | "popup menu" => Role::Menu,
2079 "menu item" | "tearoff menu item" => Role::MenuItem,
2080 "menu bar" => Role::MenuBar,
2081 "page tab" => Role::Tab,
2082 "page tab list" => Role::TabGroup,
2083 "table" | "tree table" => Role::Table,
2084 "table row" => Role::TableRow,
2085 "table cell"
2088 | "table column header"
2089 | "table row header"
2090 | "column header"
2091 | "row header" => Role::TableCell,
2092 "tool bar" => Role::Toolbar,
2093 "scroll bar" => Role::ScrollBar,
2094 "slider" => Role::Slider,
2095 "image" | "icon" | "desktop icon" => Role::Image,
2096 "link" => Role::Link,
2097 "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2098 | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2099 | "comment" | "title bar" | "calendar" => Role::Group,
2100 "progress bar" | "level bar" => Role::ProgressBar,
2101 "tree item" => Role::TreeItem,
2102 "document web"
2103 | "document frame"
2104 | "document spreadsheet"
2105 | "document presentation"
2106 | "document text"
2107 | "document email" => Role::WebArea,
2108 "heading" => Role::Heading,
2109 "separator" => Role::Separator,
2110 "split pane" => Role::SplitGroup,
2111 "tooltip" | "tool tip" => Role::Tooltip,
2112 "status bar" | "statusbar" => Role::Status,
2113 "landmark" | "navigation" => Role::Navigation,
2114 _ => xa11y_core::unknown_role(role_name),
2115 }
2116}
2117
2118pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2121 match role {
2122 2 => Role::Alert, 5 => Role::Group, 7 => Role::CheckBox, 8 => Role::CheckBox, 10 => Role::TableCell, 11 => Role::ComboBox, 13 => Role::Image, 16 => Role::Dialog, 19 => Role::Dialog, 20 => Role::Group, 23 => Role::Window, 26 => Role::Image, 27 => Role::Image, 29 => Role::StaticText, 31 => Role::List, 32 => Role::ListItem, 33 => Role::Menu, 34 => Role::MenuBar, 35 => Role::MenuItem, 37 => Role::Tab, 38 => Role::TabGroup, 39 => Role::Group, 40 => Role::TextField, 41 => Role::Menu, 42 => Role::ProgressBar, 43 => Role::Button, 44 => Role::RadioButton, 45 => Role::RadioButton, 47 => Role::TableCell, 48 => Role::ScrollBar, 49 => Role::Group, 50 => Role::Separator, 51 => Role::Slider, 52 => Role::SpinButton, 53 => Role::SplitGroup, 54 => Role::Status, 55 => Role::Table, 56 => Role::TableCell, 57 => Role::TableCell, 58 => Role::TableCell, 59 => Role::MenuItem, 60 => Role::TextArea, 61 => Role::TextArea, 62 => Role::Switch, 63 => Role::Toolbar, 64 => Role::Tooltip, 65 => Role::Group, 66 => Role::Table, 67 => Role::Unknown, 68 => Role::Group, 69 => Role::Window, 71 => Role::Group, 72 => Role::Group, 73 => Role::Group, 75 => Role::Application, 78 => Role::TextArea, 79 => Role::TextField, 81 => Role::StaticText, 82 => Role::WebArea, 83 => Role::Heading, 85 => Role::Group, 86 => Role::Group, 87 => Role::Group, 88 => Role::Link, 90 => Role::TableRow, 91 => Role::TreeItem, 92 => Role::WebArea, 93 => Role::WebArea, 94 => Role::WebArea, 95 => Role::WebArea, 96 => Role::WebArea, 97 => Role::Group, 98 => Role::List, 99 => Role::Group, 101 => Role::Alert, 102 => Role::Alert, 103 => Role::ProgressBar, 104 => Role::Group, 105 => Role::Group, 110 => Role::Navigation, 116 => Role::StaticText, 129 => Role::Button, _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
2216 }
2217}
2218
2219const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2223
2224const GTK_FALLBACK_MAX_NODES: usize = 200;
2227
2228fn is_actionable_atspi_role(role: &str) -> bool {
2233 matches!(
2234 role,
2235 "push button"
2236 | "toggle button"
2237 | "check box"
2238 | "radio button"
2239 | "menu item"
2240 | "check menu item"
2241 | "radio menu item"
2242 | "link"
2243 | "page tab"
2244 | "tab"
2245 | "list item"
2246 | "tree item"
2247 )
2248}
2249
2250fn is_never_descend_atspi_role(role: &str) -> bool {
2255 matches!(
2256 role,
2257 "label" | "separator" | "image" | "icon" | "static" | "caption"
2258 )
2259}
2260
2261fn map_atspi_action_name(action_name: &str) -> Option<String> {
2275 let lower = action_name.to_lowercase();
2284 let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
2285 let canonical = match collapsed.as_str() {
2286 "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
2287 "toggle" | "check" | "uncheck" => "toggle",
2288 "expand" | "open" => "expand",
2289 "collapse" | "close" => "collapse",
2290 "select" => "select",
2291 "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
2292 "increment" => "increment",
2293 "decrement" => "decrement",
2294 _ => return None,
2295 };
2296 Some(canonical.to_string())
2297}
2298
2299#[cfg(test)]
2300mod tests {
2301 use super::*;
2302
2303 #[test]
2304 fn test_role_mapping() {
2305 assert_eq!(map_atspi_role("push button"), Role::Button);
2306 assert_eq!(map_atspi_role("toggle button"), Role::Switch);
2307 assert_eq!(map_atspi_role("check box"), Role::CheckBox);
2308 assert_eq!(map_atspi_role("entry"), Role::TextField);
2309 assert_eq!(map_atspi_role("label"), Role::StaticText);
2310 assert_eq!(map_atspi_role("window"), Role::Window);
2311 assert_eq!(map_atspi_role("frame"), Role::Window);
2312 assert_eq!(map_atspi_role("dialog"), Role::Dialog);
2313 assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
2314 assert_eq!(map_atspi_role("slider"), Role::Slider);
2315 assert_eq!(map_atspi_role("panel"), Role::Group);
2316 assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
2317 }
2318
2319 #[test]
2320 fn test_numeric_role_mapping() {
2321 assert_eq!(map_atspi_role_number(62), Role::Switch);
2324 assert_eq!(map_atspi_role_number(43), Role::Button); assert_eq!(map_atspi_role_number(7), Role::CheckBox);
2327 assert_eq!(map_atspi_role_number(67), Role::Unknown); }
2329
2330 const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
2334 (0, "invalid"),
2335 (1, "accelerator label"),
2336 (2, "alert"),
2337 (3, "animation"),
2338 (4, "arrow"),
2339 (5, "calendar"),
2340 (6, "canvas"),
2341 (7, "check box"),
2342 (8, "check menu item"),
2343 (9, "color chooser"),
2344 (10, "column header"),
2345 (11, "combo box"),
2346 (12, "date editor"),
2347 (13, "desktop icon"),
2348 (14, "desktop frame"),
2349 (15, "dial"),
2350 (16, "dialog"),
2351 (17, "directory pane"),
2352 (18, "drawing area"),
2353 (19, "file chooser"),
2354 (20, "filler"),
2355 (21, "focus traversable"),
2356 (22, "font chooser"),
2357 (23, "frame"),
2358 (24, "glass pane"),
2359 (25, "html container"),
2360 (26, "icon"),
2361 (27, "image"),
2362 (28, "internal frame"),
2363 (29, "label"),
2364 (30, "layered pane"),
2365 (31, "list"),
2366 (32, "list item"),
2367 (33, "menu"),
2368 (34, "menu bar"),
2369 (35, "menu item"),
2370 (36, "option pane"),
2371 (37, "page tab"),
2372 (38, "page tab list"),
2373 (39, "panel"),
2374 (40, "password text"),
2375 (41, "popup menu"),
2376 (42, "progress bar"),
2377 (43, "push button"),
2378 (44, "radio button"),
2379 (45, "radio menu item"),
2380 (46, "root pane"),
2381 (47, "row header"),
2382 (48, "scroll bar"),
2383 (49, "scroll pane"),
2384 (50, "separator"),
2385 (51, "slider"),
2386 (52, "spin button"),
2387 (53, "split pane"),
2388 (54, "status bar"),
2389 (55, "table"),
2390 (56, "table cell"),
2391 (57, "table column header"),
2392 (58, "table row header"),
2393 (59, "tearoff menu item"),
2394 (60, "terminal"),
2395 (61, "text"),
2396 (62, "toggle button"),
2397 (63, "tool bar"),
2398 (64, "tool tip"),
2399 (65, "tree"),
2400 (66, "tree table"),
2401 (67, "unknown"),
2402 (68, "viewport"),
2403 (69, "window"),
2404 (70, "extended"),
2405 (71, "header"),
2406 (72, "footer"),
2407 (73, "paragraph"),
2408 (74, "ruler"),
2409 (75, "application"),
2410 (76, "autocomplete"),
2411 (77, "editbar"),
2412 (78, "embedded"),
2413 (79, "entry"),
2414 (80, "chart"),
2415 (81, "caption"),
2416 (82, "document frame"),
2417 (83, "heading"),
2418 (84, "page"),
2419 (85, "section"),
2420 (86, "redundant object"),
2421 (87, "form"),
2422 (88, "link"),
2423 (89, "input method window"),
2424 (90, "table row"),
2425 (91, "tree item"),
2426 (92, "document spreadsheet"),
2427 (93, "document presentation"),
2428 (94, "document text"),
2429 (95, "document web"),
2430 (96, "document email"),
2431 (97, "comment"),
2432 (98, "list box"),
2433 (99, "grouping"),
2434 (100, "image map"),
2435 (101, "notification"),
2436 (102, "info bar"),
2437 (103, "level bar"),
2438 (104, "title bar"),
2439 (105, "block quote"),
2440 (106, "audio"),
2441 (107, "video"),
2442 (108, "definition"),
2443 (109, "article"),
2444 (110, "landmark"),
2445 (111, "log"),
2446 (112, "marquee"),
2447 (113, "math"),
2448 (114, "rating"),
2449 (115, "timer"),
2450 (116, "static"),
2451 (117, "math fraction"),
2452 (118, "math root"),
2453 (119, "subscript"),
2454 (120, "superscript"),
2455 (121, "description list"),
2456 (122, "description term"),
2457 (123, "description value"),
2458 (124, "footnote"),
2459 (125, "content deletion"),
2460 (126, "content insertion"),
2461 (127, "mark"),
2462 (128, "suggestion"),
2463 (129, "push button menu"),
2464 ];
2465
2466 #[test]
2467 fn numeric_and_name_role_maps_agree() {
2468 for &(num, name) in ATSPI_ROLE_ENUM {
2475 let by_num = map_atspi_role_number(num);
2476 let by_name = map_atspi_role(name);
2477 assert_eq!(
2478 by_num, by_name,
2479 "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
2480 {by_num:?}, name map gives {by_name:?}"
2481 );
2482 }
2483 }
2484
2485 #[test]
2486 fn test_action_name_mapping() {
2487 assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
2488 assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
2489 assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
2490 assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
2491 assert_eq!(
2493 map_atspi_action_name("doDefault"),
2494 Some("press".to_string())
2495 );
2496 assert_eq!(
2497 map_atspi_action_name("do_default"),
2498 Some("press".to_string())
2499 );
2500 assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
2501 assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
2502 assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
2503 assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
2504 assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
2505 assert_eq!(
2506 map_atspi_action_name("collapse"),
2507 Some("collapse".to_string())
2508 );
2509 assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
2510 assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
2511 assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
2512 assert_eq!(
2513 map_atspi_action_name("showmenu"),
2514 Some("show_menu".to_string())
2515 );
2516 assert_eq!(
2517 map_atspi_action_name("popup"),
2518 Some("show_menu".to_string())
2519 );
2520 assert_eq!(
2521 map_atspi_action_name("show menu"),
2522 Some("show_menu".to_string())
2523 );
2524 assert_eq!(
2527 map_atspi_action_name("showContextMenu"),
2528 Some("show_menu".to_string())
2529 );
2530 assert_eq!(
2531 map_atspi_action_name("show_context_menu"),
2532 Some("show_menu".to_string())
2533 );
2534 assert_eq!(
2535 map_atspi_action_name("increment"),
2536 Some("increment".to_string())
2537 );
2538 assert_eq!(
2539 map_atspi_action_name("decrement"),
2540 Some("decrement".to_string())
2541 );
2542 assert_eq!(map_atspi_action_name("foobar"), None);
2543 }
2544
2545 #[test]
2548 fn test_action_name_aliases_roundtrip() {
2549 let atspi_names = [
2550 "click",
2551 "activate",
2552 "press",
2553 "invoke",
2554 "doDefault",
2555 "do_default",
2556 "toggle",
2557 "check",
2558 "uncheck",
2559 "expand",
2560 "open",
2561 "collapse",
2562 "close",
2563 "select",
2564 "menu",
2565 "showmenu",
2566 "showContextMenu",
2567 "show_context_menu",
2568 "popup",
2569 "show menu",
2570 "increment",
2571 "decrement",
2572 ];
2573 for name in atspi_names {
2574 let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
2575 panic!("AT-SPI2 name {:?} should map to a canonical name", name)
2576 });
2577 let back = map_atspi_action_name(&canonical)
2579 .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
2580 assert_eq!(
2581 canonical, back,
2582 "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
2583 name, canonical, back, canonical
2584 );
2585 }
2586 }
2587
2588 #[test]
2590 fn test_action_name_case_insensitive() {
2591 assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
2592 assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
2593 assert_eq!(
2594 map_atspi_action_name("Increment"),
2595 Some("increment".to_string())
2596 );
2597 }
2598
2599 #[test]
2604 fn test_gtk_fallback_actionable_roles() {
2605 for role in [
2606 "push button",
2607 "toggle button",
2608 "check box",
2609 "radio button",
2610 "menu item",
2611 "link",
2612 "tab",
2613 "list item",
2614 "tree item",
2615 ] {
2616 assert!(
2617 is_actionable_atspi_role(role),
2618 "{role:?} should be actionable"
2619 );
2620 }
2621 }
2622
2623 #[test]
2628 fn test_gtk_fallback_non_actionable_roles() {
2629 for role in [
2630 "label",
2631 "panel",
2632 "filler",
2633 "section",
2634 "group",
2635 "image",
2636 "separator",
2637 "static",
2638 "frame",
2639 "window",
2640 ] {
2641 assert!(
2642 !is_actionable_atspi_role(role),
2643 "{role:?} must not be treated as actionable"
2644 );
2645 }
2646 }
2647
2648 #[test]
2652 fn test_gtk_fallback_never_descend_roles() {
2653 for role in ["label", "separator", "image", "icon", "static", "caption"] {
2654 assert!(
2655 is_never_descend_atspi_role(role),
2656 "{role:?} must block BFS descent"
2657 );
2658 }
2659 for role in ["panel", "filler", "section", "group", "frame"] {
2662 assert!(
2663 !is_never_descend_atspi_role(role),
2664 "container role {role:?} must remain descendable"
2665 );
2666 }
2667 }
2668}