diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 4e7453dcc..4f7667448 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -24,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>); @@ -438,6 +438,10 @@ impl NodeWrapper<'_> { self.0.raw_bounds().is_some() || self.is_root() } + fn supports_editable_text(&self) -> bool { + self.0.is_text_input() && self.0.supports_text_ranges() + } + fn supports_hyperlink(&self) -> bool { self.0.supports_url() } @@ -462,6 +466,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); } @@ -487,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 { @@ -966,6 +977,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); @@ -1136,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) }) } @@ -1942,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())), + }] + ); + } +} 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..5681729a7 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/editable_text.rs @@ -0,0 +1,52 @@ +// 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) + } + + 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")] +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(self.map_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::*;