From 8ced905843af67bd36ba0bdcbae4649ee2a8fb66 Mon Sep 17 00:00:00 2001 From: semmelrinde <124386289+semmelrinde@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:35:37 +0000 Subject: [PATCH] macOS: quitting on a Mac with a Touch Bar no longer crashes (#7535) (#7762) * claim: #7535 Co-Authored-By: Claude Opus 5.5 * Vendor accesskit_macos 0.26.3 as published (#7535) The published package, unchanged, so the next commit shows the fix as a diff. Co-Authored-By: Claude Opus 5.5 * macOS: quitting on a Mac with a Touch Bar no longer crashes (#7535) eframe's accessibility adapter (accesskit_macos 0.26.3) swaps the window's view to a subclass of its own. When AppKit's Touch Bar observes the first responder (nextResponder), key-value observing subclasses the view again. Dropping the adapter on quit reset the view's class, which threw away KVO's subclass, so AppKit's removal of its observer raised NSRangeException ("not registered as an observer") and the app ended with EXC_BREAKPOINT. accesskit_macos 0.27.2 fixes this (AccessKit/accesskit#835), but egui 0.36 is on accesskit 0.24, so vendor 0.26.3 with 0.27.2's src/subclass.rs and its tests: the adapter leaves the class alone when KVO subclassed the view. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + Cargo.lock | 2 - Cargo.toml | 3 + vendor/README.md | 21 + vendor/accesskit_macos/Cargo.toml | 87 ++ vendor/accesskit_macos/Cargo.toml.orig | 38 + vendor/accesskit_macos/README.md | 7 + vendor/accesskit_macos/src/adapter.rs | 295 ++++ vendor/accesskit_macos/src/context.rs | 103 ++ vendor/accesskit_macos/src/event.rs | 330 ++++ vendor/accesskit_macos/src/filters.rs | 6 + vendor/accesskit_macos/src/lib.rs | 25 + vendor/accesskit_macos/src/node.rs | 1338 +++++++++++++++++ vendor/accesskit_macos/src/patch.rs | 93 ++ vendor/accesskit_macos/src/subclass.rs | 354 +++++ vendor/accesskit_macos/src/util.rs | 108 ++ .../tests/integration/harness.rs | 38 + .../accesskit_macos/tests/integration/main.rs | 12 + .../tests/integration/subclassing_adapter.rs | 178 +++ .../accesskit_macos/tests/integration/util.rs | 49 + 20 files changed, 3086 insertions(+), 2 deletions(-) create mode 100644 vendor/accesskit_macos/Cargo.toml create mode 100644 vendor/accesskit_macos/Cargo.toml.orig create mode 100644 vendor/accesskit_macos/README.md create mode 100644 vendor/accesskit_macos/src/adapter.rs create mode 100644 vendor/accesskit_macos/src/context.rs create mode 100644 vendor/accesskit_macos/src/event.rs create mode 100644 vendor/accesskit_macos/src/filters.rs create mode 100644 vendor/accesskit_macos/src/lib.rs create mode 100644 vendor/accesskit_macos/src/node.rs create mode 100644 vendor/accesskit_macos/src/patch.rs create mode 100644 vendor/accesskit_macos/src/subclass.rs create mode 100644 vendor/accesskit_macos/src/util.rs create mode 100644 vendor/accesskit_macos/tests/integration/harness.rs create mode 100644 vendor/accesskit_macos/tests/integration/main.rs create mode 100644 vendor/accesskit_macos/tests/integration/subclassing_adapter.rs create mode 100644 vendor/accesskit_macos/tests/integration/util.rs 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() +}