use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use rayon::prelude::*;
use xa11y_core::selector::{Combinator, MatchOp, SelectorSegment};
use xa11y_core::{
CancelHandle, ElementData, Error, Event, EventReceiver, EventType, Provider, Rect, Result,
Role, Selector, StateSet, Subscription, Toggled,
};
use zbus::blocking::{Connection, Proxy};
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
pub struct LinuxProvider {
a11y_bus: Connection,
handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
}
#[derive(Debug, Clone)]
struct AccessibleRef {
bus_name: String,
path: String,
}
impl LinuxProvider {
pub fn new() -> Result<Self> {
let a11y_bus = Self::connect_a11y_bus()?;
Ok(Self {
a11y_bus,
handle_cache: Mutex::new(HashMap::new()),
action_indices: Mutex::new(HashMap::new()),
})
}
fn connect_a11y_bus() -> Result<Connection> {
if let Ok(session) = Connection::session() {
let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to create a11y bus proxy: {}", e),
})?;
if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
if let Ok(address) = addr_reply.body().deserialize::<String>() {
if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
if let Ok(Ok(conn)) =
zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
{
return Ok(conn);
}
}
}
}
return Ok(session);
}
Connection::session().map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to connect to D-Bus session bus: {}", e),
})
}
fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
.destination(bus_name.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy destination: {}", e),
})?
.path(path.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy path: {}", e),
})?
.interface(interface.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy interface: {}", e),
})?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to create proxy: {}", e),
})
}
fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
Ok(p) => p,
Err(_) => return false,
};
let reply = match proxy.call_method("GetInterfaces", &()) {
Ok(r) => r,
Err(_) => return false,
};
let interfaces: Vec<String> = match reply.body().deserialize() {
Ok(v) => v,
Err(_) => return false,
};
interfaces.iter().any(|i| i.contains(iface))
}
fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetRole", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRole failed: {}", e),
})?;
reply
.body()
.deserialize::<u32>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRole deserialize failed: {}", e),
})
}
fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetRoleName", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRoleName failed: {}", e),
})?;
reply
.body()
.deserialize::<String>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRoleName deserialize failed: {}", e),
})
}
fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
proxy
.get_property::<String>("Name")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Name property failed: {}", e),
})
}
fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
proxy
.get_property::<String>("Description")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Description property failed: {}", e),
})
}
fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetChildren", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetChildren failed: {}", e),
})?;
let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("GetChildren deserialize failed: {}", e),
})?;
Ok(children
.into_iter()
.map(|(bus_name, path)| AccessibleRef {
bus_name,
path: path.to_string(),
})
.collect())
}
fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetState", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetState failed: {}", e),
})?;
reply
.body()
.deserialize::<Vec<u32>>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetState deserialize failed: {}", e),
})
}
fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
let state_bits = self.get_state(aref).unwrap_or_default();
let bits: u64 = if state_bits.len() >= 2 {
(state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
} else if state_bits.len() == 1 {
state_bits[0] as u64
} else {
0
};
const MULTI_LINE: u64 = 1 << 17;
(bits & MULTI_LINE) != 0
}
fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
if !self.has_interface(aref, "Component") {
return None;
}
let proxy = self
.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
.ok()?;
let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
if w <= 0 && h <= 0 {
return None;
}
Some(Rect {
x,
y,
width: w.max(0) as u32,
height: h.max(0) as u32,
})
}
fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
let mut actions = Vec::new();
let mut indices = HashMap::new();
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
let n_actions = proxy
.get_property::<i32>("NActions")
.or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
.unwrap_or(0);
for i in 0..n_actions {
if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
if let Ok(name) = reply.body().deserialize::<String>() {
if let Some(action_name) = map_atspi_action_name(&name) {
if !actions.contains(&action_name) {
indices.insert(action_name.clone(), i);
actions.push(action_name);
}
}
}
}
}
}
if !actions.contains(&"focus".to_string()) {
if let Ok(proxy) =
self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
{
if proxy.call_method("GetExtents", &(0u32,)).is_ok() {
actions.push("focus".to_string());
}
}
}
(actions, indices)
}
fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
let text_value = self.get_text_content(aref);
if text_value.is_some() {
return text_value;
}
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
return Some(val.to_string());
}
}
None
}
fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
let proxy = self
.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
.ok()?;
let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
if char_count > 0 {
let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
let text: String = reply.body().deserialize().ok()?;
if !text.is_empty() {
return Some(text);
}
}
None
}
fn cache_element(&self, aref: AccessibleRef) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
self.handle_cache.lock().unwrap().insert(handle, aref);
handle
}
fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
self.handle_cache
.lock()
.unwrap()
.get(&handle)
.cloned()
.ok_or(Error::ElementStale {
selector: format!("handle:{}", handle),
})
}
fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
let role_name = self.get_role_name(aref).unwrap_or_default();
let role_num = self.get_role_number(aref).unwrap_or(0);
let role = {
let by_name = if !role_name.is_empty() {
map_atspi_role(&role_name)
} else {
Role::Unknown
};
let coarse = if by_name != Role::Unknown {
by_name
} else {
map_atspi_role_number(role_num)
};
if coarse == Role::TextArea && !self.is_multi_line(aref) {
Role::TextField
} else {
coarse
}
};
let (
((mut name, value), description),
(
(states, bounds),
((actions, action_index_map), (numeric_value, min_value, max_value)),
),
) = rayon::join(
|| {
rayon::join(
|| {
let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
let value = if role_has_value(role) {
self.get_value(aref)
} else {
None
};
(name, value)
},
|| self.get_description(aref).ok().filter(|s| !s.is_empty()),
)
},
|| {
rayon::join(
|| {
rayon::join(
|| self.parse_states(aref, role),
|| {
if role != Role::Application {
self.get_extents(aref)
} else {
None
}
},
)
},
|| {
rayon::join(
|| {
if role_has_actions(role) {
self.get_actions(aref)
} else {
(vec![], HashMap::new())
}
},
|| {
if matches!(
role,
Role::Slider
| Role::ProgressBar
| Role::ScrollBar
| Role::SpinButton
) {
if let Ok(proxy) = self.make_proxy(
&aref.bus_name,
&aref.path,
"org.a11y.atspi.Value",
) {
(
proxy.get_property::<f64>("CurrentValue").ok(),
proxy.get_property::<f64>("MinimumValue").ok(),
proxy.get_property::<f64>("MaximumValue").ok(),
)
} else {
(None, None, None)
}
} else {
(None, None, None)
}
},
)
},
)
},
);
if name.is_none() && role == Role::StaticText {
if let Some(ref v) = value {
name = Some(v.clone());
}
}
let raw = {
let raw_role = if role_name.is_empty() {
format!("role_num:{}", role_num)
} else {
role_name
};
{
let mut raw = HashMap::new();
raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
raw.insert(
"bus_name".into(),
serde_json::Value::String(aref.bus_name.clone()),
);
raw.insert(
"object_path".into(),
serde_json::Value::String(aref.path.clone()),
);
raw
}
};
let handle = self.cache_element(aref.clone());
if !action_index_map.is_empty() {
self.action_indices
.lock()
.unwrap()
.insert(handle, action_index_map);
}
let mut data = ElementData {
role,
name,
value,
description,
bounds,
actions,
states,
numeric_value,
min_value,
max_value,
pid,
stable_id: Some(aref.path.clone()),
attributes: HashMap::new(),
raw,
handle,
};
data.populate_attributes();
data
}
fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
let proxy = self.make_proxy(
&aref.bus_name,
&aref.path,
"org.freedesktop.DBus.Properties",
)?;
let reply = proxy
.call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Parent property failed: {}", e),
})?;
let variant: zbus::zvariant::OwnedValue =
reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Parent deserialize variant failed: {}", e),
})?;
let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
zbus::zvariant::Value::from(variant).try_into().map_err(
|e: zbus::zvariant::Error| Error::Platform {
code: -1,
message: format!("Parent deserialize struct failed: {}", e),
},
)?;
let path_str = path.as_str();
if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
return Ok(None);
}
if path_str == "/org/a11y/atspi/accessible/root" {
return Ok(None);
}
Ok(Some(AccessibleRef {
bus_name: bus,
path: path_str.to_string(),
}))
}
fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
let state_bits = self.get_state(aref).unwrap_or_default();
let bits: u64 = if state_bits.len() >= 2 {
(state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
} else if state_bits.len() == 1 {
state_bits[0] as u64
} else {
0
};
const BUSY: u64 = 1 << 3;
const CHECKED: u64 = 1 << 4;
const EDITABLE: u64 = 1 << 7;
const ENABLED: u64 = 1 << 8;
const EXPANDABLE: u64 = 1 << 9;
const EXPANDED: u64 = 1 << 10;
const FOCUSABLE: u64 = 1 << 11;
const FOCUSED: u64 = 1 << 12;
const MODAL: u64 = 1 << 16;
const SELECTED: u64 = 1 << 23;
const SENSITIVE: u64 = 1 << 24;
const SHOWING: u64 = 1 << 25;
const VISIBLE: u64 = 1 << 30;
const INDETERMINATE: u64 = 1 << 32;
const REQUIRED: u64 = 1 << 33;
let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
let checked = match role {
Role::CheckBox | Role::RadioButton | Role::MenuItem => {
if (bits & INDETERMINATE) != 0 {
Some(Toggled::Mixed)
} else if (bits & CHECKED) != 0 {
Some(Toggled::On)
} else {
Some(Toggled::Off)
}
}
_ => None,
};
let expanded = if (bits & EXPANDABLE) != 0 {
Some((bits & EXPANDED) != 0)
} else {
None
};
StateSet {
enabled,
visible,
focused: (bits & FOCUSED) != 0,
checked,
selected: (bits & SELECTED) != 0,
expanded,
editable: (bits & EDITABLE) != 0,
focusable: (bits & FOCUSABLE) != 0,
modal: (bits & MODAL) != 0,
required: (bits & REQUIRED) != 0,
busy: (bits & BUSY) != 0,
}
}
fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let children = self.get_atspi_children(®istry)?;
for child in &children {
if child.path == "/org/a11y/atspi/null" {
continue;
}
if let Ok(proxy) =
self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
{
if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
if app_pid as u32 == pid {
return Ok(child.clone());
}
}
}
if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
if app_pid == pid {
return Ok(child.clone());
}
}
}
Err(Error::Platform {
code: -1,
message: format!("No application found with PID {}", pid),
})
}
fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
let proxy = self
.make_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
)
.ok()?;
let reply = proxy
.call_method("GetConnectionUnixProcessID", &(bus_name,))
.ok()?;
let pid: u32 = reply.body().deserialize().ok()?;
if pid > 0 {
Some(pid)
} else {
None
}
}
fn do_atspi_action_by_name(&self, aref: &AccessibleRef, action_name: &str) -> Result<()> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
let n_actions = proxy
.get_property::<i32>("NActions")
.or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
.unwrap_or(0);
for i in 0..n_actions {
if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
if let Ok(name) = reply.body().deserialize::<String>() {
if name.eq_ignore_ascii_case(action_name) {
proxy
.call_method("DoAction", &(i,))
.map_err(|e| Error::Platform {
code: -1,
message: format!("DoAction failed: {}", e),
})?;
return Ok(());
}
}
}
}
Err(Error::Platform {
code: -1,
message: format!("Action '{}' not found", action_name),
})
}
fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
proxy
.call_method("DoAction", &(index,))
.map_err(|e| Error::Platform {
code: -1,
message: format!("DoAction({}) failed: {}", index, e),
})?;
Ok(())
}
fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
self.action_indices
.lock()
.unwrap()
.get(&handle)
.and_then(|map| map.get(action).copied())
.ok_or_else(|| Error::ActionNotSupported {
action: action.to_string(),
role: Role::Unknown, })
}
fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
{
if let Ok(pid) = proxy.get_property::<i32>("Id") {
if pid > 0 {
return Some(pid as u32);
}
}
}
if let Ok(proxy) = self.make_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
) {
if let Ok(reply) =
proxy.call_method("GetConnectionUnixProcessID", &(aref.bus_name.as_str(),))
{
if let Ok(pid) = reply.body().deserialize::<u32>() {
if pid > 0 {
return Some(pid);
}
}
}
}
None
}
fn resolve_role(&self, aref: &AccessibleRef) -> Role {
let role_name = self.get_role_name(aref).unwrap_or_default();
let by_name = if !role_name.is_empty() {
map_atspi_role(&role_name)
} else {
Role::Unknown
};
let coarse = if by_name != Role::Unknown {
by_name
} else {
let role_num = self.get_role_number(aref).unwrap_or(0);
map_atspi_role_number(role_num)
};
if coarse == Role::TextArea && !self.is_multi_line(aref) {
Role::TextField
} else {
coarse
}
}
fn matches_ref(
&self,
aref: &AccessibleRef,
simple: &xa11y_core::selector::SimpleSelector,
) -> bool {
let needs_role = simple.role.is_some() || simple.filters.iter().any(|f| f.attr == "role");
let role = if needs_role {
Some(self.resolve_role(aref))
} else {
None
};
if let Some(ref role_match) = simple.role {
match role_match {
xa11y_core::selector::RoleMatch::Normalized(expected) => {
if role != Some(*expected) {
return false;
}
}
xa11y_core::selector::RoleMatch::Platform(platform_role) => {
let raw_role = self.get_role_name(aref).unwrap_or_default();
if raw_role != *platform_role {
return false;
}
}
}
}
for filter in &simple.filters {
let attr_value: Option<String> = match filter.attr.as_str() {
"role" => role.map(|r| r.to_snake_case().to_string()),
"name" => {
let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
if name.is_none() && role == Some(Role::StaticText) {
self.get_value(aref)
} else {
name
}
}
"value" => self.get_value(aref),
"description" => self.get_description(aref).ok().filter(|s| !s.is_empty()),
_ => None,
};
let matches = match &filter.op {
MatchOp::Exact => attr_value.as_deref() == Some(filter.value.as_str()),
MatchOp::Contains => {
let fl = filter.value.to_lowercase();
attr_value
.as_deref()
.is_some_and(|v| v.to_lowercase().contains(&fl))
}
MatchOp::StartsWith => {
let fl = filter.value.to_lowercase();
attr_value
.as_deref()
.is_some_and(|v| v.to_lowercase().starts_with(&fl))
}
MatchOp::EndsWith => {
let fl = filter.value.to_lowercase();
attr_value
.as_deref()
.is_some_and(|v| v.to_lowercase().ends_with(&fl))
}
};
if !matches {
return false;
}
}
true
}
fn collect_matching_refs(
&self,
parent: &AccessibleRef,
simple: &xa11y_core::selector::SimpleSelector,
depth: u32,
max_depth: u32,
limit: Option<usize>,
) -> Result<Vec<AccessibleRef>> {
if depth > max_depth {
return Ok(vec![]);
}
let children = self.get_atspi_children(parent)?;
let mut to_search: Vec<AccessibleRef> = Vec::new();
for child in children {
if child.path == "/org/a11y/atspi/null"
|| child.bus_name.is_empty()
|| child.path.is_empty()
{
continue;
}
let child_role = self.get_role_name(&child).unwrap_or_default();
if child_role == "application" {
let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
for gc in grandchildren {
if gc.path == "/org/a11y/atspi/null"
|| gc.bus_name.is_empty()
|| gc.path.is_empty()
{
continue;
}
let gc_role = self.get_role_name(&gc).unwrap_or_default();
if gc_role == "application" {
continue;
}
to_search.push(gc);
}
continue;
}
to_search.push(child);
}
let per_child: Vec<Vec<AccessibleRef>> = to_search
.par_iter()
.map(|child| {
let mut child_results = Vec::new();
if self.matches_ref(child, simple) {
child_results.push(child.clone());
}
if let Ok(sub) =
self.collect_matching_refs(child, simple, depth + 1, max_depth, limit)
{
child_results.extend(sub);
}
child_results
})
.collect();
let mut results = Vec::new();
for batch in per_child {
for r in batch {
results.push(r);
if let Some(limit) = limit {
if results.len() >= limit {
return Ok(results);
}
}
}
}
Ok(results)
}
}
impl Provider for LinuxProvider {
fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
match element {
None => {
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let children = self.get_atspi_children(®istry)?;
let valid: Vec<(&AccessibleRef, String)> = children
.iter()
.filter(|c| c.path != "/org/a11y/atspi/null")
.filter_map(|c| {
let name = self.get_name(c).unwrap_or_default();
if name.is_empty() {
None
} else {
Some((c, name))
}
})
.collect();
let results: Vec<ElementData> = valid
.par_iter()
.map(|(child, app_name)| {
let pid = self.get_app_pid(child);
let mut data = self.build_element_data(child, pid);
data.name = Some(app_name.clone());
data
})
.collect();
Ok(results)
}
Some(element_data) => {
let aref = self.get_cached(element_data.handle)?;
let children = self.get_atspi_children(&aref).unwrap_or_default();
let pid = element_data.pid;
let mut to_build: Vec<AccessibleRef> = Vec::new();
for child_ref in &children {
if child_ref.path == "/org/a11y/atspi/null"
|| child_ref.bus_name.is_empty()
|| child_ref.path.is_empty()
{
continue;
}
let child_role = self.get_role_name(child_ref).unwrap_or_default();
if child_role == "application" {
let grandchildren = self.get_atspi_children(child_ref).unwrap_or_default();
for gc_ref in grandchildren {
if gc_ref.path == "/org/a11y/atspi/null"
|| gc_ref.bus_name.is_empty()
|| gc_ref.path.is_empty()
{
continue;
}
let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
if gc_role == "application" {
continue;
}
to_build.push(gc_ref);
}
continue;
}
to_build.push(child_ref.clone());
}
let results: Vec<ElementData> = to_build
.par_iter()
.map(|r| self.build_element_data(r, pid))
.collect();
Ok(results)
}
}
}
fn find_elements(
&self,
root: Option<&ElementData>,
selector: &Selector,
limit: Option<usize>,
max_depth: Option<u32>,
) -> Result<Vec<ElementData>> {
if selector.segments.is_empty() {
return Ok(vec![]);
}
let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
let first = &selector.segments[0].simple;
let phase1_limit = if selector.segments.len() == 1 {
limit
} else {
None
};
let phase1_limit = match (phase1_limit, first.nth) {
(Some(l), Some(n)) => Some(l.max(n)),
(_, Some(n)) => Some(n),
(l, None) => l,
};
let phase1_depth = if root.is_none()
&& matches!(
first.role,
Some(xa11y_core::selector::RoleMatch::Normalized(
Role::Application
))
) {
0
} else {
max_depth_val
};
let start_ref = match root {
None => AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
},
Some(el) => self.get_cached(el.handle)?,
};
let mut matching_refs =
self.collect_matching_refs(&start_ref, first, 0, phase1_depth, phase1_limit)?;
let pid_from_root = root.and_then(|r| r.pid);
if selector.segments.len() == 1 {
if let Some(nth) = first.nth {
if nth <= matching_refs.len() {
let aref = &matching_refs[nth - 1];
let pid = if root.is_none() {
self.get_app_pid(aref)
.or_else(|| self.get_dbus_pid(&aref.bus_name))
} else {
pid_from_root
};
return Ok(vec![self.build_element_data(aref, pid)]);
} else {
return Ok(vec![]);
}
}
if let Some(limit) = limit {
matching_refs.truncate(limit);
}
let is_root_search = root.is_none();
return Ok(matching_refs
.par_iter()
.map(|aref| {
let pid = if is_root_search {
self.get_app_pid(aref)
.or_else(|| self.get_dbus_pid(&aref.bus_name))
} else {
pid_from_root
};
self.build_element_data(aref, pid)
})
.collect());
}
let is_root_search = root.is_none();
let mut candidates: Vec<ElementData> = matching_refs
.par_iter()
.map(|aref| {
let pid = if is_root_search {
self.get_app_pid(aref)
.or_else(|| self.get_dbus_pid(&aref.bus_name))
} else {
pid_from_root
};
self.build_element_data(aref, pid)
})
.collect();
for segment in &selector.segments[1..] {
let mut next_candidates = Vec::new();
for candidate in &candidates {
match segment.combinator {
Combinator::Child => {
let children = self.get_children(Some(candidate))?;
for child in children {
if xa11y_core::selector::matches_simple(&child, &segment.simple) {
next_candidates.push(child);
}
}
}
Combinator::Descendant => {
let sub_selector = Selector {
segments: vec![SelectorSegment {
combinator: Combinator::Root,
simple: segment.simple.clone(),
}],
};
let mut sub_results = xa11y_core::selector::find_elements_in_tree(
|el| self.get_children(el),
Some(candidate),
&sub_selector,
None,
Some(max_depth_val),
)?;
next_candidates.append(&mut sub_results);
}
Combinator::Root => unreachable!(),
}
}
let mut seen = HashSet::new();
next_candidates.retain(|e| seen.insert(e.handle));
candidates = next_candidates;
}
if let Some(nth) = selector.segments.last().and_then(|s| s.simple.nth) {
if nth <= candidates.len() {
candidates = vec![candidates.remove(nth - 1)];
} else {
candidates.clear();
}
}
if let Some(limit) = limit {
candidates.truncate(limit);
}
Ok(candidates)
}
fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
let aref = self.get_cached(element.handle)?;
match self.get_atspi_parent(&aref)? {
Some(parent_ref) => {
let data = self.build_element_data(&parent_ref, element.pid);
Ok(Some(data))
}
None => Ok(None),
}
}
fn press(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "press")
.map_err(|_| Error::ActionNotSupported {
action: "press".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn focus(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(proxy) =
self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
{
if proxy.call_method("GrabFocus", &()).is_ok() {
return Ok(());
}
}
if let Ok(index) = self.get_action_index(element.handle, "focus") {
return self.do_atspi_action_by_index(&target, index);
}
Err(Error::ActionNotSupported {
action: "focus".to_string(),
role: element.role,
})
}
fn blur(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(Some(parent_ref)) = self.get_atspi_parent(&target) {
if parent_ref.path != "/org/a11y/atspi/null" {
if let Ok(p) = self.make_proxy(
&parent_ref.bus_name,
&parent_ref.path,
"org.a11y.atspi.Component",
) {
let _ = p.call_method("GrabFocus", &());
return Ok(());
}
}
}
Ok(())
}
fn toggle(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "toggle")
.map_err(|_| Error::ActionNotSupported {
action: "toggle".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn select(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "select")
.map_err(|_| Error::ActionNotSupported {
action: "select".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn expand(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "expand")
.map_err(|_| Error::ActionNotSupported {
action: "expand".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn collapse(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "collapse")
.map_err(|_| Error::ActionNotSupported {
action: "collapse".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn show_menu(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "show_menu")
.map_err(|_| Error::ActionNotSupported {
action: "show_menu".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn increment(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "increment") {
return self.do_atspi_action_by_index(&target, index);
}
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
let current: f64 = proxy
.get_property("CurrentValue")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.CurrentValue failed: {}", e),
})?;
let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
let step = if step <= 0.0 { 1.0 } else { step };
proxy
.set_property("CurrentValue", current + step)
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.SetCurrentValue failed: {}", e),
})
}
fn decrement(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "decrement") {
return self.do_atspi_action_by_index(&target, index);
}
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
let current: f64 = proxy
.get_property("CurrentValue")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.CurrentValue failed: {}", e),
})?;
let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
let step = if step <= 0.0 { 1.0 } else { step };
proxy
.set_property("CurrentValue", current - step)
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.SetCurrentValue failed: {}", e),
})
}
fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
proxy
.call_method("ScrollTo", &(0u32,))
.map_err(|e| Error::Platform {
code: -1,
message: format!("ScrollTo failed: {}", e),
})?;
Ok(())
}
fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self
.make_proxy(
&target.bus_name,
&target.path,
"org.a11y.atspi.EditableText",
)
.map_err(|_| Error::TextValueNotSupported)?;
if proxy.call_method("SetTextContents", &(value,)).is_ok() {
return Ok(());
}
let _ = proxy.call_method("DeleteText", &(0i32, i32::MAX));
proxy
.call_method("InsertText", &(0i32, value, value.len() as i32))
.map_err(|_| Error::TextValueNotSupported)?;
Ok(())
}
fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
proxy
.set_property("CurrentValue", value)
.map_err(|e| Error::Platform {
code: -1,
message: format!("SetValue failed: {}", e),
})
}
fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
let target = self.get_cached(element.handle)?;
let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
let insert_pos = text_proxy
.as_ref()
.ok()
.and_then(|p| p.get_property::<i32>("CaretOffset").ok())
.unwrap_or(-1);
let proxy = self
.make_proxy(
&target.bus_name,
&target.path,
"org.a11y.atspi.EditableText",
)
.map_err(|_| Error::TextValueNotSupported)?;
let pos = if insert_pos >= 0 {
insert_pos
} else {
i32::MAX
};
proxy
.call_method("InsertText", &(pos, text, text.len() as i32))
.map_err(|e| Error::Platform {
code: -1,
message: format!("EditableText.InsertText failed: {}", e),
})?;
Ok(())
}
fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
if proxy
.call_method("SetSelection", &(0i32, start as i32, end as i32))
.is_err()
{
proxy
.call_method("AddSelection", &(start as i32, end as i32))
.map_err(|e| Error::Platform {
code: -1,
message: format!("Text.AddSelection failed: {}", e),
})?;
}
Ok(())
}
fn scroll_down(&self, element: &ElementData, amount: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let count = (amount.abs() as u32).max(1);
for _ in 0..count {
if self
.do_atspi_action_by_name(&target, "scroll down")
.is_err()
{
if let Ok(proxy) =
self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
{
if proxy.call_method("ScrollTo", &(3u32,)).is_ok() {
return Ok(());
}
}
return Err(Error::ActionNotSupported {
action: "scroll_down".to_string(),
role: element.role,
});
}
}
Ok(())
}
fn scroll_up(&self, element: &ElementData, amount: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let count = (amount.abs() as u32).max(1);
for _ in 0..count {
if self.do_atspi_action_by_name(&target, "scroll up").is_err() {
if let Ok(proxy) =
self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
{
if proxy.call_method("ScrollTo", &(2u32,)).is_ok() {
return Ok(());
}
}
return Err(Error::ActionNotSupported {
action: "scroll_up".to_string(),
role: element.role,
});
}
}
Ok(())
}
fn scroll_right(&self, element: &ElementData, amount: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let count = (amount.abs() as u32).max(1);
for _ in 0..count {
if self
.do_atspi_action_by_name(&target, "scroll right")
.is_err()
{
if let Ok(proxy) =
self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
{
if proxy.call_method("ScrollTo", &(5u32,)).is_ok() {
return Ok(());
}
}
return Err(Error::ActionNotSupported {
action: "scroll_right".to_string(),
role: element.role,
});
}
}
Ok(())
}
fn scroll_left(&self, element: &ElementData, amount: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let count = (amount.abs() as u32).max(1);
for _ in 0..count {
if self
.do_atspi_action_by_name(&target, "scroll left")
.is_err()
{
if let Ok(proxy) =
self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
{
if proxy.call_method("ScrollTo", &(4u32,)).is_ok() {
return Ok(());
}
}
return Err(Error::ActionNotSupported {
action: "scroll_left".to_string(),
role: element.role,
});
}
}
Ok(())
}
fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
match action {
"press" => self.press(element),
"focus" => self.focus(element),
"blur" => self.blur(element),
"toggle" => self.toggle(element),
"select" => self.select(element),
"expand" => self.expand(element),
"collapse" => self.collapse(element),
"show_menu" => self.show_menu(element),
"increment" => self.increment(element),
"decrement" => self.decrement(element),
"scroll_into_view" => self.scroll_into_view(element),
_ => Err(Error::ActionNotSupported {
action: action.to_string(),
role: element.role,
}),
}
}
fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
let pid = element.pid.ok_or(Error::Platform {
code: -1,
message: "Element has no PID for subscribe".to_string(),
})?;
let app_name = element.name.clone().unwrap_or_default();
self.subscribe_impl(app_name, pid, pid)
}
}
impl LinuxProvider {
fn subscribe_impl(&self, app_name: String, app_pid: u32, pid: u32) -> Result<Subscription> {
let (tx, rx) = std::sync::mpsc::channel();
let poll_provider = LinuxProvider::new()?;
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let handle = std::thread::spawn(move || {
let mut prev_focused: Option<String> = None;
let mut prev_element_count: usize = 0;
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
let app_ref = match poll_provider.find_app_by_pid(pid) {
Ok(r) => r,
Err(_) => continue,
};
let app_data = poll_provider.build_element_data(&app_ref, Some(pid));
let mut stack = vec![app_data];
let mut element_count: usize = 0;
let mut focused_element: Option<ElementData> = None;
let mut visited = HashSet::new();
while let Some(el) = stack.pop() {
let path_key = format!("{:?}:{}", el.raw, el.handle);
if !visited.insert(path_key) {
continue;
}
element_count += 1;
if el.states.focused && focused_element.is_none() {
focused_element = Some(el.clone());
}
if let Ok(children) = poll_provider.get_children(Some(&el)) {
stack.extend(children);
}
}
let focused_name = focused_element.as_ref().and_then(|e| e.name.clone());
if focused_name != prev_focused {
if prev_focused.is_some() {
let _ = tx.send(Event {
event_type: EventType::FocusChanged,
app_name: app_name.clone(),
app_pid,
target: focused_element,
state_flag: None,
state_value: None,
text_change: None,
timestamp: std::time::Instant::now(),
});
}
prev_focused = focused_name;
}
if element_count != prev_element_count && prev_element_count > 0 {
let _ = tx.send(Event {
event_type: EventType::StructureChanged,
app_name: app_name.clone(),
app_pid,
target: None,
state_flag: None,
state_value: None,
text_change: None,
timestamp: std::time::Instant::now(),
});
}
prev_element_count = element_count;
}
});
let cancel = CancelHandle::new(move || {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.join();
});
Ok(Subscription::new(EventReceiver::new(rx), cancel))
}
}
fn role_has_value(role: Role) -> bool {
!matches!(
role,
Role::Application
| Role::Window
| Role::Dialog
| Role::Group
| Role::MenuBar
| Role::Toolbar
| Role::TabGroup
| Role::SplitGroup
| Role::Table
| Role::TableRow
| Role::Separator
)
}
fn role_has_actions(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::CheckBox
| Role::RadioButton
| Role::MenuItem
| Role::Link
| Role::ComboBox
| Role::TextField
| Role::TextArea
| Role::SpinButton
| Role::Tab
| Role::TreeItem
| Role::ListItem
| Role::ScrollBar
| Role::Slider
| Role::Menu
| Role::Image
| Role::Unknown
)
}
fn map_atspi_role(role_name: &str) -> Role {
match role_name.to_lowercase().as_str() {
"application" => Role::Application,
"window" | "frame" => Role::Window,
"dialog" | "file chooser" => Role::Dialog,
"alert" | "notification" => Role::Alert,
"push button" | "push button menu" => Role::Button,
"toggle button" => Role::Switch,
"check box" | "check menu item" => Role::CheckBox,
"radio button" | "radio menu item" => Role::RadioButton,
"entry" | "password text" => Role::TextField,
"spin button" | "spinbutton" => Role::SpinButton,
"text" | "textbox" => Role::TextArea,
"label" | "static" | "caption" => Role::StaticText,
"combo box" | "combobox" => Role::ComboBox,
"list" | "list box" | "listbox" => Role::List,
"list item" => Role::ListItem,
"menu" => Role::Menu,
"menu item" | "tearoff menu item" => Role::MenuItem,
"menu bar" => Role::MenuBar,
"page tab" => Role::Tab,
"page tab list" => Role::TabGroup,
"table" | "tree table" => Role::Table,
"table row" => Role::TableRow,
"table cell" | "table column header" | "table row header" => Role::TableCell,
"tool bar" => Role::Toolbar,
"scroll bar" => Role::ScrollBar,
"slider" => Role::Slider,
"image" | "icon" | "desktop icon" => Role::Image,
"link" => Role::Link,
"panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" => Role::Group,
"progress bar" => Role::ProgressBar,
"tree item" => Role::TreeItem,
"document web" | "document frame" => Role::WebArea,
"heading" => Role::Heading,
"separator" => Role::Separator,
"split pane" => Role::SplitGroup,
"tooltip" | "tool tip" => Role::Tooltip,
"status bar" | "statusbar" => Role::Status,
"landmark" | "navigation" => Role::Navigation,
_ => xa11y_core::unknown_role(role_name),
}
}
fn map_atspi_role_number(role: u32) -> Role {
match role {
2 => Role::Alert, 7 => Role::CheckBox, 8 => Role::CheckBox, 11 => Role::ComboBox, 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, 42 => Role::ProgressBar, 43 => Role::Button, 44 => Role::RadioButton, 45 => Role::RadioButton, 48 => Role::ScrollBar, 49 => Role::Group, 50 => Role::Separator, 51 => Role::Slider, 52 => Role::SpinButton, 53 => Role::SplitGroup, 55 => Role::Table, 56 => Role::TableCell, 57 => Role::TableCell, 58 => Role::TableCell, 61 => Role::TextArea, 62 => Role::Switch, 63 => Role::Toolbar, 65 => Role::Group, 66 => Role::Table, 67 => Role::Unknown, 68 => Role::Group, 69 => Role::Window, 75 => Role::Application, 78 => Role::TextArea, 79 => Role::TextField, 82 => Role::WebArea, 83 => Role::Heading, 85 => Role::Group, 86 => Role::Group, 87 => Role::Group, 88 => Role::Link, 90 => Role::TableRow, 91 => Role::TreeItem, 95 => Role::WebArea, 97 => Role::List, 98 => Role::List, 93 => Role::Tooltip, 101 => Role::Alert, 116 => Role::StaticText, 129 => Role::Button, _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
}
}
fn map_atspi_action_name(action_name: &str) -> Option<String> {
let lower = action_name.to_lowercase();
let canonical = match lower.as_str() {
"click" | "activate" | "press" | "invoke" => "press",
"toggle" | "check" | "uncheck" => "toggle",
"expand" | "open" => "expand",
"collapse" | "close" => "collapse",
"select" => "select",
"menu" | "showmenu" | "show_menu" | "popup" | "show menu" => "show_menu",
"increment" => "increment",
"decrement" => "decrement",
_ => return None,
};
Some(canonical.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_role_mapping() {
assert_eq!(map_atspi_role("push button"), Role::Button);
assert_eq!(map_atspi_role("toggle button"), Role::Switch);
assert_eq!(map_atspi_role("check box"), Role::CheckBox);
assert_eq!(map_atspi_role("entry"), Role::TextField);
assert_eq!(map_atspi_role("label"), Role::StaticText);
assert_eq!(map_atspi_role("window"), Role::Window);
assert_eq!(map_atspi_role("frame"), Role::Window);
assert_eq!(map_atspi_role("dialog"), Role::Dialog);
assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
assert_eq!(map_atspi_role("slider"), Role::Slider);
assert_eq!(map_atspi_role("panel"), Role::Group);
assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
}
#[test]
fn test_numeric_role_mapping() {
assert_eq!(map_atspi_role_number(62), Role::Switch);
assert_eq!(map_atspi_role_number(43), Role::Button); assert_eq!(map_atspi_role_number(7), Role::CheckBox);
assert_eq!(map_atspi_role_number(67), Role::Unknown); }
#[test]
fn test_action_name_mapping() {
assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
assert_eq!(
map_atspi_action_name("collapse"),
Some("collapse".to_string())
);
assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
assert_eq!(
map_atspi_action_name("showmenu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("popup"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("show menu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("increment"),
Some("increment".to_string())
);
assert_eq!(
map_atspi_action_name("decrement"),
Some("decrement".to_string())
);
assert_eq!(map_atspi_action_name("foobar"), None);
}
#[test]
fn test_action_name_aliases_roundtrip() {
let atspi_names = [
"click",
"activate",
"press",
"invoke",
"toggle",
"check",
"uncheck",
"expand",
"open",
"collapse",
"close",
"select",
"menu",
"showmenu",
"popup",
"show menu",
"increment",
"decrement",
];
for name in atspi_names {
let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
panic!("AT-SPI2 name {:?} should map to a canonical name", name)
});
let back = map_atspi_action_name(&canonical)
.unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
assert_eq!(
canonical, back,
"AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
name, canonical, back, canonical
);
}
}
#[test]
fn test_action_name_case_insensitive() {
assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
assert_eq!(
map_atspi_action_name("Increment"),
Some("increment".to_string())
);
}
}