From 08e0e73e5efac7ad333e89b67fb58f891fac0562 Mon Sep 17 00:00:00 2001 From: mirsella Date: Sun, 9 Aug 2026 14:31:08 +0200 Subject: [PATCH] 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::*;