From d78ce8b873b10ebd31c107595b9ea16b8078cbe6 Mon Sep 17 00:00:00 2001 From: Matt Campbell Date: Thu, 26 Feb 2026 20:45:27 -0600 Subject: [PATCH 1/2] feat: Expose text attributes on macOS --- consumer/src/text.rs | 27 ++++--- platforms/macos/Cargo.toml | 2 + platforms/macos/src/node.rs | 137 +++++++++++++++++++++++++++++++++++- platforms/macos/src/util.rs | 19 ++++- 4 files changed, 171 insertions(+), 14 deletions(-) diff --git a/consumer/src/text.rs b/consumer/src/text.rs index dfce8093..375cd37c 100644 --- a/consumer/src/text.rs +++ b/consumer/src/text.rs @@ -677,14 +677,11 @@ impl<'a> Range<'a> { None } - pub fn text(&self) -> String { - let mut result = String::new(); - self.write_text(&mut result).unwrap(); - result - } - - pub fn write_text(&self, mut writer: W) -> fmt::Result { - if let Some(err) = self.walk(|node| { + pub fn traverse_text(&self, mut f: F) -> Option + where + F: FnMut(&Node<'a>, &str) -> Option, + { + self.walk(|node| { let character_lengths = node.data().character_lengths(); let start_index = if node.id() == self.start.node.id() { self.start.character_index @@ -715,14 +712,24 @@ impl<'a> Range<'a> { .sum::(); &value[slice_start..slice_end] }; - writer.write_str(s).err() - }) { + f(node, s) + }) + } + + pub fn write_text(&self, mut writer: W) -> fmt::Result { + if let Some(err) = self.traverse_text(|_, s| writer.write_str(s).err()) { Err(err) } else { Ok(()) } } + pub fn text(&self) -> String { + let mut result = String::new(); + self.write_text(&mut result).unwrap(); + result + } + /// Returns the range's transformed bounding boxes relative to the tree's /// container (e.g. window). /// diff --git a/platforms/macos/Cargo.toml b/platforms/macos/Cargo.toml index 3faf4e36..099530d9 100644 --- a/platforms/macos/Cargo.toml +++ b/platforms/macos/Cargo.toml @@ -30,7 +30,9 @@ objc2-app-kit = { version = "0.2.0", features = [ "NSAccessibilityConstants", "NSAccessibilityElement", "NSAccessibilityProtocols", + "NSColor", "NSResponder", + "NSText", "NSView", "NSWindow", ] } diff --git a/platforms/macos/src/node.rs b/platforms/macos/src/node.rs index 8e213b25..2ffe97e6 100644 --- a/platforms/macos/src/node.rs +++ b/platforms/macos/src/node.rs @@ -10,7 +10,9 @@ #![allow(non_upper_case_globals)] -use accesskit::{Action, ActionData, ActionRequest, Orientation, Role, TextSelection, Toggled}; +use accesskit::{ + Action, ActionData, ActionRequest, Orientation, Role, TextAlign, TextSelection, Toggled, +}; use accesskit_consumer::{FilterResult, Node, NodeId, Tree}; use objc2::{ declare_class, msg_send_id, @@ -21,8 +23,9 @@ use objc2::{ }; use objc2_app_kit::*; use objc2_foundation::{ - ns_string, NSArray, NSCopying, NSInteger, NSNumber, NSObject, NSObjectProtocol, NSPoint, - NSRange, NSRect, NSString, NSURL, + ns_string, NSArray, NSAttributedString, NSCopying, NSInteger, NSMutableAttributedString, + NSMutableDictionary, NSNumber, NSObject, NSObjectProtocol, NSPoint, NSRange, NSRect, NSString, + NSURL, }; use std::rc::{Rc, Weak}; @@ -823,6 +826,112 @@ declare_class!( .flatten() } + #[method_id(accessibilityAttributedStringForRange:)] + fn attributed_string_for_range(&self, range: NSRange) -> Option> { + self.resolve(|node| { + if node.supports_text_ranges() { + if let Some(range) = from_ns_range(node, range) { + let mut result = NSMutableAttributedString::new(); + unsafe { result.beginEditing() }; + range.traverse_text::<_, ()>(|node, text| { + let ns_text = NSString::from_str(text); + let mut attrs = NSMutableDictionary::new(); + if let Some(color) = node.background_color() { + attrs.insert_id( + unsafe { NSAccessibilityBackgroundColorTextAttribute }, + to_color_attribute(color) + ); + } + if let Some(color) = node.foreground_color() { + attrs.insert_id( + unsafe { NSAccessibilityForegroundColorTextAttribute }, + to_color_attribute(color) + ); + } + let mut font_attrs = NSMutableDictionary::::new(); + if let Some(family) = node.font_family() { + font_attrs.insert_id( + unsafe { NSAccessibilityFontFamilyKey }, + Id::into_super(Id::into_super(NSString::from_str(family))) + ); + } + if let Some(size) = node.font_size() { + font_attrs.insert_id( + unsafe { NSAccessibilityFontSizeKey }, + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_f32(size)))) + ); + } + if let Some(weight) = node.font_weight() { + if weight >= 700.0 { + font_attrs.insert_id( + ns_string!("AXFontBold"), + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_bool(true)))) + ); + } + } + if node.is_italic() { + font_attrs.insert_id( + ns_string!("AXFontItalic"), + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_bool(true)))) + ); + } + if !font_attrs.is_empty() { + attrs.insert_id( + unsafe { NSAccessibilityFontTextAttribute }, + Id::into_super(Id::into_super(Id::into_super(font_attrs))) + ); + } + if let Some(deco) = node.underline() { + attrs.insert_id( + unsafe { NSAccessibilityUnderlineTextAttribute }, + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_bool(true)))) + ); + attrs.insert_id( + unsafe { NSAccessibilityUnderlineColorTextAttribute }, + to_color_attribute(deco.color) + ); + } + if let Some(deco) = node.strikethrough() { + attrs.insert_id( + unsafe { NSAccessibilityStrikethroughTextAttribute }, + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_bool(true)))) + ); + attrs.insert_id( + unsafe { NSAccessibilityStrikethroughColorTextAttribute }, + to_color_attribute(deco.color) + ); + } + if let Some(language) = node.language() { + attrs.insert_id( + unsafe { NSAccessibilityLanguageTextAttribute }, + Id::into_super(Id::into_super(NSString::from_str(language))) + ); + } + if let Some(align) = node.text_align() { + let ns_align = match align { + TextAlign::Left => NSTextAlignment::Left, + TextAlign::Center => NSTextAlignment::Center, + TextAlign::Right => NSTextAlignment::Right, + TextAlign::Justify => NSTextAlignment::Justified, + }; + attrs.insert_id( + unsafe { NSAccessibilityTextAlignmentAttribute }, + Id::into_super(Id::into_super(Id::into_super(NSNumber::new_isize(ns_align.0)))) + ); + } + let part = unsafe { NSAttributedString::new_with_attributes(&ns_text, &attrs) }; + unsafe { result.appendAttributedString(&part) }; + None + }); + unsafe { result.endEditing() }; + return Some(Id::into_super(result)); + } + } + None + }) + .flatten() + } + #[method(accessibilityFrameForRange:)] fn frame_for_range(&self, range: NSRange) -> NSRect { self.resolve_with_context(|node, _, context| { @@ -874,6 +983,26 @@ declare_class!( .unwrap_or_else(|| NSRange::new(0, 0)) } + #[method(accessibilityStyleRangeForIndex:)] + fn style_range_for_index(&self, index: NSInteger) -> NSRange { + self.resolve(|node| { + if node.supports_text_ranges() && index >= 0 { + if let Some(pos) = node.text_position_from_global_utf16_index(index as _) { + let start = if pos.is_format_start() { + pos + } else { + pos.backward_to_format_start() + }; + let mut range = start.to_degenerate_range(); + range.set_end(pos.forward_to_format_end()); + return to_ns_range(&range); + } + } + NSRange::new(0, 0) + }) + .unwrap_or_else(|| NSRange::new(0, 0)) + } + #[method(setAccessibilitySelectedTextRange:)] fn set_selected_text_range(&self, range: NSRange) { self.resolve_with_context(|node, tree, context| { @@ -1097,9 +1226,11 @@ declare_class!( || selector == sel!(accessibilityRangeForLine:) || selector == sel!(accessibilityRangeForPosition:) || selector == sel!(accessibilityStringForRange:) + || selector == sel!(accessibilityAttributedStringForRange:) || selector == sel!(accessibilityFrameForRange:) || selector == sel!(accessibilityLineForIndex:) || selector == sel!(accessibilityRangeForIndex:) + || selector == sel!(accessibilityStyleRangeForIndex:) || selector == sel!(setAccessibilitySelectedTextRange:) { return node.supports_text_ranges(); diff --git a/platforms/macos/src/util.rs b/platforms/macos/src/util.rs index e31dacd6..d08b52c7 100644 --- a/platforms/macos/src/util.rs +++ b/platforms/macos/src/util.rs @@ -3,8 +3,9 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. -use accesskit::{Point, Rect}; +use accesskit::{Color, Point, Rect}; use accesskit_consumer::{Node, TextPosition, TextRange}; +use objc2::{msg_send_id, rc::Id, runtime::AnyObject}; use objc2_app_kit::*; use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize}; @@ -77,3 +78,19 @@ pub(crate) fn to_ns_rect(view: &NSView, rect: Rect) -> NSRect { let window = view.window().unwrap(); window.convertRectToScreen(rect) } + +fn color_channel_to_f64(channel: u8) -> f64 { + (channel as f64) / 255.0 +} + +pub(crate) fn to_color_attribute(color: Color) -> Id { + let ns_color = unsafe { + NSColor::colorWithSRGBRed_green_blue_alpha( + color_channel_to_f64(color.red), + color_channel_to_f64(color.green), + color_channel_to_f64(color.blue), + color_channel_to_f64(color.alpha), + ) + }; + unsafe { msg_send_id![&ns_color, CGColor] } +} From 265a4ec20fbada0e5b3e6673ab190a8478c56d0a Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 27 Feb 2026 18:11:16 +0100 Subject: [PATCH 2/2] Bring our own CGColor type for now --- platforms/macos/src/util.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/platforms/macos/src/util.rs b/platforms/macos/src/util.rs index d08b52c7..4015e1de 100644 --- a/platforms/macos/src/util.rs +++ b/platforms/macos/src/util.rs @@ -5,7 +5,8 @@ use accesskit::{Color, Point, Rect}; use accesskit_consumer::{Node, TextPosition, TextRange}; -use objc2::{msg_send_id, rc::Id, runtime::AnyObject}; +use objc2::encode::{Encoding, RefEncode}; +use objc2::{msg_send, rc::Id, runtime::AnyObject}; use objc2_app_kit::*; use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize}; @@ -83,6 +84,16 @@ fn color_channel_to_f64(channel: u8) -> f64 { (channel as f64) / 255.0 } +// TODO: can be removed after updating objc2 to 0.6 which has proper `CGColor` support +#[repr(C)] +struct CGColor { + _private: [u8; 0], +} + +unsafe impl RefEncode for CGColor { + const ENCODING_REF: Encoding = Encoding::Pointer(&Encoding::Struct("CGColor", &[])); +} + pub(crate) fn to_color_attribute(color: Color) -> Id { let ns_color = unsafe { NSColor::colorWithSRGBRed_green_blue_alpha( @@ -92,5 +103,6 @@ pub(crate) fn to_color_attribute(color: Color) -> Id { color_channel_to_f64(color.alpha), ) }; - unsafe { msg_send_id![&ns_color, CGColor] } + let cg_color: *const CGColor = unsafe { msg_send![&ns_color, CGColor] }; + unsafe { Id::retain(cg_color as *mut AnyObject).unwrap() } }