diff --git a/Cargo.lock b/Cargo.lock index aee470eed..7d9f0a312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ "getrandom 0.2.8", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.32", ] [[package]] @@ -357,19 +357,18 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atspi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +version = "0.31.0" +source = "git+https://github.com/odilia-app/atspi?rev=2aec2fc#2aec2fce392d5c8e53f3a2727c8c3292c7cf5ba9" dependencies = [ "atspi-common", + "atspi-connection", "atspi-proxies", ] [[package]] name = "atspi-common" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +version = "0.31.0" +source = "git+https://github.com/odilia-app/atspi?rev=2aec2fc#2aec2fce392d5c8e53f3a2727c8c3292c7cf5ba9" dependencies = [ "enumflags2", "serde", @@ -381,11 +380,21 @@ dependencies = [ "zvariant", ] +[[package]] +name = "atspi-connection" +version = "0.31.0" +source = "git+https://github.com/odilia-app/atspi?rev=2aec2fc#2aec2fce392d5c8e53f3a2727c8c3292c7cf5ba9" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + [[package]] name = "atspi-proxies" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +version = "0.31.0" +source = "git+https://github.com/odilia-app/atspi?rev=2aec2fc#2aec2fce392d5c8e53f3a2727c8c3292c7cf5ba9" dependencies = [ "atspi-common", "serde", @@ -1578,6 +1587,15 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.27", +] + [[package]] name = "proc-macro-crate" version = "3.2.0" @@ -1687,6 +1705,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -2984,6 +3031,7 @@ dependencies = [ "hex", "nix", "ordered-stream", + "rand", "serde", "serde_repr", "static_assertions", @@ -2999,9 +3047,9 @@ dependencies = [ [[package]] name = "zbus-lockstep" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e96e38ded30eeab90b6ba88cb888d70aef4e7489b6cd212c5e5b5ec38045b6" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", "zvariant", @@ -3009,9 +3057,9 @@ dependencies = [ [[package]] name = "zbus-lockstep-macros" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", @@ -3067,7 +3115,16 @@ version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.7.32", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive 0.8.27", ] [[package]] @@ -3081,6 +3138,17 @@ dependencies = [ "syn", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zvariant" version = "5.4.0" diff --git a/accesskit_consumer/src/node.rs b/accesskit_consumer/src/node.rs index e81a54479..914159153 100644 --- a/accesskit_consumer/src/node.rs +++ b/accesskit_consumer/src/node.rs @@ -921,6 +921,10 @@ impl<'a> NodeRef<'a> { ) } + pub fn is_container(&self) -> bool { + return true; + } + pub fn is_container_with_selectable_children(&self) -> bool { matches!( self.role(), diff --git a/accesskit_consumer/src/text.rs b/accesskit_consumer/src/text.rs index 811329eb8..0d161cd0e 100644 --- a/accesskit_consumer/src/text.rs +++ b/accesskit_consumer/src/text.rs @@ -1400,9 +1400,7 @@ impl<'a> NodeRef<'a> { } pub fn supports_text_ranges(&self) -> bool { - (self.is_text_input() - || matches!(self.role(), Role::Label | Role::Document | Role::Terminal)) - && self.text_runs().next().is_some() + self.text_runs().next().is_some() } fn document_start_inner(&self) -> InnerPosition<'a> { diff --git a/accesskit_consumer/src/tree.rs b/accesskit_consumer/src/tree.rs index c3c6604b9..31279c0f6 100644 --- a/accesskit_consumer/src/tree.rs +++ b/accesskit_consumer/src/tree.rs @@ -579,6 +579,20 @@ impl TreeState { .get_id(tree_index) .map(|tree_id| (local_id, tree_id)) } + + pub fn lookup_node_by_id_components(&self, tree_index: u32, node_id: NodeId) -> Option { + let own_tree_index = self.root_id().to_components().1; + if TreeIndex(tree_index) != own_tree_index { + return None; + } + + let as_full_node_id = FullNodeId::new(node_id, own_tree_index); + if self.has_node(as_full_node_id) { + Some(as_full_node_id) + } else { + None + } + } } pub trait ChangeHandler { diff --git a/adapters/atspi-common/Cargo.toml b/adapters/atspi-common/Cargo.toml index 5e3f75fd9..b2405ceaa 100644 --- a/adapters/atspi-common/Cargo.toml +++ b/adapters/atspi-common/Cargo.toml @@ -17,7 +17,7 @@ simplified-api = [] [dependencies] accesskit = { version = "0.24.1", path = "../../accesskit" } accesskit_consumer = { version = "0.38.0", path = "../../accesskit_consumer" } -atspi-common = { version = "0.13", default-features = false } +atspi-common = { git = "https://github.com/odilia-app/atspi", rev = "2aec2fc", default-features = false } phf = { version = "0.13.1", features = ["macros"] } serde = "1.0" zvariant = { version = "5.4", default-features = false } diff --git a/adapters/atspi-common/src/lib.rs b/adapters/atspi-common/src/lib.rs index 066ab9f28..0bfc4d6e4 100644 --- a/adapters/atspi-common/src/lib.rs +++ b/adapters/atspi-common/src/lib.rs @@ -11,6 +11,7 @@ mod error; mod events; mod filters; mod node; +mod node_matcher; mod rect; #[cfg(feature = "simplified-api")] pub mod simplified; diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 4e7453dcc..cddfc6e73 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -9,13 +9,11 @@ // found in the LICENSE.chromium file. use accesskit::{ - Action, ActionData, ActionRequest, Affine, Live, NodeId, Orientation, Point, Rect, Role, - Toggled, TreeId, + Action, ActionData, ActionRequest, Affine, Live, NodeId, Orientation, Point, Rect, Role, Toggled, TreeId, }; use accesskit_consumer::{FilterResult, FullNodeId, NodeRef, Tree, TreeState}; use atspi_common::{ - CoordType, Granularity, Interface, InterfaceSet, Layer, Politeness, RelationType, - Role as AtspiRole, ScrollType, State, StateSet, + CoordType, Granularity, Interface, InterfaceSet, Layer, ObjectMatchRule, Politeness, RelationType, Role as AtspiRole, ScrollType, State, StateSet, TreeTraversalType, }; use std::{ collections::HashMap, @@ -24,12 +22,7 @@ use std::{ }; use crate::{ - Action as AtspiAction, Error, ObjectEvent, Property, Rect as AtspiRect, Result, - adapter::Adapter, - context::{AppContext, Context}, - filters::filter, - text_attributes::ATTRIBUTE_GETTERS, - util::*, + Action as AtspiAction, Error, ObjectEvent, Property, Rect as AtspiRect, Result, adapter::Adapter, context::{AppContext, Context}, filters::filter, node_matcher::recurse_scan_children, text_attributes::ATTRIBUTE_GETTERS, util::*, }; pub(crate) struct NodeWrapper<'a>(pub(crate) &'a NodeRef<'a>); @@ -405,7 +398,7 @@ impl NodeWrapper<'_> { self.0.braille_role_description() } - fn attributes(&self) -> HashMap<&'static str, String> { + pub(crate) fn attributes(&self) -> HashMap<&'static str, String> { let mut attributes = HashMap::new(); if let Some(placeholder) = self.placeholder() { attributes.insert("placeholder-text", placeholder.to_string()); @@ -442,6 +435,10 @@ impl NodeWrapper<'_> { self.0.supports_url() } + fn supports_collection(&self) -> bool { + self.0.is_container() + } + fn supports_selection(&self) -> bool { self.0.is_container_with_selectable_children() } @@ -465,6 +462,9 @@ impl NodeWrapper<'_> { if self.supports_hyperlink() { interfaces.insert(Interface::Hyperlink); } + if self.supports_collection() { + interfaces.insert(Interface::Collection); + } if self.supports_selection() { interfaces.insert(Interface::Selection); } @@ -1178,6 +1178,14 @@ impl PlatformNode { self.resolve(|node| Ok(node.url().is_some())) } + pub fn active_descendant(&self) -> Result> { + self.resolve(|node| { + Ok(node + .active_descendant() + .map(|node| node.id())) + }) + } + pub fn n_selected_children(&self) -> Result { self.resolve_for_selection(|node| { node.items(filter) @@ -1663,6 +1671,108 @@ impl PlatformNode { data: Some(ActionData::NumericValue(value)), }) } + + pub fn get_matches( + &self, + match_rule: &ObjectMatchRule, + is_reverse: bool, + count: u32, + traverse: bool + ) -> Result>{ + self.resolve_with_context(|node, tree, _| { + let is_window_focused = tree.state().focus_id().is_some(); + let mut node_ids: Vec = Vec::new(); + recurse_scan_children( + node, + &mut node_ids, + match_rule, + traverse, + None, + false, + is_reverse, + count, + is_window_focused); + Ok(node_ids) + }) + } + + pub fn get_matches_to_or_from( + &self, + is_from: bool, + target_tree_index: u32, + target_node_id: NodeId, + match_rule: &ObjectMatchRule, + is_reverse: bool, + traversal_type: TreeTraversalType, + count: u32, + traverse: bool + ) -> Result>{ + let target_full_node_id = self.lookup_object_ref(target_tree_index, target_node_id)?; + let adjusted_traverse = traverse && traversal_type == TreeTraversalType::Inorder; + self.resolve_node_for_match(target_full_node_id, traversal_type, |node, tree| { + let is_window_focused = tree.state().focus_id().is_some(); + let mut node_ids: Vec = Vec::new(); + recurse_scan_children( + node, + &mut node_ids, + match_rule, + adjusted_traverse, + Some(target_full_node_id), + // Before/after are inverted if reverse + is_from != is_reverse, + is_reverse, + count, + is_window_focused); + Ok(node_ids) + }) + } + + fn lookup_object_ref(&self, tree_index: u32, node_id: NodeId) -> Result { + self.resolve_with_context(|_, tree, _| { + tree.state().lookup_node_by_id_components(tree_index, node_id) + .ok_or_else(|| Error::Defunct) + }) + } + + // Based on traversal_type, calls the function with one of the following: + // Inorder: self + // RestrictChildren: target_node (must be descendant of self) + // RestrictSiblings: Parent of target_node (must be descendant of self) + // It is expected that the matcher will also disable traverse for restricted modes + fn resolve_node_for_match( + &self, + target_node: FullNodeId, + traversal_type: TreeTraversalType, + f: F + ) -> Result> + where + for<'a> F: FnOnce(NodeRef<'a>, &Tree) -> Result>, + { + let id_to_resolve = + if traversal_type == TreeTraversalType::Inorder { self.id } + else { target_node }; + self.with_tree_and_context(|tree, _| { + if + let Some(own_node) = tree.state().node_by_id(self.id) + && let Some(node) = tree.state().node_by_id(id_to_resolve) + { + if !node.is_descendant_of(&own_node) { + return Ok(Vec::new()); + } + if traversal_type == TreeTraversalType::RestrictSibling { + if let Some(parent) = node.parent() { + f(parent, &tree) + } else { + Ok(Vec::new()) + } + } else { + f(node, &tree) + } + } else { + Err(Error::Defunct) + } + }) + } } impl PartialEq for PlatformNode { diff --git a/adapters/atspi-common/src/node_matcher.rs b/adapters/atspi-common/src/node_matcher.rs new file mode 100644 index 000000000..bd3d74812 --- /dev/null +++ b/adapters/atspi-common/src/node_matcher.rs @@ -0,0 +1,162 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use std::collections::HashMap; + +use accesskit_consumer::{FullNodeId, NodeRef}; +use atspi_common::{InterfaceSet, MatchType, ObjectMatchRule, RoleSet, StateSet} ; + +use crate::node::NodeWrapper; + +pub fn recurse_scan_children( + node: NodeRef<'_>, + current_match_list: &mut Vec, + match_rule: &ObjectMatchRule, + traverse: bool, + target_node: Option, + is_after: bool, + is_reverse: bool, + count: u32, + // Needed to get node states for comparison + has_window_focus: bool +) -> u32 { + let use_count = count != 0; + let mut count_left = count; + let mut is_scan_mode = + target_node.is_none() + || !is_after; + maybe_reverse_it_children(node, is_reverse, |child| { + if use_count && count_left == 0 { + return; + } + + let is_target_match = target_node == Some(child.id()); + if is_scan_mode && is_target_match { + return + } + + let is_node_match = is_scan_mode && is_match(child, match_rule, has_window_focus); + if is_node_match { + current_match_list.push(child.id()); + if use_count { + count_left -= 1; + } + } + + if is_scan_mode && traverse { + count_left = recurse_scan_children( + child, current_match_list, match_rule, + traverse, target_node, is_after, is_reverse, + count_left, has_window_focus); + } + + if is_target_match { + is_scan_mode = !is_scan_mode + } + }); + + count_left +} + +fn maybe_reverse_it_children(node: NodeRef<'_>, is_reverse: bool, f: F) +where F: FnMut(NodeRef<'_>), +{ + if is_reverse { + node.children().rev().for_each(f); + } else { + node.children().for_each(f); + } +} + +fn is_match(node: NodeRef<'_>, match_rule: &ObjectMatchRule, has_window_focus: bool) -> bool { + let wrapper = NodeWrapper(&node); + let states_mr = match_states(&wrapper, &match_rule.states, has_window_focus); + let is_match_states = cmp_match_type(states_mr, match_rule.states_mt); + let attributes_mr = match_attributes(&wrapper, &match_rule.attr); + let is_match_attributes = cmp_match_type(attributes_mr, match_rule.attr_mt); + let roles_mr = match_roles(&wrapper, &match_rule.roles); + let is_match_roles = cmp_match_type(roles_mr, match_rule.roles_mt); + let interfaces_mr = match_interfaces(&wrapper, &match_rule.ifaces); + let is_match_interfaces = cmp_match_type(interfaces_mr, match_rule.ifaces_mt); + let is_match_all = + is_match_states + && is_match_attributes + && is_match_roles + && is_match_interfaces; + + is_match_all != match_rule.invert +} + +fn match_states(node_wrapper: &NodeWrapper<'_>, states: &StateSet, has_window_focus: bool) -> MatchResult { + let node_states = node_wrapper.state(has_window_focus); + + let states_1_bits = node_states.bits(); + let states_2_bits = states.bits(); + + let all_matched = states_1_bits & states_2_bits == states_2_bits; + let none_matched = states_1_bits & states_2_bits == 0; + + to_match_result(all_matched, none_matched) +} + +fn match_attributes(node_wrapper: &NodeWrapper<'_>, attributes: &HashMap) -> MatchResult { + let node_attributes = node_wrapper.attributes(); + + let all_matched = attributes.iter().all( + |(k,v)| node_attributes.get(k.as_str()) == Some(v) ); + let none_matched = !all_matched && !attributes.iter().any( + |(k,v)| node_attributes.get(k.as_str()) == Some(v) ); + + to_match_result(all_matched, none_matched) +} + +fn match_roles(node_wrapper: &NodeWrapper<'_>, roles: &RoleSet) -> MatchResult { + let role = node_wrapper.role(); + + let all_matched = + if roles.is_empty() { true } + else if roles.len() == 1 { roles.contains(role) } + else { false }; + let none_matched = !roles.contains(role); + + to_match_result(all_matched, none_matched) +} + +fn match_interfaces(node_wrapper: &NodeWrapper<'_>, interfaces: &InterfaceSet) -> MatchResult { + let node_interfaces = node_wrapper.interfaces(); + + let interfaces_1_bits = node_interfaces.bits(); + let interfaces_2_bits = interfaces.bits(); + + let all_matched = interfaces_1_bits & interfaces_2_bits == interfaces_2_bits; + let none_matched = interfaces_1_bits & interfaces_2_bits == 0; + + to_match_result(all_matched, none_matched) +} + +fn to_match_result(all_matched: bool, none_matched: bool) -> MatchResult { + if all_matched { + MatchResult::All + } else if none_matched { + MatchResult::None + } else { + MatchResult::Some + } +} + +fn cmp_match_type(match_result: MatchResult, match_type: MatchType) -> bool { + match match_type { + MatchType::Invalid => false, + MatchType::All => match_result == MatchResult::All, + MatchType::Any => match_result != MatchResult::None, + MatchType::None => match_result == MatchResult::None, + MatchType::Empty => false, + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +enum MatchResult { + All, Some, None +} \ No newline at end of file diff --git a/adapters/unix/Cargo.toml b/adapters/unix/Cargo.toml index de3d137af..1223794a0 100644 --- a/adapters/unix/Cargo.toml +++ b/adapters/unix/Cargo.toml @@ -19,7 +19,7 @@ tokio = ["dep:tokio", "dep:tokio-stream"] [dependencies] accesskit = { version = "0.24.1", path = "../../accesskit" } accesskit_atspi_common = { version = "0.19.1", path = "../atspi-common" } -atspi = { version = "0.29", default-features = false, features = ["proxies"] } +atspi = { git = "https://github.com/odilia-app/atspi", rev = "2aec2fc" } futures-lite = "2.3" serde = "1.0" zbus = { version = "5.5", default-features = false, features = ["async-io"] } diff --git a/adapters/unix/src/atspi/bus.rs b/adapters/unix/src/atspi/bus.rs index a4955b726..92d0213cf 100644 --- a/adapters/unix/src/atspi/bus.rs +++ b/adapters/unix/src/atspi/bus.rs @@ -12,19 +12,14 @@ use accesskit_atspi_common::{ FullNodeId, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, Property, WindowEvent, }; use atspi::{ - Interface, InterfaceSet, ObjectRefOwned, - events::EventBodyBorrowed, - proxy::{bus::BusProxy, socket::SocketProxy}, + Interface, InterfaceSet, ObjectRef, ObjectRefOwned, events::EventBodyBorrowed, proxy::{bus::BusProxy, socket::SocketProxy}, }; use std::{ env::var, sync::{Arc, OnceLock}, }; use zbus::{ - Address, Connection, Result, - connection::Builder, - names::{BusName, InterfaceName, MemberName, OwnedUniqueName}, - zvariant::{Str, Value}, + Address, Connection, Result, connection::Builder, names::{BusName, InterfaceName, MemberName, OwnedUniqueName}, zvariant::{Str, Value}, }; pub(crate) struct Bus { @@ -82,16 +77,21 @@ impl Bus { .at(path.clone(), ApplicationInterface(node.clone())) .await? { + let name = self.unique_name().into(); + let embed = ObjectRef::try_from_bus_name_and_path( + name, path.into()) + .unwrap().into(); + let desktop = self .socket_proxy - .embed(&(self.unique_name().as_str(), ObjectId::Root.path().into())) + .embed(&embed) .await?; let _ = self.desktop.set(desktop); self.conn .object_server() .at( - path, + embed.path(), RootAccessibleInterface::new( self.unique_name().to_owned(), node.clone(), @@ -148,6 +148,13 @@ impl Bus { ) .await?; } + if new_interfaces.contains(Interface::Collection) { + self.register_interface( + &path, + CollectionInterface::new(bus_name.clone(), node.clone()), + ) + .await?; + } if new_interfaces.contains(Interface::Selection) { self.register_interface( &path, @@ -204,6 +211,10 @@ impl Bus { self.unregister_interface::(&path) .await?; } + if old_interfaces.contains(Interface::Collection) { + self.unregister_interface::(&path) + .await?; + } if old_interfaces.contains(Interface::Selection) { self.unregister_interface::(&path) .await?; diff --git a/adapters/unix/src/atspi/interfaces/collection.rs b/adapters/unix/src/atspi/interfaces/collection.rs new file mode 100644 index 000000000..954393737 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/collection.rs @@ -0,0 +1,172 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use std::collections::HashMap; + +use accesskit_atspi_common::PlatformNode; +use atspi::{InterfaceSet, MatchType, ObjectMatchRule, RoleSet, SortOrder, StateSet, TreeTraversalType}; +use serde::{Deserialize, Serialize}; +use zbus::{fdo, interface, names::{BusName, OwnedUniqueName}, zvariant::ObjectPath}; + +use crate::atspi::{ObjectId, OwnedObjectAddress, object_util::object_path_components}; + +pub(crate) struct CollectionInterface { + bus_name: OwnedUniqueName, + node: PlatformNode, +} + +impl CollectionInterface { + pub fn new(bus_name: OwnedUniqueName, node: PlatformNode) -> Self { + Self { bus_name, node } + } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.node, error) + } +} + +#[interface(name = "org.a11y.atspi.Collection")] +impl CollectionInterface { + + fn get_active_descendant(&self) -> fdo::Result<(OwnedObjectAddress,)> { + let child = self + .node + .active_descendant() + .map_err(self.map_error())? + .map(|child| ObjectId::Node { + adapter: self.node.adapter_id(), + node: child, + }); + Ok(super::optional_object_address(&self.bus_name, child)) + } + + fn get_matches(&self, fixed_rule: FixedObjectMatchRule, sortby: SortOrder, count: i32, traverse: bool) -> fdo::Result> { + let rule: ObjectMatchRule = fixed_rule.try_into() + .map_err(|err| fdo::Error::Failed(err))?; + let is_reverse = sortby == SortOrder::ReverseCanonical; + let matched_children = self.node.get_matches(&rule, is_reverse, count as u32, traverse) + .map_err(self.map_error())?; + let matched_children_add = matched_children + .into_iter() + .map(|child| ObjectId::Node { + adapter: self.node.adapter_id(), + node: child, + }) + .map(|child| super::optional_object_address(&self.bus_name, Some(child)).0) + .collect(); + Ok(matched_children_add) + } + + fn get_matches_to(&self, current_object_t: (BusName, ObjectPath), fixed_rule: FixedObjectMatchRule, sortby: SortOrder, tree: TreeTraversalType, count: i32, traverse: bool) -> fdo::Result> { + let rule: ObjectMatchRule = fixed_rule.try_into() + .map_err(|err| fdo::Error::Failed(err))?; + + let current_object_components = object_path_components(¤t_object_t.1) + .ok_or_else(|| fdo::Error::UnknownObject("Invalid Object Path: ".to_string() + current_object_t.1.as_str()) + )?; + let current_tree_index = current_object_components.1; + let current_node_id = current_object_components.2; + + let is_reverse = sortby == SortOrder::ReverseCanonical; + let matched_children = self.node.get_matches_to_or_from( + false, current_tree_index, current_node_id, &rule, is_reverse, tree, count as u32, traverse + ).map_err(self.map_error())?; + let matched_children_add = matched_children + .into_iter() + .map(|child| ObjectId::Node { + adapter: self.node.adapter_id(), + node: child, + }) + .map(|child| super::optional_object_address(&self.bus_name, Some(child)).0) + .collect(); + Ok(matched_children_add) + } + + fn get_matches_from(&self, current_object_t: (BusName, ObjectPath), fixed_rule: FixedObjectMatchRule, sortby: SortOrder, tree: TreeTraversalType, count: i32, traverse: bool) -> fdo::Result> { + let rule: ObjectMatchRule = fixed_rule.try_into() + .map_err(|err| fdo::Error::Failed(err))?; + + let current_object_components = object_path_components(¤t_object_t.1) + .ok_or_else(|| fdo::Error::UnknownObject("Invalid Object Path: ".to_string() + current_object_t.1.as_str()) + )?; + let current_tree_index = current_object_components.1; + let current_node_id = current_object_components.2; + + let is_reverse = sortby == SortOrder::ReverseCanonical; + let matched_children = self.node.get_matches_to_or_from( + true, current_tree_index, current_node_id, &rule, is_reverse, tree, count as u32, traverse + ).map_err(self.map_error())?; + let matched_children_add = matched_children + .into_iter() + .map(|child| ObjectId::Node { + adapter: self.node.adapter_id(), + node: child, + }) + .map(|child| super::optional_object_address(&self.bus_name, Some(child)).0) + .collect(); + Ok(matched_children_add) + } + +} + +// Because atspi's default rule is broken, even in 0.31 +// MatchType's deserializer expects a string +// RoleSet breaks if there are less than 5 elements +// And then StateSet just broke when I copied it here + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, zbus::zvariant::Type)] +pub struct FixedObjectMatchRule { + pub states: Vec, + pub states_mt: i32, + pub attr: HashMap, + pub attr_mt: i32, + pub roles: Vec, + pub roles_mt: i32, + pub ifaces: InterfaceSet, + pub ifaces_mt: i32, + pub invert: bool +} + +impl TryInto for FixedObjectMatchRule { + type Error = String; + + fn try_into(self) -> Result { + if self.states.len() != 2 { + return Err("Expected states vector with length 2".to_string()) + } + + let states_bits = ((self.states[0] as u32 as u64) << 32) | (self.states[1] as u32 as u64); + let state_set = StateSet::from_bits(states_bits) + .map_err(|e| e.to_string())?; + let role_set = to_role_set(self.roles)?; + + let result = ObjectMatchRule::builder() + .states(state_set, to_match_type(self.states_mt)?) + .attributes(self.attr, to_match_type(self.attr_mt)?) + .roles(&role_set.into_iter().collect::>(), to_match_type(self.roles_mt)?) + .interfaces(self.ifaces, to_match_type(self.ifaces_mt)?) + .build(); + + Ok(result) + } +} + +fn to_match_type(ordinal: i32) -> Result { + match ordinal { + 0 => Ok(MatchType::Invalid), + 1 => Ok(MatchType::All), + 2 => Ok(MatchType::Any), + 3 => Ok(MatchType::None), + 4 => Ok(MatchType::Empty), + _ => Err("Could not deserialize match type".to_string()), + } +} + +fn to_role_set(v: Vec) -> Result { + let backing_arr: [i32;5] = std::array::from_fn( + |i| if i < v.len() { v[i] } else { 0 }); + + Ok(unsafe { std::mem::transmute(backing_arr) }) +} \ No newline at end of file diff --git a/adapters/unix/src/atspi/interfaces/mod.rs b/adapters/unix/src/atspi/interfaces/mod.rs index 7e33c1e4d..c485f9bb3 100644 --- a/adapters/unix/src/atspi/interfaces/mod.rs +++ b/adapters/unix/src/atspi/interfaces/mod.rs @@ -7,6 +7,7 @@ mod accessible; mod action; mod application; mod cache; +mod collection; mod component; mod hyperlink; mod selection; @@ -34,6 +35,7 @@ pub(crate) use accessible::*; pub(crate) use action::*; pub(crate) use application::*; pub(crate) use cache::*; +pub(crate) use collection::*; pub(crate) use component::*; pub(crate) use hyperlink::*; pub(crate) use selection::*; diff --git a/adapters/unix/src/atspi/mod.rs b/adapters/unix/src/atspi/mod.rs index 799e9f3ac..4a9133d7f 100644 --- a/adapters/unix/src/atspi/mod.rs +++ b/adapters/unix/src/atspi/mod.rs @@ -7,6 +7,7 @@ mod bus; pub(crate) mod interfaces; mod object_address; mod object_id; +mod object_util; pub(crate) use bus::*; pub(crate) use object_address::OwnedObjectAddress; diff --git a/adapters/unix/src/atspi/object_util.rs b/adapters/unix/src/atspi/object_util.rs new file mode 100644 index 000000000..5a9f7c019 --- /dev/null +++ b/adapters/unix/src/atspi/object_util.rs @@ -0,0 +1,23 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use accesskit::NodeId; +use zbus::zvariant::ObjectPath; + +const ACCESSIBLE_PATH_PREFIX: &str = "/org/a11y/atspi/accessible/"; + +pub fn object_path_components(path: &ObjectPath) -> Option<(usize, u32, NodeId)> { + let stripped_path = path.as_str().strip_prefix(ACCESSIBLE_PATH_PREFIX)?; + + if let Some((adapter_str, node_str)) = stripped_path.split_once('/') { + let adapter_usize = adapter_str.parse::().ok()?; + let node_tree_u128 = node_str.parse::().ok()?; + let tree_index = node_tree_u128 as u32; + let node_id = NodeId((node_tree_u128 >> 64) as u64); + Some((adapter_usize, tree_index, node_id)) + } else { + None + } +} \ No newline at end of file diff --git a/adapters/winit/examples/document_browsing.rs b/adapters/winit/examples/document_browsing.rs new file mode 100644 index 000000000..32017a4a3 --- /dev/null +++ b/adapters/winit/examples/document_browsing.rs @@ -0,0 +1,448 @@ +#[path = "util/fill.rs"] +mod fill; + +use accesskit::{ + Action, ActionRequest, Affine, Live, Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate, + Vec2, +}; +use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; +use std::error::Error; +use std::time::{Duration, Instant}; +use winit::{ + application::ApplicationHandler, + event::{ElementState, KeyEvent, WindowEvent}, + event_loop::ControlFlow, + event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, + keyboard::Key, + window::{Window, WindowId}, +}; + +const WINDOW_TITLE: &str = "Hello world"; + +const WINDOW_ID: NodeId = NodeId(0); +const DOCUMENT_ID: NodeId = NodeId(1); +const BUTTON_1_ID: NodeId = NodeId(2); +const BUTTON_2_ID: NodeId = NodeId(3); +const HEADING_1_ID: NodeId = NodeId(4); +const HEADING_2_ID: NodeId = NodeId(5); +const TEXT_1_ID: NodeId = NodeId(6); +const TEXT_2_ID: NodeId = NodeId(7); +const ANNOUNCEMENT_ID: NodeId = NodeId(8); +const INITIAL_FOCUS: NodeId = HEADING_1_ID; + +const WINDOW_RECT: Rect = Rect { + x0: 0.0, + y0: 0.0, + x1: 393.0, + y1: 759.0, +}; + +const BUTTON_1_RECT: Rect = Rect { + x0: 20.0, + y0: 20.0, + x1: 200.0, + y1: 64.0, +}; + +const BUTTON_2_RECT: Rect = Rect { + x0: 20.0, + y0: 84.0, + x1: 200.0, + y1: 128.0, +}; + +const HEADING_1_RECT: Rect = Rect { + x0: 220.0, + y0: 20.0, + x1: 420.0, + y1: 64.0, +}; + +const HEADING_2_RECT: Rect = Rect { + x0: 220.0, + y0: 20.0, + x1: 420.0, + y1: 64.0, +}; + +#[cfg(target_os = "ios")] +fn safe_area_inset(window: &Window) -> Vec2 { + let Ok(outer) = window.outer_position() else { + return Vec2::ZERO; + }; + let Ok(inner) = window.inner_position() else { + return Vec2::ZERO; + }; + Vec2::new((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) +} + +#[cfg(not(target_os = "ios"))] +fn safe_area_inset(_: &Window) -> Vec2 { + Vec2::ZERO +} + +fn build_document() -> Node { + let mut node = Node::new(Role::Document); + node.set_bounds(WINDOW_RECT); + node.set_children(vec![HEADING_1_ID, BUTTON_1_ID, BUTTON_2_ID, HEADING_2_ID]); + node +} + +fn build_button(id: NodeId, label: &str) -> Node { + let rect = match id { + BUTTON_1_ID => BUTTON_1_RECT, + BUTTON_2_ID => BUTTON_2_RECT, + _ => unreachable!(), + }; + let mut node = Node::new(Role::Button); + node.set_bounds(rect); + node.set_label(label); + node.add_action(Action::Focus); + node.add_action(Action::Click); + node.add_action(Action::SetTextSelection); + node +} + +fn build_text(id: NodeId, label: &str) -> Node { + let rect = match id { + TEXT_1_ID => HEADING_1_RECT, + TEXT_2_ID => HEADING_2_RECT, + _ => unreachable!(), + }; + let mut node = Node::new(Role::TextRun); + let char_lengths: Vec = label.chars().map(|c| c.len_utf8() as u8).collect(); + node.set_bounds(rect); + node.set_value(label); + node.add_action(Action::SetTextSelection); + node.set_character_lengths(char_lengths); + node +} + +fn build_heading(id: NodeId, label: &str) -> Node { + let rect = match id { + HEADING_1_ID => HEADING_1_RECT, + HEADING_2_ID => HEADING_2_RECT, + _ => unreachable!(), + }; + let mut node = Node::new(Role::Heading); + node.set_bounds(rect); + node.set_label(label); + node.set_value(label); + node.set_level(1); + if id == HEADING_1_ID { + node.set_children(vec![TEXT_1_ID]); + } else if id == HEADING_2_ID { + node.set_children(vec![TEXT_2_ID]); + } + node.add_action(Action::Focus); + node.add_action(Action::SetTextSelection); + node +} + +fn build_announcement(text: &str) -> Node { + let mut node = Node::new(Role::Label); + node.set_value(text); + node.set_live(Live::Polite); + node +} + +const ANNOUNCEMENT_DELAY: Duration = Duration::from_millis(150); + +struct UiState { + focus: NodeId, + announcement: Option<&'static str>, + pending_announcement: Option<(&'static str, Instant)>, + scale_factor: f64, + inset: Vec2, +} + +impl UiState { + fn new(scale_factor: f64, inset: Vec2) -> Self { + Self { + focus: INITIAL_FOCUS, + announcement: None, + pending_announcement: None, + scale_factor, + inset, + } + } + + fn build_root(&mut self) -> Node { + let mut node = Node::new(Role::Window); + node.set_bounds(WINDOW_RECT); + node.set_transform(Affine::translate(self.inset) * Affine::scale(self.scale_factor)); + node.set_children(vec![DOCUMENT_ID]); + if self.announcement.is_some() { + node.push_child(ANNOUNCEMENT_ID); + } + node.set_label(WINDOW_TITLE); + node + } + + fn build_initial_tree(&mut self) -> TreeUpdate { + let root = self.build_root(); + let document = build_document(); + let button_1 = build_button(BUTTON_1_ID, "Button 1"); + let button_2 = build_button(BUTTON_2_ID, "Button 2"); + let heading_1 = build_heading(HEADING_1_ID, "Heading 1"); + let heading_2 = build_heading(HEADING_2_ID, "Heading 2"); + let text_1 = build_text(TEXT_1_ID, "Text 1"); + let text_2 = build_text(TEXT_2_ID, "Text 2"); + let tree = TreeInfo::new(WINDOW_ID); + let mut result = TreeUpdate { + nodes: vec![ + (WINDOW_ID, root), + (DOCUMENT_ID, document), + (BUTTON_1_ID, button_1), + (BUTTON_2_ID, button_2), + (HEADING_1_ID, heading_1), + (HEADING_2_ID, heading_2), + (TEXT_1_ID, text_1), + (TEXT_2_ID, text_2), + ], + tree: Some(tree), + tree_id: TreeId::ROOT, + focus: self.focus, + }; + if let Some(announcement) = &self.announcement { + result + .nodes + .push((ANNOUNCEMENT_ID, build_announcement(announcement))); + } + result + } + + fn set_focus(&mut self, adapter: &mut Adapter, focus: NodeId) { + self.focus = focus; + adapter.update_if_active(|| TreeUpdate { + nodes: vec![], + tree: None, + tree_id: TreeId::ROOT, + focus, + }); + } + + fn press_button(&mut self, id: NodeId) { + let text = if id == BUTTON_1_ID { + "You pressed button 1" + } else { + "You pressed button 2" + }; + // On iOS, VoiceOver announces the label of the activated button. + // Postpone the live region update so the messages don't overlap. + self.pending_announcement = Some((text, Instant::now())); + } + + fn flush_announcement(&mut self, adapter: &mut Adapter) -> bool { + let Some((_, queued_at)) = &self.pending_announcement else { + return false; + }; + if queued_at.elapsed() < ANNOUNCEMENT_DELAY { + return true; + } + if let Some((text, _)) = self.pending_announcement.take() { + self.announcement = Some(text); + adapter.update_if_active(|| { + let announcement = build_announcement(text); + let root = self.build_root(); + TreeUpdate { + nodes: vec![(ANNOUNCEMENT_ID, announcement), (WINDOW_ID, root)], + tree: None, + tree_id: TreeId::ROOT, + focus: self.focus, + } + }); + } + false + } +} + +struct WindowState { + window: Window, + adapter: Adapter, + ui: UiState, +} + +impl WindowState { + fn new(window: Window, adapter: Adapter, ui: UiState) -> Self { + Self { + window, + adapter, + ui, + } + } +} + +struct Application { + event_loop_proxy: EventLoopProxy, + window: Option, +} + +impl Application { + fn new(event_loop_proxy: EventLoopProxy) -> Self { + Self { + event_loop_proxy, + window: None, + } + } + + fn create_window(&mut self, event_loop: &ActiveEventLoop) -> Result<(), Box> { + let window_attributes = Window::default_attributes() + .with_title(WINDOW_TITLE) + .with_visible(false); + + let window = event_loop.create_window(window_attributes)?; + let ui = UiState::new(window.scale_factor(), safe_area_inset(&window)); + let adapter = + Adapter::with_event_loop_proxy(event_loop, &window, self.event_loop_proxy.clone()); + window.set_visible(true); + + self.window = Some(WindowState::new(window, adapter, ui)); + Ok(()) + } +} + +impl ApplicationHandler for Application { + fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + let window = match &mut self.window { + Some(window) => window, + None => return, + }; + let adapter = &mut window.adapter; + let state = &mut window.ui; + + adapter.process_event(&window.window, &event); + match event { + WindowEvent::CloseRequested => { + fill::cleanup_window(&window.window); + self.window = None; + } + WindowEvent::Resized(_) => { + state.scale_factor = window.window.scale_factor(); + state.inset = safe_area_inset(&window.window); + adapter.update_if_active(|| state.build_initial_tree()); + window.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + fill::fill_window(&window.window); + } + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key: virtual_code, + state: ElementState::Pressed, + .. + }, + .. + } => match virtual_code { + Key::Named(winit::keyboard::NamedKey::Tab) => { + let new_focus = match state.focus { + HEADING_1_ID => BUTTON_1_ID, + BUTTON_1_ID => BUTTON_2_ID, + BUTTON_2_ID => HEADING_2_ID, + HEADING_2_ID => HEADING_1_ID, + _ => HEADING_1_ID, + }; + state.set_focus(adapter, new_focus); + window.window.request_redraw(); + } + Key::Named(winit::keyboard::NamedKey::Space) => { + let id = state.focus; + state.press_button(id); + window.window.request_redraw(); + } + _ => (), + }, + _ => (), + } + } + + fn user_event(&mut self, _: &ActiveEventLoop, user_event: AccessKitEvent) { + let window = match &mut self.window { + Some(window) => window, + None => return, + }; + let adapter = &mut window.adapter; + let state = &mut window.ui; + + match user_event.window_event { + AccessKitWindowEvent::InitialTreeRequested => { + adapter.update_if_active(|| state.build_initial_tree()); + } + AccessKitWindowEvent::ActionRequested(ActionRequest { + action, + target_node, + .. + }) => { + if + target_node == BUTTON_1_ID + || target_node == BUTTON_2_ID + || target_node == HEADING_1_ID + || target_node == HEADING_2_ID + { + match action { + Action::Focus => { + state.set_focus(adapter, target_node); + } + Action::Click => { + state.press_button(target_node); + } + Action::SetTextSelection => { + state.set_focus(adapter, target_node); + } + _ => (), + } + } + window.window.request_redraw(); + } + AccessKitWindowEvent::AccessibilityDeactivated => (), + } + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + self.create_window(event_loop) + .expect("failed to create initial window"); + } + if let Some(window) = self.window.as_ref() { + window.window.request_redraw(); + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if let Some(window) = &mut self.window { + if window.ui.flush_announcement(&mut window.adapter) { + event_loop.set_control_flow(ControlFlow::wait_duration(ANNOUNCEMENT_DELAY)); + } + } else { + event_loop.exit(); + } + } +} + +fn main() -> Result<(), Box> { + println!("This example has no visible GUI, and a keyboard interface:"); + println!("- [Tab] switches focus between two logical buttons."); + println!( + "- [Space] 'presses' the button, adding static text in a live region announcing that it was pressed." + ); + #[cfg(target_os = "windows")] + println!( + "Enable Narrator with [Win]+[Ctrl]+[Enter] (or [Win]+[Enter] on older versions of Windows)." + ); + #[cfg(all( + feature = "accesskit_unix", + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ) + ))] + println!("Enable Orca with [Super]+[Alt]+[S]."); + + let event_loop = EventLoop::with_user_event().build()?; + let mut state = Application::new(event_loop.create_proxy()); + event_loop.run_app(&mut state).map_err(Into::into) +}