From 2f6f2f5629eb5e1c9fa91fd64ab0771f742e5028 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Thu, 27 Aug 2026 00:09:08 +0200 Subject: [PATCH] feat: Support the Document interface on Unix --- adapters/atspi-common/src/adapter.rs | 7 ++- adapters/atspi-common/src/events.rs | 9 +++ adapters/atspi-common/src/node.rs | 54 ++++++++++++++++- adapters/atspi-common/src/simplified.rs | 37 +++++++++++- adapters/unix/src/atspi/bus.rs | 33 +++++++++- .../unix/src/atspi/interfaces/document.rs | 60 +++++++++++++++++++ adapters/unix/src/atspi/interfaces/mod.rs | 2 + adapters/unix/src/context.rs | 8 +++ 8 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 adapters/unix/src/atspi/interfaces/document.rs diff --git a/adapters/atspi-common/src/adapter.rs b/adapters/atspi-common/src/adapter.rs index fdf64a4d3..58255cfd3 100644 --- a/adapters/atspi-common/src/adapter.rs +++ b/adapters/atspi-common/src/adapter.rs @@ -9,7 +9,7 @@ // found in the LICENSE.chromium file. use crate::{ - AdapterCallback, CacheEvent, Event, ObjectEvent, WindowEvent, + AdapterCallback, CacheEvent, DocumentEvent, Event, ObjectEvent, WindowEvent, context::{ActionHandlerNoMut, ActionHandlerWrapper, AppContext, Context}, filters::filter, node::{NodeIdOrRoot, NodeWrapper, PlatformNode, PlatformRoot}, @@ -510,6 +510,11 @@ impl Adapter { .emit_event(self, Event::Object { target, event }); } + pub(crate) fn emit_document_event(&self, target: FullNodeId, event: DocumentEvent) { + self.callback + .emit_event(self, Event::Document { target, event }); + } + fn emit_cache_added(&self, target: FullNodeId) { self.callback .emit_event(self, Event::Cache(CacheEvent::Added(target))); diff --git a/adapters/atspi-common/src/events.rs b/adapters/atspi-common/src/events.rs index c789a762e..c176c4bf3 100644 --- a/adapters/atspi-common/src/events.rs +++ b/adapters/atspi-common/src/events.rs @@ -19,6 +19,10 @@ pub enum Event { name: String, event: WindowEvent, }, + Document { + target: FullNodeId, + event: DocumentEvent, + }, Cache(CacheEvent), } @@ -67,3 +71,8 @@ pub enum WindowEvent { Activated, Deactivated, } + +#[derive(Debug)] +pub enum DocumentEvent { + LoadComplete, +} diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index 97c403768..22ff9e52e 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -24,7 +24,7 @@ use std::{ }; use crate::{ - Action as AtspiAction, Error, ObjectEvent, Property, Rect as AtspiRect, Result, + Action as AtspiAction, DocumentEvent, Error, ObjectEvent, Property, Rect as AtspiRect, Result, adapter::Adapter, context::{AppContext, Context}, filters::filter, @@ -336,6 +336,9 @@ impl NodeWrapper<'_> { if atspi_role != AtspiRole::ToggleButton && state.toggled().is_some() { atspi_state.insert(State::Checkable); } + if state.is_busy() { + atspi_state.insert(State::Busy); + } if state.is_modal() { atspi_state.insert(State::Modal); } @@ -445,6 +448,29 @@ impl NodeWrapper<'_> { self.0.raw_bounds().is_some() || self.is_root() } + fn supports_document(&self) -> bool { + matches!(self.0.role(), Role::RootWebArea | Role::PdfRoot) + } + + fn document_attributes(&self) -> HashMap<&'static str, String> { + let mut attributes = HashMap::new(); + if let Some(title) = self.0.label() { + attributes.insert("title", title); + } + if let Some(uri) = self.0.url() { + attributes.insert("uri", uri.to_string()); + } + + attributes + } + + fn document_attribute_value(&self, name: &str) -> Option { + self.document_attributes() + .into_iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value) + } + fn supports_editable_text(&self) -> bool { self.0.is_text_input() && self.0.supports_text_ranges() } @@ -473,6 +499,9 @@ impl NodeWrapper<'_> { if self.supports_component() { interfaces.insert(Interface::Component); } + if self.supports_document() { + interfaces.insert(Interface::Document); + } if self.supports_editable_text() { interfaces.insert(Interface::EditableText); } @@ -559,6 +588,7 @@ impl NodeWrapper<'_> { self.notify_property_changes(adapter, old); self.notify_bounds_changes(window_bounds, adapter, old); self.notify_children_changes(adapter, old); + self.notify_document_changes(adapter, old); } fn notify_state_changes(&self, adapter: &Adapter, old: &NodeWrapper<'_>) { @@ -641,6 +671,12 @@ impl NodeWrapper<'_> { } } + fn notify_document_changes(&self, adapter: &Adapter, old: &NodeWrapper<'_>) { + if self.supports_document() && old.0.is_busy() && !self.0.is_busy() { + adapter.emit_document_event(self.id(), DocumentEvent::LoadComplete); + } + } + fn notify_children_changes(&self, adapter: &Adapter, old: &NodeWrapper<'_>) { let old_filtered_children = old.filtered_child_ids().collect::>(); let new_filtered_children = self.filtered_child_ids().collect::>(); @@ -980,6 +1016,22 @@ impl PlatformNode { }) } + pub fn supports_document(&self) -> Result { + self.resolve(|node| Ok(NodeWrapper(&node).supports_document())) + } + + pub fn document_attributes(&self) -> Result> { + self.resolve(|node| Ok(NodeWrapper(&node).document_attributes())) + } + + pub fn document_attribute_value(&self, name: &str) -> Result { + self.resolve(|node| { + Ok(NodeWrapper(&node) + .document_attribute_value(name) + .unwrap_or_default()) + }) + } + pub fn supports_editable_text(&self) -> Result { self.resolve(|node| Ok(NodeWrapper(&node).supports_editable_text())) } diff --git a/adapters/atspi-common/src/simplified.rs b/adapters/atspi-common/src/simplified.rs index f8241be10..ba814cb21 100644 --- a/adapters/atspi-common/src/simplified.rs +++ b/adapters/atspi-common/src/simplified.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; use crate::{ - Adapter, CacheEvent, Event as EventEnum, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, - Property, WindowEvent, + Adapter, CacheEvent, DocumentEvent, Event as EventEnum, NodeIdOrRoot, ObjectEvent, + PlatformNode, PlatformRoot, Property, WindowEvent, }; pub use crate::{ @@ -238,6 +238,27 @@ impl Accessible { } } + pub fn supports_document(&self) -> Result { + match self { + Self::Node(node) => node.supports_document(), + Self::Root(_) => Ok(false), + } + } + + pub fn document_attributes(&self) -> Result> { + match self { + Self::Node(node) => node.document_attributes(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn document_attribute_value(&self, name: &str) -> Result { + match self { + Self::Node(node) => node.document_attribute_value(name), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + pub fn supports_editable_text(&self) -> Result { match self { Self::Node(node) => node.supports_editable_text(), @@ -777,6 +798,18 @@ impl Event { data: Some(EventData::String(name)), } } + EventEnum::Document { target, event } => { + let kind = match event { + DocumentEvent::LoadComplete => "document:load-complete", + }; + Self { + kind: kind.into(), + source: Accessible::Node(adapter.platform_node(target)), + detail1: 0, + detail2: 0, + data: None, + } + } EventEnum::Cache(cache_event) => { let (kind, target) = match cache_event { CacheEvent::Added(target) => ("cache:add", target), diff --git a/adapters/unix/src/atspi/bus.rs b/adapters/unix/src/atspi/bus.rs index 87567756a..9375ff5eb 100644 --- a/adapters/unix/src/atspi/bus.rs +++ b/adapters/unix/src/atspi/bus.rs @@ -9,7 +9,8 @@ use crate::{ executor::{Executor, Task}, }; use accesskit_atspi_common::{ - FullNodeId, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, Property, WindowEvent, + DocumentEvent, FullNodeId, NodeIdOrRoot, ObjectEvent, PlatformNode, PlatformRoot, Property, + WindowEvent, }; use atspi::{ Interface, InterfaceSet, ObjectRefOwned, @@ -141,6 +142,10 @@ impl Bus { ) .await?; } + if new_interfaces.contains(Interface::Document) { + self.register_interface(&path, DocumentInterface::new(node.clone())) + .await?; + } if new_interfaces.contains(Interface::EditableText) { self.register_interface(&path, EditableTextInterface::new(node.clone())) .await?; @@ -204,6 +209,10 @@ impl Bus { self.unregister_interface::(&path) .await?; } + if old_interfaces.contains(Interface::Document) { + self.unregister_interface::(&path) + .await?; + } if old_interfaces.contains(Interface::EditableText) { self.unregister_interface::(&path) .await?; @@ -400,6 +409,28 @@ impl Bus { .await } + pub(crate) async fn emit_document_event( + &self, + adapter_id: usize, + target: FullNodeId, + event: DocumentEvent, + ) -> Result<()> { + let target = ObjectId::Node { + adapter: adapter_id, + node: target, + }; + let signal = match event { + DocumentEvent::LoadComplete => "LoadComplete", + }; + self.emit_event( + target, + "org.a11y.atspi.Event.Document", + signal, + EventBodyBorrowed::default(), + ) + .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(()); diff --git a/adapters/unix/src/atspi/interfaces/document.rs b/adapters/unix/src/atspi/interfaces/document.rs new file mode 100644 index 000000000..84c87f492 --- /dev/null +++ b/adapters/unix/src/atspi/interfaces/document.rs @@ -0,0 +1,60 @@ +// 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 atspi::TextSelection; +use std::collections::HashMap; +use zbus::{fdo, interface}; + +fn unsupported() -> fdo::Error { + fdo::Error::NotSupported("document operation is not supported".into()) +} + +pub(crate) struct DocumentInterface(PlatformNode); + +impl DocumentInterface { + 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.Document")] +impl DocumentInterface { + #[zbus(property)] + fn current_page_number(&self) -> fdo::Result { + Err(unsupported()) + } + + #[zbus(property)] + fn page_count(&self) -> fdo::Result { + Err(unsupported()) + } + + fn get_attribute_value(&self, attribute_name: &str) -> fdo::Result { + self.0 + .document_attribute_value(attribute_name) + .map_err(self.map_error()) + } + + fn get_attributes(&self) -> fdo::Result> { + self.0.document_attributes().map_err(self.map_error()) + } + + fn get_locale(&self) -> fdo::Result { + Err(unsupported()) + } + + fn get_text_selections(&self) -> fdo::Result> { + Err(unsupported()) + } + + fn set_text_selections(&self, _selections: Vec) -> fdo::Result { + Err(unsupported()) + } +} diff --git a/adapters/unix/src/atspi/interfaces/mod.rs b/adapters/unix/src/atspi/interfaces/mod.rs index b1d96a382..99bdbd294 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 document; mod editable_text; mod hyperlink; mod selection; @@ -36,6 +37,7 @@ pub(crate) use action::*; pub(crate) use application::*; pub(crate) use cache::*; pub(crate) use component::*; +pub(crate) use document::*; pub(crate) use editable_text::*; pub(crate) use hyperlink::*; pub(crate) use selection::*; diff --git a/adapters/unix/src/context.rs b/adapters/unix/src/context.rs index 5a990886f..b2e7d4b1f 100644 --- a/adapters/unix/src/context.rs +++ b/adapters/unix/src/context.rs @@ -275,6 +275,14 @@ async fn process_adapter_message( .await?; } } + Message::EmitEvent { + adapter_id, + event: Event::Document { target, event }, + } => { + if let Some(bus) = atspi_bus { + bus.emit_document_event(adapter_id, target, event).await?; + } + } Message::EmitEvent { event: Event::Cache(_), ..