diff --git a/adapters/macos/src/adapter.rs b/adapters/macos/src/adapter.rs index 86b4b0073..88eda8ca8 100644 --- a/adapters/macos/src/adapter.rs +++ b/adapters/macos/src/adapter.rs @@ -14,7 +14,7 @@ use accesskit::{ ActionHandler, ActionRequest, ActivationHandler, Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate, }; -use accesskit_consumer::{FilterResult, Tree}; +use accesskit_consumer::{FilterResult, FullNodeId, Tree, TreeState}; use objc2::rc::{Id, WeakId}; use objc2_app_kit::NSView; use objc2_foundation::{MainThreadMarker, NSArray, NSObject, NSPoint}; @@ -23,6 +23,14 @@ use std::{ffi::c_void, ptr::null_mut, rc::Rc}; const PLACEHOLDER_ROOT_ID: NodeId = NodeId(0); +/// The focused node, if the platform can focus it. +pub(crate) fn settle_focus_target(state: &TreeState) -> Option { + state + .focus() + .filter(|node| can_be_focused(node)) + .map(|node| node.id()) +} + enum State { Inactive { view: WeakId, @@ -136,14 +144,37 @@ impl Adapter { 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()) + let mut event_generator = EventGenerator::new(); + { + let mut tree = context.tree.borrow_mut(); + tree.update_and_process_changes(update_factory(), &mut event_generator); + } + Some(QueuedEvents::new( + Rc::clone(context), + event_generator.into_events(), + )) } } } + /// If a focusable node has focus, return events that re-post + /// `NSAccessibilityFocusedUIElementChangedNotification` on it, which makes + /// VoiceOver re-read the element's frame. Call this once per scroll + /// gesture, after the scroll offset has settled, never once per frame. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn scroll_settled_event(&self) -> Option { + let State::Active(context) = &self.state else { + return None; + }; + let target = { + let tree = context.tree.borrow(); + settle_focus_target(tree.state()) + }; + target.map(|id| QueuedEvents::new(Rc::clone(context), vec![focus_event(id)])) + } + /// Update the tree state based on whether the window is focused. /// /// If a [`QueuedEvents`] instance is returned, the caller must call @@ -163,10 +194,18 @@ impl Adapter { 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()) + let mut event_generator = EventGenerator::new(); + { + let mut tree = context.tree.borrow_mut(); + tree.update_host_focus_state_and_process_changes( + is_focused, + &mut event_generator, + ); + } + Some(QueuedEvents::new( + Rc::clone(context), + event_generator.into_events(), + )) } } } diff --git a/adapters/macos/src/event.rs b/adapters/macos/src/event.rs index 445419cb3..eb32e051d 100644 --- a/adapters/macos/src/event.rs +++ b/adapters/macos/src/event.rs @@ -4,7 +4,7 @@ // the LICENSE-MIT file), at your option. use accesskit::{Live, Role}; -use accesskit_consumer::{FilterResult, FullNodeId, NodeRef, TreeChangeHandler}; +use accesskit_consumer::{FilterResult, FullNodeId, NodeRef, TreeChangeHandler, TreeState}; use hashbrown::HashSet; use objc2::runtime::{AnyObject, ProtocolObject}; use objc2_app_kit::*; @@ -25,6 +25,10 @@ pub(crate) enum QueuedEvent { notification: &'static NSAccessibilityNotificationName, }, NodeDestroyed(FullNodeId), + /// One notification per tree update in which an exposed node changed + /// geometry. The target is resolved when raised, since the tree is still + /// borrowed while events are generated. + LayoutChanged, Announcement { text: String, priority: NSAccessibilityPriorityLevel, @@ -62,6 +66,22 @@ impl QueuedEvent { }; } } + Self::LayoutChanged => { + // Posted on an element without user info: `NSAccessibilityUIElementsKey` + // would make VoiceOver move focus and re-announce. The borrow is + // released first because posting can re-enter the platform node. + let target = { + let tree = context.tree.borrow(); + layout_changed_target(tree.state()) + }; + let platform_node = context.get_or_create_platform_node(target); + unsafe { + NSAccessibilityPostNotification( + &platform_node, + NSAccessibilityLayoutChangedNotification, + ) + }; + } Self::Announcement { text, priority } => { let view = match context.view.load() { Some(view) => view, @@ -130,6 +150,13 @@ impl QueuedEvents { } } +/// The focused node, or the root if the host isn't focused. +pub(crate) fn layout_changed_target(state: &TreeState) -> FullNodeId { + state + .focus() + .map_or_else(|| state.root().id(), |node| node.id()) +} + pub(crate) fn focus_event(node_id: FullNodeId) -> QueuedEvent { QueuedEvent::Generic { node_id, @@ -137,25 +164,27 @@ pub(crate) fn focus_event(node_id: FullNodeId) -> QueuedEvent { } } +#[derive(Default)] pub(crate) struct EventGenerator { - context: Rc, events: Vec, text_changed: HashSet, selected_rows_changed: HashSet, + /// Number of exposed nodes whose bounding box changed in this update. + moved: usize, } 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 new() -> Self { + Self::default() } - pub(crate) fn into_result(self) -> QueuedEvents { - QueuedEvents::new(self.context, self.events) + pub(crate) fn into_events(mut self) -> Vec { + // One notification per update; one per moved node would make VoiceOver + // re-announce at scroll rate. + if self.moved > 0 { + self.events.push(QueuedEvent::LayoutChanged); + } + self.events } fn remove_subtree(&mut self, node: &NodeRef) { @@ -259,6 +288,9 @@ impl TreeChangeHandler for EventGenerator { return; } let node_id = new_node.id(); + if old_node.bounding_box() != new_node.bounding_box() { + self.moved += 1; + } let old_wrapper = NodeWrapper(old_node); let new_wrapper = NodeWrapper(new_node); if old_wrapper.title() != new_wrapper.title() { diff --git a/adapters/macos/src/lib.rs b/adapters/macos/src/lib.rs index 5b795424e..924231749 100644 --- a/adapters/macos/src/lib.rs +++ b/adapters/macos/src/lib.rs @@ -22,4 +22,7 @@ pub use patch::add_focus_forwarder_to_window_class; mod subclass; pub use subclass::SubclassingAdapter; +#[cfg(test)] +mod tests; + pub use objc2_foundation::{NSArray, NSObject, NSPoint}; diff --git a/adapters/macos/src/subclass.rs b/adapters/macos/src/subclass.rs index a873f7c85..0a34bceb9 100644 --- a/adapters/macos/src/subclass.rs +++ b/adapters/macos/src/subclass.rs @@ -254,6 +254,15 @@ impl SubclassingAdapter { let mut state = self.associated.ivars().state.borrow_mut(); state.adapter.update_view_focus_state(is_focused) } + + /// See [`Adapter::scroll_settled_event`]. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn scroll_settled_event(&self) -> Option { + let state = self.associated.ivars().state.borrow(); + state.adapter.scroll_settled_event() + } } impl Drop for SubclassingAdapter { diff --git a/adapters/macos/src/tests.rs b/adapters/macos/src/tests.rs new file mode 100644 index 000000000..ebcac9fb4 --- /dev/null +++ b/adapters/macos/src/tests.rs @@ -0,0 +1,226 @@ +// 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. + +//! These tests don't touch AppKit, so they don't need the main thread. + +use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate}; +use accesskit_consumer::Tree; + +use crate::{ + adapter::settle_focus_target, + event::{EventGenerator, QueuedEvent, layout_changed_target}, +}; + +const ROOT: NodeId = NodeId(0); +const BUTTON: NodeId = NodeId(1); +const OTHER_BUTTON: NodeId = NodeId(2); + +fn rect(y0: f64, height: f64) -> Rect { + Rect { + x0: 0.0, + y0, + x1: 100.0, + y1: y0 + height, + } +} + +fn root_node() -> Node { + let mut node = Node::new(Role::Window); + node.push_child(BUTTON); + node.push_child(OTHER_BUTTON); + node +} + +fn button(label: &str, bounds: Rect) -> Node { + let mut node = Node::new(Role::Button); + node.set_label(label); + node.set_bounds(bounds); + node +} + +/// A window with two buttons, the first of which has focus. +fn tree(is_host_focused: bool) -> Tree { + Tree::new( + TreeUpdate { + nodes: vec![ + (ROOT, root_node()), + (BUTTON, button("first", rect(0.0, 20.0))), + (OTHER_BUTTON, button("second", rect(40.0, 20.0))), + ], + tree: Some(TreeInfo::new(ROOT)), + tree_id: TreeId::ROOT, + focus: BUTTON, + }, + is_host_focused, + ) +} + +fn update(nodes: Vec<(NodeId, Node)>) -> TreeUpdate { + TreeUpdate { + nodes, + tree: None, + tree_id: TreeId::ROOT, + focus: BUTTON, + } +} + +fn apply(tree: &mut Tree, update: TreeUpdate) -> Vec { + let mut event_generator = EventGenerator::new(); + tree.update_and_process_changes(update, &mut event_generator); + event_generator.into_events() +} + +fn layout_changed_count(events: &[QueuedEvent]) -> usize { + events + .iter() + .filter(|event| matches!(event, QueuedEvent::LayoutChanged)) + .count() +} + +#[test] +fn moving_a_node_queues_a_layout_changed_event() { + let mut tree = tree(true); + let events = apply( + &mut tree, + update(vec![(BUTTON, button("first", rect(-30.0, 20.0)))]), + ); + assert_eq!(layout_changed_count(&events), 1); +} + +#[test] +fn resizing_a_node_queues_a_layout_changed_event() { + // Same origin, different size. + let mut tree = tree(true); + let events = apply( + &mut tree, + update(vec![(BUTTON, button("first", rect(0.0, 50.0)))]), + ); + assert_eq!(layout_changed_count(&events), 1); +} + +#[test] +fn moving_several_nodes_queues_only_one_layout_changed_event() { + let mut tree = tree(true); + let events = apply( + &mut tree, + update(vec![ + (BUTTON, button("first", rect(-30.0, 20.0))), + (OTHER_BUTTON, button("second", rect(10.0, 20.0))), + ]), + ); + assert_eq!(layout_changed_count(&events), 1); +} + +#[test] +fn an_update_that_moves_nothing_queues_no_layout_changed_event() { + let mut tree = tree(true); + let events = apply( + &mut tree, + update(vec![(BUTTON, button("renamed", rect(0.0, 20.0)))]), + ); + assert_eq!(layout_changed_count(&events), 0); + // Not vacuous: the update did change something. + assert!(!events.is_empty()); +} + +#[test] +fn re_submitting_an_unchanged_tree_queues_no_layout_changed_event() { + let mut tree = tree(true); + let events = apply( + &mut tree, + update(vec![ + (BUTTON, button("first", rect(0.0, 20.0))), + (OTHER_BUTTON, button("second", rect(40.0, 20.0))), + ]), + ); + assert_eq!(layout_changed_count(&events), 0); +} + +#[test] +fn moving_a_node_that_is_not_exposed_queues_no_layout_changed_event() { + let mut tree = tree(true); + let hidden = |bounds| { + let mut node = button("second", bounds); + node.set_hidden(); + node + }; + apply( + &mut tree, + update(vec![(OTHER_BUTTON, hidden(rect(40.0, 20.0)))]), + ); + let events = apply( + &mut tree, + update(vec![(OTHER_BUTTON, hidden(rect(70.0, 20.0)))]), + ); + assert_eq!(layout_changed_count(&events), 0); +} + +#[test] +fn adding_and_removing_nodes_queues_no_layout_changed_event() { + // Additions and removals have their own notifications. + let mut tree = tree(true); + let mut root_with_one_child = Node::new(Role::Window); + root_with_one_child.push_child(BUTTON); + let events = apply(&mut tree, update(vec![(ROOT, root_with_one_child)])); + assert_eq!(layout_changed_count(&events), 0); + assert!( + events + .iter() + .any(|event| matches!(event, QueuedEvent::NodeDestroyed(_))) + ); +} + +#[test] +fn layout_changed_is_targeted_at_the_focused_node() { + let tree = tree(true); + let state = tree.state(); + assert_eq!( + layout_changed_target(state), + state.focus().unwrap().id(), + "expected the focused node" + ); + assert_ne!(layout_changed_target(state), state.root().id()); +} + +#[test] +fn layout_changed_falls_back_to_the_root_when_the_host_is_not_focused() { + let tree = tree(false); + let state = tree.state(); + assert!(state.focus().is_none()); + assert_eq!(layout_changed_target(state), state.root().id()); +} + +#[test] +fn scroll_settle_targets_the_focused_node() { + let tree = tree(true); + let state = tree.state(); + assert_eq!( + settle_focus_target(state), + Some(state.focus().unwrap().id()) + ); +} + +#[test] +fn scroll_settle_has_no_target_when_the_host_is_not_focused() { + let tree = tree(false); + assert_eq!(settle_focus_target(tree.state()), None); +} + +#[test] +fn scroll_settle_has_no_target_when_focus_is_on_the_window_itself() { + // The placeholder tree focuses the window node. + let tree = Tree::new( + TreeUpdate { + nodes: vec![(ROOT, Node::new(Role::Window))], + tree: Some(TreeInfo::new(ROOT)), + tree_id: TreeId::ROOT, + focus: ROOT, + }, + true, + ); + let state = tree.state(); + assert!(state.focus().is_some()); + assert_eq!(settle_focus_target(state), None); +}