diff --git a/platforms/atspi-common/src/adapter.rs b/platforms/atspi-common/src/adapter.rs index f37f7c33..da028d2d 100644 --- a/platforms/atspi-common/src/adapter.rs +++ b/platforms/atspi-common/src/adapter.rs @@ -9,7 +9,7 @@ // found in the LICENSE.chromium file. use crate::{ - AdapterCallback, Event, ObjectEvent, WindowEvent, + AdapterCallback, CacheEvent, Event, ObjectEvent, WindowEvent, context::{ActionHandlerNoMut, ActionHandlerWrapper, AppContext, Context}, filters::filter, node::{NodeIdOrRoot, NodeWrapper, PlatformNode, PlatformRoot}, @@ -58,6 +58,7 @@ impl<'a> AdapterChangeHandler<'a> { let wrapper = NodeWrapper(node); let interfaces = wrapper.interfaces(); self.adapter.register_interfaces(node.id(), interfaces); + self.adapter.emit_cache_added(node.id()); if is_root && role == Role::Window { let adapter_index = self .adapter @@ -102,6 +103,7 @@ impl<'a> AdapterChangeHandler<'a> { } self.adapter .emit_object_event(node.id(), ObjectEvent::StateChanged(State::Defunct, true)); + self.adapter.emit_cache_removed(node.id()); self.adapter .unregister_interfaces(node.id(), wrapper.interfaces()); if let Some(true) = node.is_selected() { @@ -508,6 +510,16 @@ impl Adapter { .emit_event(self, Event::Object { target, event }); } + fn emit_cache_added(&self, target: NodeId) { + self.callback + .emit_event(self, Event::Cache(CacheEvent::Added(target))); + } + + fn emit_cache_removed(&self, target: NodeId) { + self.callback + .emit_event(self, Event::Cache(CacheEvent::Removed(target))); + } + pub fn set_root_window_bounds(&mut self, new_bounds: WindowBounds) { let mut bounds = self.context.root_window_bounds.write().unwrap(); *bounds = new_bounds; @@ -599,3 +611,212 @@ impl Drop for Adapter { self.context.write_app_context().remove_adapter(self.id); } } + +#[cfg(test)] +mod tests { + use super::Adapter; + use crate::{AdapterCallback, AppContext, CacheEvent, Event, InterfaceSet, WindowBounds}; + use accesskit::{ + ActionHandler, ActionRequest, Node, NodeId as LocalNodeId, Role, Tree, TreeId, TreeUpdate, + }; + use accesskit_consumer::NodeId; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum CacheOp { + Added(NodeId), + Removed(NodeId), + } + + struct CapturingCallback { + ops: Arc>>, + } + + impl AdapterCallback for CapturingCallback { + fn register_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn emit_event(&self, _: &Adapter, event: Event) { + let mut ops = self.ops.lock().unwrap(); + match event { + Event::Cache(CacheEvent::Added(id)) => ops.push(CacheOp::Added(id)), + Event::Cache(CacheEvent::Removed(id)) => ops.push(CacheOp::Removed(id)), + _ => {} + } + } + } + + struct NoOpActionHandler; + impl ActionHandler for NoOpActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} + } + + fn with_children(role: Role, children: &[LocalNodeId]) -> Node { + let mut node = Node::new(role); + node.set_children(children.to_vec()); + node + } + + fn build(initial: TreeUpdate) -> (Adapter, Arc>>) { + let ops = Arc::new(Mutex::new(Vec::new())); + let app_context = AppContext::new(None); + let adapter = Adapter::new( + &app_context, + CapturingCallback { ops: ops.clone() }, + initial, + false, + WindowBounds::default(), + NoOpActionHandler, + ); + (adapter, ops) + } + + fn initial_tree() -> TreeUpdate { + TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + ), + (LocalNodeId(1), Node::new(Role::Button)), + ], + tree: Some(Tree::new(LocalNodeId(0))), + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + } + } + + fn update(nodes: Vec<(LocalNodeId, Node)>) -> TreeUpdate { + TreeUpdate { + nodes, + tree: None, + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + } + } + + #[test] + fn no_cache_events_on_construction() { + let (_adapter, ops) = build(initial_tree()); + assert!(ops.lock().unwrap().is_empty()); + } + + #[test] + fn add_node_emits_one_added() { + let (mut adapter, ops) = build(initial_tree()); + ops.lock().unwrap().clear(); + adapter.update(update(vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + (LocalNodeId(2), Node::new(Role::Button)), + ])); + let ops = ops.lock().unwrap(); + assert_eq!(ops.len(), 1); + assert!(matches!(ops[0], CacheOp::Added(_))); + } + + #[test] + fn remove_node_emits_removed_for_same_id() { + let (mut adapter, ops) = build(initial_tree()); + adapter.update(update(vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + (LocalNodeId(2), Node::new(Role::Button)), + ])); + let added_id = match ops.lock().unwrap().as_slice() { + [CacheOp::Added(id)] => *id, + other => panic!("expected exactly one Added, got {other:?}"), + }; + ops.lock().unwrap().clear(); + adapter.update(update(vec![( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + )])); + assert_eq!(*ops.lock().unwrap(), vec![CacheOp::Removed(added_id)]); + } + + #[test] + fn subtree_add_emits_added_per_node() { + let (mut adapter, ops) = build(initial_tree()); + ops.lock().unwrap().clear(); + adapter.update(update(vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + ( + LocalNodeId(2), + with_children(Role::Group, &[LocalNodeId(3), LocalNodeId(4)]), + ), + (LocalNodeId(3), Node::new(Role::Button)), + (LocalNodeId(4), Node::new(Role::Button)), + ])); + let ops = ops.lock().unwrap(); + assert_eq!(ops.len(), 3); + assert!(ops.iter().all(|op| matches!(op, CacheOp::Added(_)))); + } + + #[test] + fn subtree_remove_emits_removed_per_node() { + let (mut adapter, ops) = build(initial_tree()); + adapter.update(update(vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + ( + LocalNodeId(2), + with_children(Role::Group, &[LocalNodeId(3), LocalNodeId(4)]), + ), + (LocalNodeId(3), Node::new(Role::Button)), + (LocalNodeId(4), Node::new(Role::Button)), + ])); + ops.lock().unwrap().clear(); + adapter.update(update(vec![( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + )])); + let ops = ops.lock().unwrap(); + assert_eq!(ops.len(), 3); + assert!(ops.iter().all(|op| matches!(op, CacheOp::Removed(_)))); + } + + #[test] + fn filter_transition_into_tree_emits_added() { + let mut hidden = Node::new(Role::Button); + hidden.set_hidden(); + let (mut adapter, ops) = build(TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + (LocalNodeId(1), Node::new(Role::Button)), + (LocalNodeId(2), hidden), + ], + tree: Some(Tree::new(LocalNodeId(0))), + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + }); + ops.lock().unwrap().clear(); + adapter.update(update(vec![(LocalNodeId(2), Node::new(Role::Button))])); + let ops = ops.lock().unwrap(); + assert_eq!(ops.len(), 1); + assert!(matches!(ops[0], CacheOp::Added(_))); + } + + #[test] + fn filter_transition_out_of_tree_emits_removed() { + let (mut adapter, ops) = build(initial_tree()); + ops.lock().unwrap().clear(); + let mut hidden = Node::new(Role::Button); + hidden.set_hidden(); + adapter.update(update(vec![(LocalNodeId(1), hidden)])); + let ops = ops.lock().unwrap(); + assert_eq!(ops.len(), 1); + assert!(matches!(ops[0], CacheOp::Removed(_))); + } +} diff --git a/platforms/atspi-common/src/events.rs b/platforms/atspi-common/src/events.rs index ae031ed6..aadc1a5a 100644 --- a/platforms/atspi-common/src/events.rs +++ b/platforms/atspi-common/src/events.rs @@ -19,6 +19,13 @@ pub enum Event { name: String, event: WindowEvent, }, + Cache(CacheEvent), +} + +#[derive(Debug)] +pub enum CacheEvent { + Added(NodeId), + Removed(NodeId), } #[derive(Debug)] diff --git a/platforms/atspi-common/src/lib.rs b/platforms/atspi-common/src/lib.rs index 3d8c0aca..f296305f 100644 --- a/platforms/atspi-common/src/lib.rs +++ b/platforms/atspi-common/src/lib.rs @@ -28,6 +28,6 @@ pub use callback::AdapterCallback; pub use context::{ActionHandlerNoMut, ActionHandlerWrapper, AppContext}; pub use error::*; pub use events::*; -pub use node::{NodeIdOrRoot, PlatformNode, PlatformRoot}; +pub use node::{CacheNode, NodeIdOrRoot, PlatformNode, PlatformRoot}; pub use rect::*; pub use util::WindowBounds; diff --git a/platforms/atspi-common/src/node.rs b/platforms/atspi-common/src/node.rs index f4361ee5..adce6ba4 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -56,6 +56,31 @@ impl NodeWrapper<'_> { self.0.id() } + pub(crate) fn cache_node( + &self, + adapter_id: usize, + index_in_parent: i32, + child_count: i32, + window_focused: bool, + ) -> CacheNode { + let parent = self + .0 + .filtered_parent(&filter) + .map_or(NodeIdOrRoot::Root, |node| NodeIdOrRoot::Node(node.id())); + CacheNode { + adapter_id, + id: self.id(), + parent, + index_in_parent, + child_count, + interfaces: self.interfaces(), + name: self.name().unwrap_or_default(), + description: self.description().unwrap_or_default(), + role: self.role(), + states: self.state(window_focused), + } + } + fn filtered_child_ids( &self, ) -> impl DoubleEndedIterator + FusedIterator + '_ { @@ -861,6 +886,30 @@ impl PlatformNode { }) } + pub fn cache_node(&self) -> Result { + self.resolve_with_context(|node, tree, context| { + let index_in_parent = if node.filtered_parent(&filter).is_some() { + i32::try_from(node.preceding_filtered_siblings(&filter).count()) + .map_err(|_| Error::IndexOutOfRange)? + } else { + let index = context + .read_app_context() + .adapter_index(self.adapter_id) + .map_err(|_| Error::Defunct)?; + i32::try_from(index).map_err(|_| Error::IndexOutOfRange)? + }; + let child_count = i32::try_from(node.filtered_children(&filter).count()) + .map_err(|_| Error::TooManyChildren)?; + let wrapper = NodeWrapper(&node); + Ok(wrapper.cache_node( + self.adapter_id, + index_in_parent, + child_count, + tree.state().focus_id().is_some(), + )) + }) + } + pub fn relation_set( &self, f: impl Fn(NodeId) -> T, @@ -1662,12 +1711,32 @@ impl PlatformRoot { self.resolve_app_context(|context| Ok(context.name.clone().unwrap_or_default())) } + pub fn description(&self) -> Result { + Ok(String::new()) + } + pub fn child_count(&self) -> Result { self.resolve_app_context(|context| { i32::try_from(context.adapters.len()).map_err(|_| Error::TooManyChildren) }) } + pub fn interfaces(&self) -> InterfaceSet { + InterfaceSet::new(Interface::Accessible | Interface::Application) + } + + pub fn role(&self) -> AtspiRole { + AtspiRole::Application + } + + pub fn index_in_parent(&self) -> i32 { + -1 + } + + pub fn state(&self) -> StateSet { + StateSet::empty() + } + pub fn child_at_index(&self, index: usize) -> Result> { self.resolve_app_context(|context| { let child = context @@ -1739,6 +1808,111 @@ impl PlatformRoot { app_context.id = Some(id); Ok(()) } + + pub fn map_descendants(&self, f: impl Fn(PlatformNode, usize, usize) -> I) -> Result + where + T: FromIterator, + { + fn collect_descendants( + node: Node<'_>, + index_in_parent: usize, + adapter_id: usize, + context: &Arc, + f: &impl Fn(PlatformNode, usize, usize) -> I, + results: &mut Vec, + ) { + let children: Vec<_> = node.filtered_children(&filter).collect(); + let platform_node = PlatformNode::new(context, adapter_id, node.id()); + results.push(f(platform_node, index_in_parent, children.len())); + for (child_index, child) in children.into_iter().enumerate() { + collect_descendants(child, child_index, adapter_id, context, f, results); + } + } + + self.resolve_app_context(|context| { + let mut results = Vec::new(); + + for (adapter_slot, (adapter_id, adapter_context)) in context.adapters.iter().enumerate() + { + let tree = adapter_context.read_tree(); + let state = tree.state(); + let root = state.root(); + + collect_descendants( + root, + adapter_slot, + *adapter_id, + adapter_context, + &f, + &mut results, + ); + } + + Ok(results.into_iter().collect()) + }) + } + + pub fn map_descendant_cache_nodes(&self, f: impl Fn(CacheNode) -> I) -> Result + where + T: FromIterator, + { + fn collect_descendants( + node: Node<'_>, + index_in_parent: i32, + adapter_id: usize, + window_focused: bool, + f: &impl Fn(CacheNode) -> I, + results: &mut Vec>, + ) { + let children: Vec<_> = node.filtered_children(&filter).collect(); + let child_count = match i32::try_from(children.len()) { + Ok(count) => count, + Err(_) => { + results.push(Err(Error::TooManyChildren)); + return; + } + }; + let wrapper = NodeWrapper(&node); + let cache_node = + wrapper.cache_node(adapter_id, index_in_parent, child_count, window_focused); + results.push(Ok(f(cache_node))); + for (child_index, child) in children.into_iter().enumerate() { + let child_index = match i32::try_from(child_index) { + Ok(index) => index, + Err(_) => { + results.push(Err(Error::IndexOutOfRange)); + return; + } + }; + collect_descendants(child, child_index, adapter_id, window_focused, f, results); + } + } + + self.resolve_app_context(|context| { + let mut results = Vec::new(); + + for (adapter_slot, (adapter_id, adapter_context)) in context.adapters.iter().enumerate() + { + let tree = adapter_context.read_tree(); + let state = tree.state(); + let root = state.root(); + let index_in_parent = + i32::try_from(adapter_slot).map_err(|_| Error::IndexOutOfRange)?; + let window_focused = state.focus_id().is_some(); + + collect_descendants( + root, + index_in_parent, + *adapter_id, + window_focused, + &f, + &mut results, + ); + } + + results.into_iter().collect() + }) + } } impl PartialEq for PlatformRoot { @@ -1758,3 +1932,16 @@ pub enum NodeIdOrRoot { Node(NodeId), Root, } + +pub struct CacheNode { + pub adapter_id: usize, + pub id: NodeId, + pub parent: NodeIdOrRoot, + pub index_in_parent: i32, + pub child_count: i32, + pub interfaces: InterfaceSet, + pub name: String, + pub description: String, + pub role: AtspiRole, + pub states: StateSet, +} diff --git a/platforms/atspi-common/src/simplified.rs b/platforms/atspi-common/src/simplified.rs index a8eabcf6..9ff3580c 100644 --- a/platforms/atspi-common/src/simplified.rs +++ b/platforms/atspi-common/src/simplified.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; use crate::{ - Adapter, Event as EventEnum, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, Property, - WindowEvent, + Adapter, CacheEvent, Event as EventEnum, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, + Property, WindowEvent, }; pub use crate::{ @@ -28,7 +28,7 @@ impl Accessible { pub fn role(&self) -> Result { match self { Self::Node(node) => node.role(), - Self::Root(_) => Ok(Role::Application), + Self::Root(root) => Ok(root.role()), } } @@ -49,14 +49,14 @@ impl Accessible { pub fn description(&self) -> Result { match self { Self::Node(node) => node.description(), - Self::Root(_) => Ok("".into()), + Self::Root(root) => root.description(), } } pub fn state(&self) -> StateSet { match self { Self::Node(node) => node.state(), - Self::Root(_) => StateSet::empty(), + Self::Root(root) => root.state(), } } @@ -80,7 +80,7 @@ impl Accessible { pub fn index_in_parent(&self) -> Result { match self { Self::Node(node) => node.index_in_parent(), - Self::Root(_) => Ok(-1), + Self::Root(root) => Ok(root.index_in_parent()), } } @@ -572,6 +572,26 @@ impl Accessible { } } +pub struct Cache { + root: PlatformRoot, +} + +impl Cache { + pub fn new(root: PlatformRoot) -> Self { + Self { root } + } + + pub fn items(&self) -> Result> { + let descendants: Vec = self + .root + .map_descendants(|node, _, _| Accessible::Node(node))?; + let mut items = Vec::with_capacity(descendants.len() + 1); + items.push(Accessible::Root(self.root.clone())); + items.extend(descendants); + Ok(items) + } +} + #[derive(PartialEq)] pub enum EventData { U32(u32), @@ -743,6 +763,139 @@ impl Event { data: Some(EventData::String(name)), } } + EventEnum::Cache(cache_event) => { + let (kind, target) = match cache_event { + CacheEvent::Added(target) => ("cache:add", target), + CacheEvent::Removed(target) => ("cache:remove", target), + }; + Self { + kind: kind.into(), + source: Accessible::Node(adapter.platform_node(target)), + detail1: 0, + detail2: 0, + data: None, + } + } } } } + +#[cfg(test)] +mod tests { + use super::{Accessible, Cache, Event as SimplifiedEvent}; + use crate::{Adapter, AdapterCallback, AppContext, Event, WindowBounds}; + use accesskit::{ + ActionHandler, ActionRequest, Node, NodeId as LocalNodeId, Role, Tree, TreeId, TreeUpdate, + }; + use accesskit_consumer::NodeId; + use atspi_common::InterfaceSet; + use std::sync::{Arc, Mutex}; + + struct NoOpActionHandler; + impl ActionHandler for NoOpActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} + } + + struct CacheKindCallback { + kinds: Arc>>, + } + + impl AdapterCallback for CacheKindCallback { + fn register_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn emit_event(&self, adapter: &Adapter, event: Event) { + if matches!(event, Event::Cache(_)) { + let simplified = SimplifiedEvent::new(adapter, event); + self.kinds.lock().unwrap().push(simplified.kind); + } + } + } + + struct NoOpCallback; + impl AdapterCallback for NoOpCallback { + fn register_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: NodeId, _: InterfaceSet) {} + fn emit_event(&self, _: &Adapter, _: Event) {} + } + + fn with_children(role: Role, children: &[LocalNodeId]) -> Node { + let mut node = Node::new(role); + node.set_children(children.to_vec()); + node + } + + fn initial_tree() -> TreeUpdate { + TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + ), + (LocalNodeId(1), Node::new(Role::Button)), + ], + tree: Some(Tree::new(LocalNodeId(0))), + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + } + } + + #[test] + fn cache_events_map_to_kind_strings() { + let kinds = Arc::new(Mutex::new(Vec::new())); + let app_context = AppContext::new(None); + let mut adapter = Adapter::new( + &app_context, + CacheKindCallback { + kinds: kinds.clone(), + }, + initial_tree(), + false, + WindowBounds::default(), + NoOpActionHandler, + ); + adapter.update(TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + (LocalNodeId(2), Node::new(Role::Button)), + ], + tree: None, + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + }); + adapter.update(TreeUpdate { + nodes: vec![( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + )], + tree: None, + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + }); + assert_eq!(*kinds.lock().unwrap(), vec!["cache:add", "cache:remove"]); + } + + fn build_cache() -> (Adapter, Cache) { + let app_context = AppContext::new(None); + let adapter = Adapter::new( + &app_context, + NoOpCallback, + initial_tree(), + false, + WindowBounds::default(), + NoOpActionHandler, + ); + let cache = Cache::new(adapter.platform_root()); + (adapter, cache) + } + + #[test] + fn items_prepends_application_root() { + let (_adapter, cache) = build_cache(); + let items = cache.items().unwrap(); + assert_eq!(items.len(), 3); + assert!(matches!(items[0], Accessible::Root(_))); + } +} diff --git a/platforms/unix/src/adapter.rs b/platforms/unix/src/adapter.rs index 402545ec..afca31f8 100644 --- a/platforms/unix/src/adapter.rs +++ b/platforms/unix/src/adapter.rs @@ -5,8 +5,8 @@ use accesskit::{ActionHandler, ActivationHandler, DeactivationHandler, Rect, TreeUpdate}; use accesskit_atspi_common::{ - ActionHandlerNoMut, ActionHandlerWrapper, Adapter as AdapterImpl, AdapterCallback, Event, - NodeId, PlatformNode, WindowBounds, next_adapter_id, + ActionHandlerNoMut, ActionHandlerWrapper, Adapter as AdapterImpl, AdapterCallback, CacheEvent, + Event, NodeId, PlatformNode, WindowBounds, next_adapter_id, }; #[cfg(not(feature = "tokio"))] use async_channel::Sender; @@ -51,10 +51,24 @@ impl AdapterCallback for Callback { } fn emit_event(&self, adapter: &AdapterImpl, event: Event) { - self.send_message(Message::EmitEvent { - adapter_id: adapter.id(), - event, - }); + match event { + Event::Cache(CacheEvent::Added(id)) => { + let node = adapter.platform_node(id); + self.send_message(Message::EmitCacheAdd { node }); + } + Event::Cache(CacheEvent::Removed(id)) => { + self.send_message(Message::EmitCacheRemove { + adapter_id: adapter.id(), + node_id: id, + }); + } + event => { + self.send_message(Message::EmitEvent { + adapter_id: adapter.id(), + event, + }); + } + } } } @@ -247,4 +261,11 @@ pub(crate) enum Message { adapter_id: usize, event: Event, }, + EmitCacheAdd { + node: PlatformNode, + }, + EmitCacheRemove { + adapter_id: usize, + node_id: NodeId, + }, } diff --git a/platforms/unix/src/atspi/bus.rs b/platforms/unix/src/atspi/bus.rs index 003b0c8e..e793af5e 100644 --- a/platforms/unix/src/atspi/bus.rs +++ b/platforms/unix/src/atspi/bus.rs @@ -4,7 +4,7 @@ // the LICENSE-MIT file), at your option. use crate::{ - atspi::{ObjectId, interfaces::*}, + atspi::{ObjectId, cache_path, interfaces::*}, context::get_or_init_app_context, executor::{Executor, Task}, }; @@ -85,7 +85,15 @@ impl Bus { .object_server() .at( path, - RootAccessibleInterface::new(self.unique_name().to_owned(), node), + RootAccessibleInterface::new(self.unique_name().to_owned(), node.clone()), + ) + .await?; + + self.conn + .object_server() + .at( + cache_path(), + CacheInterface::new(self.unique_name().to_owned(), node), ) .await?; } @@ -369,6 +377,43 @@ impl Bus { .await } + pub(crate) async fn emit_cache_add(&self, node: PlatformNode) -> Result<()> { + let Ok(item) = cache_item_for_node(self.unique_name().inner(), &node) else { + return Ok(()); + }; + self.emit_cache_signal("AddAccessible", &item).await + } + + pub(crate) async fn emit_cache_remove(&self, adapter_id: usize, node_id: NodeId) -> Result<()> { + let reference = object_ref( + self.unique_name().inner(), + ObjectId::Node { + adapter: adapter_id, + node: node_id, + }, + ); + self.emit_cache_signal("RemoveAccessible", &reference).await + } + + async fn emit_cache_signal(&self, signal_name: &str, body: &B) -> Result<()> + where + B: serde::Serialize + zbus::zvariant::DynamicType, + { + map_or_ignoring_broken_pipe( + self.conn + .emit_signal( + Option::::None, + cache_path(), + InterfaceName::from_str_unchecked("org.a11y.atspi.Cache"), + MemberName::from_str_unchecked(signal_name), + body, + ) + .await, + (), + |_| (), + ) + } + async fn emit_event( &self, target: ObjectId, diff --git a/platforms/unix/src/atspi/interfaces/accessible.rs b/platforms/unix/src/atspi/interfaces/accessible.rs index 16d50a44..797b2371 100644 --- a/platforms/unix/src/atspi/interfaces/accessible.rs +++ b/platforms/unix/src/atspi/interfaces/accessible.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use accesskit_atspi_common::{NodeIdOrRoot, PlatformNode, PlatformRoot}; -use atspi::{Interface, InterfaceSet, RelationType, Role, StateSet}; +use atspi::{InterfaceSet, RelationType, Role, StateSet}; use zbus::{fdo, interface, names::OwnedUniqueName}; use super::map_root_error; @@ -159,8 +159,8 @@ impl RootAccessibleInterface { } #[zbus(property)] - fn description(&self) -> &str { - "" + fn description(&self) -> fdo::Result { + self.root.description().map_err(map_root_error) } #[zbus(property)] @@ -204,7 +204,7 @@ impl RootAccessibleInterface { } fn get_index_in_parent(&self) -> i32 { - -1 + self.root.index_in_parent() } fn get_relation_set(&self) -> Vec<(RelationType, Vec)> { @@ -212,11 +212,11 @@ impl RootAccessibleInterface { } fn get_role(&self) -> Role { - Role::Application + self.root.role() } fn get_state(&self) -> StateSet { - StateSet::empty() + self.root.state() } fn get_application(&self) -> (OwnedObjectAddress,) { @@ -224,6 +224,6 @@ impl RootAccessibleInterface { } fn get_interfaces(&self) -> InterfaceSet { - InterfaceSet::new(Interface::Accessible | Interface::Application) + self.root.interfaces() } } diff --git a/platforms/unix/src/atspi/interfaces/cache.rs b/platforms/unix/src/atspi/interfaces/cache.rs new file mode 100644 index 00000000..ad2a219e --- /dev/null +++ b/platforms/unix/src/atspi/interfaces/cache.rs @@ -0,0 +1,288 @@ +// 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_atspi_common::{CacheNode, Error, NodeIdOrRoot, PlatformNode, PlatformRoot}; +use atspi::{CacheItem, ObjectRef, ObjectRefOwned}; +use zbus::{ + fdo, interface, + names::{OwnedUniqueName, UniqueName}, +}; + +use super::map_root_error; +use crate::atspi::ObjectId; + +pub(crate) fn object_ref(bus_name: &UniqueName, id: ObjectId) -> ObjectRefOwned { + ObjectRef::new_owned(bus_name.to_owned(), id.path()) +} + +pub(crate) fn cache_item_for_node( + bus_name: &UniqueName, + node: &PlatformNode, +) -> Result { + Ok(cache_item(bus_name, node.cache_node()?)) +} + +fn cache_item(bus_name: &UniqueName, node: CacheNode) -> CacheItem { + let parent = match node.parent { + NodeIdOrRoot::Root => ObjectId::Root, + NodeIdOrRoot::Node(node_id) => ObjectId::Node { + adapter: node.adapter_id, + node: node_id, + }, + }; + CacheItem { + object: object_ref( + bus_name, + ObjectId::Node { + adapter: node.adapter_id, + node: node.id, + }, + ), + app: object_ref(bus_name, ObjectId::Root), + parent: object_ref(bus_name, parent), + index: node.index_in_parent, + children: node.child_count, + ifaces: node.interfaces, + short_name: node.name, + role: node.role, + name: node.description, + states: node.states, + } +} + +fn application_cache_item(bus_name: &UniqueName, root: &PlatformRoot) -> Result { + Ok(CacheItem { + object: object_ref(bus_name, ObjectId::Root), + app: object_ref(bus_name, ObjectId::Root), + parent: ObjectRefOwned::new(ObjectRef::Null), + index: root.index_in_parent(), + children: root.child_count()?, + ifaces: root.interfaces(), + short_name: root.name()?, + role: root.role(), + name: root.description()?, + states: root.state(), + }) +} + +pub(crate) struct CacheInterface { + bus_name: OwnedUniqueName, + root: PlatformRoot, +} + +impl CacheInterface { + pub fn new(bus_name: OwnedUniqueName, root: PlatformRoot) -> Self { + Self { bus_name, root } + } + + fn items(&self) -> Result, Error> { + let bus_name = self.bus_name.inner(); + let descendants: Vec = self + .root + .map_descendant_cache_nodes(|node| cache_item(bus_name, node))?; + + let mut items = Vec::with_capacity(descendants.len() + 1); + items.push(application_cache_item(bus_name, &self.root)?); + items.extend(descendants); + Ok(items) + } +} + +#[interface(name = "org.a11y.atspi.Cache")] +impl CacheInterface { + fn get_items(&self) -> fdo::Result> { + self.items().map_err(map_root_error) + } +} + +#[cfg(test)] +mod tests { + use super::{CacheInterface, object_ref}; + use crate::atspi::ObjectId; + use accesskit::{ + ActionHandler, ActionRequest, Node, NodeId as LocalNodeId, Role, Tree, TreeId, TreeUpdate, + }; + use accesskit_atspi_common::{ + Adapter, AdapterCallback, AppContext, Event, NodeId, PlatformRoot, WindowBounds, + }; + use atspi::{Interface, ObjectRef, ObjectRefOwned, Role as AtspiRole}; + use zbus::names::{OwnedUniqueName, UniqueName}; + + struct NoOpActionHandler; + impl ActionHandler for NoOpActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} + } + + struct NoOpCallback; + impl AdapterCallback for NoOpCallback { + fn register_interfaces(&self, _: &Adapter, _: NodeId, _: atspi::InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: NodeId, _: atspi::InterfaceSet) {} + fn emit_event(&self, _: &Adapter, _: Event) {} + } + + fn with_children(role: Role, children: &[LocalNodeId]) -> Node { + let mut node = Node::new(role); + node.set_children(children.to_vec()); + node + } + + const BUS_NAME: &str = ":1.0"; + + fn root_for(update: TreeUpdate) -> (Adapter, PlatformRoot) { + let app_context = AppContext::new(None); + let adapter = Adapter::new( + &app_context, + NoOpCallback, + update, + false, + WindowBounds::default(), + NoOpActionHandler, + ); + let root = adapter.platform_root(); + (adapter, root) + } + + fn bus_name() -> OwnedUniqueName { + OwnedUniqueName::try_from(BUS_NAME).unwrap() + } + + fn cache(update: TreeUpdate) -> (Adapter, CacheInterface) { + let (adapter, root) = root_for(update); + (adapter, CacheInterface::new(bus_name(), root)) + } + + fn window_with_button() -> TreeUpdate { + TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1)]), + ), + (LocalNodeId(1), Node::new(Role::Button)), + ], + tree: Some(Tree::new(LocalNodeId(0))), + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + } + } + + fn root_ref() -> ObjectRefOwned { + object_ref( + &UniqueName::from_static_str_unchecked(BUS_NAME), + ObjectId::Root, + ) + } + + #[test] + fn get_items_prepends_application_root() { + let (_adapter, iface) = cache(window_with_button()); + let items = iface.items().unwrap(); + let [app, _window, _button] = items.as_slice() else { + panic!("expected application root, window, and button"); + }; + assert_eq!(app.object, root_ref()); + assert_eq!(app.app, root_ref()); + assert_eq!(app.parent, ObjectRefOwned::new(ObjectRef::Null)); + assert_eq!(app.index, -1); + assert_eq!(app.children, 1); + assert_eq!(app.role, AtspiRole::Application); + assert!(app.ifaces.contains(Interface::Application)); + } + + #[test] + fn top_window_parent_is_application_root() { + let (_adapter, iface) = cache(window_with_button()); + let items = iface.items().unwrap(); + let [_app, window, _button] = items.as_slice() else { + panic!("expected application root, window, and button"); + }; + assert_eq!(window.parent, root_ref()); + assert_eq!(window.index, 0); + assert!( + window + .object + .path_as_str() + .starts_with("/org/a11y/atspi/accessible/") + ); + assert_eq!(window.object.name_as_str(), Some(BUS_NAME)); + } + + #[test] + fn filtered_child_excluded_from_items_and_counts() { + let mut hidden = Node::new(Role::Button); + hidden.set_hidden(); + let update = TreeUpdate { + nodes: vec![ + ( + LocalNodeId(0), + with_children(Role::Window, &[LocalNodeId(1), LocalNodeId(2)]), + ), + (LocalNodeId(1), Node::new(Role::Button)), + (LocalNodeId(2), hidden), + ], + tree: Some(Tree::new(LocalNodeId(0))), + tree_id: TreeId::ROOT, + focus: LocalNodeId(0), + }; + let (_adapter, iface) = cache(update); + let items = iface.items().unwrap(); + let [app_root, window, _visible_button] = items.as_slice() else { + panic!("expected application root, window, and visible button only"); + }; + assert_eq!(app_root.children, 1); + assert_eq!(window.children, 1); + } + + #[test] + fn object_ref_encodes_bus_name_and_node_path() { + let (_adapter, root) = root_for(window_with_button()); + let (adapter_id, node_id) = root.child_id_at_index(0).unwrap().unwrap(); + let expected_path = ObjectId::Node { + adapter: adapter_id, + node: node_id, + } + .path(); + let reference = object_ref( + &UniqueName::from_static_str_unchecked(BUS_NAME), + ObjectId::Node { + adapter: adapter_id, + node: node_id, + }, + ); + assert_eq!(reference.name_as_str(), Some(BUS_NAME)); + assert_eq!(reference.path_as_str(), expected_path.as_str()); + } + + #[test] + fn multi_adapter_windows_have_distinct_indices() { + let app_context = AppContext::new(None); + let _a0 = Adapter::new( + &app_context, + NoOpCallback, + window_with_button(), + false, + WindowBounds::default(), + NoOpActionHandler, + ); + let _a1 = Adapter::new( + &app_context, + NoOpCallback, + window_with_button(), + false, + WindowBounds::default(), + NoOpActionHandler, + ); + let iface = CacheInterface::new(bus_name(), PlatformRoot::new(&app_context)); + let items = iface.items().unwrap(); + + let app_root = &items[0]; + assert_eq!(app_root.children, 2); + + let windows = items.iter().filter(|item| item.parent == root_ref()); + let mut window_indices: Vec = windows.map(|item| item.index).collect(); + window_indices.sort(); + assert_eq!(window_indices, vec![0, 1]); + } +} diff --git a/platforms/unix/src/atspi/interfaces/mod.rs b/platforms/unix/src/atspi/interfaces/mod.rs index 2e30905c..7e33c1e4 100644 --- a/platforms/unix/src/atspi/interfaces/mod.rs +++ b/platforms/unix/src/atspi/interfaces/mod.rs @@ -6,6 +6,7 @@ mod accessible; mod action; mod application; +mod cache; mod component; mod hyperlink; mod selection; @@ -32,6 +33,7 @@ fn optional_object_address( pub(crate) use accessible::*; pub(crate) use action::*; pub(crate) use application::*; +pub(crate) use cache::*; pub(crate) use component::*; pub(crate) use hyperlink::*; pub(crate) use selection::*; diff --git a/platforms/unix/src/atspi/mod.rs b/platforms/unix/src/atspi/mod.rs index b4f7a165..799e9f3a 100644 --- a/platforms/unix/src/atspi/mod.rs +++ b/platforms/unix/src/atspi/mod.rs @@ -10,4 +10,4 @@ mod object_id; pub(crate) use bus::*; pub(crate) use object_address::OwnedObjectAddress; -pub(crate) use object_id::ObjectId; +pub(crate) use object_id::{ObjectId, cache_path}; diff --git a/platforms/unix/src/atspi/object_id.rs b/platforms/unix/src/atspi/object_id.rs index e89dde9d..91ed0eba 100644 --- a/platforms/unix/src/atspi/object_id.rs +++ b/platforms/unix/src/atspi/object_id.rs @@ -13,6 +13,11 @@ use zbus::{ const ACCESSIBLE_PATH_PREFIX: &str = "/org/a11y/atspi/accessible/"; const ROOT_PATH: &str = "/org/a11y/atspi/accessible/root"; +const CACHE_PATH: &str = "/org/a11y/atspi/cache"; + +pub(crate) fn cache_path() -> OwnedObjectPath { + ObjectPath::from_str_unchecked(CACHE_PATH).into() +} #[derive(Debug, PartialEq)] pub(crate) enum ObjectId { diff --git a/platforms/unix/src/context.rs b/platforms/unix/src/context.rs index 2503178c..5720532b 100644 --- a/platforms/unix/src/context.rs +++ b/platforms/unix/src/context.rs @@ -270,6 +270,23 @@ async fn process_adapter_message( .await?; } } + Message::EmitEvent { + event: Event::Cache(_), + .. + } => unreachable!("cache events are sent as EmitCacheAdd/EmitCacheRemove"), + Message::EmitCacheAdd { node } => { + if let Some(bus) = atspi_bus { + bus.emit_cache_add(node).await?; + } + } + Message::EmitCacheRemove { + adapter_id, + node_id, + } => { + if let Some(bus) = atspi_bus { + bus.emit_cache_remove(adapter_id, node_id).await?; + } + } } Ok(())