From 08e0e73e5efac7ad333e89b67fb58f891fac0562 Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 9 Aug 2026 14:31:08 +0200 Subject: [PATCH 1/6] feat: Expose EditableText.SetTextContents on Unix --- adapters/atspi-common/src/adapter.rs | 98 +++++++++++++++---- adapters/atspi-common/src/node.rs | 93 +++++++++++------- adapters/atspi-common/src/simplified.rs | 14 +++ adapters/unix/src/atspi/bus.rs | 8 ++ .../src/atspi/interfaces/editable_text.rs | 48 +++++++++ adapters/unix/src/atspi/interfaces/mod.rs | 2 + 6 files changed, 206 insertions(+), 57 deletions(-) create mode 100644 adapters/unix/src/atspi/interfaces/editable_text.rs diff --git a/adapters/atspi-common/src/adapter.rs b/adapters/atspi-common/src/adapter.rs index fdf64a4d3..a71fa1a8f 100644 --- a/adapters/atspi-common/src/adapter.rs +++ b/adapters/atspi-common/src/adapter.rs @@ -617,26 +617,28 @@ mod tests { use super::Adapter; use crate::{AdapterCallback, AppContext, CacheEvent, Event, InterfaceSet, WindowBounds}; use accesskit::{ - ActionHandler, ActionRequest, Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate, + Action, ActionData, ActionHandler, ActionRequest, Node, NodeId, Role, TreeId, TreeInfo, + TreeUpdate, }; use accesskit_consumer::FullNodeId; + use atspi_common::Interface; use std::sync::{Arc, Mutex}; + type Log = Arc>>; + #[derive(Clone, Copy, Debug, PartialEq)] enum CacheOp { Added(FullNodeId), Removed(FullNodeId), } - struct CapturingCallback { - ops: Arc>>, - } + struct Recorder(Log); - impl AdapterCallback for CapturingCallback { + impl AdapterCallback for Recorder { fn register_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} fn unregister_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} fn emit_event(&self, _: &Adapter, event: Event) { - let mut ops = self.ops.lock().unwrap(); + let mut ops = self.0.lock().unwrap(); match event { Event::Cache(CacheEvent::Added(id)) => ops.push(CacheOp::Added(id)), Event::Cache(CacheEvent::Removed(id)) => ops.push(CacheOp::Removed(id)), @@ -645,9 +647,10 @@ mod tests { } } - struct NoOpActionHandler; - impl ActionHandler for NoOpActionHandler { - fn do_action(&mut self, _request: ActionRequest) {} + impl ActionHandler for Recorder { + fn do_action(&mut self, request: ActionRequest) { + self.0.lock().unwrap().push(request); + } } fn with_children(role: Role, children: &[NodeId]) -> Node { @@ -656,18 +659,19 @@ mod tests { node } - fn build(initial: TreeUpdate) -> (Adapter, Arc>>) { + fn build(initial: TreeUpdate) -> (Adapter, Log, Log) { let ops = Arc::new(Mutex::new(Vec::new())); + let actions = Arc::new(Mutex::new(Vec::new())); let app_context = AppContext::new(None); let adapter = Adapter::new( &app_context, - CapturingCallback { ops: ops.clone() }, + Recorder(ops.clone()), initial, false, WindowBounds::default(), - NoOpActionHandler, + Recorder(actions.clone()), ); - (adapter, ops) + (adapter, ops, actions) } fn initial_tree() -> TreeUpdate { @@ -691,15 +695,69 @@ mod tests { } } + #[test] + fn editable_text_support_and_dispatch() { + let mut text_input = Node::new(Role::TextInput); + text_input.add_action(Action::SetValue); + let (mut adapter, _, requests) = build(TreeUpdate { + nodes: vec![(NodeId(0), text_input.clone())], + ..initial_tree() + }); + let platform = adapter.platform_node(adapter.root_id()); + assert!( + platform + .interfaces() + .unwrap() + .contains(Interface::EditableText) + ); + + #[cfg(feature = "simplified-api")] + let node = crate::simplified::Accessible::Node(platform.clone()); + #[cfg(not(feature = "simplified-api"))] + let node = platform.clone(); + + assert!(node.supports_editable_text().unwrap()); + assert!(node.set_text_contents("hello").unwrap()); + + let mut read_only_input = text_input.clone(); + read_only_input.set_read_only(); + let mut numeric_input = text_input.clone(); + numeric_input.set_numeric_value(0.0); + let mut button = Node::new(Role::Button); + button.add_action(Action::SetValue); + for unsupported in [ + Node::new(Role::TextInput), + read_only_input, + numeric_input, + button, + ] { + adapter.update(update(vec![(NodeId(0), unsupported)])); + assert!(!node.supports_editable_text().unwrap()); + assert!(matches!( + node.set_text_contents("ignored"), + Err(crate::Error::UnsupportedInterface) + )); + } + assert_eq!( + requests.lock().unwrap().as_slice(), + &[ActionRequest { + action: Action::SetValue, + target_tree: TreeId::ROOT, + target_node: NodeId(0), + data: Some(ActionData::Value("hello".into())), + }] + ); + } + #[test] fn no_cache_events_on_construction() { - let (_adapter, ops) = build(initial_tree()); + 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()); + let (mut adapter, ops, _) = build(initial_tree()); ops.lock().unwrap().clear(); adapter.update(update(vec![ ( @@ -715,7 +773,7 @@ mod tests { #[test] fn remove_node_emits_removed_for_same_id() { - let (mut adapter, ops) = build(initial_tree()); + let (mut adapter, ops, _) = build(initial_tree()); adapter.update(update(vec![ ( NodeId(0), @@ -737,7 +795,7 @@ mod tests { #[test] fn subtree_add_emits_added_per_node() { - let (mut adapter, ops) = build(initial_tree()); + let (mut adapter, ops, _) = build(initial_tree()); ops.lock().unwrap().clear(); adapter.update(update(vec![ ( @@ -758,7 +816,7 @@ mod tests { #[test] fn subtree_remove_emits_removed_per_node() { - let (mut adapter, ops) = build(initial_tree()); + let (mut adapter, ops, _) = build(initial_tree()); adapter.update(update(vec![ ( NodeId(0), @@ -785,7 +843,7 @@ mod tests { fn filter_transition_into_tree_emits_added() { let mut hidden = Node::new(Role::Button); hidden.set_hidden(); - let (mut adapter, ops) = build(TreeUpdate { + let (mut adapter, ops, _) = build(TreeUpdate { nodes: vec![ ( NodeId(0), @@ -807,7 +865,7 @@ mod tests { #[test] fn filter_transition_out_of_tree_emits_removed() { - let (mut adapter, ops) = build(initial_tree()); + let (mut adapter, ops, _) = build(initial_tree()); ops.lock().unwrap().clear(); let mut hidden = Node::new(Role::Button); hidden.set_hidden(); diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 4e7453dcc..2ace9b6c6 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -9,8 +9,7 @@ // 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, Orientation, Point, Rect, Role, Toggled, }; use accesskit_consumer::{FilterResult, FullNodeId, NodeRef, Tree, TreeState}; use atspi_common::{ @@ -438,6 +437,15 @@ impl NodeWrapper<'_> { self.0.raw_bounds().is_some() || self.is_root() } + fn supports_editable_text(&self) -> bool { + // Empty inputs may have no text ranges. Numeric controls use AT-SPI Value because + // SetValue doesn't declare which ActionData variant the handler accepts. + self.0.is_text_input() + && !self.0.is_read_only() + && !self.supports_value() + && self.0.supports_action(Action::SetValue, &filter) + } + fn supports_hyperlink(&self) -> bool { self.0.supports_url() } @@ -462,6 +470,9 @@ impl NodeWrapper<'_> { if self.supports_component() { interfaces.insert(Interface::Component); } + if self.supports_editable_text() { + interfaces.insert(Interface::EditableText); + } if self.supports_hyperlink() { interfaces.insert(Interface::Hyperlink); } @@ -759,20 +770,32 @@ impl PlatformNode { self.resolve_for_text_with_context(|node, _, _| f(node)) } - fn do_action_internal(&self, target: FullNodeId, f: F) -> Result<()> - where - F: FnOnce(&TreeState, &Context, NodeId, TreeId) -> ActionRequest, - { + fn dispatch_action(&self, action: Action, data: Option) -> Result<()> { + self.dispatch_checked_action(action, data, |_| true) + } + + fn dispatch_checked_action( + &self, + action: Action, + data: Option, + supports: impl for<'a> FnOnce(NodeRef<'a>) -> bool, + ) -> Result<()> { let context = self.upgrade_context()?; let tree = context.read_tree(); - if let Some((target_node, target_tree)) = tree.state().locate_node(target) { - let request = f(tree.state(), &context, target_node, target_tree); - drop(tree); - context.do_action(request); - Ok(()) - } else { - Err(Error::Defunct) + let state = tree.state(); + let node = state.node_by_id(self.id).ok_or(Error::Defunct)?; + if !supports(node) { + return Err(Error::UnsupportedInterface); } + let (target_node, target_tree) = state.locate_node(self.id).ok_or(Error::Defunct)?; + drop(tree); + context.do_action(ActionRequest { + action, + target_tree, + target_node, + data, + }); + Ok(()) } pub fn name(&self) -> Result { @@ -966,6 +989,10 @@ impl PlatformNode { }) } + pub fn supports_editable_text(&self) -> Result { + self.resolve(|node| Ok(NodeWrapper(&node).supports_editable_text())) + } + pub fn supports_hyperlink(&self) -> Result { self.resolve(|node| { let wrapper = NodeWrapper(&node); @@ -1035,12 +1062,7 @@ impl PlatformNode { if index != 0 { return Ok(false); } - self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { - action: Action::Click, - target_tree, - target_node, - data: None, - })?; + self.dispatch_action(Action::Click, None)?; Ok(true) } @@ -1096,22 +1118,15 @@ impl PlatformNode { } pub fn grab_focus(&self) -> Result { - self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { - action: Action::Focus, - target_tree, - target_node, - data: None, - })?; + self.dispatch_action(Action::Focus, None)?; Ok(true) } pub fn scroll_to(&self, scroll_type: ScrollType) -> Result { - self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { - action: Action::ScrollIntoView, - target_tree, - target_node, - data: atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint), - })?; + self.dispatch_action( + Action::ScrollIntoView, + atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint), + )?; Ok(true) } @@ -1656,12 +1671,16 @@ impl PlatformNode { } pub fn set_current_value(&self, value: f64) -> Result<()> { - self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { - action: Action::SetValue, - target_tree, - target_node, - data: Some(ActionData::NumericValue(value)), - }) + self.dispatch_action(Action::SetValue, Some(ActionData::NumericValue(value))) + } + + pub fn set_text_contents(&self, value: &str) -> Result { + self.dispatch_checked_action( + Action::SetValue, + Some(ActionData::Value(value.into())), + |node| NodeWrapper(&node).supports_editable_text(), + )?; + Ok(true) } } diff --git a/adapters/atspi-common/src/simplified.rs b/adapters/atspi-common/src/simplified.rs index a369bb85b..f8241be10 100644 --- a/adapters/atspi-common/src/simplified.rs +++ b/adapters/atspi-common/src/simplified.rs @@ -238,6 +238,20 @@ impl Accessible { } } + pub fn supports_editable_text(&self) -> Result { + match self { + Self::Node(node) => node.supports_editable_text(), + Self::Root(_) => Ok(false), + } + } + + pub fn set_text_contents(&self, value: &str) -> Result { + match self { + Self::Node(node) => node.set_text_contents(value), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + pub fn supports_hyperlink(&self) -> Result { match self { Self::Node(node) => node.supports_hyperlink(), diff --git a/adapters/unix/src/atspi/bus.rs b/adapters/unix/src/atspi/bus.rs index a4955b726..87567756a 100644 --- a/adapters/unix/src/atspi/bus.rs +++ b/adapters/unix/src/atspi/bus.rs @@ -141,6 +141,10 @@ impl Bus { ) .await?; } + if new_interfaces.contains(Interface::EditableText) { + self.register_interface(&path, EditableTextInterface::new(node.clone())) + .await?; + } if new_interfaces.contains(Interface::Hyperlink) { self.register_interface( &path, @@ -200,6 +204,10 @@ impl Bus { self.unregister_interface::(&path) .await?; } + if old_interfaces.contains(Interface::EditableText) { + self.unregister_interface::(&path) + .await?; + } if old_interfaces.contains(Interface::Hyperlink) { self.unregister_interface::(&path) .await?; diff --git a/adapters/unix/src/atspi/interfaces/editable_text.rs b/adapters/unix/src/atspi/interfaces/editable_text.rs new file mode 100644 index 000000000..3eb8079d6 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/editable_text.rs @@ -0,0 +1,48 @@ +// 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::PlatformNode; +use zbus::{fdo, interface}; + +fn unsupported() -> fdo::Error { + fdo::Error::NotSupported("editing operation is not supported".into()) +} + +pub(crate) struct EditableTextInterface(PlatformNode); + +impl EditableTextInterface { + pub fn new(node: PlatformNode) -> Self { + Self(node) + } +} + +#[interface(name = "org.a11y.atspi.EditableText")] +impl EditableTextInterface { + fn copy_text(&self, _start_pos: i32, _end_pos: i32) -> fdo::Result<()> { + Err(unsupported()) + } + + fn cut_text(&self, _start_pos: i32, _end_pos: i32) -> fdo::Result { + Err(unsupported()) + } + + fn delete_text(&self, _start_pos: i32, _end_pos: i32) -> fdo::Result { + Err(unsupported()) + } + + fn insert_text(&self, _position: i32, _text: &str, _length: i32) -> fdo::Result { + Err(unsupported()) + } + + fn paste_text(&self, _position: i32) -> fdo::Result { + Err(unsupported()) + } + + fn set_text_contents(&self, new_contents: &str) -> fdo::Result { + self.0 + .set_text_contents(new_contents) + .map_err(|error| crate::util::map_error_from_node(&self.0, error)) + } +} diff --git a/adapters/unix/src/atspi/interfaces/mod.rs b/adapters/unix/src/atspi/interfaces/mod.rs index 7e33c1e4d..b1d96a382 100644 --- a/adapters/unix/src/atspi/interfaces/mod.rs +++ b/adapters/unix/src/atspi/interfaces/mod.rs @@ -8,6 +8,7 @@ mod action; mod application; mod cache; mod component; +mod editable_text; mod hyperlink; mod selection; mod text; @@ -35,6 +36,7 @@ pub(crate) use action::*; pub(crate) use application::*; pub(crate) use cache::*; pub(crate) use component::*; +pub(crate) use editable_text::*; pub(crate) use hyperlink::*; pub(crate) use selection::*; pub(crate) use text::*; From fe081aa3c5a7db7bdb5eb3d9d74a5345e45682fc Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 16 Aug 2026 17:12:12 +0200 Subject: [PATCH 2/6] docs(atspi): remove misleading editable text comment The removed explanation described restrictions that do not match how other adapters determine EditableText support. --- adapters/atspi-common/src/node.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 2ace9b6c6..49bc4214f 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -438,8 +438,6 @@ impl NodeWrapper<'_> { } fn supports_editable_text(&self) -> bool { - // Empty inputs may have no text ranges. Numeric controls use AT-SPI Value because - // SetValue doesn't declare which ActionData variant the handler accepts. self.0.is_text_input() && !self.0.is_read_only() && !self.supports_value() From e815c89d568683c499efb1bb911619641d74f3e9 Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 16 Aug 2026 17:12:19 +0200 Subject: [PATCH 3/6] fix(atspi): align editable text support detection Expose EditableText for text inputs with text ranges, consistent with the other platform adapters. Read-only state and action advertisement do not determine interface availability. --- adapters/atspi-common/src/node.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 49bc4214f..94c99eb3b 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -438,10 +438,7 @@ impl NodeWrapper<'_> { } fn supports_editable_text(&self) -> bool { - self.0.is_text_input() - && !self.0.is_read_only() - && !self.supports_value() - && self.0.supports_action(Action::SetValue, &filter) + self.0.is_text_input() && self.0.supports_text_ranges() } fn supports_hyperlink(&self) -> bool { From 9654e0019e9b309d76dc3ad318c32b1f7cca31ba Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 16 Aug 2026 17:13:13 +0200 Subject: [PATCH 4/6] refactor(atspi): restore existing action dispatch path Remove the generic dispatch helpers introduced for EditableText and route actions through the established do_action_internal helper again. SetTextContents now handles read-only nodes locally while D-Bus interface registration remains the source of capability validation. --- adapters/atspi-common/src/node.rs | 98 ++++++++++++++++++------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 94c99eb3b..5d38a0771 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -9,7 +9,8 @@ // found in the LICENSE.chromium file. use accesskit::{ - Action, ActionData, ActionRequest, Affine, Live, Orientation, Point, Rect, Role, Toggled, + Action, ActionData, ActionRequest, Affine, Live, NodeId, Orientation, Point, Rect, Role, + Toggled, TreeId, }; use accesskit_consumer::{FilterResult, FullNodeId, NodeRef, Tree, TreeState}; use atspi_common::{ @@ -23,12 +24,12 @@ 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, }; pub(crate) struct NodeWrapper<'a>(pub(crate) &'a NodeRef<'a>); @@ -493,7 +494,11 @@ impl NodeWrapper<'_> { } fn n_actions(&self) -> i32 { - if self.0.is_clickable(&filter) { 1 } else { 0 } + if self.0.is_clickable(&filter) { + 1 + } else { + 0 + } } fn get_action_name(&self, index: i32) -> String { @@ -765,32 +770,20 @@ impl PlatformNode { self.resolve_for_text_with_context(|node, _, _| f(node)) } - fn dispatch_action(&self, action: Action, data: Option) -> Result<()> { - self.dispatch_checked_action(action, data, |_| true) - } - - fn dispatch_checked_action( - &self, - action: Action, - data: Option, - supports: impl for<'a> FnOnce(NodeRef<'a>) -> bool, - ) -> Result<()> { + fn do_action_internal(&self, target: FullNodeId, f: F) -> Result<()> + where + F: FnOnce(&TreeState, &Context, NodeId, TreeId) -> ActionRequest, + { let context = self.upgrade_context()?; let tree = context.read_tree(); - let state = tree.state(); - let node = state.node_by_id(self.id).ok_or(Error::Defunct)?; - if !supports(node) { - return Err(Error::UnsupportedInterface); + if let Some((target_node, target_tree)) = tree.state().locate_node(target) { + let request = f(tree.state(), &context, target_node, target_tree); + drop(tree); + context.do_action(request); + Ok(()) + } else { + Err(Error::Defunct) } - let (target_node, target_tree) = state.locate_node(self.id).ok_or(Error::Defunct)?; - drop(tree); - context.do_action(ActionRequest { - action, - target_tree, - target_node, - data, - }); - Ok(()) } pub fn name(&self) -> Result { @@ -1057,7 +1050,12 @@ impl PlatformNode { if index != 0 { return Ok(false); } - self.dispatch_action(Action::Click, None)?; + self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + })?; Ok(true) } @@ -1113,15 +1111,22 @@ impl PlatformNode { } pub fn grab_focus(&self) -> Result { - self.dispatch_action(Action::Focus, None)?; + self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { + action: Action::Focus, + target_tree, + target_node, + data: None, + })?; Ok(true) } pub fn scroll_to(&self, scroll_type: ScrollType) -> Result { - self.dispatch_action( - Action::ScrollIntoView, - atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint), - )?; + self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { + action: Action::ScrollIntoView, + target_tree, + target_node, + data: atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint), + })?; Ok(true) } @@ -1146,6 +1151,19 @@ impl PlatformNode { Ok(true) } + pub fn set_text_contents(&self, value: &str) -> Result { + if self.resolve(|node| Ok(node.is_read_only()))? { + return Ok(false); + } + self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { + action: Action::SetValue, + target_tree, + target_node, + data: Some(ActionData::Value(value.into())), + })?; + Ok(true) + } + pub fn n_anchors(&self) -> Result { self.resolve(|node| if node.url().is_some() { Ok(1) } else { Ok(0) }) } @@ -1666,16 +1684,12 @@ impl PlatformNode { } pub fn set_current_value(&self, value: f64) -> Result<()> { - self.dispatch_action(Action::SetValue, Some(ActionData::NumericValue(value))) - } - - pub fn set_text_contents(&self, value: &str) -> Result { - self.dispatch_checked_action( - Action::SetValue, - Some(ActionData::Value(value.into())), - |node| NodeWrapper(&node).supports_editable_text(), - )?; - Ok(true) + self.do_action_internal(self.id, |_, _, target_node, target_tree| ActionRequest { + action: Action::SetValue, + target_tree, + target_node, + data: Some(ActionData::NumericValue(value)), + }) } } From 6e8ad7f322ea7a5646aa4f070fdfd25106e6bf0b Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 16 Aug 2026 17:13:45 +0200 Subject: [PATCH 5/6] refactor(unix): centralize editable text error mapping Use the interface-local map_error pattern shared by the other AT-SPI interfaces so node errors are translated consistently. --- adapters/unix/src/atspi/interfaces/editable_text.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/adapters/unix/src/atspi/interfaces/editable_text.rs b/adapters/unix/src/atspi/interfaces/editable_text.rs index 3eb8079d6..5681729a7 100644 --- a/adapters/unix/src/atspi/interfaces/editable_text.rs +++ b/adapters/unix/src/atspi/interfaces/editable_text.rs @@ -16,6 +16,10 @@ impl EditableTextInterface { pub fn new(node: PlatformNode) -> Self { Self(node) } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.0, error) + } } #[interface(name = "org.a11y.atspi.EditableText")] @@ -43,6 +47,6 @@ impl EditableTextInterface { fn set_text_contents(&self, new_contents: &str) -> fdo::Result { self.0 .set_text_contents(new_contents) - .map_err(|error| crate::util::map_error_from_node(&self.0, error)) + .map_err(self.map_error()) } } From 6a38e2c2c6f3943d2654c48faa664288ccecd1bb Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 16 Aug 2026 17:13:53 +0200 Subject: [PATCH 6/6] test(atspi): move editable text coverage to node module Keep EditableText behavior tests beside PlatformNode and restore the adapter cache tests to their focused fixtures. The relocated test covers writable dispatch, read-only rejection, and text-range-based interface support. --- adapters/atspi-common/src/adapter.rs | 98 ++++++---------------------- adapters/atspi-common/src/node.rs | 82 +++++++++++++++++++++++ 2 files changed, 102 insertions(+), 78 deletions(-) diff --git a/adapters/atspi-common/src/adapter.rs b/adapters/atspi-common/src/adapter.rs index a71fa1a8f..fdf64a4d3 100644 --- a/adapters/atspi-common/src/adapter.rs +++ b/adapters/atspi-common/src/adapter.rs @@ -617,28 +617,26 @@ mod tests { use super::Adapter; use crate::{AdapterCallback, AppContext, CacheEvent, Event, InterfaceSet, WindowBounds}; use accesskit::{ - Action, ActionData, ActionHandler, ActionRequest, Node, NodeId, Role, TreeId, TreeInfo, - TreeUpdate, + ActionHandler, ActionRequest, Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate, }; use accesskit_consumer::FullNodeId; - use atspi_common::Interface; use std::sync::{Arc, Mutex}; - type Log = Arc>>; - #[derive(Clone, Copy, Debug, PartialEq)] enum CacheOp { Added(FullNodeId), Removed(FullNodeId), } - struct Recorder(Log); + struct CapturingCallback { + ops: Arc>>, + } - impl AdapterCallback for Recorder { + impl AdapterCallback for CapturingCallback { fn register_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} fn unregister_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} fn emit_event(&self, _: &Adapter, event: Event) { - let mut ops = self.0.lock().unwrap(); + 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)), @@ -647,10 +645,9 @@ mod tests { } } - impl ActionHandler for Recorder { - fn do_action(&mut self, request: ActionRequest) { - self.0.lock().unwrap().push(request); - } + struct NoOpActionHandler; + impl ActionHandler for NoOpActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} } fn with_children(role: Role, children: &[NodeId]) -> Node { @@ -659,19 +656,18 @@ mod tests { node } - fn build(initial: TreeUpdate) -> (Adapter, Log, Log) { + fn build(initial: TreeUpdate) -> (Adapter, Arc>>) { let ops = Arc::new(Mutex::new(Vec::new())); - let actions = Arc::new(Mutex::new(Vec::new())); let app_context = AppContext::new(None); let adapter = Adapter::new( &app_context, - Recorder(ops.clone()), + CapturingCallback { ops: ops.clone() }, initial, false, WindowBounds::default(), - Recorder(actions.clone()), + NoOpActionHandler, ); - (adapter, ops, actions) + (adapter, ops) } fn initial_tree() -> TreeUpdate { @@ -695,69 +691,15 @@ mod tests { } } - #[test] - fn editable_text_support_and_dispatch() { - let mut text_input = Node::new(Role::TextInput); - text_input.add_action(Action::SetValue); - let (mut adapter, _, requests) = build(TreeUpdate { - nodes: vec![(NodeId(0), text_input.clone())], - ..initial_tree() - }); - let platform = adapter.platform_node(adapter.root_id()); - assert!( - platform - .interfaces() - .unwrap() - .contains(Interface::EditableText) - ); - - #[cfg(feature = "simplified-api")] - let node = crate::simplified::Accessible::Node(platform.clone()); - #[cfg(not(feature = "simplified-api"))] - let node = platform.clone(); - - assert!(node.supports_editable_text().unwrap()); - assert!(node.set_text_contents("hello").unwrap()); - - let mut read_only_input = text_input.clone(); - read_only_input.set_read_only(); - let mut numeric_input = text_input.clone(); - numeric_input.set_numeric_value(0.0); - let mut button = Node::new(Role::Button); - button.add_action(Action::SetValue); - for unsupported in [ - Node::new(Role::TextInput), - read_only_input, - numeric_input, - button, - ] { - adapter.update(update(vec![(NodeId(0), unsupported)])); - assert!(!node.supports_editable_text().unwrap()); - assert!(matches!( - node.set_text_contents("ignored"), - Err(crate::Error::UnsupportedInterface) - )); - } - assert_eq!( - requests.lock().unwrap().as_slice(), - &[ActionRequest { - action: Action::SetValue, - target_tree: TreeId::ROOT, - target_node: NodeId(0), - data: Some(ActionData::Value("hello".into())), - }] - ); - } - #[test] fn no_cache_events_on_construction() { - let (_adapter, ops, _) = build(initial_tree()); + 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()); + let (mut adapter, ops) = build(initial_tree()); ops.lock().unwrap().clear(); adapter.update(update(vec![ ( @@ -773,7 +715,7 @@ mod tests { #[test] fn remove_node_emits_removed_for_same_id() { - let (mut adapter, ops, _) = build(initial_tree()); + let (mut adapter, ops) = build(initial_tree()); adapter.update(update(vec![ ( NodeId(0), @@ -795,7 +737,7 @@ mod tests { #[test] fn subtree_add_emits_added_per_node() { - let (mut adapter, ops, _) = build(initial_tree()); + let (mut adapter, ops) = build(initial_tree()); ops.lock().unwrap().clear(); adapter.update(update(vec![ ( @@ -816,7 +758,7 @@ mod tests { #[test] fn subtree_remove_emits_removed_per_node() { - let (mut adapter, ops, _) = build(initial_tree()); + let (mut adapter, ops) = build(initial_tree()); adapter.update(update(vec![ ( NodeId(0), @@ -843,7 +785,7 @@ mod tests { fn filter_transition_into_tree_emits_added() { let mut hidden = Node::new(Role::Button); hidden.set_hidden(); - let (mut adapter, ops, _) = build(TreeUpdate { + let (mut adapter, ops) = build(TreeUpdate { nodes: vec![ ( NodeId(0), @@ -865,7 +807,7 @@ mod tests { #[test] fn filter_transition_out_of_tree_emits_removed() { - let (mut adapter, ops, _) = build(initial_tree()); + let (mut adapter, ops) = build(initial_tree()); ops.lock().unwrap().clear(); let mut hidden = Node::new(Role::Button); hidden.set_hidden(); diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 5d38a0771..4f7667448 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -1970,3 +1970,85 @@ pub struct CacheNode { pub role: AtspiRole, pub states: StateSet, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AdapterCallback, Event}; + use accesskit::{ActionHandler, Node, TreeInfo, TreeUpdate}; + use std::sync::mpsc::{self, Sender}; + + struct NoOpCallback; + + impl AdapterCallback for NoOpCallback { + fn register_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} + fn unregister_interfaces(&self, _: &Adapter, _: FullNodeId, _: InterfaceSet) {} + fn emit_event(&self, _: &Adapter, _: Event) {} + } + + struct Recorder(Sender); + + impl ActionHandler for Recorder { + fn do_action(&mut self, request: ActionRequest) { + self.0.send(request).unwrap(); + } + } + + #[test] + fn editable_text_support_and_dispatch() { + let mut input = Node::new(Role::TextInput); + input.push_child(NodeId(1)); + let mut text_run = Node::new(Role::TextRun); + text_run.set_value(""); + text_run.set_character_lengths([]); + let (sender, actions) = mpsc::channel(); + let app_context = AppContext::new(None); + let mut adapter = Adapter::new( + &app_context, + NoOpCallback, + TreeUpdate { + nodes: vec![(NodeId(0), input.clone()), (NodeId(1), text_run)], + tree: Some(TreeInfo::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }, + false, + WindowBounds::default(), + Recorder(sender), + ); + let node = adapter.platform_node(adapter.root_id()); + + assert!(node.supports_editable_text().unwrap()); + assert!(node.interfaces().unwrap().contains(Interface::EditableText)); + assert!(node.set_text_contents("hello").unwrap()); + + input.set_read_only(); + adapter.update(TreeUpdate { + nodes: vec![(NodeId(0), input.clone())], + tree: None, + tree_id: TreeId::ROOT, + focus: NodeId(0), + }); + assert!(node.supports_editable_text().unwrap()); + assert!(!node.set_text_contents("ignored").unwrap()); + + input.clear_children(); + adapter.update(TreeUpdate { + nodes: vec![(NodeId(0), input)], + tree: None, + tree_id: TreeId::ROOT, + focus: NodeId(0), + }); + assert!(!node.supports_editable_text().unwrap()); + + assert_eq!( + actions.try_iter().collect::>(), + [ActionRequest { + action: Action::SetValue, + target_tree: TreeId::ROOT, + target_node: NodeId(0), + data: Some(ActionData::Value("hello".into())), + }] + ); + } +}