From ebf5981fbe70cff4083b75e2b554bd0ee24d2547 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 21:25:04 -0700 Subject: [PATCH] fix(macos): report focus within inactive windows --- adapters/macos/Cargo.toml | 7 ++ adapters/macos/README.md | 6 ++ adapters/macos/src/adapter.rs | 12 ++- adapters/macos/src/node.rs | 6 +- adapters/macos/tests/focus.rs | 194 ++++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 adapters/macos/tests/focus.rs diff --git a/adapters/macos/Cargo.toml b/adapters/macos/Cargo.toml index 8b424e535..32f26c044 100644 --- a/adapters/macos/Cargo.toml +++ b/adapters/macos/Cargo.toml @@ -11,6 +11,10 @@ readme = "README.md" edition.workspace = true rust-version.workspace = true +[[test]] +name = "focus" +harness = false + [package.metadata.docs.rs] default-target = "x86_64-apple-darwin" @@ -36,3 +40,6 @@ objc2-app-kit = { version = "0.2.0", features = [ "NSView", "NSWindow", ] } + +[dev-dependencies] +objc2-app-kit = { version = "0.2.0", features = ["NSApplication", "NSGraphics"] } diff --git a/adapters/macos/README.md b/adapters/macos/README.md index 4a7bbef4e..66f3a9da6 100644 --- a/adapters/macos/README.md +++ b/adapters/macos/README.md @@ -2,6 +2,12 @@ This is the macOS adapter for [AccessKit](https://accesskit.dev/). It exposes an AccessKit accessibility tree through the Cocoa `NSAccessibility` protocol. +Focus queries report the focused element within each view, including inactive +windows, as native AppKit controls do. If that element has an active descendant, +the descendant receives accessibility focus. Focus-change notifications remain +restricted to the focused host, so updates in background windows do not move +the screen reader's focus. + ## Known issues - The selected state of ListBox items is not reported ([#520](https://github.com/AccessKit/accesskit/issues/520)) diff --git a/adapters/macos/src/adapter.rs b/adapters/macos/src/adapter.rs index 86b4b0073..b79f0e01b 100644 --- a/adapters/macos/src/adapter.rs +++ b/adapters/macos/src/adapter.rs @@ -252,11 +252,13 @@ impl Adapter { 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 _; - } + // AppKit exposes a window's first responder even when it isn't key. + // Global focus notifications still use the host-focused consumer state. + let focused = state.focus_in_tree(); + let node = focused.active_descendant().unwrap_or(focused); + if can_be_focused(&node) { + return Id::autorelease_return(context.get_or_create_platform_node(node.id())) + as *mut _; } null_mut() } diff --git a/adapters/macos/src/node.rs b/adapters/macos/src/node.rs index 7be2e59b6..343c76095 100644 --- a/adapters/macos/src/node.rs +++ b/adapters/macos/src/node.rs @@ -651,7 +651,11 @@ declare_class!( #[method(isAccessibilityFocused)] fn is_focused(&self) -> bool { - self.resolve(|node| node.is_focused() && can_be_focused(node)) + self.resolve(|node| { + let focused = node.tree_state.focus_in_tree(); + let focused = focused.active_descendant().unwrap_or(focused); + node.id() == focused.id() && can_be_focused(node) + }) .unwrap_or(false) } diff --git a/adapters/macos/tests/focus.rs b/adapters/macos/tests/focus.rs new file mode 100644 index 000000000..80a1cb0a5 --- /dev/null +++ b/adapters/macos/tests/focus.rs @@ -0,0 +1,194 @@ +// 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 accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate, +}; +use accesskit_macos::Adapter; +use objc2::{ + msg_send, + rc::{Id, autoreleasepool}, +}; +use objc2_app_kit::{NSApplication, NSBackingStoreType, NSView, NSWindow, NSWindowStyleMask}; +use objc2_foundation::{MainThreadMarker, NSObject, NSRect}; + +const ROOT: NodeId = NodeId(0); +const FIRST: NodeId = NodeId(1); +const SECOND: NodeId = NodeId(2); +const LIST: NodeId = NodeId(3); +const OPTION: NodeId = NodeId(4); + +struct NoActions; +impl ActionHandler for NoActions { + fn do_action(&mut self, _: ActionRequest) { + panic!("unexpected accessibility action"); + } +} + +struct InitialTree; +impl ActivationHandler for InitialTree { + fn request_initial_tree(&mut self) -> Option { + let mut root = Node::new(Role::Window); + root.set_children([FIRST, SECOND, LIST]); + let mut list = Node::new(Role::ListBox); + list.set_children([OPTION]); + list.set_active_descendant(OPTION); + Some(TreeUpdate { + nodes: vec![ + (ROOT, root), + (FIRST, Node::new(Role::TextInput)), + (SECOND, Node::new(Role::TextInput)), + (LIST, list), + (OPTION, Node::new(Role::ListBoxOption)), + ], + tree: Some(TreeInfo::new(ROOT)), + tree_id: TreeId::ROOT, + focus: FIRST, + }) + } +} + +fn focused(node: &NSObject) -> bool { + unsafe { msg_send![node, isAccessibilityFocused] } +} + +fn set_focus(adapter: &mut Adapter, focus: NodeId) { + adapter + .update_if_active(|| TreeUpdate { + nodes: vec![], + tree: None, + tree_id: TreeId::ROOT, + focus, + }) + .unwrap() + .raise(); +} + +fn assert_focus(adapter: &mut Adapter, expected: Option<&NSObject>) { + let actual = adapter.focus(&mut InitialTree); + assert_eq!( + actual.cast_const(), + expected.map_or(std::ptr::null(), |node| node as *const _) + ); +} + +// AppKit requires the real main thread, so this test uses a custom harness. +// The views are never shown and cannot change the user's active window. +fn main() { + autoreleasepool(|_| { + let mtm = MainThreadMarker::new().expect("native test must run on the main thread"); + let _app = NSApplication::sharedApplication(mtm); + let make_window = || unsafe { + NSWindow::initWithContentRect_styleMask_backing_defer( + mtm.alloc::(), + NSRect::ZERO, + NSWindowStyleMask::Titled, + NSBackingStoreType::NSBackingStoreBuffered, + true, + ) + }; + let first_window = make_window(); + let second_window = make_window(); + let first_view = unsafe { NSView::new(mtm) }; + let second_view = unsafe { NSView::new(mtm) }; + first_window.setContentView(Some(&first_view)); + second_window.setContentView(Some(&second_view)); + let mut first = + unsafe { Adapter::new(Id::as_ptr(&first_view).cast_mut().cast(), false, NoActions) }; + let mut second = + unsafe { Adapter::new(Id::as_ptr(&second_view).cast_mut().cast(), true, NoActions) }; + let roots = unsafe { Id::retain(first.view_children(&mut InitialTree)) }.unwrap(); + let root = unsafe { roots.objectAtIndex(0) }; + let children: Id> = + unsafe { objc2::msg_send_id![&root, accessibilityChildren] }; + let field1 = unsafe { children.objectAtIndex(0) }; + let field2 = unsafe { children.objectAtIndex(1) }; + let list = unsafe { children.objectAtIndex(2) }; + let options: Id> = + unsafe { objc2::msg_send_id![&list, accessibilityChildren] }; + let option = unsafe { options.objectAtIndex(0) }; + let other_focus = unsafe { Id::retain(second.focus(&mut InitialTree)) }.unwrap(); + + assert!( + focused(&field1), + "inactive view must expose its first responder" + ); + assert!(!focused(&field2)); + assert_focus(&mut first, Some(&field1)); + assert!(focused(&other_focus)); + + println!("background focus change"); + set_focus(&mut first, SECOND); + assert!(!focused(&field1)); + assert!(focused(&field2)); + assert_focus(&mut first, Some(&field2)); + assert_focus(&mut second, Some(&other_focus)); + assert!( + focused(&other_focus), + "background updates must not steal another view's focus" + ); + + for active in [true, false] { + println!("host focus: {active}"); + first.update_view_focus_state(active).unwrap().raise(); + assert_focus(&mut first, Some(&field2)); + assert!(focused(&field2)); + set_focus(&mut first, LIST); + assert!( + !focused(&list), + "active descendant owns accessibility focus" + ); + assert!(focused(&option)); + assert_focus(&mut first, Some(&option)); + + println!("clear active descendant: host focused={active}"); + first + .update_if_active(|| { + let mut list = Node::new(Role::ListBox); + list.set_children([OPTION]); + TreeUpdate { + nodes: vec![(LIST, list)], + tree: None, + tree_id: TreeId::ROOT, + focus: LIST, + } + }) + .unwrap() + .raise(); + assert!(focused(&list)); + assert!(!focused(&option)); + assert_focus(&mut first, Some(&list)); + first + .update_if_active(|| { + let mut list = Node::new(Role::ListBox); + list.set_children([OPTION]); + list.set_active_descendant(OPTION); + TreeUpdate { + nodes: vec![(LIST, list)], + tree: None, + tree_id: TreeId::ROOT, + focus: LIST, + } + }) + .unwrap() + .raise(); + assert!(!focused(&list)); + assert!(focused(&option)); + assert_focus(&mut first, Some(&option)); + set_focus(&mut first, SECOND); + } + + set_focus(&mut first, ROOT); + assert!( + !focused(&root), + "the synthetic window group must not acquire focus" + ); + assert!(!focused(&field2)); + assert_focus(&mut first, None); + println!( + "focus: inactive/active views, independent hosts, active descendants, and window root passed" + ); + }); +}