diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 55210999..697bf697 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -695,6 +695,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..7fd506b9 100644 --- a/platforms/android/src/node.rs +++ b/platforms/android/src/node.rs @@ -83,6 +83,15 @@ impl NodeWrapper<'_> { self.0.label() } + fn target_url(&self) -> Option<&str> { + // Use supports_url() for link-type roles, plus Image as Android-specific + 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 +330,23 @@ impl NodeWrapper<'_> { .unwrap(); } + if let Some(url) = self.target_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 33aa2279..190667eb 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -427,6 +427,10 @@ impl NodeWrapper<'_> { self.current_value().is_some() } + fn supports_hyperlink(&self) -> bool { + self.0.supports_url() + } + pub(crate) fn interfaces(&self) -> InterfaceSet { let mut interfaces = InterfaceSet::new(Interface::Accessible); if self.supports_action() { @@ -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); @@ -1065,6 +1079,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 095e8547..91b44488 100644 --- a/platforms/macos/src/node.rs +++ b/platforms/macos/src/node.rs @@ -24,7 +24,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}; @@ -588,6 +588,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| { @@ -1092,6 +1103,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 afeb9d09..69885a67 100644 --- a/platforms/unix/src/atspi/bus.rs +++ b/platforms/unix/src/atspi/bus.rs @@ -134,6 +134,13 @@ impl Bus { self.register_interface(&path, ValueInterface::new(node.clone())) .await?; } + if new_interfaces.contains(Interface::Hyperlink) { + self.register_interface( + &path, + HyperlinkInterface::new(bus_name.clone(), node.clone()), + ) + .await?; + } Ok(()) } @@ -181,6 +188,10 @@ impl Bus { if old_interfaces.contains(Interface::Value) { self.unregister_interface::(&path).await?; } + if old_interfaces.contains(Interface::Hyperlink) { + self.unregister_interface::(&path) + .await?; + } Ok(()) } 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 ee8f37ca..27d6b3e5 100644 --- a/platforms/windows/src/node.rs +++ b/platforms/windows/src/node.rs @@ -15,7 +15,10 @@ use accesskit::{ Orientation, Point, Role, SortDirection, Toggled, }; use accesskit_consumer::{FilterResult, Node, TreeState}; -use std::sync::{atomic::Ordering, Arc, Weak}; +use std::{ + fmt::Write, + sync::{atomic::Ordering, Arc, Weak}, +}; use windows::{ core::*, Win32::{ @@ -568,6 +571,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() } @@ -576,6 +582,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