From b6a894bcd9c469ddb25a8062dea163ac38a3b226 Mon Sep 17 00:00:00 2001 From: Volodymyr Hotsyk Date: Wed, 16 Sep 2026 14:55:01 -0700 Subject: [PATCH] fix: Return the view's window from accessibilityWindow on macOS `accessibilityWindow` and `accessibilityTopLevelUIElement` returned the host view's `accessibilityParent`. That is the window only when the view is the window's content view. When the view is nested, for example as the document view of an NSScrollView or inside an NSSplitView, AppKit reports the nearest accessible ancestor instead, so assistive technologies were given a non-window object as the node's window and top-level element. Both selectors now return the live view's `window()`, or nil when the view is detached or gone. The accessibility parent of the root node is unchanged. A `harness = false` integration test covers a content view, a view nested in a scroll view, a detached view and a view moved to another window; it fails on the nested case without this change. Co-Authored-By: Claude Opus 5 --- adapters/macos/Cargo.toml | 12 +++ adapters/macos/src/node.rs | 28 +++--- adapters/macos/tests/window_ownership.rs | 119 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 adapters/macos/tests/window_ownership.rs 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"); +}