diff --git a/consumer/src/node.rs b/consumer/src/node.rs index ab3ed0e9..7429a9cf 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -738,6 +738,21 @@ impl<'a> Node<'a> { .map(|description| description.to_string()) } + pub fn url(&self) -> Option<&str> { + self.data().url() + } + + pub fn supports_url(&self) -> bool { + matches!( + self.role(), + Role::Link + | Role::DocBackLink + | Role::DocBiblioRef + | Role::DocGlossRef + | Role::DocNoteRef + ) && self.url().is_some() + } + fn is_empty_text_input(&self) -> bool { let mut text_runs = self.text_runs(); if let Some(first_text_run) = text_runs.next() { diff --git a/platforms/android/src/node.rs b/platforms/android/src/node.rs index 37848354..6a69ad5d 100644 --- a/platforms/android/src/node.rs +++ b/platforms/android/src/node.rs @@ -83,6 +83,14 @@ impl NodeWrapper<'_> { self.0.label() } + fn url(&self) -> Option<&str> { + if self.0.supports_url() || self.0.role() == Role::Image { + self.0.url() + } else { + None + } + } + pub(crate) fn text(&self) -> Option { self.0.value().or_else(|| { self.0 @@ -321,6 +329,23 @@ impl NodeWrapper<'_> { .unwrap(); } + if let Some(url) = self.url() { + let extras = env + .call_method(node_info, "getExtras", "()Landroid/os/Bundle;", &[]) + .unwrap() + .l() + .unwrap(); + let key = env.new_string("url").unwrap(); + let value = env.new_string(url).unwrap(); + env.call_method( + &extras, + "putString", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[(&key).into(), (&value).into()], + ) + .unwrap(); + } + let class_name = env.new_string(self.class_name()).unwrap(); env.call_method( node_info, diff --git a/platforms/atspi-common/src/node.rs b/platforms/atspi-common/src/node.rs index ad59a618..9e36cde3 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -415,6 +415,10 @@ impl NodeWrapper<'_> { self.0.raw_bounds().is_some() || self.is_root() } + fn supports_hyperlink(&self) -> bool { + self.0.supports_url() + } + fn supports_selection(&self) -> bool { self.0.is_container_with_selectable_children() } @@ -435,6 +439,9 @@ impl NodeWrapper<'_> { if self.supports_component() { interfaces.insert(Interface::Component); } + if self.supports_hyperlink() { + interfaces.insert(Interface::Hyperlink); + } if self.supports_selection() { interfaces.insert(Interface::Selection); } @@ -908,6 +915,13 @@ impl PlatformNode { }) } + pub fn supports_hyperlink(&self) -> Result { + self.resolve(|node| { + let wrapper = NodeWrapper(&node); + Ok(wrapper.supports_hyperlink()) + }) + } + pub fn supports_selection(&self) -> Result { self.resolve(|node| { let wrapper = NodeWrapper(&node); @@ -1070,6 +1084,48 @@ impl PlatformNode { Ok(true) } + pub fn n_anchors(&self) -> Result { + self.resolve(|node| if node.url().is_some() { Ok(1) } else { Ok(0) }) + } + + pub fn hyperlink_start_index(&self) -> Result { + self.resolve(|_| { + // TODO: Support rich text + Ok(-1) + }) + } + + pub fn hyperlink_end_index(&self) -> Result { + self.resolve(|_| { + // TODO: Support rich text + Ok(-1) + }) + } + + pub fn hyperlink_object(&self, index: i32) -> Result> { + self.resolve(|_| { + if index == 0 { + Ok(Some(self.id)) + } else { + Ok(None) + } + }) + } + + pub fn uri(&self, index: i32) -> Result { + self.resolve(|node| { + if index == 0 { + Ok(node.url().map(|s| s.to_string()).unwrap_or_default()) + } else { + Ok(String::new()) + } + }) + } + + pub fn hyperlink_is_valid(&self) -> Result { + self.resolve(|node| Ok(node.url().is_some())) + } + pub fn n_selected_children(&self) -> Result { self.resolve_for_selection(|node| { node.items(filter) diff --git a/platforms/atspi-common/src/simplified.rs b/platforms/atspi-common/src/simplified.rs index be0886af..87be5f5e 100644 --- a/platforms/atspi-common/src/simplified.rs +++ b/platforms/atspi-common/src/simplified.rs @@ -238,6 +238,57 @@ impl Accessible { } } + pub fn supports_hyperlink(&self) -> Result { + match self { + Self::Node(node) => node.supports_hyperlink(), + Self::Root(_) => Ok(false), + } + } + + pub fn n_anchors(&self) -> Result { + match self { + Self::Node(node) => node.n_anchors(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn hyperlink_start_index(&self) -> Result { + match self { + Self::Node(node) => node.hyperlink_start_index(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn hyperlink_end_index(&self) -> Result { + match self { + Self::Node(node) => node.hyperlink_end_index(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn hyperlink_object(&self, index: i32) -> Result> { + match self { + Self::Node(node) => node + .hyperlink_object(index) + .map(|id| id.map(|id| Self::Node(node.relative(id)))), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn uri(&self, index: i32) -> Result { + match self { + Self::Node(node) => node.uri(index), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn hyperlink_is_valid(&self) -> Result { + match self { + Self::Node(node) => node.hyperlink_is_valid(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + pub fn supports_selection(&self) -> Result { match self { Self::Node(node) => node.supports_selection(), diff --git a/platforms/macos/src/node.rs b/platforms/macos/src/node.rs index 0502592f..8e213b25 100644 --- a/platforms/macos/src/node.rs +++ b/platforms/macos/src/node.rs @@ -22,7 +22,7 @@ use objc2::{ use objc2_app_kit::*; use objc2_foundation::{ ns_string, NSArray, NSCopying, NSInteger, NSNumber, NSObject, NSObjectProtocol, NSPoint, - NSRange, NSRect, NSString, + NSRange, NSRect, NSString, NSURL, }; use std::rc::{Rc, Weak}; @@ -592,6 +592,17 @@ declare_class!( .flatten() } + #[method_id(accessibilityURL)] + fn url(&self) -> Option> { + self.resolve(|node| { + node.supports_url().then(|| node.url()).flatten().and_then(|url| { + let ns_string = NSString::from_str(url); + unsafe { NSURL::URLWithString(&ns_string) } + }) + }) + .flatten() + } + #[method(accessibilityOrientation)] fn orientation(&self) -> NSAccessibilityOrientation { self.resolve(|node| { @@ -1123,6 +1134,9 @@ declare_class!( if selector == sel!(accessibilityAttributeValue:) { return node.has_braille_label() || node.has_braille_role_description() } + if selector == sel!(accessibilityURL) { + return node.supports_url(); + } selector == sel!(accessibilityParent) || selector == sel!(accessibilityChildren) || selector == sel!(accessibilityChildrenInNavigationOrder) diff --git a/platforms/unix/src/atspi/bus.rs b/platforms/unix/src/atspi/bus.rs index 2e8fef47..1b0f38e6 100644 --- a/platforms/unix/src/atspi/bus.rs +++ b/platforms/unix/src/atspi/bus.rs @@ -118,20 +118,27 @@ impl Bus { ) .await?; } - if new_interfaces.contains(Interface::Selection) { + if new_interfaces.contains(Interface::Hyperlink) { self.register_interface( &path, - SelectionInterface::new(bus_name.clone(), node.clone()), + HyperlinkInterface::new(bus_name.clone(), node.clone()), ) .await?; - } - if new_interfaces.contains(Interface::Text) { - self.register_interface(&path, TextInterface::new(node.clone())) - .await?; - } - if new_interfaces.contains(Interface::Value) { - self.register_interface(&path, ValueInterface::new(node.clone())) + if new_interfaces.contains(Interface::Selection) { + self.register_interface( + &path, + SelectionInterface::new(bus_name.clone(), node.clone()), + ) .await?; + } + if new_interfaces.contains(Interface::Text) { + self.register_interface(&path, TextInterface::new(node.clone())) + .await?; + } + if new_interfaces.contains(Interface::Value) { + self.register_interface(&path, ValueInterface::new(node.clone())) + .await?; + } } Ok(()) @@ -170,6 +177,10 @@ impl Bus { self.unregister_interface::(&path) .await?; } + if old_interfaces.contains(Interface::Hyperlink) { + self.unregister_interface::(&path) + .await?; + } if old_interfaces.contains(Interface::Selection) { self.unregister_interface::(&path) .await?; diff --git a/platforms/unix/src/atspi/interfaces/hyperlink.rs b/platforms/unix/src/atspi/interfaces/hyperlink.rs new file mode 100644 index 00000000..fbb12f7e --- /dev/null +++ b/platforms/unix/src/atspi/interfaces/hyperlink.rs @@ -0,0 +1,63 @@ +// 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, names::OwnedUniqueName}; + +use crate::atspi::{ObjectId, OwnedObjectAddress}; + +pub(crate) struct HyperlinkInterface { + bus_name: OwnedUniqueName, + node: PlatformNode, +} + +impl HyperlinkInterface { + pub fn new(bus_name: OwnedUniqueName, node: PlatformNode) -> Self { + Self { bus_name, node } + } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.node, error) + } +} + +#[interface(name = "org.a11y.atspi.Hyperlink")] +impl HyperlinkInterface { + #[zbus(property)] + fn n_anchors(&self) -> fdo::Result { + self.node.n_anchors().map_err(self.map_error()) + } + + #[zbus(property)] + fn start_index(&self) -> fdo::Result { + self.node.hyperlink_start_index().map_err(self.map_error()) + } + + #[zbus(property)] + fn end_index(&self) -> fdo::Result { + self.node.hyperlink_end_index().map_err(self.map_error()) + } + + fn get_object(&self, index: i32) -> fdo::Result<(OwnedObjectAddress,)> { + let object = self + .node + .hyperlink_object(index) + .map_err(self.map_error())? + .map(|node| ObjectId::Node { + adapter: self.node.adapter_id(), + node, + }); + Ok(super::optional_object_address(&self.bus_name, object)) + } + + #[zbus(name = "GetURI")] + fn get_uri(&self, index: i32) -> fdo::Result { + self.node.uri(index).map_err(self.map_error()) + } + + fn is_valid(&self) -> fdo::Result { + self.node.hyperlink_is_valid().map_err(self.map_error()) + } +} diff --git a/platforms/unix/src/atspi/interfaces/mod.rs b/platforms/unix/src/atspi/interfaces/mod.rs index 1cb4c910..2e30905c 100644 --- a/platforms/unix/src/atspi/interfaces/mod.rs +++ b/platforms/unix/src/atspi/interfaces/mod.rs @@ -7,6 +7,7 @@ mod accessible; mod action; mod application; mod component; +mod hyperlink; mod selection; mod text; mod value; @@ -32,6 +33,7 @@ pub(crate) use accessible::*; pub(crate) use action::*; pub(crate) use application::*; pub(crate) use component::*; +pub(crate) use hyperlink::*; pub(crate) use selection::*; pub(crate) use text::*; pub(crate) use value::*; diff --git a/platforms/windows/src/node.rs b/platforms/windows/src/node.rs index d0d1c7a3..a343f762 100644 --- a/platforms/windows/src/node.rs +++ b/platforms/windows/src/node.rs @@ -15,7 +15,10 @@ use accesskit::{ Orientation, Point, Role, SortDirection, Toggled, TreeId, }; use accesskit_consumer::{FilterResult, Node, NodeId, Tree, TreeState}; -use std::sync::{atomic::Ordering, Arc, Weak}; +use std::{ + fmt::Write, + sync::{atomic::Ordering, Arc, Weak}, +}; use windows::{ core::*, Win32::{ @@ -570,6 +573,9 @@ impl NodeWrapper<'_> { } fn is_value_pattern_supported(&self) -> bool { + if self.0.supports_url() { + return true; + } self.0.has_value() && !self.0.label_comes_from_value() } @@ -578,6 +584,11 @@ impl NodeWrapper<'_> { } fn value(&self) -> WideString { + if let Some(url) = self.0.supports_url().then(|| self.0.url()).flatten() { + let mut result = WideString::default(); + result.write_str(url).unwrap(); + return result; + } let mut result = WideString::default(); self.0.write_value(&mut result).unwrap(); result