Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions adapters/macos/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<FullNodeId> {
state
.focus()
.filter(|node| can_be_focused(node))
.map(|node| node.id())
}

enum State {
Inactive {
view: WeakId<NSView>,
Expand Down Expand Up @@ -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<QueuedEvents> {
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
Expand All @@ -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(),
))
}
}
}
Expand Down
54 changes: 43 additions & 11 deletions adapters/macos/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -130,32 +150,41 @@ 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,
notification: unsafe { NSAccessibilityFocusedUIElementChangedNotification },
}
}

#[derive(Default)]
pub(crate) struct EventGenerator {
context: Rc<Context>,
events: Vec<QueuedEvent>,
text_changed: HashSet<FullNodeId>,
selected_rows_changed: HashSet<FullNodeId>,
/// Number of exposed nodes whose bounding box changed in this update.
moved: usize,
}

impl EventGenerator {
pub(crate) fn new(context: Rc<Context>) -> 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<QueuedEvent> {
// 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) {
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions adapters/macos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
9 changes: 9 additions & 0 deletions adapters/macos/src/subclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueuedEvents> {
let state = self.associated.ivars().state.borrow();
state.adapter.scroll_settled_event()
}
}

impl Drop for SubclassingAdapter {
Expand Down
Loading