diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f1ae2573ad..56379c81bf 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -691,6 +691,7 @@ copied into SolidCraft. | Directory | Source | Licence | Changes | |---|---|---|---| +| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) | | vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) | diff --git a/Cargo.lock b/Cargo.lock index d945ee31cf..32f55abcb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,8 +60,6 @@ dependencies = [ [[package]] name = "accesskit_macos" version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", "accesskit_consumer 0.38.0", diff --git a/Cargo.toml b/Cargo.toml index 988a5d5d67..1021931b12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,6 +165,9 @@ egui-wgpu = { path = "vendor/egui-wgpu" } # winit 0.30 never binds and eframe 0.36 still requires winit 0.30. winit = { path = "vendor/winit" } +# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535). +accesskit_macos = { path = "vendor/accesskit_macos" } + # Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental # builds, since every mutant rebuilds the crates above the one it changes. [profile.mutants] diff --git a/vendor/README.md b/vendor/README.md index f1c3ebc11a..c48dd81339 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -270,6 +270,27 @@ Changes: - `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources are not vendored). +## accesskit_macos 0.26.3 (MIT OR Apache-2.0, The AccessKit contributors, https://github.com/AccessKit/accesskit) + +The published package (it carries no licence files; `Cargo.toml` names the licence). eframe's +accessibility support (`accesskit_winit` 0.32) uses it on macOS. Drop this copy when eframe +moves to accesskit_macos 0.27.2 or later, which has the fix. + +Changes: +- `src/subclass.rs` is accesskit_macos 0.27.2's (AccessKit/accesskit#835). The adapter swaps + the window's view to a subclass of its own; key-value observing then subclasses it again when + AppKit's Touch Bar observes the first responder (`nextResponder`). 0.26.3 reset the view's + class when the adapter was dropped, which threw away KVO's subclass, so AppKit's removal of + its observer on quit raised `NSRangeException` and the app crashed on Macs with a Touch Bar + (#7535). Dropping the adapter now leaves the class alone when KVO subclassed it, and the + subclass's methods fall back to the view's own without the adapter. 0.27.2 also refuses + (panics) to subclass a view that is already observed; eframe creates the adapter right after + the window, before anything observes it. +- `tests/integration/` is 0.27.2's tests of the subclassing adapter, with its `[[test]]` and + `libtest-mimic` dev-dependency in `Cargo.toml` (AppKit needs the main thread, where libtest + doesn't run tests). On a Mac: `cargo test --manifest-path vendor/accesskit_macos/Cargo.toml` + (it writes a `Cargo.lock` there; don't commit it). + ## egui-wgpu 0.36.2 (MIT OR Apache-2.0, Emil Ernerfeldt and contributors) Original package sources from https://github.com/emilk/egui/tree/0.36.2/crates/egui-wgpu, diff --git a/vendor/accesskit_macos/Cargo.toml b/vendor/accesskit_macos/Cargo.toml new file mode 100644 index 0000000000..edc5ae0f09 --- /dev/null +++ b/vendor/accesskit_macos/Cargo.toml @@ -0,0 +1,87 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.85" +name = "accesskit_macos" +version = "0.26.3" +authors = ["The AccessKit contributors"] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "AccessKit UI accessibility infrastructure: macOS adapter" +readme = "README.md" +keywords = [ + "gui", + "ui", + "accessibility", +] +categories = ["gui"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/AccessKit/accesskit" +resolver = "2" + +[package.metadata.docs.rs] +default-target = "x86_64-apple-darwin" + +[lib] +name = "accesskit_macos" +path = "src/lib.rs" + +# SolidCraft: accesskit_macos 0.27.2's tests of the subclassing adapter, with its fix (vendor/README.md). +[[test]] +name = "integration" +path = "tests/integration/main.rs" +harness = false + +[dependencies.accesskit] +version = "0.24.1" + +[dependencies.accesskit_consumer] +version = "0.38.0" + +[dependencies.hashbrown] +version = "0.16" +features = ["default-hasher"] +default-features = false + +[dependencies.objc2] +version = "0.5.1" + +[dependencies.objc2-app-kit] +version = "0.2.0" +features = [ + "NSAccessibility", + "NSAccessibilityConstants", + "NSAccessibilityElement", + "NSAccessibilityProtocols", + "NSColor", + "NSResponder", + "NSText", + "NSView", + "NSWindow", +] + +[dependencies.objc2-foundation] +version = "0.2.0" +features = [ + "NSArray", + "NSDictionary", + "NSValue", + "NSThread", +] + +[dev-dependencies.libtest-mimic] +version = "0.8.2" diff --git a/vendor/accesskit_macos/Cargo.toml.orig b/vendor/accesskit_macos/Cargo.toml.orig new file mode 100644 index 0000000000..d158c111ae --- /dev/null +++ b/vendor/accesskit_macos/Cargo.toml.orig @@ -0,0 +1,38 @@ +[package] +name = "accesskit_macos" +version = "0.26.3" +authors.workspace = true +license.workspace = true +description = "AccessKit UI accessibility infrastructure: macOS adapter" +categories.workspace = true +keywords = ["gui", "ui", "accessibility"] +repository.workspace = true +readme = "README.md" +edition.workspace = true +rust-version.workspace = true + +[package.metadata.docs.rs] +default-target = "x86_64-apple-darwin" + +[dependencies] +accesskit = { version = "0.24.1", path = "../../common" } +accesskit_consumer = { version = "0.38.0", path = "../../consumer" } +hashbrown.workspace = true +objc2 = "0.5.1" +objc2-foundation = { version = "0.2.0", features = [ + "NSArray", + "NSDictionary", + "NSValue", + "NSThread", +] } +objc2-app-kit = { version = "0.2.0", features = [ + "NSAccessibility", + "NSAccessibilityConstants", + "NSAccessibilityElement", + "NSAccessibilityProtocols", + "NSColor", + "NSResponder", + "NSText", + "NSView", + "NSWindow", +] } diff --git a/vendor/accesskit_macos/README.md b/vendor/accesskit_macos/README.md new file mode 100644 index 0000000000..4a7bbef4e7 --- /dev/null +++ b/vendor/accesskit_macos/README.md @@ -0,0 +1,7 @@ +# AccessKit macOS adapter + +This is the macOS adapter for [AccessKit](https://accesskit.dev/). It exposes an AccessKit accessibility tree through the Cocoa `NSAccessibility` protocol. + +## Known issues + +- The selected state of ListBox items is not reported ([#520](https://github.com/AccessKit/accesskit/issues/520)) diff --git a/vendor/accesskit_macos/src/adapter.rs b/vendor/accesskit_macos/src/adapter.rs new file mode 100644 index 0000000000..0f894cefa1 --- /dev/null +++ b/vendor/accesskit_macos/src/adapter.rs @@ -0,0 +1,295 @@ +// Copyright 2022 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 crate::{ + context::{ActionHandlerNoMut, ActionHandlerWrapper, Context}, + event::{EventGenerator, QueuedEvents, focus_event}, + filters::filter, + node::can_be_focused, + util::*, +}; +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, Node as NodeProvider, NodeId as LocalNodeId, + Role, Tree as TreeData, TreeId, TreeUpdate, +}; +use accesskit_consumer::{FilterResult, Tree}; +use objc2::rc::{Id, WeakId}; +use objc2_app_kit::NSView; +use objc2_foundation::{MainThreadMarker, NSArray, NSObject, NSPoint}; +use std::fmt::{Debug, Formatter}; +use std::{ffi::c_void, ptr::null_mut, rc::Rc}; + +const PLACEHOLDER_ROOT_ID: LocalNodeId = LocalNodeId(0); + +enum State { + Inactive { + view: WeakId, + is_view_focused: bool, + action_handler: Rc, + mtm: MainThreadMarker, + }, + Placeholder { + placeholder_context: Rc, + is_view_focused: bool, + action_handler: Rc, + }, + Active(Rc), +} + +impl Debug for State { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + State::Inactive { + view, + is_view_focused, + action_handler: _, + mtm, + } => f + .debug_struct("Inactive") + .field("view", view) + .field("is_view_focused", is_view_focused) + .field("mtm", mtm) + .finish(), + State::Placeholder { + placeholder_context, + is_view_focused, + action_handler: _, + } => f + .debug_struct("Placeholder") + .field("placeholder_context", placeholder_context) + .field("is_view_focused", is_view_focused) + .finish(), + State::Active(context) => f.debug_struct("Active").field("context", context).finish(), + } + } +} + +struct PlaceholderActionHandler; + +impl ActionHandler for PlaceholderActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} +} + +#[derive(Debug)] +pub struct Adapter { + state: State, +} + +impl Adapter { + /// Create a new macOS adapter. This function must be called on + /// the main thread. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to an `NSView`. + pub unsafe fn new( + view: *mut c_void, + is_view_focused: bool, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let view = unsafe { Id::retain(view as *mut NSView) }.unwrap(); + let view = WeakId::from_id(&view); + let mtm = MainThreadMarker::new().unwrap(); + let state = State::Inactive { + view, + is_view_focused, + action_handler: Rc::new(ActionHandlerWrapper::new(action_handler)), + mtm, + }; + Self { state } + } + + /// If and only if the tree has been initialized, call the provided function + /// and apply the resulting update. Note: If the caller's implementation of + /// [`ActivationHandler::request_initial_tree`] initially returned `None`, + /// the [`TreeUpdate`] returned by the provided function must contain + /// a full tree. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_if_active( + &mut self, + update_factory: impl FnOnce() -> TreeUpdate, + ) -> Option { + match &self.state { + State::Inactive { .. } => None, + State::Placeholder { + placeholder_context, + is_view_focused, + action_handler, + } => { + let tree = Tree::new(update_factory(), *is_view_focused); + let context = Context::new( + placeholder_context.view.clone(), + tree, + Rc::clone(action_handler), + placeholder_context.mtm, + ); + let result = context.tree.borrow().state().focus().map(|node| { + QueuedEvents::new(Rc::clone(&context), vec![focus_event(node.id())]) + }); + self.state = State::Active(context); + result + } + State::Active(context) => { + let mut event_generator = EventGenerator::new(context.clone()); + let mut tree = context.tree.borrow_mut(); + tree.update_and_process_changes(update_factory(), &mut event_generator); + Some(event_generator.into_result()) + } + } + } + + /// Update the tree state based on whether the window is focused. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_view_focus_state(&mut self, is_focused: bool) -> Option { + match &mut self.state { + State::Inactive { + is_view_focused, .. + } => { + *is_view_focused = is_focused; + None + } + State::Placeholder { + is_view_focused, .. + } => { + *is_view_focused = is_focused; + None + } + State::Active(context) => { + let mut event_generator = EventGenerator::new(context.clone()); + let mut tree = context.tree.borrow_mut(); + tree.update_host_focus_state_and_process_changes(is_focused, &mut event_generator); + Some(event_generator.into_result()) + } + } + } + + fn get_or_init_context( + &mut self, + activation_handler: &mut H, + ) -> Rc { + match &self.state { + State::Inactive { + view, + is_view_focused, + action_handler, + mtm, + } => match activation_handler.request_initial_tree() { + Some(initial_state) => { + let tree = Tree::new(initial_state, *is_view_focused); + let context = Context::new(view.clone(), tree, Rc::clone(action_handler), *mtm); + let result = Rc::clone(&context); + self.state = State::Active(context); + result + } + None => { + let placeholder_update = TreeUpdate { + nodes: vec![(PLACEHOLDER_ROOT_ID, NodeProvider::new(Role::Window))], + tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + tree_id: TreeId::ROOT, + focus: PLACEHOLDER_ROOT_ID, + }; + let placeholder_tree = Tree::new(placeholder_update, false); + let placeholder_context = Context::new( + view.clone(), + placeholder_tree, + Rc::new(ActionHandlerWrapper::new(PlaceholderActionHandler {})), + *mtm, + ); + let result = Rc::clone(&placeholder_context); + self.state = State::Placeholder { + placeholder_context, + is_view_focused: *is_view_focused, + action_handler: Rc::clone(action_handler), + }; + result + } + }, + State::Placeholder { + placeholder_context, + .. + } => Rc::clone(placeholder_context), + State::Active(context) => Rc::clone(context), + } + } + + pub fn view_children( + &mut self, + activation_handler: &mut H, + ) -> *mut NSArray { + let context = self.get_or_init_context(activation_handler); + let tree = context.tree.borrow(); + let state = tree.state(); + let node = state.root(); + let platform_nodes = if filter(&node) == FilterResult::Include { + vec![Id::into_super(Id::into_super( + context.get_or_create_platform_node(node.id()), + ))] + } else { + node.filtered_children(filter) + .map(|node| { + Id::into_super(Id::into_super( + context.get_or_create_platform_node(node.id()), + )) + }) + .collect::>>() + }; + let array = NSArray::from_vec(platform_nodes); + Id::autorelease_return(array) + } + + pub fn focus( + &mut self, + activation_handler: &mut H, + ) -> *mut NSObject { + let context = self.get_or_init_context(activation_handler); + let tree = context.tree.borrow(); + let state = tree.state(); + if let Some(node) = state.focus() { + if can_be_focused(&node) { + return Id::autorelease_return(context.get_or_create_platform_node(node.id())) + as *mut _; + } + } + null_mut() + } + + fn weak_view(&self) -> &WeakId { + match &self.state { + State::Inactive { view, .. } => view, + State::Placeholder { + placeholder_context, + .. + } => &placeholder_context.view, + State::Active(context) => &context.view, + } + } + + pub fn hit_test( + &mut self, + point: NSPoint, + activation_handler: &mut H, + ) -> *mut NSObject { + let view = match self.weak_view().load() { + Some(view) => view, + None => { + return null_mut(); + } + }; + + let context = self.get_or_init_context(activation_handler); + let tree = context.tree.borrow(); + let state = tree.state(); + let root = state.root(); + let point = from_ns_point(&view, &root, point); + let node = root.node_at_point(point, &filter).unwrap_or(root); + Id::autorelease_return(context.get_or_create_platform_node(node.id())) as *mut _ + } +} diff --git a/vendor/accesskit_macos/src/context.rs b/vendor/accesskit_macos/src/context.rs new file mode 100644 index 0000000000..687fcbc6bb --- /dev/null +++ b/vendor/accesskit_macos/src/context.rs @@ -0,0 +1,103 @@ +// Copyright 2022 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 crate::node::PlatformNode; +use accesskit::{ActionHandler, ActionRequest}; +use accesskit_consumer::{NodeId, Tree}; +use hashbrown::HashMap; +use objc2::rc::{Id, WeakId}; +use objc2_app_kit::*; +use objc2_foundation::MainThreadMarker; +use std::fmt::Debug; +use std::{cell::RefCell, rc::Rc}; + +pub(crate) trait ActionHandlerNoMut { + fn do_action(&self, request: ActionRequest); +} + +pub(crate) struct ActionHandlerWrapper(RefCell); + +impl ActionHandlerWrapper { + pub(crate) fn new(inner: H) -> Self { + Self(RefCell::new(inner)) + } +} + +impl ActionHandlerNoMut for ActionHandlerWrapper { + fn do_action(&self, request: ActionRequest) { + self.0.borrow_mut().do_action(request) + } +} + +pub(crate) struct Context { + pub(crate) view: WeakId, + pub(crate) tree: RefCell, + pub(crate) action_handler: Rc, + platform_nodes: RefCell>>, + pub(crate) mtm: MainThreadMarker, +} + +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context") + .field("view", &self.view) + .field("tree", &self.tree) + .field("action_handler", &"ActionHandler") + .field("platform_nodes", &self.platform_nodes) + .field("mtm", &self.mtm) + .finish() + } +} + +impl Context { + pub(crate) fn new( + view: WeakId, + tree: Tree, + action_handler: Rc, + mtm: MainThreadMarker, + ) -> Rc { + Rc::new(Self { + view, + tree: RefCell::new(tree), + action_handler, + platform_nodes: RefCell::new(HashMap::new()), + mtm, + }) + } + + pub(crate) fn get_or_create_platform_node(self: &Rc, id: NodeId) -> Id { + let mut platform_nodes = self.platform_nodes.borrow_mut(); + if let Some(result) = platform_nodes.get(&id) { + return result.clone(); + } + + let result = PlatformNode::new(Rc::downgrade(self), id); + platform_nodes.insert(id, result.clone()); + result + } + + pub(crate) fn remove_platform_node(&self, id: NodeId) -> Option> { + let mut platform_nodes = self.platform_nodes.borrow_mut(); + platform_nodes.remove(&id) + } + + pub(crate) fn do_action(&self, request: ActionRequest) { + self.action_handler.do_action(request); + } +} + +impl Drop for Context { + fn drop(&mut self) { + let platform_nodes = self.platform_nodes.borrow(); + for platform_node in platform_nodes.values() { + unsafe { + NSAccessibilityPostNotification( + platform_node, + NSAccessibilityUIElementDestroyedNotification, + ) + }; + } + } +} diff --git a/vendor/accesskit_macos/src/event.rs b/vendor/accesskit_macos/src/event.rs new file mode 100644 index 0000000000..5a1cdc41ce --- /dev/null +++ b/vendor/accesskit_macos/src/event.rs @@ -0,0 +1,330 @@ +// Copyright 2022 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::{Live, Role}; +use accesskit_consumer::{FilterResult, Node, NodeId, TreeChangeHandler}; +use hashbrown::HashSet; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2_app_kit::*; +use objc2_foundation::{NSMutableDictionary, NSNumber, NSString}; +use std::{collections::VecDeque, rc::Rc}; + +use crate::{ + context::Context, + filters::filter, + node::{NodeWrapper, Value}, +}; + +// This type is designed to be safe to create on a non-main thread +// and send to the main thread. This ability isn't yet used though. +pub(crate) enum QueuedEvent { + Generic { + node_id: NodeId, + notification: &'static NSAccessibilityNotificationName, + }, + NodeDestroyed(NodeId), + Announcement { + text: String, + priority: NSAccessibilityPriorityLevel, + }, +} + +impl QueuedEvent { + fn live_region_announcement(node: &Node) -> Self { + Self::Announcement { + text: node.value().unwrap(), + priority: if node.live() == Live::Assertive { + NSAccessibilityPriorityLevel::NSAccessibilityPriorityHigh + } else { + NSAccessibilityPriorityLevel::NSAccessibilityPriorityMedium + }, + } + } + + fn raise(self, context: &Rc) { + match self { + Self::Generic { + node_id, + notification, + } => { + let platform_node = context.get_or_create_platform_node(node_id); + unsafe { NSAccessibilityPostNotification(&platform_node, notification) }; + } + Self::NodeDestroyed(node_id) => { + if let Some(platform_node) = context.remove_platform_node(node_id) { + unsafe { + NSAccessibilityPostNotification( + &platform_node, + NSAccessibilityUIElementDestroyedNotification, + ) + }; + } + } + Self::Announcement { text, priority } => { + let view = match context.view.load() { + Some(view) => view, + None => { + return; + } + }; + + let window = match view.window() { + Some(window) => window, + None => { + return; + } + }; + + let mut user_info = NSMutableDictionary::<_, AnyObject>::new(); + let text = NSString::from_str(&text); + unsafe { + user_info.setObject_forKey( + &*text, + ProtocolObject::from_ref(NSAccessibilityAnnouncementKey), + ) + }; + let priority = NSNumber::new_isize(priority.0); + unsafe { + user_info.setObject_forKey( + &*priority, + ProtocolObject::from_ref(NSAccessibilityPriorityKey), + ) + }; + + unsafe { + NSAccessibilityPostNotificationWithUserInfo( + &window, + NSAccessibilityAnnouncementRequestedNotification, + Some(&**user_info), + ) + }; + } + } + } +} + +/// Events generated by a tree update. +#[must_use = "events must be explicitly raised"] +pub struct QueuedEvents { + context: Rc, + events: Vec, +} + +impl QueuedEvents { + pub(crate) fn new(context: Rc, events: Vec) -> Self { + Self { context, events } + } + + /// Raise all queued events synchronously. + /// + /// It is unknown whether accessibility methods on the view may be + /// called while events are being raised. This means that any locks + /// or runtime borrows required to access the adapter must not + /// be held while this method is called. + pub fn raise(self) { + for event in self.events { + event.raise(&self.context); + } + } +} + +pub(crate) fn focus_event(node_id: NodeId) -> QueuedEvent { + QueuedEvent::Generic { + node_id, + notification: unsafe { NSAccessibilityFocusedUIElementChangedNotification }, + } +} + +pub(crate) struct EventGenerator { + context: Rc, + events: Vec, + text_changed: HashSet, + selected_rows_changed: HashSet, +} + +impl EventGenerator { + pub(crate) fn new(context: Rc) -> Self { + Self { + context, + events: Vec::new(), + text_changed: HashSet::new(), + selected_rows_changed: HashSet::new(), + } + } + + pub(crate) fn into_result(self) -> QueuedEvents { + QueuedEvents::new(self.context, self.events) + } + + fn remove_subtree(&mut self, node: &Node) { + let mut to_remove = VecDeque::new(); + to_remove.push_back(*node); + + while let Some(node) = to_remove.pop_front() { + for child in node.filtered_children(&filter) { + to_remove.push_back(child); + } + + self.events.push(QueuedEvent::NodeDestroyed(node.id())); + } + } + + fn insert_text_change_if_needed_parent(&mut self, node: Node) { + if !node.supports_text_ranges() { + return; + } + let id = node.id(); + if self.text_changed.contains(&id) { + return; + } + // Text change events must come before selection change + // events. It doesn't matter if text change events come + // before other events. + self.events.insert( + 0, + QueuedEvent::Generic { + node_id: id, + notification: unsafe { NSAccessibilityValueChangedNotification }, + }, + ); + self.text_changed.insert(id); + } + + fn insert_text_change_if_needed(&mut self, node: &Node) { + if node.role() != Role::TextRun { + return; + } + if let Some(node) = node.filtered_parent(&filter) { + self.insert_text_change_if_needed_parent(node); + } + } + + fn enqueue_selected_rows_change_if_needed_parent(&mut self, node: Node) { + let id = node.id(); + if self.selected_rows_changed.contains(&id) { + return; + } + self.events.push(QueuedEvent::Generic { + node_id: id, + notification: unsafe { NSAccessibilitySelectedRowsChangedNotification }, + }); + self.selected_rows_changed.insert(id); + } + + fn enqueue_selected_rows_change_if_needed(&mut self, node: &Node) { + let wrapper = NodeWrapper(node); + if !wrapper.is_item_like() { + return; + } + if let Some(node) = node.selection_container(&filter) { + self.enqueue_selected_rows_change_if_needed_parent(node); + } + } +} + +impl TreeChangeHandler for EventGenerator { + fn node_added(&mut self, node: &Node) { + self.insert_text_change_if_needed(node); + if filter(node) != FilterResult::Include { + return; + } + if let Some(true) = node.is_selected() { + self.enqueue_selected_rows_change_if_needed(node); + } + if node.value().is_some() && node.live() != Live::Off { + self.events + .push(QueuedEvent::live_region_announcement(node)); + } + } + + fn node_updated(&mut self, old_node: &Node, new_node: &Node) { + if old_node.raw_value() != new_node.raw_value() { + self.insert_text_change_if_needed(new_node); + } + let old_filter_result = filter(old_node); + let new_filter_result = filter(new_node); + if new_filter_result != FilterResult::Include { + if old_filter_result == FilterResult::Include && old_node.is_selected() == Some(true) { + self.enqueue_selected_rows_change_if_needed(old_node); + } + if new_filter_result == FilterResult::ExcludeSubtree { + self.remove_subtree(old_node); + } else { + self.events.push(QueuedEvent::NodeDestroyed(new_node.id())); + } + return; + } + let node_id = new_node.id(); + let old_wrapper = NodeWrapper(old_node); + let new_wrapper = NodeWrapper(new_node); + if old_wrapper.title() != new_wrapper.title() { + self.events.push(QueuedEvent::Generic { + node_id, + notification: unsafe { NSAccessibilityTitleChangedNotification }, + }); + } + let new_value = new_wrapper.value(); + if old_wrapper.value() != new_value { + if !new_node.is_focused() && new_value.is_some_and(|v| matches!(v, Value::Bool(_))) { + // Bool value changed event for the focused node must come last + // in order for VoiceOver to announce it. Otherwise, if we raise + // bool value changed events for other nodes after this one, VoiceOver + // will announce them instead. + self.events.insert( + 0, + QueuedEvent::Generic { + node_id, + notification: unsafe { NSAccessibilityValueChangedNotification }, + }, + ); + } else { + self.events.push(QueuedEvent::Generic { + node_id, + notification: unsafe { NSAccessibilityValueChangedNotification }, + }); + } + } + if old_wrapper.supports_text_ranges() + && new_wrapper.supports_text_ranges() + && old_wrapper.raw_text_selection() != new_wrapper.raw_text_selection() + { + self.events.push(QueuedEvent::Generic { + node_id, + notification: unsafe { NSAccessibilitySelectedTextChangedNotification }, + }); + } + if new_node.value().is_some() + && new_node.live() != Live::Off + && (new_node.value() != old_node.value() + || new_node.live() != old_node.live() + || old_filter_result != FilterResult::Include) + { + self.events + .push(QueuedEvent::live_region_announcement(new_node)); + } + if new_node.is_selected() != old_node.is_selected() + || (old_filter_result != FilterResult::Include && new_node.is_selected() == Some(true)) + { + self.enqueue_selected_rows_change_if_needed(new_node); + } + } + + fn focus_moved(&mut self, _old_node: Option<&Node>, new_node: Option<&Node>) { + if let Some(new_node) = new_node { + if filter(new_node) != FilterResult::Include { + return; + } + self.events.push(focus_event(new_node.id())); + } + } + + fn node_removed(&mut self, node: &Node) { + self.insert_text_change_if_needed(node); + if let Some(true) = node.is_selected() { + self.enqueue_selected_rows_change_if_needed(node); + } + self.events.push(QueuedEvent::NodeDestroyed(node.id())); + } +} diff --git a/vendor/accesskit_macos/src/filters.rs b/vendor/accesskit_macos/src/filters.rs new file mode 100644 index 0000000000..2802d57e64 --- /dev/null +++ b/vendor/accesskit_macos/src/filters.rs @@ -0,0 +1,6 @@ +// Copyright 2023 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. + +pub(crate) use accesskit_consumer::common_filter as filter; diff --git a/vendor/accesskit_macos/src/lib.rs b/vendor/accesskit_macos/src/lib.rs new file mode 100644 index 0000000000..5b795424ea --- /dev/null +++ b/vendor/accesskit_macos/src/lib.rs @@ -0,0 +1,25 @@ +// Copyright 2022 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. + +#![deny(unsafe_op_in_unsafe_fn)] + +mod context; +mod filters; +mod node; +mod util; + +mod adapter; +pub use adapter::Adapter; + +mod event; +pub use event::QueuedEvents; + +mod patch; +pub use patch::add_focus_forwarder_to_window_class; + +mod subclass; +pub use subclass::SubclassingAdapter; + +pub use objc2_foundation::{NSArray, NSObject, NSPoint}; diff --git a/vendor/accesskit_macos/src/node.rs b/vendor/accesskit_macos/src/node.rs new file mode 100644 index 0000000000..16314b5b47 --- /dev/null +++ b/vendor/accesskit_macos/src/node.rs @@ -0,0 +1,1338 @@ +// Copyright 2022 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. + +// Derived from Chromium's accessibility abstraction. +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE.chromium file. + +#![allow(non_upper_case_globals)] + +use accesskit::{ + Action, ActionData, ActionRequest, Orientation, Role, TextAlign, TextSelection, Toggled, +}; +use accesskit_consumer::{FilterResult, Node, NodeId, Tree}; +use objc2::{ + ClassType, DeclaredClass, declare_class, msg_send_id, + mutability::InteriorMutable, + rc::Id, + runtime::{AnyObject, Sel}, + sel, +}; +use objc2_app_kit::*; +use objc2_foundation::{ + NSArray, NSAttributedString, NSCopying, NSInteger, NSMutableAttributedString, + NSMutableDictionary, NSNumber, NSObject, NSObjectProtocol, NSPoint, NSRange, NSRect, NSString, + NSURL, ns_string, +}; +use std::rc::{Rc, Weak}; + +use crate::{context::Context, filters::filter, util::*}; + +const SCROLL_TO_VISIBLE_ACTION: &str = "AXScrollToVisible"; + +fn ns_role(node: &Node) -> &'static NSAccessibilityRole { + let role = node.role(); + // TODO: Handle special cases. + unsafe { + match role { + Role::Unknown => NSAccessibilityUnknownRole, + Role::TextRun => NSAccessibilityUnknownRole, + Role::Cell | Role::GridCell => NSAccessibilityCellRole, + Role::Label => NSAccessibilityStaticTextRole, + Role::Image => NSAccessibilityImageRole, + Role::Link => NSAccessibilityLinkRole, + Role::Row => NSAccessibilityRowRole, + Role::ListItem => NSAccessibilityGroupRole, + Role::ListMarker => ns_string!("AXListMarker"), + Role::TreeItem => NSAccessibilityRowRole, + Role::ListBoxOption => NSAccessibilityStaticTextRole, + Role::MenuItem => NSAccessibilityMenuItemRole, + Role::MenuListOption => NSAccessibilityMenuItemRole, + Role::Paragraph => NSAccessibilityGroupRole, + Role::GenericContainer => NSAccessibilityUnknownRole, + Role::CheckBox => NSAccessibilityCheckBoxRole, + Role::RadioButton => NSAccessibilityRadioButtonRole, + Role::TextInput + | Role::SearchInput + | Role::EmailInput + | Role::NumberInput + | Role::PasswordInput + | Role::PhoneNumberInput + | Role::UrlInput => NSAccessibilityTextFieldRole, + Role::Button => { + if node.toggled().is_some() { + NSAccessibilityCheckBoxRole + } else { + NSAccessibilityButtonRole + } + } + Role::DefaultButton => NSAccessibilityButtonRole, + Role::Pane => NSAccessibilityUnknownRole, + Role::RowHeader => NSAccessibilityCellRole, + Role::ColumnHeader => NSAccessibilityCellRole, + Role::RowGroup => NSAccessibilityGroupRole, + Role::List => NSAccessibilityListRole, + Role::Table => NSAccessibilityTableRole, + Role::LayoutTableCell => NSAccessibilityGroupRole, + Role::LayoutTableRow => NSAccessibilityGroupRole, + Role::LayoutTable => NSAccessibilityGroupRole, + Role::Switch => NSAccessibilityCheckBoxRole, + Role::Menu => NSAccessibilityMenuRole, + Role::MultilineTextInput => NSAccessibilityTextAreaRole, + Role::DateInput | Role::DateTimeInput | Role::WeekInput | Role::MonthInput => { + ns_string!("AXDateField") + } + Role::TimeInput => ns_string!("AXTimeField"), + Role::Abbr => NSAccessibilityGroupRole, + Role::Alert => NSAccessibilityGroupRole, + Role::AlertDialog => NSAccessibilityWindowRole, + Role::Application => NSAccessibilityGroupRole, + Role::Article => NSAccessibilityGroupRole, + Role::Audio => NSAccessibilityGroupRole, + Role::Banner => NSAccessibilityGroupRole, + Role::Blockquote => NSAccessibilityGroupRole, + Role::Canvas => NSAccessibilityImageRole, + Role::Caption => NSAccessibilityGroupRole, + Role::Caret => NSAccessibilityUnknownRole, + Role::Code => NSAccessibilityGroupRole, + Role::ColorWell => NSAccessibilityColorWellRole, + Role::ComboBox => NSAccessibilityPopUpButtonRole, + Role::EditableComboBox => NSAccessibilityComboBoxRole, + Role::Complementary => NSAccessibilityGroupRole, + Role::Comment => NSAccessibilityGroupRole, + Role::ContentDeletion => NSAccessibilityGroupRole, + Role::ContentInsertion => NSAccessibilityGroupRole, + Role::ContentInfo => NSAccessibilityGroupRole, + Role::Definition => NSAccessibilityGroupRole, + Role::DescriptionList => NSAccessibilityListRole, + Role::Details => NSAccessibilityGroupRole, + Role::Dialog => NSAccessibilityWindowRole, + Role::DisclosureTriangle => NSAccessibilityButtonRole, + Role::Document => NSAccessibilityGroupRole, + Role::EmbeddedObject => NSAccessibilityGroupRole, + Role::Emphasis => NSAccessibilityGroupRole, + Role::Feed => NSAccessibilityUnknownRole, + Role::FigureCaption => NSAccessibilityGroupRole, + Role::Figure => NSAccessibilityGroupRole, + Role::Footer => NSAccessibilityGroupRole, + Role::Form => NSAccessibilityGroupRole, + Role::Grid => NSAccessibilityTableRole, + Role::Group => NSAccessibilityGroupRole, + Role::Header => NSAccessibilityGroupRole, + Role::Heading => ns_string!("Heading"), + Role::Iframe => NSAccessibilityGroupRole, + Role::IframePresentational => NSAccessibilityGroupRole, + Role::ImeCandidate => NSAccessibilityUnknownRole, + Role::Keyboard => NSAccessibilityUnknownRole, + Role::Legend => NSAccessibilityGroupRole, + Role::LineBreak => NSAccessibilityGroupRole, + Role::ListBox => NSAccessibilityListRole, + Role::Log => NSAccessibilityGroupRole, + Role::Main => NSAccessibilityGroupRole, + Role::Mark => NSAccessibilityGroupRole, + Role::Marquee => NSAccessibilityGroupRole, + Role::Math => NSAccessibilityGroupRole, + Role::MenuBar => NSAccessibilityMenuBarRole, + Role::MenuItemCheckBox => NSAccessibilityMenuItemRole, + Role::MenuItemRadio => NSAccessibilityMenuItemRole, + Role::MenuListPopup => NSAccessibilityMenuRole, + Role::Meter => NSAccessibilityLevelIndicatorRole, + Role::Navigation => NSAccessibilityGroupRole, + Role::Note => NSAccessibilityGroupRole, + Role::PluginObject => NSAccessibilityGroupRole, + Role::ProgressIndicator => NSAccessibilityProgressIndicatorRole, + Role::RadioGroup => NSAccessibilityRadioGroupRole, + Role::Region => NSAccessibilityGroupRole, + Role::RootWebArea => ns_string!("AXWebArea"), + Role::Ruby => NSAccessibilityGroupRole, + Role::RubyAnnotation => NSAccessibilityUnknownRole, + Role::ScrollBar => NSAccessibilityScrollBarRole, + Role::ScrollView => NSAccessibilityUnknownRole, + Role::Search => NSAccessibilityGroupRole, + Role::Section => NSAccessibilityGroupRole, + Role::SectionFooter => NSAccessibilityGroupRole, + Role::SectionHeader => NSAccessibilityGroupRole, + Role::Slider => NSAccessibilitySliderRole, + Role::SpinButton => NSAccessibilityIncrementorRole, + Role::Splitter => NSAccessibilitySplitterRole, + Role::Status => NSAccessibilityGroupRole, + Role::Strong => NSAccessibilityGroupRole, + Role::Suggestion => NSAccessibilityGroupRole, + Role::SvgRoot => NSAccessibilityGroupRole, + Role::Tab => NSAccessibilityRadioButtonRole, + Role::TabList => NSAccessibilityTabGroupRole, + Role::TabPanel => NSAccessibilityGroupRole, + Role::Term => NSAccessibilityGroupRole, + Role::Time => NSAccessibilityGroupRole, + Role::Timer => NSAccessibilityGroupRole, + Role::TitleBar => NSAccessibilityStaticTextRole, + Role::Toolbar => NSAccessibilityToolbarRole, + Role::Tooltip => NSAccessibilityGroupRole, + Role::Tree => NSAccessibilityOutlineRole, + Role::TreeGrid => NSAccessibilityTableRole, + Role::Video => NSAccessibilityGroupRole, + Role::WebView => NSAccessibilityUnknownRole, + // Use the group role for Role::Window, since the NSWindow + // provides the top-level accessibility object for the window. + Role::Window => NSAccessibilityGroupRole, + Role::PdfActionableHighlight => NSAccessibilityButtonRole, + Role::PdfRoot => NSAccessibilityGroupRole, + Role::GraphicsDocument => NSAccessibilityGroupRole, + Role::GraphicsObject => NSAccessibilityGroupRole, + Role::GraphicsSymbol => NSAccessibilityImageRole, + Role::DocAbstract => NSAccessibilityGroupRole, + Role::DocAcknowledgements => NSAccessibilityGroupRole, + Role::DocAfterword => NSAccessibilityGroupRole, + Role::DocAppendix => NSAccessibilityGroupRole, + Role::DocBackLink => NSAccessibilityLinkRole, + Role::DocBiblioEntry => NSAccessibilityGroupRole, + Role::DocBibliography => NSAccessibilityGroupRole, + Role::DocBiblioRef => NSAccessibilityGroupRole, + Role::DocChapter => NSAccessibilityGroupRole, + Role::DocColophon => NSAccessibilityGroupRole, + Role::DocConclusion => NSAccessibilityGroupRole, + Role::DocCover => NSAccessibilityImageRole, + Role::DocCredit => NSAccessibilityGroupRole, + Role::DocCredits => NSAccessibilityGroupRole, + Role::DocDedication => NSAccessibilityGroupRole, + Role::DocEndnote => NSAccessibilityGroupRole, + Role::DocEndnotes => NSAccessibilityGroupRole, + Role::DocEpigraph => NSAccessibilityGroupRole, + Role::DocEpilogue => NSAccessibilityGroupRole, + Role::DocErrata => NSAccessibilityGroupRole, + Role::DocExample => NSAccessibilityGroupRole, + Role::DocFootnote => NSAccessibilityGroupRole, + Role::DocForeword => NSAccessibilityGroupRole, + Role::DocGlossary => NSAccessibilityGroupRole, + Role::DocGlossRef => NSAccessibilityLinkRole, + Role::DocIndex => NSAccessibilityGroupRole, + Role::DocIntroduction => NSAccessibilityGroupRole, + Role::DocNoteRef => NSAccessibilityLinkRole, + Role::DocNotice => NSAccessibilityGroupRole, + Role::DocPageBreak => NSAccessibilitySplitterRole, + Role::DocPageFooter => NSAccessibilityGroupRole, + Role::DocPageHeader => NSAccessibilityGroupRole, + Role::DocPageList => NSAccessibilityGroupRole, + Role::DocPart => NSAccessibilityGroupRole, + Role::DocPreface => NSAccessibilityGroupRole, + Role::DocPrologue => NSAccessibilityGroupRole, + Role::DocPullquote => NSAccessibilityGroupRole, + Role::DocQna => NSAccessibilityGroupRole, + Role::DocSubtitle => ns_string!("AXHeading"), + Role::DocTip => NSAccessibilityGroupRole, + Role::DocToc => NSAccessibilityGroupRole, + Role::ListGrid => NSAccessibilityUnknownRole, + Role::Terminal => NSAccessibilityTextAreaRole, + } + } +} + +fn ns_sub_role(node: &Node) -> &'static NSAccessibilitySubrole { + let role = node.role(); + + unsafe { + match role { + Role::Alert => ns_string!("AXApplicationAlert"), + Role::AlertDialog => NSAccessibilityDialogSubrole, + Role::Article => ns_string!("AXDocumentArticle"), + Role::Banner => ns_string!("AXLandmarkBanner"), + Role::Button if node.toggled().is_some() => NSAccessibilityToggleSubrole, + Role::Code => ns_string!("AXCodeStyleGroup"), + Role::Complementary => ns_string!("AXLandmarkComplementary"), + Role::ContentDeletion => ns_string!("AXDeleteStyleGroup"), + Role::ContentInsertion => ns_string!("AXInsertStyleGroup"), + Role::ContentInfo => ns_string!("AXLandmarkContentInfo"), + Role::Definition => ns_string!("AXDefinition"), + Role::Dialog => NSAccessibilityDialogSubrole, + Role::Document => ns_string!("AXDocument"), + Role::Emphasis => ns_string!("AXEmphasisStyleGroup"), + Role::Feed => ns_string!("AXApplicationGroup"), + Role::Footer => ns_string!("AXLandmarkContentInfo"), + Role::Form => ns_string!("AXLandmarkForm"), + Role::GraphicsDocument => ns_string!("AXDocument"), + Role::Group => ns_string!("AXApplicationGroup"), + Role::Header => ns_string!("AXLandmarkBanner"), + Role::LayoutTableCell => NSAccessibilityGroupRole, + Role::LayoutTableRow => NSAccessibilityTableRowSubrole, + Role::Log => ns_string!("AXApplicationLog"), + Role::Main => ns_string!("AXLandmarkMain"), + Role::Marquee => ns_string!("AXApplicationMarquee"), + Role::Math => ns_string!("AXDocumentMath"), + Role::Meter => ns_string!("AXMeter"), + Role::Navigation => ns_string!("AXLandmarkNavigation"), + Role::Note => ns_string!("AXDocumentNote"), + Role::PasswordInput => NSAccessibilitySecureTextFieldSubrole, + Role::Region => ns_string!("AXLandmarkRegion"), + Role::Search => ns_string!("AXLandmarkSearch"), + Role::SearchInput => NSAccessibilitySearchFieldSubrole, + Role::SectionFooter => ns_string!("AXSectionFooter"), + Role::SectionHeader => ns_string!("AXSectionHeader"), + Role::Status => ns_string!("AXApplicationStatus"), + Role::Strong => ns_string!("AXStrongStyleGroup"), + Role::Switch => NSAccessibilitySwitchSubrole, + Role::Tab => NSAccessibilityTabButtonSubrole, + Role::TabPanel => ns_string!("AXTabPanel"), + Role::Term => ns_string!("AXTerm"), + Role::Time => ns_string!("AXTimeGroup"), + Role::Timer => ns_string!("AXApplicationTimer"), + Role::TreeItem => NSAccessibilityOutlineRowSubrole, + Role::Tooltip => ns_string!("AXUserInterfaceTooltip"), + _ => NSAccessibilityUnknownSubrole, + } + } +} + +pub(crate) fn can_be_focused(node: &Node) -> bool { + filter(node) == FilterResult::Include && node.role() != Role::Window +} + +#[derive(PartialEq)] +pub(crate) enum Value { + Bool(bool), + Number(f64), + String(String), +} + +pub(crate) struct NodeWrapper<'a>(pub(crate) &'a Node<'a>); + +impl NodeWrapper<'_> { + fn is_root(&self) -> bool { + self.0.is_root() + } + + pub(crate) fn title(&self) -> Option { + if self.is_root() && self.0.role() == Role::Window { + // If the group element that we expose for the top-level window + // includes a title, VoiceOver behavior is broken. + return None; + } + self.0.label() + } + + pub(crate) fn description(&self) -> Option { + self.0.description() + } + + pub(crate) fn placeholder(&self) -> Option<&str> { + self.0.placeholder() + } + + pub(crate) fn value(&self) -> Option { + if let Some(toggled) = self.0.toggled() { + return Some(Value::Bool(toggled != Toggled::False)); + } + if self.0.role() == Role::Tab { + // On Mac, tabs are exposed as radio buttons, and are treated as checkable. + // Also, `Node::is_selected` is mapped to checked via `accessibilityValue`. + return Some(Value::Bool(self.0.is_selected().unwrap_or(false))); + } + if let Some(value) = self.0.value() { + return Some(Value::String(value)); + } + if let Some(value) = self.0.numeric_value() { + return Some(Value::Number(value)); + } + None + } + + pub(crate) fn supports_text_ranges(&self) -> bool { + self.0.supports_text_ranges() + } + + pub(crate) fn raw_text_selection(&self) -> Option<&TextSelection> { + self.0.raw_text_selection() + } + + fn is_container_with_selectable_children(&self) -> bool { + self.0.is_container_with_selectable_children() && self.0.role() != Role::TabList + } + + pub(crate) fn is_item_like(&self) -> bool { + self.0.is_item_like() && self.0.role() != Role::Tab + } +} + +// derived from objc2 0.6 `AnyObject::downcast_ref` +// TODO: can be removed after updating objc2 to 0.6 which has `AnyObject::downcast_ref` +fn downcast_ref(obj: &NSObject) -> Option<&T> { + obj.is_kind_of::() + .then(|| unsafe { &*(obj as *const NSObject).cast::() }) +} + +pub(crate) struct PlatformNodeIvars { + context: Weak, + node_id: NodeId, +} + +declare_class!( + #[derive(Debug)] + pub(crate) struct PlatformNode; + + unsafe impl ClassType for PlatformNode { + #[inherits(NSObject)] + type Super = NSAccessibilityElement; + type Mutability = InteriorMutable; + const NAME: &'static str = "AccessKitNode"; + } + + impl DeclaredClass for PlatformNode { + type Ivars = PlatformNodeIvars; + } + + unsafe impl PlatformNode { + #[method_id(accessibilityParent)] + fn parent(&self) -> Option> { + self.resolve_with_context(|node, _, context| { + if let Some(parent) = node.filtered_parent(&filter) { + Some(Id::into_super(Id::into_super(Id::into_super(context.get_or_create_platform_node(parent.id()))))) + } else { + context + .view + .load() + .and_then(|view| unsafe { NSAccessibility::accessibilityParent(&*view) }) + } + }) + .flatten() + } + + #[method_id(accessibilityWindow)] + fn window(&self) -> Option> { + self.resolve_with_context(|_, _, context| { + context + .view + .load() + .and_then(|view| unsafe { NSAccessibility::accessibilityParent(&*view) }) + }) + .flatten() + } + + #[method_id(accessibilityTopLevelUIElement)] + fn top_level(&self) -> Option> { + self.resolve_with_context(|_, _, context| { + context + .view + .load() + .and_then(|view| unsafe { NSAccessibility::accessibilityParent(&*view) }) + }) + .flatten() + } + + #[method_id(accessibilityChildren)] + fn children(&self) -> Option>> { + self.children_internal() + } + + #[method_id(accessibilityChildrenInNavigationOrder)] + fn children_in_navigation_order(&self) -> Option>> { + // For now, we assume the children are in navigation order. + self.children_internal() + } + + #[method_id(accessibilitySelectedChildren)] + fn selected_children(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + let wrapper = NodeWrapper(node); + if !wrapper.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .filter(|item| item.is_selected() == Some(true)) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method(accessibilityFrame)] + fn frame(&self) -> NSRect { + self.resolve_with_context(|node, _, context| { + let view = match context.view.load() { + Some(view) => view, + None => { + return NSRect::ZERO; + } + }; + + node.bounding_box().map_or_else( + || { + if node.is_root() { + unsafe { NSAccessibility::accessibilityFrame(&*view) } + } else { + NSRect::ZERO + } + }, + |rect| to_ns_rect(&view, rect), + ) + }) + .unwrap_or(NSRect::ZERO) + } + + #[method_id(accessibilityRole)] + fn role(&self) -> Id { + self.resolve(ns_role) + .unwrap_or(unsafe { NSAccessibilityUnknownRole }) + .copy() + } + + #[method_id(accessibilitySubrole)] + fn sub_role(&self) -> Id { + self.resolve(ns_sub_role) + .unwrap_or(unsafe { NSAccessibilityUnknownSubrole }) + .copy() + } + + #[method_id(accessibilityRoleDescription)] + fn role_description(&self) -> Option> { + self.resolve(|node| { + if let Some(role_description) = node.role_description() { + Some(NSString::from_str(role_description)) + } else { + unsafe { msg_send_id![super(self), accessibilityRoleDescription] } + } + }) + .flatten() + } + + #[method_id(accessibilityIdentifier)] + fn identifier(&self) -> Option> { + self.resolve(|node| { + node.author_id().map(NSString::from_str) + }) + .flatten() + } + + #[method_id(accessibilityTitle)] + fn title(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.title().map(|title| NSString::from_str(&title)) + }) + .flatten() + } + + #[method_id(accessibilityHelp)] + fn description(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.description().map(|description| NSString::from_str(&description)) + }) + .flatten() + } + + #[method_id(accessibilityPlaceholderValue)] + fn placeholder(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.placeholder().map(NSString::from_str) + }) + .flatten() + } + + #[method_id(accessibilityValue)] + fn value(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.value().map(|value| match value { + Value::Bool(value) => { + Id::into_super(Id::into_super(NSNumber::new_bool(value))) + } + Value::Number(value) => { + Id::into_super(Id::into_super(NSNumber::new_f64(value))) + } + Value::String(value) => { + Id::into_super(NSString::from_str(&value)) + } + }) + }) + .flatten() + } + + #[method(setAccessibilityValue:)] + fn set_value(&self, value: &NSObject) { + if let Some(string) = downcast_ref::(value) { + self.resolve_with_context(|node, tree, context| { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::SetValue, + target_tree, + target_node, + data: Some(ActionData::Value(string.to_string().into())), + }); + } + }); + } else if let Some(number) = downcast_ref::(value) { + self.resolve_with_context(|node, tree, context| { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::SetValue, + target_tree, + target_node, + data: Some(ActionData::NumericValue(number.doubleValue())), + }); + } + }); + } + } + + #[method_id(accessibilityMinValue)] + fn min_value(&self) -> Option> { + self.resolve(|node| { + node.min_numeric_value().map(NSNumber::new_f64) + }) + .flatten() + } + + #[method_id(accessibilityMaxValue)] + fn max_value(&self) -> Option> { + self.resolve(|node| { + node.max_numeric_value().map(NSNumber::new_f64) + }) + .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| { + match node.orientation() { + Some(Orientation::Horizontal) => NSAccessibilityOrientation::Horizontal, + Some(Orientation::Vertical) => NSAccessibilityOrientation::Vertical, + None => NSAccessibilityOrientation::Unknown, + } + }) + .unwrap_or(NSAccessibilityOrientation::Unknown) + } + + #[method(isAccessibilityElement)] + fn is_accessibility_element(&self) -> bool { + self.resolve(|node| filter(node) == FilterResult::Include) + .unwrap_or(false) + } + + #[method(isAccessibilityFocused)] + fn is_focused(&self) -> bool { + self.resolve(|node| node.is_focused() && can_be_focused(node)) + .unwrap_or(false) + } + + #[method(isAccessibilityEnabled)] + fn is_enabled(&self) -> bool { + self.resolve(|node| !node.is_disabled()).unwrap_or(false) + } + + #[method(setAccessibilityFocused:)] + fn set_focused(&self, focused: bool) { + self.resolve_with_context(|node, tree, context| { + if focused { + if node.is_focusable(&filter) { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Focus, + target_tree, + target_node, + data: None, + }); + } + } + } else { + let root = tree.state().root(); + if root.is_focusable(&filter) { + if let Some((target_node, target_tree)) = tree.state().locate_node(root.id()) { + context.do_action(ActionRequest { + action: Action::Focus, + target_tree, + target_node, + data: None, + }); + } + } + } + }); + } + + #[method(accessibilityPerformPress)] + fn press(&self) -> bool { + self.resolve_with_context(|node, tree, context| { + let clickable = node.is_clickable(&filter); + if clickable { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + } + } + clickable + }) + .unwrap_or(false) + } + + #[method(accessibilityPerformIncrement)] + fn increment(&self) -> bool { + self.resolve_with_context(|node, tree, context| { + let supports_increment = node.supports_increment(&filter); + if supports_increment { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Increment, + target_tree, + target_node, + data: None, + }); + } + } + supports_increment + }) + .unwrap_or(false) + } + + #[method(accessibilityPerformDecrement)] + fn decrement(&self) -> bool { + self.resolve_with_context(|node, tree, context| { + let supports_decrement = node.supports_decrement(&filter); + if supports_decrement { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Decrement, + target_tree, + target_node, + data: None, + }); + } + } + supports_decrement + }) + .unwrap_or(false) + } + + #[method(accessibilityNotifiesWhenDestroyed)] + fn notifies_when_destroyed(&self) -> bool { + true + } + + #[method(accessibilityNumberOfCharacters)] + fn number_of_characters(&self) -> NSInteger { + self.resolve(|node| { + if node.supports_text_ranges() { + node.document_range().end().to_global_utf16_index() as _ + } else { + 0 + } + }) + .unwrap_or(0) + } + + #[method_id(accessibilitySelectedText)] + fn selected_text(&self) -> Option> { + self.resolve(|node| { + if node.supports_text_ranges() { + if let Some(range) = node.text_selection() { + let text = range.text(); + return Some(NSString::from_str(&text)); + } + } + None + }) + .flatten() + } + + #[method(accessibilitySelectedTextRange)] + fn selected_text_range(&self) -> NSRange { + self.resolve(|node| { + if node.supports_text_ranges() { + if let Some(range) = node.text_selection() { + return to_ns_range(&range); + } + } + NSRange::new(0, 0) + }) + .unwrap_or_else(|| NSRange::new(0, 0)) + } + + #[method(accessibilityInsertionPointLineNumber)] + fn insertion_point_line_number(&self) -> NSInteger { + self.resolve(|node| { + if node.supports_text_ranges() { + if let Some(pos) = node.text_selection_focus() { + return pos.to_line_index() as _; + } + } + 0 + }) + .unwrap_or(0) + } + + #[method(accessibilityRangeForLine:)] + fn range_for_line(&self, line_index: NSInteger) -> NSRange { + self.resolve(|node| { + if node.supports_text_ranges() && line_index >= 0 { + if let Some(range) = node.line_range_from_index(line_index as _) { + return to_ns_range(&range); + } + } + NSRange::new(0, 0) + }) + .unwrap_or_else(|| NSRange::new(0, 0)) + } + + #[method(accessibilityRangeForPosition:)] + fn range_for_position(&self, point: NSPoint) -> NSRange { + self.resolve_with_context(|node, _, context| { + let view = match context.view.load() { + Some(view) => view, + None => { + return NSRange::new(0, 0); + } + }; + + if node.supports_text_ranges() { + let point = from_ns_point(&view, node, point); + let pos = node.text_position_at_point(point); + return to_ns_range_for_character(&pos); + } + NSRange::new(0, 0) + }) + .unwrap_or_else(|| NSRange::new(0, 0)) + } + + #[method_id(accessibilityStringForRange:)] + fn 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 text = range.text(); + return Some(NSString::from_str(&text)); + } + } + None + }) + .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| { + let view = match context.view.load() { + Some(view) => view, + None => { + return NSRect::ZERO; + } + }; + + if node.supports_text_ranges() { + if let Some(range) = from_ns_range(node, range) { + let rects = range.bounding_boxes(); + if let Some(rect) = + rects.into_iter().reduce(|rect1, rect2| rect1.union(rect2)) + { + return to_ns_rect(&view, rect); + } + } + } + NSRect::ZERO + }) + .unwrap_or(NSRect::ZERO) + } + + #[method(accessibilityLineForIndex:)] + fn line_for_index(&self, index: NSInteger) -> NSInteger { + self.resolve(|node| { + if node.supports_text_ranges() && index >= 0 { + if let Some(pos) = node.text_position_from_global_utf16_index(index as _) { + return pos.to_line_index() as _; + } + } + 0 + }) + .unwrap_or(0) + } + + #[method(accessibilityRangeForIndex:)] + fn 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 _) { + return to_ns_range_for_character(&pos); + } + } + NSRange::new(0, 0) + }) + .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| { + if node.supports_text_ranges() { + if let Some(range) = from_ns_range(node, range) { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::SetTextSelection, + target_tree, + target_node, + data: Some(ActionData::SetTextSelection(range.to_text_selection())), + }); + } + } + } + }); + } + + #[method(isAccessibilityRequired)] + fn is_required(&self) -> bool { + self.resolve(|node| node.is_required()) + .unwrap_or(false) + } + + #[method(isAccessibilitySelected)] + fn is_selected(&self) -> bool { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.is_item_like() + && node.is_selectable() + && node.is_selected().unwrap_or(false) + }) + .unwrap_or(false) + } + + #[method(setAccessibilitySelected:)] + fn set_selected(&self, selected: bool) { + self.resolve_with_context(|node, tree, context| { + let wrapper = NodeWrapper(node); + if !node.is_clickable(&filter) + || !wrapper.is_item_like() + || !node.is_selectable() + { + return; + } + if node.is_selected() == Some(selected) { + return; + } + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + } + }); + } + + #[method_id(accessibilityAttributeValue:)] + fn accessibility_attribute_value(&self, attr: &NSString) -> Option> { + self.resolve(|node| { + if attr == ns_string!("AXBrailleLabel") && node.has_braille_label() { + return Some(NSString::from_str(node.braille_label().unwrap())) + } else if attr == ns_string!("AXBrailleRoleDescription") && node.has_braille_role_description() { + return Some(NSString::from_str(node.braille_role_description().unwrap())) + } + + None + }) + .flatten() + } + + #[method_id(accessibilityRows)] + fn rows(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + let wrapper = NodeWrapper(node); + if !wrapper.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method_id(accessibilitySelectedRows)] + fn selected_rows(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + let wrapper = NodeWrapper(node); + if !wrapper.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .filter(|item| item.is_selected() == Some(true)) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method(accessibilityPerformPick)] + fn pick(&self) -> bool { + self.resolve_with_context(|node, tree, context| { + let wrapper = NodeWrapper(node); + let selectable = node.is_clickable(&filter) + && wrapper.is_item_like() + && node.is_selectable(); + if selectable { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + } + } + selectable + }) + .unwrap_or(false) + } + + #[method_id(accessibilityLinkedUIElements)] + fn linked_ui_elements(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + let platform_nodes: Vec> = node + .controls() + .filter(|controlled| filter(controlled) == FilterResult::Include) + .map(|controlled| context.get_or_create_platform_node(controlled.id())) + .collect(); + if platform_nodes.is_empty() { + None + } else { + Some(NSArray::from_vec(platform_nodes)) + } + }) + .flatten() + } + + #[method_id(accessibilityTabs)] + fn tabs(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + if node.role() != Role::TabList { + return None; + } + let platform_nodes = node + .filtered_children(filter) + .filter(|child| child.role() == Role::Tab) + .map(|tab| context.get_or_create_platform_node(tab.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method(isAccessibilityModal)] + fn is_modal(&self) -> bool { + self.resolve(|node| node.is_modal()) + .unwrap_or(false) + } + + // We discovered through experimentation that when mixing the newer + // NSAccessibility protocols with the older informal protocol, + // the platform uses both protocols to discover which actions are + // available and then perform actions. That means our implementation + // of the legacy methods below only needs to cover actions not already + // handled by the newer methods. + + #[method_id(accessibilityActionNames)] + fn action_names(&self) -> Id> { + let mut result = vec![]; + self.resolve(|node| { + if node.supports_action(Action::ScrollIntoView, &filter) { + result.push(ns_string!(SCROLL_TO_VISIBLE_ACTION).copy()); + } + }); + NSArray::from_vec(result) + } + + #[method(accessibilityPerformAction:)] + fn perform_action(&self, action: &NSString) { + self.resolve_with_context(|node, tree, context| { + if action == ns_string!(SCROLL_TO_VISIBLE_ACTION) { + if let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) { + context.do_action(ActionRequest { + action: Action::ScrollIntoView, + target_tree, + target_node, + data: None, + }); + } + } + }); + } + + #[method(isAccessibilitySelectorAllowed:)] + fn is_selector_allowed(&self, selector: Sel) -> bool { + self.resolve(|node| { + if selector == sel!(setAccessibilityFocused:) { + return node.is_focusable(&filter); + } + if selector == sel!(accessibilityPerformPress) { + return node.is_clickable(&filter); + } + if selector == sel!(accessibilityPerformIncrement) { + return node.supports_increment(&filter); + } + if selector == sel!(accessibilityPerformDecrement) { + return node.supports_decrement(&filter); + } + if selector == sel!(accessibilityNumberOfCharacters) + || selector == sel!(accessibilitySelectedText) + || selector == sel!(accessibilitySelectedTextRange) + || selector == sel!(accessibilityInsertionPointLineNumber) + || 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(); + } + if selector == sel!(setAccessibilityValue:) { + return (node.supports_text_ranges() && !node.is_read_only()) || node.supports_action(Action::SetValue, &filter); + } + if selector == sel!(isAccessibilitySelected) { + let wrapper = NodeWrapper(node); + return wrapper.is_item_like(); + } + if selector == sel!(accessibilityRows) + || selector == sel!(accessibilitySelectedRows) + { + let wrapper = NodeWrapper(node); + return wrapper.is_container_with_selectable_children() + } + if selector == sel!(setAccessibilitySelected:) + || selector == sel!(accessibilityPerformPick) + { + let wrapper = NodeWrapper(node); + return node.is_clickable(&filter) + && wrapper.is_item_like() + && node.is_selectable(); + } + if selector == sel!(accessibilityTabs) { + return node.role() == Role::TabList; + } + if selector == sel!(isAccessibilityModal) { + return node.is_dialog(); + } + 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) + || selector == sel!(accessibilitySelectedChildren) + || selector == sel!(accessibilityFrame) + || selector == sel!(accessibilityRole) + || selector == sel!(accessibilitySubrole) + || selector == sel!(isAccessibilityEnabled) + || selector == sel!(accessibilityWindow) + || selector == sel!(accessibilityTopLevelUIElement) + || selector == sel!(accessibilityLinkedUIElements) + || selector == sel!(accessibilityRoleDescription) + || selector == sel!(accessibilityIdentifier) + || selector == sel!(accessibilityTitle) + || selector == sel!(accessibilityHelp) + || selector == sel!(accessibilityPlaceholderValue) + || selector == sel!(accessibilityValue) + || selector == sel!(accessibilityMinValue) + || selector == sel!(accessibilityMaxValue) + || selector == sel!(isAccessibilityRequired) + || selector == sel!(accessibilityOrientation) + || selector == sel!(isAccessibilityElement) + || selector == sel!(isAccessibilityFocused) + || selector == sel!(accessibilityNotifiesWhenDestroyed) + || selector == sel!(isAccessibilitySelectorAllowed:) + || selector == sel!(accessibilityActionNames) + || selector == sel!(accessibilityPerformAction:) + }) + .unwrap_or(false) + } + } +); + +impl PlatformNode { + pub(crate) fn new(context: Weak, node_id: NodeId) -> Id { + let this = Self::alloc().set_ivars(PlatformNodeIvars { context, node_id }); + + unsafe { msg_send_id![super(this), init] } + } + + fn resolve_with_context(&self, f: F) -> Option + where + F: FnOnce(&Node, &Tree, &Rc) -> T, + { + let context = self.ivars().context.upgrade()?; + let tree = context.tree.borrow(); + let state = tree.state(); + let node = state.node_by_id(self.ivars().node_id)?; + Some(f(&node, &tree, &context)) + } + + fn resolve(&self, f: F) -> Option + where + F: FnOnce(&Node) -> T, + { + self.resolve_with_context(|node, _, _| f(node)) + } + + fn children_internal(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + let platform_nodes = node + .filtered_children(filter) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + NSArray::from_vec(platform_nodes) + }) + } +} diff --git a/vendor/accesskit_macos/src/patch.rs b/vendor/accesskit_macos/src/patch.rs new file mode 100644 index 0000000000..1b4732eb25 --- /dev/null +++ b/vendor/accesskit_macos/src/patch.rs @@ -0,0 +1,93 @@ +// Copyright 2023 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 objc2::{ + Message, + encode::{Encode, EncodeArguments, EncodeReturn, Encoding}, + ffi::class_addMethod, + msg_send, + runtime::{AnyClass, AnyObject, Bool, MethodImplementation, Sel}, + sel, +}; +use objc2_app_kit::NSWindow; +use std::{ffi::CString, ptr::null_mut}; + +extern "C" fn focus_forwarder(this: &NSWindow, _cmd: Sel) -> *mut AnyObject { + unsafe { + this.contentView().map_or_else(null_mut, |view| { + msg_send![&*view, accessibilityFocusedUIElement] + }) + } +} + +/// Modifies the specified class, which must be a subclass of `NSWindow`, +/// to include an `accessibilityFocusedUIElement` method that calls +/// the corresponding method on the window's content view. This is needed +/// for windowing libraries such as SDL that place the keyboard focus +/// directly on the window rather than the content view. +/// +/// # Safety +/// +/// This function is declared unsafe because the caller must ensure that the +/// code for this crate is never unloaded from the application process, +/// since it's not possible to reverse this operation. It's safest +/// if this crate is statically linked into the application's main executable. +/// Also, this function assumes that the specified class is a subclass +/// of `NSWindow`. +pub unsafe fn add_focus_forwarder_to_window_class(class_name: &str) { + let class = AnyClass::get(class_name).unwrap(); + unsafe { + add_method( + class as *const AnyClass as *mut AnyClass, + sel!(accessibilityFocusedUIElement), + focus_forwarder as unsafe extern "C" fn(_, _) -> _, + ) + }; +} + +// The rest of this file is copied from objc2 with only minor adaptations, +// to allow a method to be added to an existing class. + +unsafe fn add_method(class: *mut AnyClass, sel: Sel, func: F) +where + T: Message + ?Sized, + F: MethodImplementation, +{ + let encs = F::Arguments::ENCODINGS; + let sel_args = count_args(sel); + assert_eq!( + sel_args, + encs.len(), + "Selector {:?} accepts {} arguments, but function accepts {}", + sel, + sel_args, + encs.len(), + ); + + let types = method_type_encoding(&F::Return::ENCODING_RETURN, encs); + let success = Bool::from_raw(unsafe { + class_addMethod( + class as *mut _, + sel.as_ptr(), + Some(func.__imp()), + types.as_ptr(), + ) + }); + assert!(success.as_bool(), "Failed to add method {sel:?}"); +} + +fn count_args(sel: Sel) -> usize { + sel.name().chars().filter(|&c| c == ':').count() +} + +fn method_type_encoding(ret: &Encoding, args: &[Encoding]) -> CString { + // First two arguments are always self and the selector + let mut types = format!("{}{}{}", ret, <*mut AnyObject>::ENCODING, Sel::ENCODING); + for enc in args { + use core::fmt::Write; + write!(&mut types, "{enc}").unwrap(); + } + CString::new(types).unwrap() +} diff --git a/vendor/accesskit_macos/src/subclass.rs b/vendor/accesskit_macos/src/subclass.rs new file mode 100644 index 0000000000..b0f55477c4 --- /dev/null +++ b/vendor/accesskit_macos/src/subclass.rs @@ -0,0 +1,354 @@ +// Copyright 2022 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. + +// SolidCraft: this file is accesskit_macos 0.27.2's, which leaves the view's class alone when +// key-value observing subclassed it after the adapter did (AccessKit/accesskit#835). 0.26.3 reset +// it on drop, and AppKit's Touch Bar then failed to remove its observers on quit (#7535). + +use accesskit::{ActionHandler, ActivationHandler, TreeUpdate}; +use objc2::{ + ClassType, DeclaredClass, + declare::ClassBuilder, + declare_class, + ffi::{ + OBJC_ASSOCIATION_RETAIN_NONATOMIC, objc_getAssociatedObject, objc_setAssociatedObject, + object_setClass, + }, + msg_send, msg_send_id, + mutability::InteriorMutable, + rc::Id, + runtime::{AnyClass, AnyObject, Sel}, + sel, +}; +use objc2_app_kit::{NSView, NSWindow}; +use objc2_foundation::{NSArray, NSObject, NSPoint}; +use std::{cell::RefCell, ffi::c_void, sync::Mutex}; + +use crate::{Adapter, event::QueuedEvents}; + +static SUBCLASSES: Mutex> = Mutex::new(Vec::new()); + +static ASSOCIATED_OBJECT_KEY: u8 = 0; + +fn associated_object_key() -> *const c_void { + (&ASSOCIATED_OBJECT_KEY as *const u8).cast() +} + +struct AssociatedObjectState { + adapter: Adapter, + activation_handler: Box, +} + +struct AssociatedObjectIvars { + state: RefCell, + prev_class: &'static AnyClass, +} + +declare_class!( + struct AssociatedObject; + + unsafe impl ClassType for AssociatedObject { + type Super = NSObject; + type Mutability = InteriorMutable; + const NAME: &'static str = "AccessKitSubclassAssociatedObject"; + } + + impl DeclaredClass for AssociatedObject { + type Ivars = AssociatedObjectIvars; + } +); + +impl AssociatedObject { + fn new( + adapter: Adapter, + activation_handler: impl 'static + ActivationHandler, + prev_class: &'static AnyClass, + ) -> Id { + let state = RefCell::new(AssociatedObjectState { + adapter, + activation_handler: Box::new(activation_handler), + }); + let this = Self::alloc().set_ivars(AssociatedObjectIvars { state, prev_class }); + + unsafe { msg_send_id![super(this), init] } + } +} + +fn associated_object(view: &NSView) -> Option<&AssociatedObject> { + unsafe { + (objc_getAssociatedObject(view as *const NSView as *const _, associated_object_key()) + as *const AssociatedObject) + .as_ref() + } +} + +// Unlike `[view class]`, this sees the subclass that key-value observing +// hides by overriding `class`. +fn runtime_class(view: &NSView) -> &'static AnyClass { + let object: &AnyObject = view; + object.class() +} + +// Key-value observing may have subclassed our subclass in turn. +fn installed_subclass(view: &NSView) -> Option<(&'static AnyClass, &'static AnyClass)> { + let subclasses = SUBCLASSES.lock().unwrap(); + let mut class: Option<&AnyClass> = Some(runtime_class(view)); + while let Some(current) = class { + if let Some(entry) = subclasses + .iter() + .find(|entry| std::ptr::eq(entry.1, current)) + { + return Some(*entry); + } + class = current.superclass(); + } + None +} + +fn prev_class(view: &NSView) -> &'static AnyClass { + installed_subclass(view).unwrap().0 +} + +// Some view classes, like the one in winit 0.27, assume that they are the +// lowest subclass, and call [self superclass] to get their superclass. +// Give them the answer they need. +unsafe extern "C" fn superclass(this: &NSView, _cmd: Sel) -> Option<&AnyClass> { + let prev_class = match associated_object(this) { + Some(associated) => associated.ivars().prev_class, + None => prev_class(this), + }; + prev_class.superclass() +} + +unsafe extern "C" fn children(this: &NSView, _cmd: Sel) -> *mut NSArray { + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityChildren] }; + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .view_children(&mut *state_mut.activation_handler) +} + +unsafe extern "C" fn focus(this: &NSView, _cmd: Sel) -> *mut NSObject { + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityFocusedUIElement] }; + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut.adapter.focus(&mut *state_mut.activation_handler) +} + +unsafe extern "C" fn hit_test(this: &NSView, _cmd: Sel, point: NSPoint) -> *mut NSObject { + let Some(associated) = associated_object(this) else { + return unsafe { msg_send![super(this, prev_class(this)), accessibilityHitTest: point] }; + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .hit_test(point, &mut *state_mut.activation_handler) +} + +fn subclass_of(prev_class: &'static AnyClass) -> &'static AnyClass { + let mut subclasses = SUBCLASSES.lock().unwrap(); + match subclasses + .iter() + .find(|entry| std::ptr::eq(entry.0, prev_class)) + { + Some(entry) => entry.1, + None => { + let name = format!("AccessKitSubclassOf{}", prev_class.name()); + let mut builder = ClassBuilder::new(&name, prev_class).unwrap(); + unsafe { + builder.add_method( + sel!(superclass), + superclass as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityChildren), + children as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityFocusedUIElement), + focus as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityHitTest:), + hit_test as unsafe extern "C" fn(_, _, _) -> _, + ); + } + let class = builder.register(); + subclasses.push((prev_class, class)); + class + } + } +} + +/// Uses dynamic Objective-C subclassing to implement the `NSView` +/// accessibility methods when normal subclassing isn't an option. +pub struct SubclassingAdapter { + view: Id, + associated: Id, +} + +impl SubclassingAdapter { + /// Create an adapter that dynamically subclasses the specified view. + /// This must be done before the view is shown or focused for + /// the first time, and before anything observes the view with + /// key-value observing. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to an `NSView`. + pub unsafe fn new( + view: *mut c_void, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let view = view as *mut NSView; + let retained_view = unsafe { Id::retain(view) }.unwrap(); + Self::new_internal(retained_view, activation_handler, action_handler) + } + + fn new_internal( + retained_view: Id, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let view = Id::as_ptr(&retained_view) as *mut NSView; + if !unsafe { + objc_getAssociatedObject(view as *const NSView as *const _, associated_object_key()) + } + .is_null() + { + panic!("subclassing adapter already instantiated on view {view:?}"); + } + // A previous adapter may have left its subclass on the view, + // if key-value observing had subclassed it in turn. + let installed = installed_subclass(unsafe { &*view }); + if installed.is_none() { + // Key-value observing crashes when it adds or removes an observer + // while our subclass sits on top of its own. + let reported_class: &AnyClass = unsafe { msg_send![view, class] }; + let actual_class = runtime_class(unsafe { &*view }); + if !std::ptr::eq(reported_class, actual_class) { + panic!( + "view {view:?} is already observed with key-value observing (its class {} reports itself as {}); create the subclassing adapter before anything observes the view", + actual_class.name(), + reported_class.name() + ); + } + } + let adapter = unsafe { Adapter::new(view as *mut c_void, false, action_handler) }; + let (prev_class, subclass) = installed.unwrap_or_else(|| { + let prev_class = runtime_class(unsafe { &*view }); + (prev_class, subclass_of(prev_class)) + }); + let associated = AssociatedObject::new(adapter, activation_handler, prev_class); + unsafe { + objc_setAssociatedObject( + view as *mut _, + associated_object_key(), + Id::as_ptr(&associated) as *mut _, + OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + }; + // SAFETY: Changing the view's class is only safe because + // the subclass doesn't add any instance variables; + // it uses an associated object instead. + if installed.is_none() { + unsafe { object_setClass(view as *mut _, (subclass as *const AnyClass).cast()) }; + } + Self { + view: retained_view, + associated, + } + } + + /// Create an adapter that dynamically subclasses the content view + /// of the specified window. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `window` must be a valid, unreleased pointer to an `NSWindow`. + /// + /// # Panics + /// + /// This function panics if the specified window doesn't currently have + /// a content view. + pub unsafe fn for_window( + window: *mut c_void, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let window = unsafe { &*(window as *const NSWindow) }; + let retained_view = window.contentView().unwrap(); + Self::new_internal(retained_view, activation_handler, action_handler) + } + + /// If and only if the tree has been initialized, call the provided function + /// and apply the resulting update. Note: If the caller's implementation of + /// [`ActivationHandler::request_initial_tree`] initially returned `None`, + /// the [`TreeUpdate`] returned by the provided function must contain + /// a full tree. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_if_active( + &mut self, + update_factory: impl FnOnce() -> TreeUpdate, + ) -> Option { + let mut state = self.associated.ivars().state.borrow_mut(); + state.adapter.update_if_active(update_factory) + } + + /// Update the tree state based on whether the window is focused. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_view_focus_state(&mut self, is_focused: bool) -> Option { + let mut state = self.associated.ivars().state.borrow_mut(); + state.adapter.update_view_focus_state(is_focused) + } +} + +impl Drop for SubclassingAdapter { + fn drop(&mut self) { + let prev_class = self.associated.ivars().prev_class; + let view = Id::as_ptr(&self.view) as *mut NSView; + unsafe { + objc_setAssociatedObject( + view as *mut _, + associated_object_key(), + std::ptr::null_mut(), + OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + }; + // Key-value observing may have subclassed our subclass in turn + // (AppKit observes the first responder), and resetting the class + // would then break those observers. Our subclass is harmless + // without the associated object. + let view = unsafe { &*view }; + let is_our_subclass = SUBCLASSES + .lock() + .unwrap() + .iter() + .any(|entry| std::ptr::eq(entry.1, runtime_class(view))); + if is_our_subclass { + unsafe { + object_setClass( + view as *const _ as *mut _, + (prev_class as *const AnyClass).cast(), + ) + }; + } + } +} diff --git a/vendor/accesskit_macos/src/util.rs b/vendor/accesskit_macos/src/util.rs new file mode 100644 index 0000000000..4015e1def6 --- /dev/null +++ b/vendor/accesskit_macos/src/util.rs @@ -0,0 +1,108 @@ +// Copyright 2022 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::{Color, Point, Rect}; +use accesskit_consumer::{Node, TextPosition, TextRange}; +use objc2::encode::{Encoding, RefEncode}; +use objc2::{msg_send, rc::Id, runtime::AnyObject}; +use objc2_app_kit::*; +use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize}; + +pub(crate) fn from_ns_range<'a>(node: &'a Node<'a>, ns_range: NSRange) -> Option> { + let pos = node.text_position_from_global_utf16_index(ns_range.location)?; + let mut range = pos.to_degenerate_range(); + if ns_range.length > 0 { + let end = + node.text_position_from_global_utf16_index(ns_range.location + ns_range.length)?; + range.set_end(end); + } + Some(range) +} + +pub(crate) fn to_ns_range(range: &TextRange) -> NSRange { + let start = range.start().to_global_utf16_index(); + let end = range.end().to_global_utf16_index(); + NSRange::from(start..end) +} + +pub(crate) fn to_ns_range_for_character(pos: &TextPosition) -> NSRange { + let mut range = pos.to_degenerate_range(); + if !pos.is_document_end() { + range.set_end(pos.forward_to_character_end()); + } + to_ns_range(&range) +} + +pub(crate) fn from_ns_point(view: &NSView, node: &Node, point: NSPoint) -> Point { + let window = view.window().unwrap(); + let point = window.convertPointFromScreen(point); + let point = view.convertPoint_fromView(point, None); + // AccessKit coordinates are in physical (DPI-dependent) pixels, but + // macOS provides logical (DPI-independent) coordinates here. + let factor = window.backingScaleFactor(); + let point = Point::new( + point.x * factor, + if view.isFlipped() { + point.y * factor + } else { + let view_bounds = view.bounds(); + (view_bounds.size.height - point.y) * factor + }, + ); + node.transform().inverse() * point +} + +pub(crate) fn to_ns_rect(view: &NSView, rect: Rect) -> NSRect { + let window = view.window().unwrap(); + // AccessKit coordinates are in physical (DPI-dependent) + // pixels, but macOS expects logical (DPI-independent) + // coordinates here. + let factor = window.backingScaleFactor(); + let rect = NSRect { + origin: NSPoint { + x: rect.x0 / factor, + y: if view.isFlipped() { + rect.y0 / factor + } else { + let view_bounds = view.bounds(); + view_bounds.size.height - rect.y1 / factor + }, + }, + size: NSSize { + width: rect.width() / factor, + height: rect.height() / factor, + }, + }; + let rect = view.convertRect_toView(rect, None); + let window = view.window().unwrap(); + window.convertRectToScreen(rect) +} + +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( + color_channel_to_f64(color.red), + color_channel_to_f64(color.green), + color_channel_to_f64(color.blue), + color_channel_to_f64(color.alpha), + ) + }; + let cg_color: *const CGColor = unsafe { msg_send![&ns_color, CGColor] }; + unsafe { Id::retain(cg_color as *mut AnyObject).unwrap() } +} diff --git a/vendor/accesskit_macos/tests/integration/harness.rs b/vendor/accesskit_macos/tests/integration/harness.rs new file mode 100644 index 0000000000..3672e95f5d --- /dev/null +++ b/vendor/accesskit_macos/tests/integration/harness.rs @@ -0,0 +1,38 @@ +// 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 libtest_mimic::{Arguments, Trial}; +use std::process::ExitCode; + +pub struct Test { + pub name: &'static str, + pub run: fn(), +} + +macro_rules! tests { + ($($name:ident),* $(,)?) => { + &[$($crate::harness::Test { name: stringify!($name), run: $name }),*] + }; +} +pub(crate) use tests; + +pub fn run(suites: &[(&str, &[Test])]) -> ExitCode { + let mut args = Arguments::from_args(); + // libtest-mimic only runs tests on the main thread when it uses a single thread. + args.test_threads = Some(1); + let trials = suites + .iter() + .flat_map(|(suite, tests)| { + tests.iter().map(move |test| { + let run = test.run; + Trial::test(format!("{suite}::{}", test.name), move || { + run(); + Ok(()) + }) + }) + }) + .collect(); + libtest_mimic::run(&args, trials).exit_code() +} diff --git a/vendor/accesskit_macos/tests/integration/main.rs b/vendor/accesskit_macos/tests/integration/main.rs new file mode 100644 index 0000000000..c756fae50c --- /dev/null +++ b/vendor/accesskit_macos/tests/integration/main.rs @@ -0,0 +1,12 @@ +// 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. + +mod harness; +mod subclassing_adapter; +mod util; + +fn main() -> std::process::ExitCode { + harness::run(&[("subclassing_adapter", subclassing_adapter::TESTS)]) +} diff --git a/vendor/accesskit_macos/tests/integration/subclassing_adapter.rs b/vendor/accesskit_macos/tests/integration/subclassing_adapter.rs new file mode 100644 index 0000000000..46637dfb86 --- /dev/null +++ b/vendor/accesskit_macos/tests/integration/subclassing_adapter.rs @@ -0,0 +1,178 @@ +// 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 std::{ + ffi::c_void, + panic::{self, AssertUnwindSafe}, + rc::Rc, +}; + +use accesskit_macos::SubclassingAdapter; +use objc2::msg_send; +use objc2::rc::Id; +use objc2::runtime::{AnyClass, AnyObject}; +use objc2_app_kit::NSView; +use objc2_foundation::{NSArray, NSObject, NSPoint, NSString}; + +use crate::harness::{Test, tests}; +use crate::util::*; + +pub const TESTS: &[Test] = tests![ + the_view_is_subclassed, + querying_the_view_requests_the_tree, + dropping_restores_the_view_class, + dropping_releases_the_handlers, + a_view_can_get_a_new_adapter, + observers_can_be_removed_after_dropping, + an_observed_view_can_get_a_new_adapter, + a_view_observed_before_the_adapter_is_rejected, +]; + +fn adapter_for(view: &NSView) -> (SubclassingAdapter, Rc) { + let events = Rc::new(HandlerEvents::default()); + let adapter = unsafe { + SubclassingAdapter::new( + view as *const NSView as *mut c_void, + NoTree(Rc::clone(&events)), + NoActions, + ) + }; + (adapter, events) +} + +fn query_accessibility(view: &NSView) { + let _: *mut NSArray = unsafe { msg_send![view, accessibilityChildren] }; + let _: *mut AnyObject = unsafe { msg_send![view, accessibilityFocusedUIElement] }; + let superclass: &AnyClass = unsafe { msg_send![view, superclass] }; + assert_eq!(superclass.name(), "NSResponder"); +} + +// The adapter can only hit test a view that is in a window, but the +// fallback doesn't need one. +fn query_accessibility_without_adapter(view: &NSView) { + query_accessibility(view); + let _: *mut AnyObject = unsafe { msg_send![view, accessibilityHitTest: NSPoint::new(0., 0.)] }; +} + +struct Observation { + observer: Id, + key_path: Id, +} + +// AppKit observes the first responder this way. +fn observe(view: &NSView) -> Observation { + let observer = NSObject::new(); + let key_path = NSString::from_str("nextResponder"); + let no_context: *mut c_void = std::ptr::null_mut(); + let _: () = unsafe { + msg_send![view, addObserver: &*observer, forKeyPath: &*key_path, options: 0usize, context: no_context] + }; + Observation { observer, key_path } +} + +fn stop_observing(view: &NSView, observation: Observation) { + let _: () = unsafe { + msg_send![view, removeObserver: &*observation.observer, forKeyPath: &*observation.key_path] + }; +} + +fn the_view_is_subclassed() { + let view = new_view(); + let (_adapter, _) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); +} + +fn querying_the_view_requests_the_tree() { + let view = new_view(); + let (_adapter, events) = adapter_for(&view); + assert!(!events.tree_requested.get()); + query_accessibility(&view); + assert!(events.tree_requested.get()); +} + +fn dropping_restores_the_view_class() { + let view = new_view(); + let (adapter, _) = adapter_for(&view); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); + query_accessibility_without_adapter(&view); +} + +fn dropping_releases_the_handlers() { + let view = new_view(); + let (adapter, events) = adapter_for(&view); + drop(adapter); + assert!(events.dropped.get()); +} + +fn a_view_can_get_a_new_adapter() { + let view = new_view(); + drop(adapter_for(&view)); + let (adapter, events) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); +} + +fn observers_can_be_removed_after_dropping() { + let view = new_view(); + let (adapter, events) = adapter_for(&view); + let observation = observe(&view); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + + drop(adapter); + assert!(events.dropped.get()); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + query_accessibility_without_adapter(&view); + + stop_observing(&view, observation); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility_without_adapter(&view); +} + +fn an_observed_view_can_get_a_new_adapter() { + let view = new_view(); + let (adapter, _) = adapter_for(&view); + let observation = observe(&view); + drop(adapter); + + let (adapter, events) = adapter_for(&view); + assert_eq!( + class_name(&view), + "NSKVONotifying_AccessKitSubclassOfNSView" + ); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert!(events.dropped.get()); + + stop_observing(&view, observation); + let (adapter, events) = adapter_for(&view); + assert_eq!(class_name(&view), "AccessKitSubclassOfNSView"); + query_accessibility(&view); + assert!(events.tree_requested.get()); + drop(adapter); + assert_eq!(class_name(&view), "NSView"); +} + +fn a_view_observed_before_the_adapter_is_rejected() { + let view = new_view(); + let observation = observe(&view); + let result = panic::catch_unwind(AssertUnwindSafe(|| adapter_for(&view))); + assert!(result.is_err()); + assert_eq!(class_name(&view), "NSKVONotifying_NSView"); + + stop_observing(&view, observation); + assert_eq!(class_name(&view), "NSView"); + query_accessibility_without_adapter(&view); +} diff --git a/vendor/accesskit_macos/tests/integration/util.rs b/vendor/accesskit_macos/tests/integration/util.rs new file mode 100644 index 0000000000..e5346b8549 --- /dev/null +++ b/vendor/accesskit_macos/tests/integration/util.rs @@ -0,0 +1,49 @@ +// 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 std::{cell::Cell, rc::Rc}; + +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; +use objc2::rc::Id; +use objc2::runtime::AnyObject; +use objc2::{class, msg_send_id}; +use objc2_app_kit::NSView; + +#[derive(Default)] +pub struct HandlerEvents { + pub tree_requested: Cell, + pub dropped: Cell, +} + +pub struct NoTree(pub Rc); + +impl ActivationHandler for NoTree { + fn request_initial_tree(&mut self) -> Option { + self.0.tree_requested.set(true); + None + } +} + +impl Drop for NoTree { + fn drop(&mut self) { + self.0.dropped.set(true); + } +} + +pub struct NoActions; + +impl ActionHandler for NoActions { + fn do_action(&mut self, _request: ActionRequest) {} +} + +pub fn new_view() -> Id { + unsafe { msg_send_id![class!(NSView), new] } +} + +// KVO overrides the `class` method to hide its own subclass. +pub fn class_name(view: &NSView) -> String { + let object: &AnyObject = view; + object.class().name().to_owned() +}