diff --git a/adapters/macos/Cargo.toml b/adapters/macos/Cargo.toml index 14e853c5a..16d67f49d 100644 --- a/adapters/macos/Cargo.toml +++ b/adapters/macos/Cargo.toml @@ -36,3 +36,15 @@ objc2-app-kit = { version = "0.2.0", features = [ "NSView", "NSWindow", ] } + +[dev-dependencies] +objc2-app-kit = { version = "0.2.0", features = [ + "NSApplication", + "NSClipView", + "NSGraphics", + "NSScrollView", +] } + +[[test]] +name = "window_ownership" +harness = false diff --git a/adapters/macos/src/node.rs b/adapters/macos/src/node.rs index 7be2e59b6..7b065823d 100644 --- a/adapters/macos/src/node.rs +++ b/adapters/macos/src/node.rs @@ -33,6 +33,16 @@ use crate::{context::Context, filters::filter, util::*}; const SCROLL_TO_VISIBLE_ACTION: &str = "AXScrollToVisible"; +// The view's accessibility parent is only its window when the view is the +// window's content view. Nested inside a scroll or split view, the parent is +// that ancestor, so AppKit clients such as VoiceOver would get a non-window +// object for accessibilityWindow and accessibilityTopLevelUIElement. +fn window_of(context: &Context) -> Option> { + let view = context.view.load()?; + let window = view.window()?; + Some(Id::into_super(Id::into_super(Id::into_super(window)))) +} + fn ns_role(node: &NodeRef) -> &'static NSAccessibilityRole { let role = node.role(); // TODO: Handle special cases. @@ -426,24 +436,14 @@ declare_class!( #[method_id(accessibilityWindow)] fn window(&self) -> Option> { - self.resolve_with_context(|_, _, context| { - context - .view - .load() - .and_then(|view| unsafe { NSAccessibility::accessibilityParent(&*view) }) - }) - .flatten() + self.resolve_with_context(|_, _, context| window_of(context)) + .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() + self.resolve_with_context(|_, _, context| window_of(context)) + .flatten() } #[method_id(accessibilityChildren)] diff --git a/adapters/macos/tests/window_ownership.rs b/adapters/macos/tests/window_ownership.rs new file mode 100644 index 000000000..7926e1d14 --- /dev/null +++ b/adapters/macos/tests/window_ownership.rs @@ -0,0 +1,119 @@ +// 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. + +//! `accessibilityWindow` and `accessibilityTopLevelUIElement` must return the +//! view's window even when the view is not the window's content view. +//! +//! AppKit objects can only be created on the process main thread, which the +//! default test harness does not provide, so this test uses `harness = false`. + +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, Node, NodeId, Role, TreeId, TreeInfo, + TreeUpdate, +}; +use accesskit_macos::Adapter; +use objc2::{msg_send, rc::Id, runtime::AnyObject}; +use objc2_app_kit::{ + NSApplication, NSBackingStoreType, NSScrollView, NSView, NSWindow, NSWindowStyleMask, +}; +use objc2_foundation::{MainThreadMarker, NSArray, NSObject, NSPoint, NSRect, NSSize}; +use std::{ffi::c_void, ptr}; + +struct NoActions; + +impl ActionHandler for NoActions { + fn do_action(&mut self, _request: ActionRequest) {} +} + +struct InitialTree; + +impl ActivationHandler for InitialTree { + fn request_initial_tree(&mut self) -> Option { + let mut root = Node::new(Role::Group); + root.set_children(vec![NodeId(2)]); + Some(TreeUpdate { + nodes: vec![(NodeId(1), root), (NodeId(2), Node::new(Role::Button))], + tree: Some(TreeInfo::new(NodeId(1))), + tree_id: TreeId::ROOT, + focus: NodeId(1), + }) + } +} + +fn ownership(node: &NSObject) -> [*mut AnyObject; 2] { + unsafe { + [ + msg_send![node, accessibilityWindow], + msg_send![node, accessibilityTopLevelUIElement], + ] + } +} + +fn assert_owned_by(label: &str, nodes: &[&NSObject], window: Option<&NSWindow>) { + let expected = window.map_or(ptr::null_mut(), |window| { + window as *const NSWindow as *mut AnyObject + }); + for node in nodes { + assert_eq!(ownership(node), [expected; 2], "{label}"); + } +} + +fn main() { + let mtm = MainThreadMarker::new().expect("window ownership test must run on the main thread"); + objc2::rc::autoreleasepool(|_| unsafe { + let _app = NSApplication::sharedApplication(mtm); + let frame = NSRect::new(NSPoint::new(0., 0.), NSSize::new(320., 240.)); + let make_window = || { + let window = NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc::(), + frame, + NSWindowStyleMask::Titled, + NSBackingStoreType::NSBackingStoreBuffered, + true, + ); + window.setReleasedWhenClosed(false); + window + }; + let first = make_window(); + let second = make_window(); + let view = NSView::initWithFrame(mtm.alloc::(), frame); + let mut adapter = Adapter::new(&*view as *const NSView as *mut c_void, false, NoActions); + let roots = Id::retain(adapter.view_children(&mut InitialTree)).unwrap(); + assert_eq!(roots.len(), 1); + let root = roots.objectAtIndex(0); + let children: *mut NSArray = msg_send![&*root, accessibilityChildren]; + let children = Id::retain(children).unwrap(); + assert_eq!(children.len(), 1); + let child = children.objectAtIndex(0); + let nodes = [&*root, &*child]; + + first.setContentView(Some(&view)); + assert_owned_by("content view", &nodes, Some(&first)); + + // Nest the view the way scroll and split views do. Its accessibility + // parent is now the document view, not the window. + let scroll = NSScrollView::initWithFrame(mtm.alloc::(), frame); + let document = NSView::initWithFrame(mtm.alloc::(), frame); + let _: () = msg_send![&*document, setAccessibilityElement: true]; + first.setContentView(Some(&scroll)); + scroll.setDocumentView(Some(&document)); + document.addSubview(&view); + let parent: *mut AnyObject = msg_send![&*view, accessibilityParent]; + assert_ne!(parent, &*first as *const NSWindow as *mut AnyObject); + assert_owned_by("nested in a scroll view", &nodes, Some(&first)); + + view.removeFromSuperview(); + assert_owned_by("detached", &nodes, None); + + second.contentView().unwrap().addSubview(&view); + assert_owned_by("moved to another window", &nodes, Some(&second)); + + view.removeFromSuperview(); + first.close(); + second.close(); + drop(adapter); + }); + println!("window ownership: ok"); +}