From 66581166a5c903c4eaa5f34dcb8d3145eaa93c8e Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Mon, 19 Jan 2026 19:21:09 +0100 Subject: [PATCH 01/23] Setup the new crate --- .gitignore | 3 +++ Cargo.lock | 12 ++++++++++++ Cargo.toml | 1 + README.md | 2 +- platforms/ios/Cargo.toml | 35 +++++++++++++++++++++++++++++++++++ platforms/ios/README.md | 7 +++++++ platforms/ios/src/lib.rs | 6 ++++++ release-please-config.json | 1 + 8 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 platforms/ios/Cargo.toml create mode 100644 platforms/ios/README.md create mode 100644 platforms/ios/src/lib.rs diff --git a/.gitignore b/.gitignore index 8794fd73b..9853625b3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,8 @@ target #gedit *~ +# macOS +.DS_Store + #KDE .directory diff --git a/Cargo.lock b/Cargo.lock index 179d72dec..685c6bff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "accesskit_ios" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown", + "objc2", + "objc2-foundation", + "objc2-ui-kit", +] + [[package]] name = "accesskit_macos" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index c455e4062..a9f16b58c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "consumer", "platforms/android", "platforms/atspi-common", + "platforms/ios", "platforms/macos", "platforms/unix", "platforms/windows", diff --git a/README.md b/README.md index bf3673623..22f5ca4b9 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ The current released platform adapters are all at rough feature parity. They don The following platform adapters are currently available: * [Android adapter](https://crates.io/crates/accesskit_android): This adapter implements the Java-based Android accessibility API. +* [iOS adapter](https://crates.io/crates/accesskit_ios): This adapter implements the UIAccessibility protocols in the UIKit framework. * [macOS adapter](https://crates.io/crates/accesskit_macos): This adapter implements the NSAccessibility protocols in the AppKit framework. * [Unix adapter](https://crates.io/crates/accesskit_unix): This adapter implements the AT-SPI D-Bus interfaces, using [zbus](https://github.com/dbus2/zbus), a pure-Rust implementation of D-Bus. * [Windows adapter](https://crates.io/crates/accesskit_windows): This adapter implements UI Automation, the current Windows accessibility API. #### Planned adapters -* iOS * web (for applications that render their own UI elements to a canvas) ### Adapters for cross-platform windowing layers diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml new file mode 100644 index 000000000..d44c053e7 --- /dev/null +++ b/platforms/ios/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "accesskit_ios" +version = "0.1.0" +authors.workspace = true +license.workspace = true +description = "AccessKit UI accessibility infrastructure: iOS adapter" +categories.workspace = true +keywords = ["gui", "ui", "accessibility"] +repository.workspace = true +readme = "README.md" +edition.workspace = true +rust-version.workspace = true + +[package.metadata.docs.rs] +default-target = "aarch64-apple-ios" + +[dependencies] +accesskit = { version = "0.24.0", path = "../../common" } +accesskit_consumer = { version = "0.35.0", path = "../../consumer" } +hashbrown.workspace = true +objc2 = "0.5.1" +objc2-foundation = { version = "0.2.0", features = [ + "NSArray", + "NSDictionary", + "NSValue", + "NSThread", +] } +objc2-ui-kit = { version = "0.2.0", features = [ + "UIAccessibility", + "UIAccessibilityConstants", + "UIAccessibilityContainer", + "UIResponder", + "UIView", + "UIWindow", +] } diff --git a/platforms/ios/README.md b/platforms/ios/README.md new file mode 100644 index 000000000..69b34ea96 --- /dev/null +++ b/platforms/ios/README.md @@ -0,0 +1,7 @@ +# AccessKit iOS adapter + +This is the iOS adapter for [AccessKit](https://accesskit.dev/). It exposes an AccessKit accessibility tree through the UIKit `UIAccessibility` protocol. + +## Acknowledgements + +This project was funded through the [NGI0 Commons Fund](https://nlnet.nl/commonsfund), a fund established by [NLnet](https://nlnet.nl/). See the [project page](https://nlnet.nl/project/AccessKit-iOS/) for more information. diff --git a/platforms/ios/src/lib.rs b/platforms/ios/src/lib.rs new file mode 100644 index 000000000..11e290e7c --- /dev/null +++ b/platforms/ios/src/lib.rs @@ -0,0 +1,6 @@ +// 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. + +// TODO: Implement iOS adapter diff --git a/release-please-config.json b/release-please-config.json index addf935fe..e0cf47199 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,6 +8,7 @@ "consumer": {}, "platforms/android": {}, "platforms/atspi-common": {}, + "platforms/ios": {}, "platforms/macos": {}, "platforms/unix": {}, "platforms/windows": {}, From 1fc7f27b6bd5d01160d9024f5f3b04c364c1bf7c Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 23 Jan 2026 14:28:19 +0100 Subject: [PATCH 02/23] Configure a Cargo test runner --- .cargo/config.toml | 5 +++ ios-sim-runner.sh | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 .cargo/config.toml create mode 100755 ios-sim-runner.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..7654d19ae --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.aarch64-apple-ios-sim] +runner = "./ios-sim-runner.sh" + +[target.x86_64-apple-ios] +runner = "./ios-sim-runner.sh" diff --git a/ios-sim-runner.sh b/ios-sim-runner.sh new file mode 100755 index 000000000..a6880e5a3 --- /dev/null +++ b/ios-sim-runner.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -e +set -o pipefail + +command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but not installed" >&2; exit 1; } +command -v xcrun >/dev/null 2>&1 || { echo "Error: xcrun (Xcode command line tools) is required but not installed" >&2; exit 1; } + +EXECUTABLE="$1" +shift +ARGS="$@" +IDENTIFIER="dev.accesskit.TestRunner" +DISPLAY_NAME="TestRunner" +BUNDLE_NAME="${DISPLAY_NAME}.app" +EXECUTABLE_NAME=$(basename "$EXECUTABLE") +BUNDLE_PATH=$(dirname "$EXECUTABLE")/"${BUNDLE_NAME}" + +# Minimal Info.plist for iOS sim app +PLIST=" + + + +CFBundleIdentifier +${IDENTIFIER} +CFBundleDisplayName +${DISPLAY_NAME} +CFBundleName +${BUNDLE_NAME} +CFBundleExecutable +${EXECUTABLE_NAME} +CFBundleVersion +1.0 +CFBundleShortVersionString +1.0 +CFBundleDevelopmentRegion +en_US +UILaunchStoryboardName + +LSRequiresIPhoneOS + + +" + +rm -rf "${BUNDLE_PATH}" +mkdir -p "${BUNDLE_PATH}" +echo "$PLIST" > "${BUNDLE_PATH}/Info.plist" +cp "$EXECUTABLE" "${BUNDLE_PATH}/" + +# Helper functions for simulator management +ios_runtime() { + runtime=$(xcrun simctl list -j runtimes ios | jq -r '.runtimes | sort_by(.identifier) | last.identifier') + if [ -z "$runtime" ] || [ "$runtime" = "null" ]; then + echo "Error: no iOS runtime found (is Xcode installed with iOS platform support?)" >&2 + exit 1 + fi + echo "$runtime" +} + +ios_device_id() { + runtime=$(ios_runtime) + device_id=$(xcrun simctl list -j devices | jq -r --arg rt "$runtime" '.devices[$rt][] | select(.name | contains("iPhone")) | select(.state == "Booted") | .udid' | head -1) + if [ -z "$device_id" ]; then + device_id=$(xcrun simctl list -j devices | jq -r --arg rt "$runtime" '.devices[$rt][] | select(.name | contains("iPhone")) | select(.state == "Shutdown") | .udid' | head -1) + if [ -z "$device_id" ]; then + echo "Error: no iPhone simulator found for runtime $runtime" >&2 + exit 1 + fi + if ! xcrun simctl boot "$device_id" >&2; then + echo "Error: failed to boot simulator $device_id" >&2 + exit 1 + fi + fi + if ! xcrun simctl bootstatus "$device_id" -b >&2; then + echo "Error: simulator $device_id failed to reach booted state" >&2 + exit 1 + fi + device_name=$(xcrun simctl list -j devices | jq -r --arg id "$device_id" '.devices | to_entries[] | .value[] | select(.udid == $id) | .name' | head -1) + echo "Using simulator: $device_name ($device_id)" >&2 + echo "$device_id" +} + +DEVICE_ID=$(ios_device_id) + +xcrun simctl uninstall "$DEVICE_ID" "$IDENTIFIER" 2>/dev/null || true +xcrun simctl install "$DEVICE_ID" "$BUNDLE_PATH" + +xcrun simctl spawn "$DEVICE_ID" "$BUNDLE_PATH/$EXECUTABLE_NAME" $ARGS From 167cf06a1be9b4a1763608831ff3a4e5a9ce442d Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 6 Feb 2026 08:39:04 +0100 Subject: [PATCH 03/23] Start working on the low-level adapter --- consumer/src/node.rs | 17 + platforms/ios/Cargo.toml | 3 + platforms/ios/src/adapter.rs | 304 +++++++++++++++ platforms/ios/src/context.rs | 95 +++++ platforms/ios/src/event.rs | 171 +++++++++ platforms/ios/src/filters.rs | 130 +++++++ platforms/ios/src/lib.rs | 15 +- platforms/ios/src/node.rs | 690 +++++++++++++++++++++++++++++++++++ platforms/ios/src/util.rs | 36 ++ platforms/winit/Cargo.toml | 1 - 10 files changed, 1460 insertions(+), 2 deletions(-) create mode 100644 platforms/ios/src/adapter.rs create mode 100644 platforms/ios/src/context.rs create mode 100644 platforms/ios/src/event.rs create mode 100644 platforms/ios/src/filters.rs create mode 100644 platforms/ios/src/node.rs create mode 100644 platforms/ios/src/util.rs diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 36b527b0d..6538b4d71 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -741,6 +741,19 @@ impl<'a> Node<'a> { self.write_label(&mut result).unwrap().then_some(result) } + pub fn has_label(&self) -> bool { + if self.data().label().is_some() { + return true; + } + self.labelled_by().any(|node| { + if node.label_comes_from_value() { + node.has_value() + } else { + node.data().label().is_some() + } + }) + } + fn write_label_direct(&self, mut writer: W) -> Result { if let Some(label) = &self.data().label() { writer.write_str(label)?; @@ -777,6 +790,10 @@ impl<'a> Node<'a> { .map(|description| description.to_string()) } + pub fn has_description(&self) -> bool { + self.data().description().is_some() + } + pub fn url(&self) -> Option<&str> { self.data().url() } diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index d44c053e7..500ea86ef 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -22,6 +22,7 @@ objc2 = "0.5.1" objc2-foundation = { version = "0.2.0", features = [ "NSArray", "NSDictionary", + "NSString", "NSValue", "NSThread", ] } @@ -29,6 +30,8 @@ objc2-ui-kit = { version = "0.2.0", features = [ "UIAccessibility", "UIAccessibilityConstants", "UIAccessibilityContainer", + "UIAccessibilityElement", + "UIGeometry", "UIResponder", "UIView", "UIWindow", diff --git a/platforms/ios/src/adapter.rs b/platforms/ios/src/adapter.rs new file mode 100644 index 000000000..d8e8262b6 --- /dev/null +++ b/platforms/ios/src/adapter.rs @@ -0,0 +1,304 @@ +// 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. + +// Derived from the Flutter engine. +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE.chromium file. + +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, Node as NodeProvider, NodeId, Role, + Tree as TreeData, TreeId, TreeUpdate, +}; +use accesskit_consumer::{FilterResult, Tree}; +use objc2::rc::{Retained, WeakId}; +use objc2_foundation::{CGPoint, MainThreadMarker, NSArray, NSObject}; +use objc2_ui_kit::{ + UIAccessibilityPostNotification, UIAccessibilityScreenChangedNotification, UIView, +}; +use std::fmt::{Debug, Formatter}; +use std::{ffi::c_void, ptr::null_mut, rc::Rc}; + +use crate::{ + context::{ActionHandlerNoMut, ActionHandlerWrapper, Context}, + event::{EventGenerator, QueuedEvents, layout_event}, + filters::filter, + node::PlatformNode, + util::from_cg_point, +}; + +const PLACEHOLDER_ROOT_ID: NodeId = NodeId(0); + +enum State { + Inactive { + view: WeakId, + action_handler: Rc, + mtm: MainThreadMarker, + }, + Placeholder { + placeholder_context: Rc, + action_handler: Rc, + }, + Active(Rc), +} + +impl Debug for State { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + State::Inactive { + view, + action_handler: _, + mtm, + } => f + .debug_struct("Inactive") + .field("view", view) + .field("mtm", mtm) + .finish(), + State::Placeholder { + placeholder_context, + action_handler: _, + } => f + .debug_struct("Placeholder") + .field("placeholder_context", placeholder_context) + .finish(), + State::Active(context) => f.debug_struct("Active").field("context", context).finish(), + } + } +} + +struct PlaceholderActionHandler; + +impl ActionHandler for PlaceholderActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} +} + +/// An AccessKit adapter for an owned `UIView`. +/// +/// The adapter bridges an AccessKit tree to UIKit's informal accessibility +/// container protocol. Because UIKit dispatches accessibility queries directly +/// to the view, the caller must own a `UIView` subclass and forward the +/// relevant messages to the adapter. The view must be retained for at least +/// as long as the adapter. +/// +/// A typical setup looks like this: +/// +/// 1. In the view's initializer, create an `Adapter` with +/// [`Adapter::new`], passing a pointer to the view and an +/// [`ActionHandler`]. Store the adapter alongside the view (e.g. in +/// an associated object or a Rust-side wrapper). +/// 2. Override `isAccessibilityElement` to return the result of +/// [`Adapter::is_accessibility_element`]. +/// 3. Override `accessibilityElements` to return the result of +/// [`Adapter::accessibility_elements`]. +/// 4. Override `accessibilityHitTest:` to return the result of +/// [`Adapter::hit_test`]. +/// 5. Whenever the application's accessibility tree changes, call +/// [`Adapter::update_if_active`] and raise the returned events. +/// +/// All adapter methods must be called on the main thread. +#[derive(Debug)] +pub struct Adapter { + state: State, +} + +impl Adapter { + /// Create a new iOS adapter. This function must be called on + /// the main thread. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to a `UIView`. + pub unsafe fn new(view: *mut c_void, action_handler: impl 'static + ActionHandler) -> Self { + let view = unsafe { Retained::retain(view as *mut UIView) }.unwrap(); + let view = WeakId::from_retained(&view); + let mtm = MainThreadMarker::new().unwrap(); + let state = State::Inactive { + view, + action_handler: Rc::new(ActionHandlerWrapper::new(action_handler)), + mtm, + }; + Self { state } + } + + /// If and only if the tree has been initialized, call the provided function + /// and apply the resulting update. Note: If the caller's implementation of + /// [`ActivationHandler::request_initial_tree`] initially returned `None`, + /// the [`TreeUpdate`] returned by the provided function must contain + /// a full tree. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_if_active( + &mut self, + update_factory: impl FnOnce() -> TreeUpdate, + ) -> Option { + match &self.state { + State::Inactive { .. } => None, + State::Placeholder { + placeholder_context, + action_handler, + } => { + let tree = Tree::new(update_factory(), true); + let context = Context::new( + placeholder_context.view.clone(), + tree, + Rc::clone(action_handler), + placeholder_context.mtm, + ); + let focus_id = context.tree.borrow().state().focus().map(|node| node.id()); + let queued_events = focus_id.map(|id| { + let events = vec![layout_event(Some(id))]; + QueuedEvents::new(Rc::clone(&context), events) + }); + self.state = State::Active(context); + queued_events + } + 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()) + } + } + } + + fn get_or_init_context( + &mut self, + activation_handler: &mut H, + ) -> Rc { + match &self.state { + State::Inactive { + view, + action_handler, + mtm, + } => match activation_handler.request_initial_tree() { + Some(initial_state) => { + let tree = Tree::new(initial_state, true); + let context = Context::new(view.clone(), tree, Rc::clone(action_handler), *mtm); + let result = Rc::clone(&context); + self.state = State::Active(context); + result + } + None => { + let placeholder_update = TreeUpdate { + nodes: vec![(PLACEHOLDER_ROOT_ID, NodeProvider::new(Role::Window))], + tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + tree_id: TreeId::ROOT, + focus: PLACEHOLDER_ROOT_ID, + }; + let placeholder_tree = Tree::new(placeholder_update, true); + let placeholder_context = Context::new( + view.clone(), + placeholder_tree, + Rc::new(ActionHandlerWrapper::new(PlaceholderActionHandler {})), + *mtm, + ); + let result = Rc::clone(&placeholder_context); + self.state = State::Placeholder { + placeholder_context, + action_handler: Rc::clone(action_handler), + }; + result + } + }, + State::Placeholder { + placeholder_context, + .. + } => Rc::clone(placeholder_context), + State::Active(context) => Rc::clone(context), + } + } + + fn weak_view(&self) -> &WeakId { + match &self.state { + State::Inactive { view, .. } => view, + State::Placeholder { + placeholder_context, + .. + } => &placeholder_context.view, + State::Active(context) => &context.view, + } + } + + // UIAccessibilityContainer methods + + /// Indicates whether the view itself is an accessibility element. + /// This corresponds to `isAccessibilityElement`. + pub fn is_accessibility_element( + &mut self, + activation_handler: &mut H, + ) -> bool { + let _ = self.get_or_init_context(activation_handler); + false + } + + /// Returns all accessibility elements in the container. + /// This corresponds to `accessibilityElements`. + pub fn accessibility_elements( + &mut self, + activation_handler: &mut H, + ) -> *mut NSArray { + let context = self.get_or_init_context(activation_handler); + let tree = context.tree.borrow(); + let state = tree.state(); + let node = state.root(); + + let platform_nodes = if filter(&node) == FilterResult::Include { + context + .get_or_create_platform_node(node.id()) + .map(PlatformNode::into_ns_object) + .into_iter() + .collect::>>() + } else { + node.filtered_children(filter) + .filter_map(|node| context.get_or_create_platform_node(node.id())) + .map(PlatformNode::into_ns_object) + .collect::>>() + }; + + let array = NSArray::from_vec(platform_nodes); + Retained::autorelease_return(array) + } + + // UIAccessibilityHitTest methods + + /// Returns the accessibility element at the specified point. + /// This corresponds to `accessibilityHitTest:`. + pub fn hit_test( + &mut self, + point: CGPoint, + activation_handler: &mut H, + ) -> *mut NSObject { + let view = match self.weak_view().load() { + Some(view) => view, + None => { + return null_mut(); + } + }; + + let context = self.get_or_init_context(activation_handler); + let tree = context.tree.borrow(); + let state = tree.state(); + let root = state.root(); + let point = from_cg_point(&view, &root, point); + let node = root.node_at_point(point, &filter).unwrap_or(root); + match context.get_or_create_platform_node(node.id()) { + Some(platform_node) => Retained::autorelease_return(platform_node) as *mut _, + None => null_mut(), + } + } +} + +impl Drop for Adapter { + fn drop(&mut self) { + if !matches!(self.state, State::Inactive { .. }) { + unsafe { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, None); + } + } + } +} diff --git a/platforms/ios/src/context.rs b/platforms/ios/src/context.rs new file mode 100644 index 000000000..8eac6836c --- /dev/null +++ b/platforms/ios/src/context.rs @@ -0,0 +1,95 @@ +// 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}; +use accesskit_consumer::{NodeId, Tree}; +use hashbrown::HashMap; +use objc2::rc::{Retained, WeakId}; +use objc2_foundation::MainThreadMarker; +use objc2_ui_kit::UIView; +use std::fmt::Debug; +use std::{cell::RefCell, rc::Rc}; + +use crate::node::PlatformNode; + +pub(crate) trait ActionHandlerNoMut { + fn do_action(&self, request: ActionRequest); +} + +pub(crate) struct ActionHandlerWrapper(RefCell); + +impl ActionHandlerWrapper { + pub(crate) fn new(inner: H) -> Self { + Self(RefCell::new(inner)) + } +} + +impl ActionHandlerNoMut for ActionHandlerWrapper { + fn do_action(&self, request: ActionRequest) { + self.0.borrow_mut().do_action(request) + } +} + +pub(crate) struct Context { + pub(crate) view: WeakId, + pub(crate) tree: RefCell, + pub(crate) action_handler: Rc, + platform_nodes: RefCell>>, + pub(crate) platform_focus: RefCell>, + pub(crate) mtm: MainThreadMarker, +} + +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Context") + .field("view", &self.view) + .field("tree", &self.tree) + .field("action_handler", &"ActionHandler") + .field("platform_nodes", &self.platform_nodes) + .field("platform_focus", &self.platform_focus) + .field("mtm", &self.mtm) + .finish() + } +} + +impl Context { + pub(crate) fn new( + view: WeakId, + tree: Tree, + action_handler: Rc, + mtm: MainThreadMarker, + ) -> Rc { + Rc::new(Self { + view, + tree: RefCell::new(tree), + action_handler, + platform_nodes: RefCell::new(HashMap::new()), + platform_focus: RefCell::new(None), + mtm, + }) + } + + pub(crate) fn get_or_create_platform_node( + self: &Rc, + id: NodeId, + ) -> Option> { + if let Some(result) = self.platform_nodes.borrow().get(&id) { + return Some(result.clone()); + } + + let result = PlatformNode::new(self, id)?; + self.platform_nodes.borrow_mut().insert(id, result.clone()); + Some(result) + } + + pub(crate) fn remove_platform_node(&self, id: NodeId) -> Option> { + let mut platform_nodes = self.platform_nodes.borrow_mut(); + platform_nodes.remove(&id) + } + + pub(crate) fn do_action(&self, request: ActionRequest) { + self.action_handler.do_action(request); + } +} diff --git a/platforms/ios/src/event.rs b/platforms/ios/src/event.rs new file mode 100644 index 000000000..438784904 --- /dev/null +++ b/platforms/ios/src/event.rs @@ -0,0 +1,171 @@ +// 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_consumer::{FilterResult, Node, NodeId, TreeChangeHandler}; +use objc2_ui_kit::{ + UIAccessibilityLayoutChangedNotification, UIAccessibilityNotifications, + UIAccessibilityPostNotification, +}; +use std::collections::VecDeque; +use std::rc::Rc; + +use crate::{context::Context, filters::filter, node::PlatformNode}; + +pub(crate) enum QueuedEvent { + Generic { + node_id: Option, + notification: UIAccessibilityNotifications, + }, + NodeDestroyed(NodeId), +} + +impl QueuedEvent { + fn raise(self, context: &Rc) { + match self { + Self::Generic { + node_id, + notification, + } => { + let argument = node_id + .and_then(|id| context.get_or_create_platform_node(id)) + .map(PlatformNode::into_any_object); + unsafe { + UIAccessibilityPostNotification(notification, argument.as_deref()); + } + } + Self::NodeDestroyed(node_id) => { + context.remove_platform_node(node_id); + } + } + } +} + +/// Events generated by a tree update. +#[must_use = "events must be explicitly raised"] +pub struct QueuedEvents { + context: Rc, + events: Vec, +} + +impl QueuedEvents { + pub(crate) fn new(context: Rc, events: Vec) -> Self { + Self { context, events } + } + + /// Raise all queued events synchronously. + /// + /// It is unknown whether accessibility methods on the view may be + /// called while events are being raised. This means that any locks + /// or runtime borrows required to access the adapter must not + /// be held while this method is called. + pub fn raise(self) { + for event in self.events { + event.raise(&self.context); + } + } +} + +pub(crate) fn layout_event(node_id: Option) -> QueuedEvent { + QueuedEvent::Generic { + node_id, + notification: unsafe { UIAccessibilityLayoutChangedNotification }, + } +} + +pub(crate) struct EventGenerator { + context: Rc, + layout_event_focus: Option>, + events: Vec, +} + +impl EventGenerator { + pub(crate) fn new(context: Rc) -> Self { + Self { + context, + layout_event_focus: None, + events: Vec::new(), + } + } + + fn insert_focus_moved_event_if_needed(&mut self, focus: NodeId) { + if *self.context.platform_focus.borrow() != Some(focus) { + self.layout_event_focus = Some(Some(focus)); + } + } + + fn insert_layout_changed_event_if_needed(&mut self) { + if self.layout_event_focus.is_none() { + self.layout_event_focus = Some(None); + } + } + + fn remove_subtree(&mut self, node: &Node) { + let mut to_remove = VecDeque::new(); + to_remove.push_back(*node); + + while let Some(node) = to_remove.pop_front() { + for child in node.filtered_children(&filter) { + to_remove.push_back(child); + } + + self.events.push(QueuedEvent::NodeDestroyed(node.id())); + } + } + + pub(crate) fn into_result(self) -> QueuedEvents { + let mut events = self + .layout_event_focus + .map(|focus| vec![layout_event(focus)]) + .unwrap_or_default(); + events.extend(self.events); + QueuedEvents::new(self.context, events) + } +} + +impl TreeChangeHandler for EventGenerator { + fn node_added(&mut self, node: &Node) { + if filter(node) != FilterResult::Include { + return; + } + self.insert_layout_changed_event_if_needed(); + } + + fn node_updated(&mut self, old_node: &Node, new_node: &Node) { + let old_included = filter(old_node) == FilterResult::Include; + let new_filter_result = filter(new_node); + let new_included = new_filter_result == FilterResult::Include; + + if old_included && !new_included { + self.insert_layout_changed_event_if_needed(); + if new_filter_result == FilterResult::ExcludeSubtree { + self.remove_subtree(old_node); + } else { + self.events.push(QueuedEvent::NodeDestroyed(new_node.id())); + } + return; + } + + if new_included { + self.insert_layout_changed_event_if_needed(); + } + } + + fn focus_moved(&mut self, _old_node: Option<&Node>, new_node: Option<&Node>) { + if let Some(new_node) = new_node { + if filter(new_node) != FilterResult::Include { + return; + } + self.insert_focus_moved_event_if_needed(new_node.id()); + } + } + + fn node_removed(&mut self, node: &Node) { + if filter(node) != FilterResult::Include { + return; + } + self.insert_layout_changed_event_if_needed(); + self.events.push(QueuedEvent::NodeDestroyed(node.id())); + } +} diff --git a/platforms/ios/src/filters.rs b/platforms/ios/src/filters.rs new file mode 100644 index 000000000..adf137b12 --- /dev/null +++ b/platforms/ios/src/filters.rs @@ -0,0 +1,130 @@ +// 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_consumer::{FilterResult, Node}; + +pub(crate) use accesskit_consumer::common_filter as filter; + +use crate::node::NodeWrapper; + +/// Filter for determining if a node should be an accessibility element. +/// On iOS, a node with focusable children must NOT be an accessibility element +/// (must return false for isAccessibilityElement), otherwise VoiceOver will +/// ignore its children entirely. +/// +/// A node that has its own interaction semantics — actions, a value, or a +/// toggled state — is always a leaf accessibility element. Its descendants +/// (e.g. a `Role::Label` child) are collapsed into the parent's +/// `accessibilityLabel` via `accesskit_consumer::Node::labelled_by`, so +/// exposing them separately would let VoiceOver focus the label and skip +/// the actionable parent. +/// +/// Otherwise, non-focusable children (e.g. Labels, Images) just provide +/// labeling info to the parent, so the parent should remain the +/// accessibility element. +pub(crate) fn filter_for_is_accessibility_element(node: &Node) -> FilterResult { + let result = filter(node); + if result != FilterResult::Include { + return result; + } + + let wrapper = NodeWrapper(node); + if wrapper.has_non_scroll_action() + || node.toggled().is_some() + || node.has_value() + || node.numeric_value().is_some() + { + return FilterResult::Include; + } + + // If this node has any filtered children that are focusable or are + // themselves containers (have their own filtered children), it should be + // a container, not an accessibility element. + if node.filtered_children(&filter).any(|child| { + NodeWrapper(&child).can_be_focused() || child.filtered_children(&filter).next().is_some() + }) { + return FilterResult::ExcludeNode; + } + + FilterResult::Include +} + +#[cfg(test)] +mod tests { + use super::*; + use accesskit::{Action, Node as NodeBuilder, NodeId, Role, Tree, TreeId, TreeUpdate}; + + const ROOT_ID: NodeId = NodeId(0); + const CHILD_1_ID: NodeId = NodeId(1); + + fn build_tree(nodes: Vec<(NodeId, NodeBuilder)>) -> accesskit_consumer::Tree { + let update = TreeUpdate { + nodes, + tree: Some(Tree::new(ROOT_ID)), + tree_id: TreeId::ROOT, + focus: ROOT_ID, + }; + accesskit_consumer::Tree::new(update, false) + } + + fn filter_node(nodes: Vec<(NodeId, NodeBuilder)>, target: NodeId) -> FilterResult { + let tree = build_tree(nodes); + let node = tree + .state() + .node_by_tree_local_id(target, TreeId::ROOT) + .unwrap(); + filter_for_is_accessibility_element(&node) + } + + fn make_button(label: &str) -> NodeBuilder { + let mut node = NodeBuilder::new(Role::Button); + node.set_label(label); + node.add_action(Action::Click); + node + } + + #[test] + fn leaf_button_is_element() { + let mut root = NodeBuilder::new(Role::Window); + root.set_children(vec![CHILD_1_ID]); + let child = make_button("OK"); + assert_eq!( + filter_node(vec![(ROOT_ID, root), (CHILD_1_ID, child)], CHILD_1_ID), + FilterResult::Include, + ); + } + + #[test] + fn hidden_node_excluded() { + let mut root = NodeBuilder::new(Role::Window); + root.set_children(vec![CHILD_1_ID]); + let mut hidden = make_button("Hidden"); + hidden.set_hidden(); + assert_ne!( + filter_node(vec![(ROOT_ID, root), (CHILD_1_ID, hidden)], CHILD_1_ID), + FilterResult::Include, + ); + } + + #[test] + fn checkbox_with_label_child_is_leaf() { + const CHECKBOX_ID: NodeId = NodeId(1); + const LABEL_ID: NodeId = NodeId(2); + let mut root = NodeBuilder::new(Role::Window); + root.set_children(vec![CHECKBOX_ID]); + let mut checkbox = NodeBuilder::new(Role::CheckBox); + checkbox.add_action(Action::Click); + checkbox.set_children(vec![LABEL_ID]); + let mut label = NodeBuilder::new(Role::Label); + label.set_value("Accept terms"); + assert_eq!( + filter_node( + vec![(ROOT_ID, root), (CHECKBOX_ID, checkbox), (LABEL_ID, label),], + CHECKBOX_ID, + ), + FilterResult::Include, + ); + } +} diff --git a/platforms/ios/src/lib.rs b/platforms/ios/src/lib.rs index 11e290e7c..6fd38df1e 100644 --- a/platforms/ios/src/lib.rs +++ b/platforms/ios/src/lib.rs @@ -3,4 +3,17 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. -// TODO: Implement iOS adapter +#![deny(unsafe_op_in_unsafe_fn)] + +mod context; +mod filters; +mod node; +mod util; + +mod adapter; +pub use adapter::Adapter; + +mod event; +pub use event::QueuedEvents; + +pub use objc2_foundation::{CGPoint, NSArray, NSInteger, NSObject}; diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs new file mode 100644 index 000000000..4614ade90 --- /dev/null +++ b/platforms/ios/src/node.rs @@ -0,0 +1,690 @@ +// 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. + +// Derived from the Flutter engine. +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE.chromium file. + +use accesskit::{Action, ActionRequest, Rect, Role, Toggled}; +use accesskit_consumer::{FilterResult, Node, NodeId, Tree}; +use objc2::{ + ClassType, DeclaredClass, declare_class, msg_send_id, mutability::MainThreadOnly, rc::Retained, + runtime::AnyObject, +}; +use objc2_foundation::{CGRect, NSArray, NSObject, NSObjectProtocol, NSString}; +use objc2_ui_kit::{ + UIAccessibilityElement, UIAccessibilityTraitAdjustable, UIAccessibilityTraitButton, + UIAccessibilityTraitHeader, UIAccessibilityTraitImage, UIAccessibilityTraitLink, + UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, UIAccessibilityTraitSelected, + UIAccessibilityTraitStaticText, UIAccessibilityTraits, +}; +use std::rc::{Rc, Weak}; + +use crate::{ + context::Context, + filters::{filter, filter_for_is_accessibility_element}, + util::to_cg_rect, +}; + +#[derive(Debug, PartialEq)] +enum Value { + Bool(bool), + Number(f64), + String(String), +} + +impl From for String { + fn from(value: Value) -> Self { + match value { + Value::Bool(true) => "1".into(), + Value::Bool(false) => "0".into(), + Value::Number(n) => n.to_string(), + Value::String(s) => s, + } + } +} + +#[derive(Debug, PartialEq)] +enum FrameSource { + ViewBounds, + Rect(Rect), + Zero, +} + +pub(crate) struct NodeWrapper<'a>(pub(crate) &'a Node<'a>); + +impl NodeWrapper<'_> { + fn label(&self) -> Option { + self.0.label() + } + + fn hint(&self) -> Option { + self.0.description() + } + + fn value(&self) -> Option { + if let Some(toggled) = self.0.toggled() { + return Some(Value::Bool(toggled != Toggled::False)); + } + if let Some(value) = self.0.value() { + return Some(Value::String(value)); + } + if let Some(value) = self.0.numeric_value() { + return Some(Value::Number(value)); + } + None + } + + fn frame_source(&self) -> FrameSource { + if let Some(rect) = self.0.bounding_box() { + FrameSource::Rect(rect) + } else if self.0.is_root() { + FrameSource::ViewBounds + } else { + FrameSource::Zero + } + } + + pub(crate) fn has_non_scroll_action(&self) -> bool { + self.0.supports_action(Action::Click, &filter) + || self.0.supports_action(Action::Focus, &filter) + || self.0.supports_action(Action::Increment, &filter) + || self.0.supports_action(Action::Decrement, &filter) + || self.0.supports_action(Action::Expand, &filter) + || self.0.supports_action(Action::Collapse, &filter) + || self.0.supports_action(Action::CustomAction, &filter) + || self.0.supports_action(Action::ReplaceSelectedText, &filter) + || self.0.supports_action(Action::SetTextSelection, &filter) + || self.0.supports_action(Action::SetValue, &filter) + } + + pub(crate) fn can_be_focused(&self) -> bool { + self.0.has_label() + || self.0.toggled().is_some() + || self.0.has_value() + || self.0.numeric_value().is_some() + || self.0.has_description() + || self.has_non_scroll_action() + } + + fn traits(&self) -> UIAccessibilityTraits { + let mut traits = match self.0.role() { + Role::Button | Role::DefaultButton | Role::DisclosureTriangle => unsafe { + UIAccessibilityTraitButton + }, + Role::Link => unsafe { UIAccessibilityTraitLink }, + Role::Image => unsafe { UIAccessibilityTraitImage }, + Role::Label => unsafe { UIAccessibilityTraitStaticText }, + Role::Heading => unsafe { UIAccessibilityTraitHeader }, + Role::Slider | Role::SpinButton => unsafe { UIAccessibilityTraitAdjustable }, + _ => unsafe { UIAccessibilityTraitNone }, + }; + + if self.0.is_disabled() { + traits |= unsafe { UIAccessibilityTraitNotEnabled }; + } + + if self.0.is_selected() == Some(true) { + traits |= unsafe { UIAccessibilityTraitSelected }; + } + + traits + } +} + +pub(crate) struct PlatformNodeIvars { + context: Weak, + node_id: NodeId, +} + +declare_class!( + #[derive(Debug)] + pub(crate) struct PlatformNode; + + unsafe impl ClassType for PlatformNode { + #[inherits(NSObject)] + type Super = UIAccessibilityElement; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitNode"; + } + + impl DeclaredClass for PlatformNode { + type Ivars = PlatformNodeIvars; + } + + unsafe impl NSObjectProtocol for PlatformNode {} + + #[allow(non_snake_case)] + unsafe impl PlatformNode { + #[method_id(accessibilityContainer)] + fn container(&self) -> Option> { + self.resolve_container() + } + + // Explicit no-op. The container is computed dynamically in the + // `accessibilityContainer` getter. If we let UIAccessibilityElement's + // implementation stash the init-time placeholder, internal UIKit + // paths can return it and bypass our getter override. + // See https://github.com/flutter/flutter/issues/54366. + #[method(setAccessibilityContainer:)] + fn set_container(&self, _container: Option<&AnyObject>) {} + + #[method(isAccessibilityElement)] + fn is_element(&self) -> bool { + self.resolve(|node| filter_for_is_accessibility_element(node) == FilterResult::Include) + .unwrap_or(false) + } + + #[method_id(accessibilityLabel)] + fn label(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.label().map(|s| NSString::from_str(&s)) + }) + .flatten() + } + + #[method_id(accessibilityHint)] + fn hint(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.hint().map(|s| NSString::from_str(&s)) + }) + .flatten() + } + + #[method_id(accessibilityValue)] + fn value(&self) -> Option> { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper + .value() + .map(|v| NSString::from_str(&String::from(v))) + }) + .flatten() + } + + #[method(accessibilityTraits)] + fn traits(&self) -> UIAccessibilityTraits { + self.resolve(|node| NodeWrapper(node).traits()) + .unwrap_or(unsafe { UIAccessibilityTraitNone }) + } + + #[method(accessibilityFrame)] + fn frame(&self) -> CGRect { + self.resolve_with_context(|node, _, context| { + let view = context.view.load()?; + Some(match NodeWrapper(node).frame_source() { + FrameSource::Rect(rect) => to_cg_rect(&view, rect), + FrameSource::ViewBounds => { + let bounds = view.bounds(); + unsafe { view.convertRect_toView(bounds, None) } + } + FrameSource::Zero => CGRect::ZERO, + }) + }) + .flatten() + .unwrap_or(CGRect::ZERO) + } + + #[method_id(accessibilityLanguage)] + fn language(&self) -> Option> { + self.resolve(|node| node.language().map(NSString::from_str)) + .flatten() + } + + #[method_id(accessibilityElements)] + fn elements(&self) -> Option>> { + self.resolve_with_context(|node, _, context| { + // If this node is itself a leaf accessibility element, hide + // its descendants — they contribute to the node's label, not + // independent focus targets. + if filter_for_is_accessibility_element(node) == FilterResult::Include { + return NSArray::new(); + } + let children: Vec> = node + .filtered_children(&filter) + .filter_map(|child| context.get_or_create_platform_node(child.id())) + .map(PlatformNode::into_ns_object) + .collect(); + NSArray::from_vec(children) + }) + } + + #[method(accessibilityElementDidBecomeFocused)] + fn element_did_become_focused(&self) { + self.resolve_with_context(|node, tree, context| { + let node_id = node.id(); + *context.platform_focus.borrow_mut() = Some(node_id); + if let Some((target_node, target_tree)) = tree.state().locate_node(node_id) { + context.do_action(ActionRequest { + action: Action::Focus, + target_tree, + target_node, + data: None, + }); + } + }); + } + } +); + +impl PlatformNode { + pub(crate) fn into_ns_object(this: Retained) -> Retained { + let element = Retained::into_super(this); + let responder = Retained::into_super(element); + Retained::into_super(responder) + } + + pub(crate) fn into_any_object(this: Retained) -> Retained { + Retained::into_super(Self::into_ns_object(this)) + } + + pub(crate) fn new(context: &Rc, node_id: NodeId) -> Option> { + // UIAccessibilityElement's designated initializer is + // `initWithAccessibilityContainer:`; plain `init` raises + // NSInvalidArgumentException at runtime. Following Flutter's iOS + // adapter, we pass the backing view as the init-time container + // regardless of the node's real position in the tree, and report the + // actual parent dynamically via the `accessibilityContainer` override. + let view = context.view.load()?; + let container = Retained::into_super(Retained::into_super(Retained::into_super(view))); + let this = context.mtm.alloc::().set_ivars(PlatformNodeIvars { + context: Rc::downgrade(context), + node_id, + }); + Some(unsafe { msg_send_id![super(this), initWithAccessibilityContainer: &*container] }) + } + + fn resolve(&self, f: F) -> Option + where + F: FnOnce(&Node) -> T, + { + let context = self.ivars().context.upgrade()?; + let tree = context.tree.borrow(); + let tree_state = tree.state(); + let node = tree_state.node_by_id(self.ivars().node_id)?; + Some(f(&node)) + } + + fn resolve_with_context(&self, f: F) -> Option + where + F: FnOnce(&Node, &Tree, &Rc) -> T, + { + let context = self.ivars().context.upgrade()?; + let tree = context.tree.borrow(); + let tree_state = tree.state(); + let node = tree_state.node_by_id(self.ivars().node_id)?; + Some(f(&node, &tree, &context)) + } + + fn resolve_container(&self) -> Option> { + let context = self.ivars().context.upgrade()?; + let parent_id = { + let tree = context.tree.borrow(); + let node = tree.state().node_by_id(self.ivars().node_id)?; + node.parent().map(|p| p.id()) + }; + match parent_id { + Some(parent_id) => context + .get_or_create_platform_node(parent_id) + .map(PlatformNode::into_any_object), + None => { + let view = context.view.load()?; + Some(Retained::into_super(Retained::into_super( + Retained::into_super(view), + ))) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use accesskit::{Action, Node as NodeBuilder, NodeId, Rect, Toggled, Tree, TreeId, TreeUpdate}; + + const ROOT_ID: NodeId = NodeId(0); + + fn build_tree(nodes: Vec<(NodeId, NodeBuilder)>) -> accesskit_consumer::Tree { + let update = TreeUpdate { + nodes, + tree: Some(Tree::new(ROOT_ID)), + tree_id: TreeId::ROOT, + focus: ROOT_ID, + }; + accesskit_consumer::Tree::new(update, false) + } + + fn with_single(node: &NodeBuilder, f: F) -> R + where + F: FnOnce(&Node) -> R, + { + let tree = build_tree(vec![(ROOT_ID, node.clone())]); + let state = tree.state(); + let tree_node = state.node_by_tree_local_id(ROOT_ID, TreeId::ROOT).unwrap(); + f(&tree_node) + } + + fn wrapper_value(node: &NodeBuilder) -> Option { + with_single(node, |n| NodeWrapper(n).value()) + } + + fn wrapper_label(node: &NodeBuilder) -> Option { + with_single(node, |n| NodeWrapper(n).label()) + } + + fn wrapper_hint(node: &NodeBuilder) -> Option { + with_single(node, |n| NodeWrapper(n).hint()) + } + + fn node_traits(node: &NodeBuilder) -> UIAccessibilityTraits { + with_single(node, |n| NodeWrapper(n).traits()) + } + + fn node_can_be_focused(nodes: Vec<(NodeId, NodeBuilder)>, target: NodeId) -> bool { + let tree = build_tree(nodes); + let state = tree.state(); + let node = state.node_by_tree_local_id(target, TreeId::ROOT).unwrap(); + NodeWrapper(&node).can_be_focused() + } + + // ---- label ---- + + #[test] + fn label_present() { + let mut node = NodeBuilder::new(Role::Button); + node.set_label("OK"); + assert_eq!(wrapper_label(&node), Some("OK".into())); + } + + #[test] + fn label_absent() { + let node = NodeBuilder::new(Role::Button); + assert_eq!(wrapper_label(&node), None); + } + + // ---- hint ---- + + #[test] + fn hint_present() { + let mut node = NodeBuilder::new(Role::Button); + node.set_description("Confirms the action"); + assert_eq!(wrapper_hint(&node), Some("Confirms the action".into())); + } + + #[test] + fn hint_absent() { + let node = NodeBuilder::new(Role::Button); + assert_eq!(wrapper_hint(&node), None); + } + + // ---- value ---- + + #[test] + fn value_toggled_true() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::True); + assert_eq!(wrapper_value(&node), Some(Value::Bool(true))); + } + + #[test] + fn value_toggled_false() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::False); + assert_eq!(wrapper_value(&node), Some(Value::Bool(false))); + } + + #[test] + fn value_toggled_mixed() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::Mixed); + assert_eq!(wrapper_value(&node), Some(Value::Bool(true))); + } + + #[test] + fn value_text_string() { + let mut node = NodeBuilder::new(Role::Label); + node.set_value("hello"); + assert_eq!(wrapper_value(&node), Some(Value::String("hello".into()))); + } + + #[test] + fn value_numeric() { + let mut node = NodeBuilder::new(Role::Slider); + node.set_numeric_value(42.5); + assert_eq!(wrapper_value(&node), Some(Value::Number(42.5))); + } + + #[test] + fn value_toggled_takes_priority() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::True); + node.set_value("ignored"); + node.set_numeric_value(99.0); + assert_eq!(wrapper_value(&node), Some(Value::Bool(true))); + } + + #[test] + fn value_string_over_numeric() { + let mut node = NodeBuilder::new(Role::Label); + node.set_value("text"); + node.set_numeric_value(1.0); + assert_eq!(wrapper_value(&node), Some(Value::String("text".into()))); + } + + #[test] + fn value_none() { + let node = NodeBuilder::new(Role::Button); + assert_eq!(wrapper_value(&node), None); + } + + // ---- String::from(Value) ---- + + #[test] + fn rendered_value_bool_true_is_one() { + assert_eq!(String::from(Value::Bool(true)), "1"); + } + + #[test] + fn rendered_value_bool_false_is_zero() { + assert_eq!(String::from(Value::Bool(false)), "0"); + } + + #[test] + fn rendered_value_number_uses_display() { + assert_eq!(String::from(Value::Number(42.5)), "42.5"); + } + + #[test] + fn rendered_value_string_passthrough() { + assert_eq!(String::from(Value::String("hello".into())), "hello"); + } + + // ---- can_be_focused ---- + + #[test] + fn focusable_button() { + let mut node = NodeBuilder::new(Role::Button); + node.set_label("OK"); + node.add_action(Action::Click); + assert!(node_can_be_focused(vec![(ROOT_ID, node)], ROOT_ID)); + } + + #[test] + fn window_not_focusable() { + let node = NodeBuilder::new(Role::Window); + assert!(!node_can_be_focused(vec![(ROOT_ID, node)], ROOT_ID)); + } + + // ---- traits ---- + + #[test] + fn traits_button() { + let node = NodeBuilder::new(Role::Button); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitButton } != 0); + } + + #[test] + fn traits_default_button() { + let node = NodeBuilder::new(Role::DefaultButton); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitButton } != 0); + } + + #[test] + fn traits_disclosure_triangle() { + let node = NodeBuilder::new(Role::DisclosureTriangle); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitButton } != 0); + } + + #[test] + fn traits_link() { + let node = NodeBuilder::new(Role::Link); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitLink } != 0); + } + + #[test] + fn traits_image() { + let node = NodeBuilder::new(Role::Image); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitImage } != 0); + } + + #[test] + fn traits_label() { + let node = NodeBuilder::new(Role::Label); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitStaticText } != 0); + } + + #[test] + fn traits_heading() { + let node = NodeBuilder::new(Role::Heading); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitHeader } != 0); + } + + #[test] + fn traits_slider() { + let node = NodeBuilder::new(Role::Slider); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitAdjustable } != 0); + } + + #[test] + fn traits_spin_button() { + let node = NodeBuilder::new(Role::SpinButton); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitAdjustable } != 0); + } + + #[test] + fn traits_disabled() { + let mut node = NodeBuilder::new(Role::Button); + node.set_disabled(); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitNotEnabled } != 0); + } + + #[test] + fn traits_selected() { + let mut node = NodeBuilder::new(Role::Tab); + node.set_selected(true); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitSelected } != 0); + } + + #[test] + fn traits_selected_false_does_not_set_selected() { + let mut node = NodeBuilder::new(Role::Tab); + node.set_selected(false); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitSelected } == 0); + } + + #[test] + fn traits_plain_button_has_no_modifiers() { + let node = NodeBuilder::new(Role::Button); + let t = node_traits(&node); + assert!(t & unsafe { UIAccessibilityTraitButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitNotEnabled } == 0); + assert!(t & unsafe { UIAccessibilityTraitSelected } == 0); + } + + #[test] + fn traits_disabled_and_selected_accumulate() { + let mut node = NodeBuilder::new(Role::Button); + node.set_disabled(); + node.set_selected(true); + let t = node_traits(&node); + assert!(t & unsafe { UIAccessibilityTraitButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitNotEnabled } != 0); + assert!(t & unsafe { UIAccessibilityTraitSelected } != 0); + } + + #[test] + fn traits_none_for_group() { + let node = NodeBuilder::new(Role::Group); + assert_eq!(node_traits(&node), unsafe { UIAccessibilityTraitNone }); + } + + // ---- frame_source ---- + + fn node_frame_source(nodes: Vec<(NodeId, NodeBuilder)>, target: NodeId) -> FrameSource { + let tree = build_tree(nodes); + let state = tree.state(); + let node = state.node_by_tree_local_id(target, TreeId::ROOT).unwrap(); + NodeWrapper(&node).frame_source() + } + + #[test] + fn frame_source_uses_bounding_box_when_present() { + let mut node = NodeBuilder::new(Role::Button); + node.set_bounds(Rect { + x0: 1.0, + y0: 2.0, + x1: 3.0, + y1: 4.0, + }); + assert_eq!( + node_frame_source(vec![(ROOT_ID, node)], ROOT_ID), + FrameSource::Rect(Rect { + x0: 1.0, + y0: 2.0, + x1: 3.0, + y1: 4.0, + }), + ); + } + + #[test] + fn frame_source_root_without_bounds_uses_view_bounds() { + let node = NodeBuilder::new(Role::Window); + assert_eq!( + node_frame_source(vec![(ROOT_ID, node)], ROOT_ID), + FrameSource::ViewBounds, + ); + } + + #[test] + fn frame_source_non_root_without_bounds_is_zero() { + const CHILD_ID: NodeId = NodeId(1); + let mut root = NodeBuilder::new(Role::Window); + root.set_children(vec![CHILD_ID]); + let child = NodeBuilder::new(Role::Button); + assert_eq!( + node_frame_source(vec![(ROOT_ID, root), (CHILD_ID, child)], CHILD_ID), + FrameSource::Zero, + ); + } + + #[test] + fn frame_source_bounding_box_takes_priority_on_root() { + let mut node = NodeBuilder::new(Role::Window); + node.set_bounds(Rect { + x0: 0.0, + y0: 0.0, + x1: 10.0, + y1: 10.0, + }); + assert!(matches!( + node_frame_source(vec![(ROOT_ID, node)], ROOT_ID), + FrameSource::Rect(_), + )); + } +} diff --git a/platforms/ios/src/util.rs b/platforms/ios/src/util.rs new file mode 100644 index 000000000..5ddf954a7 --- /dev/null +++ b/platforms/ios/src/util.rs @@ -0,0 +1,36 @@ +// 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::Point; +use accesskit_consumer::Node; +use objc2_foundation::{CGPoint, CGRect, CGSize}; +use objc2_ui_kit::UIView; + +pub(crate) fn from_cg_point(view: &UIView, node: &Node, point: CGPoint) -> Point { + let local_point = unsafe { view.convertPoint_fromView(point, None) }; + let insets = view.safeAreaInsets(); + let factor = view.contentScaleFactor(); + let point = Point::new( + (local_point.x - insets.left) * factor, + (local_point.y - insets.top) * factor, + ); + node.transform().inverse() * point +} + +pub(crate) fn to_cg_rect(view: &UIView, rect: accesskit::Rect) -> CGRect { + let insets = view.safeAreaInsets(); + let factor = view.contentScaleFactor(); + let local_rect = CGRect { + origin: CGPoint { + x: rect.x0 / factor + insets.left, + y: rect.y0 / factor + insets.top, + }, + size: CGSize { + width: rect.width() / factor, + height: rect.height() / factor, + }, + }; + unsafe { view.convertRect_toView(local_rect, None) } +} diff --git a/platforms/winit/Cargo.toml b/platforms/winit/Cargo.toml index 94ae7621c..a83de056d 100644 --- a/platforms/winit/Cargo.toml +++ b/platforms/winit/Cargo.toml @@ -48,4 +48,3 @@ softbuffer = { version = "0.4.0", default-features = false, features = [ "wayland", "wayland-dlopen", ] } - From a8b5dbfb91cc44f035dd93a9be5ba6fe1e580f1c Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 6 Feb 2026 09:47:59 +0100 Subject: [PATCH 04/23] Implement accessibilityContainerType --- platforms/ios/src/node.rs | 111 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index 4614ade90..d1e6805f2 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -16,10 +16,10 @@ use objc2::{ }; use objc2_foundation::{CGRect, NSArray, NSObject, NSObjectProtocol, NSString}; use objc2_ui_kit::{ - UIAccessibilityElement, UIAccessibilityTraitAdjustable, UIAccessibilityTraitButton, - UIAccessibilityTraitHeader, UIAccessibilityTraitImage, UIAccessibilityTraitLink, - UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, UIAccessibilityTraitSelected, - UIAccessibilityTraitStaticText, UIAccessibilityTraits, + UIAccessibilityContainerType, UIAccessibilityElement, UIAccessibilityTraitAdjustable, + UIAccessibilityTraitButton, UIAccessibilityTraitHeader, UIAccessibilityTraitImage, + UIAccessibilityTraitLink, UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, + UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, UIAccessibilityTraits, }; use std::rc::{Rc, Weak}; @@ -133,6 +133,29 @@ impl NodeWrapper<'_> { traits } + + fn container_type(&self) -> UIAccessibilityContainerType { + match self.0.role() { + Role::Table | Role::Grid | Role::TreeGrid | Role::ListGrid => { + UIAccessibilityContainerType::DataTable + } + Role::List | Role::ListBox | Role::DescriptionList | Role::Tree => { + UIAccessibilityContainerType::List + } + Role::Article + | Role::Banner + | Role::Complementary + | Role::ContentInfo + | Role::Footer + | Role::Form + | Role::Main + | Role::Navigation + | Role::Region + | Role::Search => UIAccessibilityContainerType::Landmark, + Role::Group => UIAccessibilityContainerType::SemanticGroup, + _ => UIAccessibilityContainerType::None, + } + } } pub(crate) struct PlatformNodeIvars { @@ -254,6 +277,15 @@ declare_class!( }) } + #[method(accessibilityContainerType)] + fn container_type(&self) -> UIAccessibilityContainerType { + self.resolve(|node| { + let wrapper = NodeWrapper(node); + wrapper.container_type() + }) + .unwrap_or(UIAccessibilityContainerType::None) + } + #[method(accessibilityElementDidBecomeFocused)] fn element_did_become_focused(&self) { self.resolve_with_context(|node, tree, context| { @@ -385,6 +417,10 @@ mod tests { with_single(node, |n| NodeWrapper(n).traits()) } + fn node_container_type(node: &NodeBuilder) -> UIAccessibilityContainerType { + with_single(node, |n| NodeWrapper(n).container_type()) + } + fn node_can_be_focused(nodes: Vec<(NodeId, NodeBuilder)>, target: NodeId) -> bool { let tree = build_tree(nodes); let state = tree.state(); @@ -623,6 +659,73 @@ mod tests { assert_eq!(node_traits(&node), unsafe { UIAccessibilityTraitNone }); } + // ---- container_type ---- + + #[test] + fn container_type_data_table_roles() { + for role in [Role::Table, Role::Grid, Role::TreeGrid, Role::ListGrid] { + let node = NodeBuilder::new(role); + assert_eq!( + node_container_type(&node), + UIAccessibilityContainerType::DataTable, + "role {role:?}", + ); + } + } + + #[test] + fn container_type_list_roles() { + for role in [Role::List, Role::ListBox, Role::DescriptionList, Role::Tree] { + let node = NodeBuilder::new(role); + assert_eq!( + node_container_type(&node), + UIAccessibilityContainerType::List, + "role {role:?}", + ); + } + } + + #[test] + fn container_type_landmark_roles() { + for role in [ + Role::Article, + Role::Banner, + Role::Complementary, + Role::ContentInfo, + Role::Footer, + Role::Form, + Role::Main, + Role::Navigation, + Role::Region, + Role::Search, + ] { + let node = NodeBuilder::new(role); + assert_eq!( + node_container_type(&node), + UIAccessibilityContainerType::Landmark, + "role {role:?}", + ); + } + } + + #[test] + fn container_type_semantic_group_for_group() { + let node = NodeBuilder::new(Role::Group); + assert_eq!( + node_container_type(&node), + UIAccessibilityContainerType::SemanticGroup, + ); + } + + #[test] + fn container_type_none_for_button() { + let node = NodeBuilder::new(Role::Button); + assert_eq!( + node_container_type(&node), + UIAccessibilityContainerType::None, + ); + } + // ---- frame_source ---- fn node_frame_source(nodes: Vec<(NodeId, NodeBuilder)>, target: NodeId) -> FrameSource { From d66fd8583594520b2aab96943536a55887b39ffc Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 6 Feb 2026 10:53:30 +0100 Subject: [PATCH 05/23] Implement accessibilityExpandedStatus --- platforms/ios/src/node.rs | 12 +++++++++++- platforms/ios/src/util.rs | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index d1e6805f2..377f33584 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -26,7 +26,7 @@ use std::rc::{Rc, Weak}; use crate::{ context::Context, filters::{filter, filter_for_is_accessibility_element}, - util::to_cg_rect, + util::{UIAccessibilityExpandedStatus, to_cg_rect}, }; #[derive(Debug, PartialEq)] @@ -259,6 +259,16 @@ declare_class!( .flatten() } + #[method(accessibilityExpandedStatus)] + fn expanded_status(&self) -> UIAccessibilityExpandedStatus { + self.resolve(|node| match node.data().is_expanded() { + Some(true) => UIAccessibilityExpandedStatus::Expanded, + Some(false) => UIAccessibilityExpandedStatus::Collapsed, + None => UIAccessibilityExpandedStatus::Unsupported, + }) + .unwrap_or(UIAccessibilityExpandedStatus::Unsupported) + } + #[method_id(accessibilityElements)] fn elements(&self) -> Option>> { self.resolve_with_context(|node, _, context| { diff --git a/platforms/ios/src/util.rs b/platforms/ios/src/util.rs index 5ddf954a7..e49a6d2b7 100644 --- a/platforms/ios/src/util.rs +++ b/platforms/ios/src/util.rs @@ -5,9 +5,30 @@ use accesskit::Point; use accesskit_consumer::Node; -use objc2_foundation::{CGPoint, CGRect, CGSize}; +use objc2::encode::{Encode, Encoding, RefEncode}; +use objc2_foundation::{CGPoint, CGRect, CGSize, NSInteger}; use objc2_ui_kit::UIView; +// TODO: Remove once we update to objc2 0.6 +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) struct UIAccessibilityExpandedStatus(pub NSInteger); + +#[allow(non_upper_case_globals)] +impl UIAccessibilityExpandedStatus { + pub(crate) const Unsupported: Self = Self(0); + pub(crate) const Expanded: Self = Self(1); + pub(crate) const Collapsed: Self = Self(2); +} + +unsafe impl Encode for UIAccessibilityExpandedStatus { + const ENCODING: Encoding = NSInteger::ENCODING; +} + +unsafe impl RefEncode for UIAccessibilityExpandedStatus { + const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING); +} + pub(crate) fn from_cg_point(view: &UIView, node: &Node, point: CGPoint) -> Point { let local_point = unsafe { view.convertPoint_fromView(point, None) }; let insets = view.safeAreaInsets(); From ec0da00edd71fccc7569791b2215af74530a48de Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 6 Feb 2026 11:25:25 +0100 Subject: [PATCH 06/23] Implement accessibilityIdentifier --- platforms/ios/Cargo.toml | 1 + platforms/ios/src/node.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index 500ea86ef..aace764ee 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -31,6 +31,7 @@ objc2-ui-kit = { version = "0.2.0", features = [ "UIAccessibilityConstants", "UIAccessibilityContainer", "UIAccessibilityElement", + "UIAccessibilityIdentification", "UIGeometry", "UIResponder", "UIView", diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index 377f33584..4b2368afa 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -311,6 +311,14 @@ declare_class!( } }); } + + #[method_id(accessibilityIdentifier)] + fn identifier(&self) -> Option> { + self.resolve(|node| { + node.author_id().map(NSString::from_str) + }) + .flatten() + } } ); From f2651ac050b92f328f7b34d1ad7b7aa856f6d9a9 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 20 Feb 2026 15:35:50 +0100 Subject: [PATCH 07/23] Add the subclassing adapter --- platforms/ios/Cargo.toml | 1 + platforms/ios/src/lib.rs | 22 +++ platforms/ios/src/subclass.rs | 290 ++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 platforms/ios/src/subclass.rs diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index aace764ee..4b28b2910 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -35,5 +35,6 @@ objc2-ui-kit = { version = "0.2.0", features = [ "UIGeometry", "UIResponder", "UIView", + "UIViewController", "UIWindow", ] } diff --git a/platforms/ios/src/lib.rs b/platforms/ios/src/lib.rs index 6fd38df1e..a32aa4ab0 100644 --- a/platforms/ios/src/lib.rs +++ b/platforms/ios/src/lib.rs @@ -3,6 +3,25 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. +//! iOS adapter for AccessKit. +//! +//! This crate provides two adapters for exposing an AccessKit accessibility +//! tree to iOS via UIKit's accessibility API: +//! +//! - [`Adapter`] is the low-level adapter. It gives you full control over +//! when the accessibility tree is initialized and updated, but requires you +//! to manually forward UIKit accessibility methods +//! (`isAccessibilityElement`, `accessibilityElements`, +//! `accessibilityHitTest:`) from your `UIView` subclass to the adapter. +//! Use this when you own the `UIView` subclass and can override these +//! methods directly. +//! +//! - [`SubclassingAdapter`] wraps [`Adapter`] and uses dynamic Objective-C +//! subclassing to automatically override the accessibility methods on an +//! existing `UIView`. Use this when you cannot subclass the view yourself, +//! for example when integrating with a framework that creates views on your +//! behalf. + #![deny(unsafe_op_in_unsafe_fn)] mod context; @@ -16,4 +35,7 @@ pub use adapter::Adapter; mod event; pub use event::QueuedEvents; +mod subclass; +pub use subclass::SubclassingAdapter; + pub use objc2_foundation::{CGPoint, NSArray, NSInteger, NSObject}; diff --git a/platforms/ios/src/subclass.rs b/platforms/ios/src/subclass.rs new file mode 100644 index 000000000..8027d2e22 --- /dev/null +++ b/platforms/ios/src/subclass.rs @@ -0,0 +1,290 @@ +// 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, ActivationHandler, TreeUpdate}; +use objc2::{ + ClassType, DeclaredClass, + declare::ClassBuilder, + declare_class, + ffi::{ + OBJC_ASSOCIATION_RETAIN_NONATOMIC, objc_getAssociatedObject, objc_setAssociatedObject, + object_setClass, + }, + msg_send_id, + mutability::MainThreadOnly, + rc::Retained, + runtime::{AnyClass, AnyObject, Bool, Sel}, + sel, +}; +use objc2_foundation::{CGPoint, MainThreadMarker, NSArray, NSObject}; +use objc2_ui_kit::{UIView, UIWindow}; +use std::{cell::RefCell, ffi::c_void, ptr::null_mut, sync::Mutex}; + +use crate::{Adapter, event::QueuedEvents}; + +static SUBCLASSES: Mutex> = Mutex::new(Vec::new()); + +static ASSOCIATED_OBJECT_KEY: u8 = 0; + +fn associated_object_key() -> *const c_void { + (&ASSOCIATED_OBJECT_KEY as *const u8).cast() +} + +struct AssociatedObjectState { + adapter: Adapter, + activation_handler: Box, +} + +struct AssociatedObjectIvars { + state: RefCell, + prev_class: &'static AnyClass, +} + +declare_class!( + struct AssociatedObject; + + unsafe impl ClassType for AssociatedObject { + type Super = NSObject; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitSubclassAssociatedObject"; + } + + impl DeclaredClass for AssociatedObject { + type Ivars = AssociatedObjectIvars; + } +); + +impl AssociatedObject { + fn new( + adapter: Adapter, + activation_handler: impl 'static + ActivationHandler, + prev_class: &'static AnyClass, + mtm: MainThreadMarker, + ) -> Retained { + let state = RefCell::new(AssociatedObjectState { + adapter, + activation_handler: Box::new(activation_handler), + }); + let this = mtm + .alloc::() + .set_ivars(AssociatedObjectIvars { state, prev_class }); + + unsafe { msg_send_id![super(this), init] } + } +} + +fn associated_object(view: &UIView) -> Option<&AssociatedObject> { + unsafe { + (objc_getAssociatedObject(view as *const UIView as *const _, associated_object_key()) + as *const AssociatedObject) + .as_ref() + } +} + +// Some view classes assume that they are the lowest subclass, +// and call [self superclass] to get their superclass. +// Give them the answer they need. +unsafe extern "C" fn superclass(this: &UIView, _cmd: Sel) -> Option<&AnyClass> { + let associated = associated_object(this)?; + associated.ivars().prev_class.superclass() +} + +// UIAccessibilityContainer methods + +unsafe extern "C" fn is_accessibility_element(this: &UIView, _cmd: Sel) -> Bool { + let Some(associated) = associated_object(this) else { + return Bool::YES; + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + Bool::new( + state_mut + .adapter + .is_accessibility_element(&mut *state_mut.activation_handler), + ) +} + +unsafe extern "C" fn accessibility_elements(this: &UIView, _cmd: Sel) -> *mut NSArray { + let Some(associated) = associated_object(this) else { + return Retained::autorelease_return(NSArray::new()); + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .accessibility_elements(&mut *state_mut.activation_handler) +} + +// UIAccessibilityHitTest methods + +unsafe extern "C" fn accessibility_hit_test( + this: &UIView, + _cmd: Sel, + point: CGPoint, +) -> *mut AnyObject { + let Some(associated) = associated_object(this) else { + return null_mut(); + }; + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .hit_test(point, &mut *state_mut.activation_handler) as *mut AnyObject +} + +/// Uses dynamic Objective-C subclassing to implement the `UIView` +/// accessibility methods when normal subclassing isn't an option. +pub struct SubclassingAdapter { + view: Retained, + associated: Retained, +} + +impl SubclassingAdapter { + /// Create an adapter that dynamically subclasses the specified view. + /// This must be done before the view is shown or focused for + /// the first time. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to a `UIView`. + pub unsafe fn new( + view: *mut c_void, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let view = view as *mut UIView; + let retained_view = unsafe { Retained::retain(view) }.unwrap(); + Self::new_internal(retained_view, activation_handler, action_handler) + } + + fn new_internal( + retained_view: Retained, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let mtm = MainThreadMarker::new().unwrap(); + let view = Retained::as_ptr(&retained_view) as *mut UIView; + if !unsafe { + objc_getAssociatedObject(view as *const UIView as *const _, associated_object_key()) + } + .is_null() + { + panic!("subclassing adapter already instantiated on view {view:?}"); + } + let adapter = unsafe { Adapter::new(view as *mut c_void, action_handler) }; + // Cast to a pointer and back to force the lifetime to 'static + // SAFETY: We know the class will live as long as the instance, + // and we only use this reference while the instance is alive. + let prev_class = unsafe { &*((*view).class() as *const AnyClass) }; + let associated = AssociatedObject::new(adapter, activation_handler, prev_class, mtm); + unsafe { + objc_setAssociatedObject( + view as *mut _, + associated_object_key(), + Retained::as_ptr(&associated) as *mut _, + OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + }; + let mut subclasses = SUBCLASSES.lock().unwrap(); + let subclass = match subclasses.iter().find(|entry| entry.0 == prev_class) { + Some(entry) => entry.1, + None => { + let name = format!("AccessKitSubclassOf{}", prev_class.name()); + let mut builder = ClassBuilder::new(&name, prev_class).unwrap(); + unsafe { + builder.add_method( + sel!(superclass), + superclass as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(isAccessibilityElement), + is_accessibility_element as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityElements), + accessibility_elements as unsafe extern "C" fn(_, _) -> _, + ); + builder.add_method( + sel!(accessibilityHitTest:), + accessibility_hit_test as unsafe extern "C" fn(_, _, _) -> _, + ); + } + let class = builder.register(); + subclasses.push((prev_class, class)); + class + } + }; + // SAFETY: Changing the view's class is only safe because + // the subclass doesn't add any instance variables; + // it uses an associated object instead. + unsafe { object_setClass(view as *mut _, (subclass as *const AnyClass).cast()) }; + Self { + view: retained_view, + associated, + } + } + + /// Create an adapter that dynamically subclasses the root view + /// of the specified window. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `window` must be a valid, unreleased pointer to a `UIWindow`. + /// + /// # Panics + /// + /// This function panics if the specified window doesn't currently have + /// a root view controller with a view. + pub unsafe fn for_window( + window: *mut c_void, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + ) -> Self { + let window = unsafe { &*(window as *const UIWindow) }; + let root_view_controller = window + .rootViewController() + .expect("window has no root view controller"); + let retained_view = root_view_controller + .view() + .expect("root view controller has no view"); + Self::new_internal(retained_view, activation_handler, action_handler) + } + + /// If and only if the tree has been initialized, call the provided function + /// and apply the resulting update. Note: If the caller's implementation of + /// [`ActivationHandler::request_initial_tree`] initially returned `None`, + /// the [`TreeUpdate`] returned by the provided function must contain + /// a full tree. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn update_if_active( + &mut self, + update_factory: impl FnOnce() -> TreeUpdate, + ) -> Option { + let mut state = self.associated.ivars().state.borrow_mut(); + state.adapter.update_if_active(update_factory) + } +} + +impl Drop for SubclassingAdapter { + fn drop(&mut self) { + let prev_class = self.associated.ivars().prev_class; + let view = Retained::as_ptr(&self.view) as *mut UIView; + unsafe { object_setClass(view as *mut _, (prev_class as *const AnyClass).cast()) }; + unsafe { + objc_setAssociatedObject( + view as *mut _, + associated_object_key(), + std::ptr::null_mut(), + OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + }; + } +} From d7fb5a8b97b82dba02e18a146385f0128e3746a0 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 27 Feb 2026 11:56:27 +0100 Subject: [PATCH 08/23] Integrate into the winit adapter --- Cargo.lock | 1 + platforms/winit/Cargo.toml | 5 ++- platforms/winit/src/platform_impl/ios.rs | 48 ++++++++++++++++++++++++ platforms/winit/src/platform_impl/mod.rs | 9 ++++- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 platforms/winit/src/platform_impl/ios.rs diff --git a/Cargo.lock b/Cargo.lock index 685c6bff3..dbcd9ab8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ version = "0.32.2" dependencies = [ "accesskit", "accesskit_android", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", diff --git a/platforms/winit/Cargo.toml b/platforms/winit/Cargo.toml index a83de056d..8db4e8c6f 100644 --- a/platforms/winit/Cargo.toml +++ b/platforms/winit/Cargo.toml @@ -36,12 +36,15 @@ accesskit_unix = { version = "0.21.0", path = "../unix", optional = true, defaul [target.'cfg(target_os = "android")'.dependencies] accesskit_android = { version = "0.7.2", path = "../android", optional = true, features = ["embedded-dex"] } +[target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))'.dependencies] +accesskit_ios = { version = "0.1.0", path = "../ios" } + [dev-dependencies.winit] version = "0.30.5" default-features = false features = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] -[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dev-dependencies] +[target.'cfg(not(target_os = "android"))'.dev-dependencies] softbuffer = { version = "0.4.0", default-features = false, features = [ "x11", "x11-dlopen", diff --git a/platforms/winit/src/platform_impl/ios.rs b/platforms/winit/src/platform_impl/ios.rs new file mode 100644 index 000000000..42c14dcc8 --- /dev/null +++ b/platforms/winit/src/platform_impl/ios.rs @@ -0,0 +1,48 @@ +// Copyright 2026 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file). + +#[cfg(feature = "rwh_05")] +use crate::raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; +#[cfg(feature = "rwh_06")] +use crate::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + +use accesskit::{ActionHandler, ActivationHandler, DeactivationHandler, TreeUpdate}; +use accesskit_ios::SubclassingAdapter; +use winit::{event::WindowEvent, event_loop::ActiveEventLoop, window::Window}; + +pub struct Adapter { + adapter: SubclassingAdapter, +} + +impl Adapter { + pub fn new( + _event_loop: &ActiveEventLoop, + window: &Window, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + _deactivation_handler: impl 'static + DeactivationHandler, + ) -> Self { + #[cfg(feature = "rwh_05")] + let view = match window.raw_window_handle() { + RawWindowHandle::UiKit(handle) => handle.ui_view, + _ => unreachable!(), + }; + #[cfg(feature = "rwh_06")] + let view = match window.window_handle().unwrap().as_raw() { + RawWindowHandle::UiKit(handle) => handle.ui_view.as_ptr(), + _ => unreachable!(), + }; + + let adapter = unsafe { SubclassingAdapter::new(view, activation_handler, action_handler) }; + Self { adapter } + } + + pub fn update_if_active(&mut self, updater: impl FnOnce() -> TreeUpdate) { + if let Some(events) = self.adapter.update_if_active(updater) { + events.raise(); + } + } + + pub fn process_event(&mut self, _window: &Window, _event: &WindowEvent) {} +} diff --git a/platforms/winit/src/platform_impl/mod.rs b/platforms/winit/src/platform_impl/mod.rs index 27f7df0c5..297ecc17e 100644 --- a/platforms/winit/src/platform_impl/mod.rs +++ b/platforms/winit/src/platform_impl/mod.rs @@ -31,6 +31,10 @@ mod platform; #[path = "android.rs"] mod platform; +#[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] +#[path = "ios.rs"] +mod platform; + #[cfg(not(any( target_os = "windows", target_os = "macos", @@ -44,7 +48,10 @@ mod platform; target_os = "openbsd" ) ), - all(feature = "accesskit_android", target_os = "android") + all(feature = "accesskit_android", target_os = "android"), + target_os = "ios", + target_os = "tvos", + target_os = "visionos" )))] #[path = "null.rs"] mod platform; From b2949cb8092851768a80505d7a0efb32868ba842 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 6 Mar 2026 17:48:11 +0100 Subject: [PATCH 09/23] Allow running the winit examples on iOS --- .gitignore | 4 + Cargo.lock | 193 +++++++++++++----- platforms/winit/Cargo.toml | 12 +- platforms/winit/README.md | 13 ++ platforms/winit/examples/apple/.gitignore | 2 + .../examples/apple/build_with_cargo.bash | 88 ++++++++ platforms/winit/examples/apple/project.yml | 36 ++++ platforms/winit/examples/mixed_handlers.rs | 26 ++- platforms/winit/examples/simple.rs | 24 ++- platforms/winit/examples/util/fill.rs | 8 +- 10 files changed, 330 insertions(+), 76 deletions(-) create mode 100644 platforms/winit/examples/apple/.gitignore create mode 100755 platforms/winit/examples/apple/build_with_cargo.bash create mode 100644 platforms/winit/examples/apple/project.yml diff --git a/.gitignore b/.gitignore index 9853625b3..2b462b369 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ target #KDE .directory + +# Xcode (generated by xcodegen) +*.xcodeproj +xcuserdata diff --git a/Cargo.lock b/Cargo.lock index dbcd9ab8e..7a9cec28a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,8 +67,8 @@ dependencies = [ "accesskit", "accesskit_consumer", "hashbrown", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-ui-kit", ] @@ -79,9 +79,9 @@ dependencies = [ "accesskit", "accesskit_consumer", "hashbrown", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -432,7 +432,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", ] [[package]] @@ -605,6 +605,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.4", +] + [[package]] name = "dlib" version = "0.5.2" @@ -1130,6 +1140,15 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-app-kit" version = "0.2.2" @@ -1139,11 +1158,11 @@ dependencies = [ "bitflags 2.8.0", "block2", "libc", - "objc2", + "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -1154,9 +1173,9 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -1166,8 +1185,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1178,8 +1197,32 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.8.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.8.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -1189,8 +1232,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -1201,9 +1244,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2", - "objc2", + "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -1222,7 +1265,29 @@ dependencies = [ "block2", "dispatch", "libc", - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] @@ -1232,9 +1297,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -1245,8 +1310,8 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1257,19 +1322,31 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1280,14 +1357,14 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -1300,8 +1377,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1312,9 +1389,9 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -1916,33 +1993,32 @@ dependencies = [ [[package]] name = "softbuffer" -version = "0.4.5" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d623bff5d06f60d738990980d782c8c866997d9194cfe79ecad00aa2f76826dd" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "as-raw-xcb-connection", "bytemuck", - "cfg_aliases", - "core-graphics", "fastrand", - "foreign-types", "js-sys", - "log", "memmap2", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-quartz-core", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle 0.6.2", "redox_syscall 0.5.13", - "rustix 0.38.44", + "rustix 1.0.3", "tiny-xlib", + "tracing", "wasm-bindgen", "wayland-backend", "wayland-client", "wayland-sys", "web-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "x11rb", ] @@ -2102,9 +2178,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2113,9 +2189,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2124,9 +2200,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -2565,6 +2641,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -2775,9 +2860,9 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "percent-encoding", diff --git a/platforms/winit/Cargo.toml b/platforms/winit/Cargo.toml index 8db4e8c6f..c40e33287 100644 --- a/platforms/winit/Cargo.toml +++ b/platforms/winit/Cargo.toml @@ -44,10 +44,16 @@ version = "0.30.5" default-features = false features = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] -[target.'cfg(not(target_os = "android"))'.dev-dependencies] -softbuffer = { version = "0.4.0", default-features = false, features = [ +[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "tvos", target_os = "visionos")))'.dev-dependencies.softbuffer] +version = "0.4.8" +default-features = false +features = [ "x11", "x11-dlopen", "wayland", "wayland-dlopen", -] } +] + +[target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))'.dev-dependencies.softbuffer] +version = "0.4.8" +default-features = false diff --git a/platforms/winit/README.md b/platforms/winit/README.md index bbcf3f299..2eae18328 100644 --- a/platforms/winit/README.md +++ b/platforms/winit/README.md @@ -14,3 +14,16 @@ While this crate's API is purely blocking, it internally spawns asynchronous tas ## Android activity compatibility The Android implementation of this adapter currently only works with [GameActivity](https://developer.android.com/games/agdk/game-activity), which is one of the two activity implementations that winit currently supports. + +## Examples + +The `examples/` directory contains two runnable examples: + +- `simple` — a minimal window exposing a single accessible label. +- `mixed_handlers` — demonstrates combining AccessKit's action handling with winit event handling. + +On desktop platforms, run them with `cargo run --example simple` or `cargo run --example mixed_handlers` from this crate's directory. + +### Running the examples on iOS + +Install [XcodeGen](https://github.com/yonaskolb/XcodeGen) and the iOS Rust targets, then run `xcodegen` from `examples/apple/` to generate the Xcode project. Open it in Xcode and build/run the `Simple` or `MixedHandlers` target on a device or simulator. diff --git a/platforms/winit/examples/apple/.gitignore b/platforms/winit/examples/apple/.gitignore new file mode 100644 index 000000000..01c9df8ac --- /dev/null +++ b/platforms/winit/examples/apple/.gitignore @@ -0,0 +1,2 @@ +# Generated by xcodegen +Info.plist diff --git a/platforms/winit/examples/apple/build_with_cargo.bash b/platforms/winit/examples/apple/build_with_cargo.bash new file mode 100755 index 000000000..1905e2fb9 --- /dev/null +++ b/platforms/winit/examples/apple/build_with_cargo.bash @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -eux +: "${1:?example name required}" +: "${SRCROOT:?}" "${DERIVED_FILE_DIR:?}" "${TARGET_BUILD_DIR:?}" "${EXECUTABLE_PATH:?}" "${ARCHS:?}" "${PLATFORM_NAME:?}" +export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH:$HOME/.cargo/bin" + +if [[ "$CONFIGURATION" != "Debug" ]]; then + CARGO_PROFILE=release + cargo_args=(--release) +else + CARGO_PROFILE=debug + cargo_args=() +fi + +# Make Cargo output cache files in Xcode's directories +export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo" + +case "$PLATFORM_NAME" in + iphoneos) CARGO_OS=ios; BUILD_KIND=device ;; + iphonesimulator) CARGO_OS=ios; BUILD_KIND=simulator ;; + appletvos) CARGO_OS=tvos; BUILD_KIND=device ;; + appletvsimulator) CARGO_OS=tvos; BUILD_KIND=simulator ;; + xros) CARGO_OS=visionos; BUILD_KIND=device ;; + xrsimulator) CARGO_OS=visionos; BUILD_KIND=simulator ;; + macosx) + if [[ "${IS_MACCATALYST:-NO}" != "YES" ]]; then + echo "non-Catalyst macOS builds are not supported" >&2 + exit 1 + fi + CARGO_OS=ios + BUILD_KIND=catalyst + ;; + *) + echo "unsupported platform: $PLATFORM_NAME" >&2 + exit 1 + ;; +esac + +cd "$SRCROOT/../.." + +executables=() +for arch in $ARCHS; do + case "$arch" in + arm64) RUST_ARCH=aarch64 ;; + x86_64) RUST_ARCH=x86_64 ;; + *) + echo "unsupported arch: $arch" >&2 + exit 1 + ;; + esac + + case "$BUILD_KIND" in + device) + if [[ "$RUST_ARCH" = "x86_64" ]]; then + echo "x86_64 is not valid for device builds" >&2 + exit 1 + fi + CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}" + ;; + simulator) + if [[ "$RUST_ARCH" = "x86_64" ]]; then + if [[ "$CARGO_OS" = "visionos" ]]; then + echo "x86_64 is not supported for visionOS" >&2 + exit 1 + fi + # Rust names the x86_64 simulator target without a -sim suffix, + # but clang still needs the -simulator triple. + CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}" + export "CFLAGS_${RUST_ARCH}_apple_${CARGO_OS}=-target ${RUST_ARCH}-apple-${CARGO_OS}-simulator" + else + CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}-sim" + fi + ;; + catalyst) + CARGO_TARGET="${RUST_ARCH}-apple-ios-macabi" + ;; + esac + + cargo build ${cargo_args[@]+"${cargo_args[@]}"} \ + --target "$CARGO_TARGET" \ + --example "$1" \ + --no-default-features --features rwh_06 + + executables+=("$DERIVED_FILE_DIR/cargo/$CARGO_TARGET/$CARGO_PROFILE/examples/$1") +done + +lipo -create -output "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" "${executables[@]}" + diff --git a/platforms/winit/examples/apple/project.yml b/platforms/winit/examples/apple/project.yml new file mode 100644 index 000000000..3bd1175f5 --- /dev/null +++ b/platforms/winit/examples/apple/project.yml @@ -0,0 +1,36 @@ +name: AccessKit Winit Examples +options: + bundleIdPrefix: dev.accesskit +settings: + ENABLE_USER_SCRIPT_SANDBOXING: NO +targets: + Simple: + type: application + platform: iOS + deploymentTarget: "18.0" + info: + path: Info.plist + properties: + UILaunchScreen: + - ImageRespectSafeAreaInsets: false + sources: [] + postCompileScripts: + - script: | + ./build_with_cargo.bash simple + outputFiles: + - $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) + MixedHandlers: + type: application + platform: iOS + deploymentTarget: "18.0" + info: + path: Info.plist + properties: + UILaunchScreen: + - ImageRespectSafeAreaInsets: false + sources: [] + postCompileScripts: + - script: | + ./build_with_cargo.bash mixed_handlers + outputFiles: + - $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) diff --git a/platforms/winit/examples/mixed_handlers.rs b/platforms/winit/examples/mixed_handlers.rs index 4e5a7bdf5..d37d65d1c 100644 --- a/platforms/winit/examples/mixed_handlers.rs +++ b/platforms/winit/examples/mixed_handlers.rs @@ -26,18 +26,25 @@ const BUTTON_2_ID: NodeId = NodeId(2); const ANNOUNCEMENT_ID: NodeId = NodeId(3); const INITIAL_FOCUS: NodeId = BUTTON_1_ID; +const WINDOW_RECT: Rect = Rect { + x0: 0.0, + y0: 0.0, + x1: 393.0, + y1: 759.0, +}; + const BUTTON_1_RECT: Rect = Rect { x0: 20.0, y0: 20.0, - x1: 100.0, - y1: 60.0, + x1: 200.0, + y1: 64.0, }; const BUTTON_2_RECT: Rect = Rect { x0: 20.0, - y0: 60.0, - x1: 100.0, - y1: 100.0, + y0: 84.0, + x1: 200.0, + y1: 128.0, }; fn build_button(id: NodeId, label: &str) -> Node { @@ -77,6 +84,7 @@ impl UiState { fn build_root(&mut self) -> Node { let mut node = Node::new(Role::Window); + node.set_bounds(WINDOW_RECT); node.set_children(vec![BUTTON_1_ID, BUTTON_2_ID]); if self.announcement.is_some() { node.push_child(ANNOUNCEMENT_ID); @@ -281,13 +289,15 @@ impl ApplicationHandler for Application { } window.window.request_redraw(); } - AccessKitWindowEvent::AccessibilityDeactivated => (), + AccessKitWindowEvent::AccessibilityDeactivated => {} } } fn resumed(&mut self, event_loop: &ActiveEventLoop) { - self.create_window(event_loop) - .expect("failed to create initial window"); + if self.window.is_none() { + self.create_window(event_loop) + .expect("failed to create initial window"); + } if let Some(window) = self.window.as_ref() { window.window.request_redraw(); } diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index 388072582..c4a329940 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -20,18 +20,25 @@ const BUTTON_2_ID: NodeId = NodeId(2); const ANNOUNCEMENT_ID: NodeId = NodeId(3); const INITIAL_FOCUS: NodeId = BUTTON_1_ID; +const WINDOW_RECT: Rect = Rect { + x0: 0.0, + y0: 0.0, + x1: 393.0, + y1: 759.0, +}; + const BUTTON_1_RECT: Rect = Rect { x0: 20.0, y0: 20.0, - x1: 100.0, - y1: 60.0, + x1: 200.0, + y1: 64.0, }; const BUTTON_2_RECT: Rect = Rect { x0: 20.0, - y0: 60.0, - x1: 100.0, - y1: 100.0, + y0: 84.0, + x1: 200.0, + y1: 128.0, }; fn build_button(id: NodeId, label: &str) -> Node { @@ -71,6 +78,7 @@ impl UiState { fn build_root(&mut self) -> Node { let mut node = Node::new(Role::Window); + node.set_bounds(WINDOW_RECT); node.set_children(vec![BUTTON_1_ID, BUTTON_2_ID]); if self.announcement.is_some() { node.push_child(ANNOUNCEMENT_ID); @@ -261,8 +269,10 @@ impl ApplicationHandler for Application { } fn resumed(&mut self, event_loop: &ActiveEventLoop) { - self.create_window(event_loop) - .expect("failed to create initial window"); + if self.window.is_none() { + self.create_window(event_loop) + .expect("failed to create initial window"); + } if let Some(window) = self.window.as_ref() { window.window.request_redraw(); } diff --git a/platforms/winit/examples/util/fill.rs b/platforms/winit/examples/util/fill.rs index 27625e2e9..bc104c1c0 100644 --- a/platforms/winit/examples/util/fill.rs +++ b/platforms/winit/examples/util/fill.rs @@ -12,7 +12,7 @@ pub use platform::cleanup_window; pub use platform::fill_window; -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(not(target_os = "android"))] mod platform { use std::cell::RefCell; use std::collections::HashMap; @@ -111,13 +111,13 @@ mod platform { } } -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(target_os = "android")] mod platform { pub fn fill_window(_window: &winit::window::Window) { - // No-op on mobile platforms. + // No-op on Android platform. } pub fn cleanup_window(_window: &winit::window::Window) { - // No-op on mobile platforms. + // No-op on Android platform. } } From 94bae0c40dd9f27547f8dce40751cc5ff4939814 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 13 Mar 2026 16:04:52 +0100 Subject: [PATCH 10/23] Implement more actions --- platforms/ios/src/node.rs | 105 +++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index 4b2368afa..d67e7a32e 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -11,15 +11,18 @@ use accesskit::{Action, ActionRequest, Rect, Role, Toggled}; use accesskit_consumer::{FilterResult, Node, NodeId, Tree}; use objc2::{ - ClassType, DeclaredClass, declare_class, msg_send_id, mutability::MainThreadOnly, rc::Retained, - runtime::AnyObject, + ClassType, DeclaredClass, declare_class, msg_send_id, + mutability::MainThreadOnly, + rc::Retained, + runtime::{AnyObject, Bool}, }; use objc2_foundation::{CGRect, NSArray, NSObject, NSObjectProtocol, NSString}; use objc2_ui_kit::{ - UIAccessibilityContainerType, UIAccessibilityElement, UIAccessibilityTraitAdjustable, - UIAccessibilityTraitButton, UIAccessibilityTraitHeader, UIAccessibilityTraitImage, - UIAccessibilityTraitLink, UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, - UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, UIAccessibilityTraits, + UIAccessibilityContainerType, UIAccessibilityElement, UIAccessibilityScrollDirection, + UIAccessibilityTraitAdjustable, UIAccessibilityTraitButton, UIAccessibilityTraitHeader, + UIAccessibilityTraitImage, UIAccessibilityTraitLink, UIAccessibilityTraitNone, + UIAccessibilityTraitNotEnabled, UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, + UIAccessibilityTraits, }; use std::rc::{Rc, Weak}; @@ -269,6 +272,96 @@ declare_class!( .unwrap_or(UIAccessibilityExpandedStatus::Unsupported) } + #[method(accessibilityActivate)] + fn activate(&self) -> bool { + self.resolve_with_context(|node, tree, context| { + if !node.is_clickable(&filter) { + return false; + } + let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) else { + return false; + }; + context.do_action(ActionRequest { + action: Action::Click, + target_tree, + target_node, + data: None, + }); + true + }) + .unwrap_or(false) + } + + #[method(accessibilityIncrement)] + fn increment(&self) { + self.resolve_with_context(|node, tree, context| { + if !node.supports_increment(&filter) { + return; + } + let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) else { + return; + }; + context.do_action(ActionRequest { + action: Action::Increment, + target_tree, + target_node, + data: None, + }); + }); + } + + #[method(accessibilityDecrement)] + fn decrement(&self) { + self.resolve_with_context(|node, tree, context| { + if !node.supports_decrement(&filter) { + return; + } + let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) else { + return; + }; + context.do_action(ActionRequest { + action: Action::Decrement, + target_tree, + target_node, + data: None, + }); + }); + } + + #[method(accessibilityScroll:)] + fn scroll(&self, direction: UIAccessibilityScrollDirection) -> Bool { + // `UIAccessibilityScrollDirection` describes the direction the + // scroll bar moves, while AccessKit's scroll actions describe the + // direction the content (finger) moves, so the vertical cases are + // inverted while the horizontal cases match directly. + let action = match direction { + UIAccessibilityScrollDirection::Right + | UIAccessibilityScrollDirection::Previous => Action::ScrollRight, + UIAccessibilityScrollDirection::Left | UIAccessibilityScrollDirection::Next => { + Action::ScrollLeft + } + UIAccessibilityScrollDirection::Up => Action::ScrollDown, + UIAccessibilityScrollDirection::Down => Action::ScrollUp, + _ => return Bool::NO, + }; + self.resolve_with_context(|node, tree, context| { + if !node.supports_action(action, &filter) { + return Bool::NO; + } + let Some((target_node, target_tree)) = tree.state().locate_node(node.id()) else { + return Bool::NO; + }; + context.do_action(ActionRequest { + action, + target_tree, + target_node, + data: None, + }); + Bool::YES + }) + .unwrap_or(Bool::NO) + } + #[method_id(accessibilityElements)] fn elements(&self) -> Option>> { self.resolve_with_context(|node, _, context| { From a7ee1c9414cdeef0752212037b391c3ca322abad Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 27 Mar 2026 18:26:39 +0100 Subject: [PATCH 11/23] Support live regions --- platforms/ios/Cargo.toml | 1 + platforms/ios/src/adapter.rs | 4 +- platforms/ios/src/event.rs | 93 +++++++++++++++++++--- platforms/winit/examples/mixed_handlers.rs | 58 ++++++++++---- platforms/winit/examples/simple.rs | 53 ++++++++---- 5 files changed, 168 insertions(+), 41 deletions(-) diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index 4b28b2910..f97b14b4b 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -21,6 +21,7 @@ hashbrown.workspace = true objc2 = "0.5.1" objc2-foundation = { version = "0.2.0", features = [ "NSArray", + "NSAttributedString", "NSDictionary", "NSString", "NSValue", diff --git a/platforms/ios/src/adapter.rs b/platforms/ios/src/adapter.rs index d8e8262b6..6ddab0246 100644 --- a/platforms/ios/src/adapter.rs +++ b/platforms/ios/src/adapter.rs @@ -23,7 +23,7 @@ use std::{ffi::c_void, ptr::null_mut, rc::Rc}; use crate::{ context::{ActionHandlerNoMut, ActionHandlerWrapper, Context}, - event::{EventGenerator, QueuedEvents, layout_event}, + event::{EventGenerator, QueuedEvents, screen_changed_event}, filters::filter, node::PlatformNode, util::from_cg_point, @@ -151,7 +151,7 @@ impl Adapter { ); let focus_id = context.tree.borrow().state().focus().map(|node| node.id()); let queued_events = focus_id.map(|id| { - let events = vec![layout_event(Some(id))]; + let events = vec![screen_changed_event(Some(id))]; QueuedEvents::new(Rc::clone(&context), events) }); self.state = State::Active(context); diff --git a/platforms/ios/src/event.rs b/platforms/ios/src/event.rs index 438784904..e8361dba9 100644 --- a/platforms/ios/src/event.rs +++ b/platforms/ios/src/event.rs @@ -3,10 +3,18 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. +use accesskit::Live; use accesskit_consumer::{FilterResult, Node, NodeId, TreeChangeHandler}; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2_foundation::{ + NSAttributedString, NSAttributedStringKey, NSMutableDictionary, NSNumber, NSString, +}; use objc2_ui_kit::{ - UIAccessibilityLayoutChangedNotification, UIAccessibilityNotifications, - UIAccessibilityPostNotification, + UIAccessibilityAnnouncementNotification, UIAccessibilityLayoutChangedNotification, + UIAccessibilityNotifications, UIAccessibilityPostNotification, UIAccessibilityPriority, + UIAccessibilityPriorityHigh, UIAccessibilityPriorityLow, + UIAccessibilityScreenChangedNotification, UIAccessibilitySpeechAttributeAnnouncementPriority, + UIAccessibilitySpeechAttributeQueueAnnouncement, }; use std::collections::VecDeque; use std::rc::Rc; @@ -19,9 +27,24 @@ pub(crate) enum QueuedEvent { notification: UIAccessibilityNotifications, }, NodeDestroyed(NodeId), + Announcement { + text: String, + priority: &'static UIAccessibilityPriority, + }, } impl QueuedEvent { + fn live_region_announcement(text: String, priority: Live) -> Self { + Self::Announcement { + text, + priority: if priority == Live::Assertive { + unsafe { UIAccessibilityPriorityHigh } + } else { + unsafe { UIAccessibilityPriorityLow } + }, + } + } + fn raise(self, context: &Rc) { match self { Self::Generic { @@ -38,6 +61,32 @@ impl QueuedEvent { Self::NodeDestroyed(node_id) => { context.remove_platform_node(node_id); } + Self::Announcement { text, priority } => { + Self::raise_announcement(&text, priority); + } + } + } + + fn raise_announcement(text: &str, priority: &'static UIAccessibilityPriority) { + let text = NSString::from_str(text); + let mut attrs: objc2::rc::Retained> = + NSMutableDictionary::new(); + unsafe { + attrs.setObject_forKey( + priority, + ProtocolObject::from_ref(UIAccessibilitySpeechAttributeAnnouncementPriority), + ); + attrs.setObject_forKey( + &*NSNumber::new_bool(true), + ProtocolObject::from_ref(UIAccessibilitySpeechAttributeQueueAnnouncement), + ); + } + let announcement = unsafe { NSAttributedString::new_with_attributes(&text, &attrs) }; + unsafe { + UIAccessibilityPostNotification( + UIAccessibilityAnnouncementNotification, + Some(&announcement), + ); } } } @@ -54,7 +103,7 @@ impl QueuedEvents { Self { context, events } } - /// Raise all queued events synchronously. + /// Raise all queued events. /// /// It is unknown whether accessibility methods on the view may be /// called while events are being raised. This means that any locks @@ -74,6 +123,13 @@ pub(crate) fn layout_event(node_id: Option) -> QueuedEvent { } } +pub(crate) fn screen_changed_event(node_id: Option) -> QueuedEvent { + QueuedEvent::Generic { + node_id, + notification: unsafe { UIAccessibilityScreenChangedNotification }, + } +} + pub(crate) struct EventGenerator { context: Rc, layout_event_focus: Option>, @@ -115,11 +171,10 @@ impl EventGenerator { } pub(crate) fn into_result(self) -> QueuedEvents { - let mut events = self - .layout_event_focus - .map(|focus| vec![layout_event(focus)]) - .unwrap_or_default(); - events.extend(self.events); + let mut events = self.events; + if let Some(focus) = self.layout_event_focus { + events.push(layout_event(focus)); + } QueuedEvents::new(self.context, events) } } @@ -130,10 +185,17 @@ impl TreeChangeHandler for EventGenerator { return; } self.insert_layout_changed_event_if_needed(); + if let Some(value) = node.value() + && node.live() != Live::Off + { + self.events + .push(QueuedEvent::live_region_announcement(value, node.live())); + } } fn node_updated(&mut self, old_node: &Node, new_node: &Node) { - let old_included = filter(old_node) == FilterResult::Include; + let old_filter_result = filter(old_node); + let old_included = old_filter_result == FilterResult::Include; let new_filter_result = filter(new_node); let new_included = new_filter_result == FilterResult::Include; @@ -150,6 +212,19 @@ impl TreeChangeHandler for EventGenerator { if new_included { self.insert_layout_changed_event_if_needed(); } + + let was_filtered_out = old_filter_result != FilterResult::Include; + if let Some(value) = new_node.value() + && new_node.live() != Live::Off + && (Some(&value) != old_node.value().as_ref() + || new_node.live() != old_node.live() + || was_filtered_out) + { + self.events.push(QueuedEvent::live_region_announcement( + value, + new_node.live(), + )); + } } fn focus_moved(&mut self, _old_node: Option<&Node>, new_node: Option<&Node>) { diff --git a/platforms/winit/examples/mixed_handlers.rs b/platforms/winit/examples/mixed_handlers.rs index d37d65d1c..327b7dcde 100644 --- a/platforms/winit/examples/mixed_handlers.rs +++ b/platforms/winit/examples/mixed_handlers.rs @@ -9,10 +9,12 @@ use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKit use std::{ error::Error, sync::{Arc, Mutex}, + time::{Duration, Instant}, }; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, WindowEvent}, + event_loop::ControlFlow, event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, keyboard::Key, window::{Window, WindowId}, @@ -69,9 +71,12 @@ fn build_announcement(text: &str) -> Node { node } +const ANNOUNCEMENT_DELAY: Duration = Duration::from_millis(150); + struct UiState { focus: NodeId, announcement: Option, + pending_announcement: Option<(String, Instant)>, } impl UiState { @@ -79,6 +84,7 @@ impl UiState { Arc::new(Mutex::new(Self { focus: INITIAL_FOCUS, announcement: None, + pending_announcement: None, })) } @@ -126,23 +132,36 @@ impl UiState { }); } - fn press_button(&mut self, adapter: &mut Adapter, id: NodeId) { + fn press_button(&mut self, id: NodeId) { let text = if id == BUTTON_1_ID { "You pressed button 1" } else { "You pressed button 2" }; - self.announcement = Some(text.into()); - adapter.update_if_active(|| { - let announcement = build_announcement(text); - let root = self.build_root(); - TreeUpdate { - nodes: vec![(ANNOUNCEMENT_ID, announcement), (WINDOW_ID, root)], - tree: None, - tree_id: TreeId::ROOT, - focus: self.focus, - } - }); + self.pending_announcement = Some((text.into(), Instant::now())); + } + + fn flush_announcement(&mut self, adapter: &mut Adapter) -> bool { + let Some((_, queued_at)) = &self.pending_announcement else { + return false; + }; + if queued_at.elapsed() < ANNOUNCEMENT_DELAY { + return true; + } + if let Some((text, _)) = self.pending_announcement.take() { + self.announcement = Some(text.clone()); + adapter.update_if_active(|| { + let announcement = build_announcement(&text); + let root = self.build_root(); + TreeUpdate { + nodes: vec![(ANNOUNCEMENT_ID, announcement), (WINDOW_ID, root)], + tree: None, + tree_id: TreeId::ROOT, + focus: self.focus, + } + }); + } + false } } @@ -251,7 +270,7 @@ impl ApplicationHandler for Application { Key::Named(winit::keyboard::NamedKey::Space) => { let mut state = state.lock().unwrap(); let id = state.focus; - state.press_button(adapter, id); + state.press_button(id); window.window.request_redraw(); } _ => (), @@ -282,7 +301,7 @@ impl ApplicationHandler for Application { state.set_focus(adapter, target_node); } Action::Click => { - state.press_button(adapter, target_node); + state.press_button(target_node); } _ => (), } @@ -304,7 +323,16 @@ impl ApplicationHandler for Application { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_none() { + if let Some(window) = &mut self.window { + if window + .ui + .lock() + .unwrap() + .flush_announcement(&mut window.adapter) + { + event_loop.set_control_flow(ControlFlow::wait_duration(ANNOUNCEMENT_DELAY)); + } + } else { event_loop.exit(); } } diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index c4a329940..396c4a131 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -4,9 +4,11 @@ mod fill; use accesskit::{Action, ActionRequest, Live, Node, NodeId, Rect, Role, Tree, TreeId, TreeUpdate}; use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; use std::error::Error; +use std::time::{Duration, Instant}; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, WindowEvent}, + event_loop::ControlFlow, event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, keyboard::Key, window::{Window, WindowId}, @@ -63,9 +65,12 @@ fn build_announcement(text: &str) -> Node { node } +const ANNOUNCEMENT_DELAY: Duration = Duration::from_millis(150); + struct UiState { focus: NodeId, announcement: Option, + pending_announcement: Option<(String, Instant)>, } impl UiState { @@ -73,6 +78,7 @@ impl UiState { Self { focus: INITIAL_FOCUS, announcement: None, + pending_announcement: None, } } @@ -120,23 +126,36 @@ impl UiState { }); } - fn press_button(&mut self, adapter: &mut Adapter, id: NodeId) { + fn press_button(&mut self, id: NodeId) { let text = if id == BUTTON_1_ID { "You pressed button 1" } else { "You pressed button 2" }; - self.announcement = Some(text.into()); - adapter.update_if_active(|| { - let announcement = build_announcement(text); - let root = self.build_root(); - TreeUpdate { - nodes: vec![(ANNOUNCEMENT_ID, announcement), (WINDOW_ID, root)], - tree: None, - tree_id: TreeId::ROOT, - focus: self.focus, - } - }); + self.pending_announcement = Some((text.into(), Instant::now())); + } + + fn flush_announcement(&mut self, adapter: &mut Adapter) -> bool { + let Some((_, queued_at)) = &self.pending_announcement else { + return false; + }; + if queued_at.elapsed() < ANNOUNCEMENT_DELAY { + return true; + } + if let Some((text, _)) = self.pending_announcement.take() { + self.announcement = Some(text.clone()); + adapter.update_if_active(|| { + let announcement = build_announcement(&text); + let root = self.build_root(); + TreeUpdate { + nodes: vec![(ANNOUNCEMENT_ID, announcement), (WINDOW_ID, root)], + tree: None, + tree_id: TreeId::ROOT, + focus: self.focus, + } + }); + } + false } } @@ -225,7 +244,7 @@ impl ApplicationHandler for Application { } Key::Named(winit::keyboard::NamedKey::Space) => { let id = state.focus; - state.press_button(adapter, id); + state.press_button(id); window.window.request_redraw(); } _ => (), @@ -257,7 +276,7 @@ impl ApplicationHandler for Application { state.set_focus(adapter, target_node); } Action::Click => { - state.press_button(adapter, target_node); + state.press_button(target_node); } _ => (), } @@ -279,7 +298,11 @@ impl ApplicationHandler for Application { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_none() { + if let Some(window) = &mut self.window { + if window.ui.flush_announcement(&mut window.adapter) { + event_loop.set_control_flow(ControlFlow::wait_duration(ANNOUNCEMENT_DELAY)); + } + } else { event_loop.exit(); } } From 57ead9f32da61ddf467ba7b6ebfa1e9041cd6026 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 10 Apr 2026 15:43:16 +0200 Subject: [PATCH 12/23] Expand accessibility traits mapping --- consumer/src/node.rs | 4 ++ platforms/ios/src/node.rs | 130 +++++++++++++++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 6538b4d71..10bde0dbe 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -904,6 +904,10 @@ impl<'a> Node<'a> { self.data().is_selected() } + pub fn is_touch_transparent(&self) -> bool { + self.data().is_touch_transparent() + } + pub fn is_item_like(&self) -> bool { matches!( self.role(), diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index d67e7a32e..df8b01e64 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -8,7 +8,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE.chromium file. -use accesskit::{Action, ActionRequest, Rect, Role, Toggled}; +use accesskit::{Action, ActionRequest, Live, Rect, Role, Toggled}; use accesskit_consumer::{FilterResult, Node, NodeId, Tree}; use objc2::{ ClassType, DeclaredClass, declare_class, msg_send_id, @@ -19,10 +19,11 @@ use objc2::{ use objc2_foundation::{CGRect, NSArray, NSObject, NSObjectProtocol, NSString}; use objc2_ui_kit::{ UIAccessibilityContainerType, UIAccessibilityElement, UIAccessibilityScrollDirection, - UIAccessibilityTraitAdjustable, UIAccessibilityTraitButton, UIAccessibilityTraitHeader, - UIAccessibilityTraitImage, UIAccessibilityTraitLink, UIAccessibilityTraitNone, - UIAccessibilityTraitNotEnabled, UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, - UIAccessibilityTraits, + UIAccessibilityTraitAdjustable, UIAccessibilityTraitAllowsDirectInteraction, + UIAccessibilityTraitButton, UIAccessibilityTraitHeader, UIAccessibilityTraitImage, + UIAccessibilityTraitLink, UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, + UIAccessibilityTraitSearchField, UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, + UIAccessibilityTraitToggleButton, UIAccessibilityTraitUpdatesFrequently, UIAccessibilityTraits, }; use std::rc::{Rc, Weak}; @@ -115,14 +116,21 @@ impl NodeWrapper<'_> { fn traits(&self) -> UIAccessibilityTraits { let mut traits = match self.0.role() { - Role::Button | Role::DefaultButton | Role::DisclosureTriangle => unsafe { - UIAccessibilityTraitButton - }, + Role::Button + | Role::DefaultButton + | Role::DisclosureTriangle + | Role::CheckBox + | Role::RadioButton + | Role::Switch + | Role::MenuItemCheckBox + | Role::MenuItemRadio + | Role::Tab => unsafe { UIAccessibilityTraitButton }, Role::Link => unsafe { UIAccessibilityTraitLink }, Role::Image => unsafe { UIAccessibilityTraitImage }, Role::Label => unsafe { UIAccessibilityTraitStaticText }, Role::Heading => unsafe { UIAccessibilityTraitHeader }, Role::Slider | Role::SpinButton => unsafe { UIAccessibilityTraitAdjustable }, + Role::SearchInput => unsafe { UIAccessibilityTraitSearchField }, _ => unsafe { UIAccessibilityTraitNone }, }; @@ -130,10 +138,22 @@ impl NodeWrapper<'_> { traits |= unsafe { UIAccessibilityTraitNotEnabled }; } + if self.0.toggled().is_some() { + traits |= unsafe { UIAccessibilityTraitToggleButton }; + } + if self.0.is_selected() == Some(true) { traits |= unsafe { UIAccessibilityTraitSelected }; } + if self.0.role() == Role::ProgressIndicator || self.0.live() != Live::Off { + traits |= unsafe { UIAccessibilityTraitUpdatesFrequently }; + } + + if self.0.is_touch_transparent() { + traits |= unsafe { UIAccessibilityTraitAllowsDirectInteraction }; + } + traits } @@ -687,6 +707,53 @@ mod tests { assert!(node_traits(&node) & unsafe { UIAccessibilityTraitButton } != 0); } + #[test] + fn traits_checkbox_is_button_and_toggle() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::False); + let t = node_traits(&node); + assert!(t & unsafe { UIAccessibilityTraitButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitToggleButton } != 0); + } + + #[test] + fn traits_switch_is_button_and_toggle() { + let mut node = NodeBuilder::new(Role::Switch); + node.set_toggled(Toggled::True); + let t = node_traits(&node); + assert!(t & unsafe { UIAccessibilityTraitButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitToggleButton } != 0); + } + + #[test] + fn traits_button_mapped_roles() { + for role in [ + Role::RadioButton, + Role::Switch, + Role::MenuItemCheckBox, + Role::MenuItemRadio, + Role::Tab, + ] { + let node = NodeBuilder::new(role); + assert!( + node_traits(&node) & unsafe { UIAccessibilityTraitButton } != 0, + "role {role:?}", + ); + } + } + + #[test] + fn traits_toggled_true_and_mixed_set_toggle_button() { + for toggled in [Toggled::True, Toggled::Mixed] { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(toggled); + assert!( + node_traits(&node) & unsafe { UIAccessibilityTraitToggleButton } != 0, + "toggled {toggled:?}", + ); + } + } + #[test] fn traits_link() { let node = NodeBuilder::new(Role::Link); @@ -723,6 +790,12 @@ mod tests { assert!(node_traits(&node) & unsafe { UIAccessibilityTraitAdjustable } != 0); } + #[test] + fn traits_search_input() { + let node = NodeBuilder::new(Role::SearchInput); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitSearchField } != 0); + } + #[test] fn traits_disabled() { let mut node = NodeBuilder::new(Role::Button); @@ -751,6 +824,9 @@ mod tests { assert!(t & unsafe { UIAccessibilityTraitButton } != 0); assert!(t & unsafe { UIAccessibilityTraitNotEnabled } == 0); assert!(t & unsafe { UIAccessibilityTraitSelected } == 0); + assert!(t & unsafe { UIAccessibilityTraitToggleButton } == 0); + assert!(t & unsafe { UIAccessibilityTraitUpdatesFrequently } == 0); + assert!(t & unsafe { UIAccessibilityTraitAllowsDirectInteraction } == 0); } #[test] @@ -764,6 +840,44 @@ mod tests { assert!(t & unsafe { UIAccessibilityTraitSelected } != 0); } + #[test] + fn traits_live_region() { + let mut node = NodeBuilder::new(Role::Label); + node.set_live(Live::Polite); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitUpdatesFrequently } != 0); + } + + #[test] + fn traits_live_off_not_updating() { + let mut node = NodeBuilder::new(Role::Label); + node.set_live(Live::Off); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitUpdatesFrequently } == 0); + } + + #[test] + fn traits_progress_indicator_updates_frequently() { + let node = NodeBuilder::new(Role::ProgressIndicator); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitUpdatesFrequently } != 0); + } + + #[test] + fn traits_combined() { + let mut node = NodeBuilder::new(Role::CheckBox); + node.set_toggled(Toggled::False); + node.set_disabled(); + let t = node_traits(&node); + assert!(t & unsafe { UIAccessibilityTraitButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitToggleButton } != 0); + assert!(t & unsafe { UIAccessibilityTraitNotEnabled } != 0); + } + + #[test] + fn traits_touch_transparent() { + let mut node = NodeBuilder::new(Role::Image); + node.set_touch_transparent(); + assert!(node_traits(&node) & unsafe { UIAccessibilityTraitAllowsDirectInteraction } != 0); + } + #[test] fn traits_none_for_group() { let node = NodeBuilder::new(Role::Group); From c53775f7e92d6ad44d4601c869207ce442f66258 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 17 Apr 2026 17:02:31 +0200 Subject: [PATCH 13/23] Update the CI --- .github/workflows/ci.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b351b5b9..5a65f4092 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: matrix: include: - target: aarch64-apple-darwin + - target: aarch64-apple-ios - target: aarch64-linux-android - target: i686-pc-windows-gnu - target: i686-pc-windows-msvc @@ -76,6 +77,10 @@ jobs: - os: ubuntu-latest name: Android adapters: "-p accesskit_android" + - os: macOS-latest + name: iOS Simulator + target: "aarch64-apple-ios-sim" + adapters: "-p accesskit_ios" name: cargo clippy (${{ matrix.name }}) steps: - uses: actions/checkout@v6 @@ -84,15 +89,16 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: clippy + targets: ${{ matrix.target }} - name: restore cache uses: Swatinem/rust-cache@v2 - name: cargo clippy (common packages) - run: cargo clippy -p accesskit -p accesskit_consumer -p accesskit_winit --all-targets -- -D warnings + run: cargo clippy -p accesskit -p accesskit_consumer -p accesskit_winit ${{ matrix.target && format('--target {0}', matrix.target) }} --all-targets -- -D warnings - name: cargo clippy (adapters) - run: cargo clippy ${{ matrix.adapters }} --all-targets -- -D warnings + run: cargo clippy ${{ matrix.adapters }} ${{ matrix.target && format('--target {0}', matrix.target) }} --all-targets -- -D warnings - name: cargo clippy (extra adapters) if: ${{ matrix.extra_adapter_clippy }} @@ -126,6 +132,10 @@ jobs: - os: ubuntu-latest name: Android adapters: "-p accesskit_android" + - os: macOS-latest + name: iOS Simulator + target: "aarch64-apple-ios-sim" + adapters: "-p accesskit_ios" name: cargo test (${{ matrix.name }}) steps: - uses: actions/checkout@v6 @@ -134,15 +144,16 @@ jobs: uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.find-msrv.outputs.version }} + targets: ${{ matrix.target }} - name: restore cache uses: Swatinem/rust-cache@v2 - name: cargo test (common packages) - run: cargo test -p accesskit -p accesskit_consumer -p accesskit_winit + run: cargo test -p accesskit -p accesskit_consumer -p accesskit_winit ${{ matrix.target && format('--target {0}', matrix.target) }} - name: cargo test (adapters) - run: cargo test ${{ matrix.adapters }} + run: cargo test ${{ matrix.adapters }} ${{ matrix.target && format('--target {0}', matrix.target) }} check-android-dex: runs-on: ubuntu-latest From db37a3c1bd8df3e5c4fa4cedfac0a547ee281810 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Sun, 19 Apr 2026 20:42:26 +0200 Subject: [PATCH 14/23] Use nested if let for now --- platforms/ios/src/event.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/platforms/ios/src/event.rs b/platforms/ios/src/event.rs index e8361dba9..3820668e7 100644 --- a/platforms/ios/src/event.rs +++ b/platforms/ios/src/event.rs @@ -185,11 +185,11 @@ impl TreeChangeHandler for EventGenerator { return; } self.insert_layout_changed_event_if_needed(); - if let Some(value) = node.value() - && node.live() != Live::Off - { - self.events - .push(QueuedEvent::live_region_announcement(value, node.live())); + if let Some(value) = node.value() { + if node.live() != Live::Off { + self.events + .push(QueuedEvent::live_region_announcement(value, node.live())); + } } } @@ -214,16 +214,17 @@ impl TreeChangeHandler for EventGenerator { } let was_filtered_out = old_filter_result != FilterResult::Include; - if let Some(value) = new_node.value() - && new_node.live() != Live::Off - && (Some(&value) != old_node.value().as_ref() - || new_node.live() != old_node.live() - || was_filtered_out) - { - self.events.push(QueuedEvent::live_region_announcement( - value, - new_node.live(), - )); + if let Some(value) = new_node.value() { + if new_node.live() != Live::Off + && (Some(&value) != old_node.value().as_ref() + || new_node.live() != old_node.live() + || was_filtered_out) + { + self.events.push(QueuedEvent::live_region_announcement( + value, + new_node.live(), + )); + } } } From 261bd66d4734ed9cfe9f76ea0a7fc0a630ebdef9 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Mon, 20 Apr 2026 19:21:53 +0200 Subject: [PATCH 15/23] Document VoiceOver peculiarity in the examples --- platforms/winit/examples/mixed_handlers.rs | 2 ++ platforms/winit/examples/simple.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/platforms/winit/examples/mixed_handlers.rs b/platforms/winit/examples/mixed_handlers.rs index 327b7dcde..493192317 100644 --- a/platforms/winit/examples/mixed_handlers.rs +++ b/platforms/winit/examples/mixed_handlers.rs @@ -138,6 +138,8 @@ impl UiState { } else { "You pressed button 2" }; + // On iOS, VoiceOver announces the label of the activated button. + // Postpone the live region update so the messages don't overlap. self.pending_announcement = Some((text.into(), Instant::now())); } diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index 396c4a131..ea9e76175 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -132,6 +132,8 @@ impl UiState { } else { "You pressed button 2" }; + // On iOS, VoiceOver announces the label of the activated button. + // Postpone the live region update so the messages don't overlap. self.pending_announcement = Some((text.into(), Instant::now())); } From dbcafb00cc3fe8d51f37fa20eee16470cb81793f Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Mon, 20 Apr 2026 20:07:20 +0200 Subject: [PATCH 16/23] Clear platform_focus when backing node is destroyed --- platforms/ios/src/event.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/platforms/ios/src/event.rs b/platforms/ios/src/event.rs index 3820668e7..1f9945efb 100644 --- a/platforms/ios/src/event.rs +++ b/platforms/ios/src/event.rs @@ -132,21 +132,24 @@ pub(crate) fn screen_changed_event(node_id: Option) -> QueuedEvent { pub(crate) struct EventGenerator { context: Rc, + platform_focus: Option, layout_event_focus: Option>, events: Vec, } impl EventGenerator { pub(crate) fn new(context: Rc) -> Self { + let platform_focus = *context.platform_focus.borrow(); Self { context, + platform_focus, layout_event_focus: None, events: Vec::new(), } } fn insert_focus_moved_event_if_needed(&mut self, focus: NodeId) { - if *self.context.platform_focus.borrow() != Some(focus) { + if self.platform_focus != Some(focus) { self.layout_event_focus = Some(Some(focus)); } } @@ -157,6 +160,15 @@ impl EventGenerator { } } + fn remove_node(&mut self, id: NodeId) { + self.insert_layout_changed_event_if_needed(); + if self.platform_focus == Some(id) { + *self.context.platform_focus.borrow_mut() = None; + self.platform_focus = None; + } + self.events.push(QueuedEvent::NodeDestroyed(id)); + } + fn remove_subtree(&mut self, node: &Node) { let mut to_remove = VecDeque::new(); to_remove.push_back(*node); @@ -166,7 +178,7 @@ impl EventGenerator { to_remove.push_back(child); } - self.events.push(QueuedEvent::NodeDestroyed(node.id())); + self.remove_node(node.id()); } } @@ -200,11 +212,10 @@ impl TreeChangeHandler for EventGenerator { let new_included = new_filter_result == FilterResult::Include; if old_included && !new_included { - self.insert_layout_changed_event_if_needed(); if new_filter_result == FilterResult::ExcludeSubtree { self.remove_subtree(old_node); } else { - self.events.push(QueuedEvent::NodeDestroyed(new_node.id())); + self.remove_node(new_node.id()); } return; } @@ -241,7 +252,6 @@ impl TreeChangeHandler for EventGenerator { if filter(node) != FilterResult::Include { return; } - self.insert_layout_changed_event_if_needed(); - self.events.push(QueuedEvent::NodeDestroyed(node.id())); + self.remove_node(node.id()); } } From 73e4d061962cf0b44cc0cced6b7dfe0b67d55d9d Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Wed, 22 Apr 2026 21:54:09 +0200 Subject: [PATCH 17/23] Fix coordinate convertions --- platforms/ios/Cargo.toml | 1 + platforms/ios/src/adapter.rs | 4 +++- platforms/ios/src/node.rs | 7 ++----- platforms/ios/src/util.rs | 16 +++++++++++----- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index f97b14b4b..3e0500be4 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -35,6 +35,7 @@ objc2-ui-kit = { version = "0.2.0", features = [ "UIAccessibilityIdentification", "UIGeometry", "UIResponder", + "UIScreen", "UIView", "UIViewController", "UIWindow", diff --git a/platforms/ios/src/adapter.rs b/platforms/ios/src/adapter.rs index 6ddab0246..91d5d1b07 100644 --- a/platforms/ios/src/adapter.rs +++ b/platforms/ios/src/adapter.rs @@ -284,7 +284,9 @@ impl Adapter { let tree = context.tree.borrow(); let state = tree.state(); let root = state.root(); - let point = from_cg_point(&view, &root, point); + let Some(point) = from_cg_point(&view, &root, point) else { + return null_mut(); + }; let node = root.node_at_point(point, &filter).unwrap_or(root); match context.get_or_create_platform_node(node.id()) { Some(platform_node) => Retained::autorelease_return(platform_node) as *mut _, diff --git a/platforms/ios/src/node.rs b/platforms/ios/src/node.rs index df8b01e64..c7e317f1f 100644 --- a/platforms/ios/src/node.rs +++ b/platforms/ios/src/node.rs @@ -30,7 +30,7 @@ use std::rc::{Rc, Weak}; use crate::{ context::Context, filters::{filter, filter_for_is_accessibility_element}, - util::{UIAccessibilityExpandedStatus, to_cg_rect}, + util::{UIAccessibilityExpandedStatus, to_cg_rect, to_screen_rect}, }; #[derive(Debug, PartialEq)] @@ -265,10 +265,7 @@ declare_class!( let view = context.view.load()?; Some(match NodeWrapper(node).frame_source() { FrameSource::Rect(rect) => to_cg_rect(&view, rect), - FrameSource::ViewBounds => { - let bounds = view.bounds(); - unsafe { view.convertRect_toView(bounds, None) } - } + FrameSource::ViewBounds => to_screen_rect(&view, view.bounds()), FrameSource::Zero => CGRect::ZERO, }) }) diff --git a/platforms/ios/src/util.rs b/platforms/ios/src/util.rs index e49a6d2b7..6d917ae06 100644 --- a/platforms/ios/src/util.rs +++ b/platforms/ios/src/util.rs @@ -7,7 +7,7 @@ use accesskit::Point; use accesskit_consumer::Node; use objc2::encode::{Encode, Encoding, RefEncode}; use objc2_foundation::{CGPoint, CGRect, CGSize, NSInteger}; -use objc2_ui_kit::UIView; +use objc2_ui_kit::{UIAccessibilityConvertFrameToScreenCoordinates, UICoordinateSpace, UIView}; // TODO: Remove once we update to objc2 0.6 #[repr(transparent)] @@ -29,15 +29,21 @@ unsafe impl RefEncode for UIAccessibilityExpandedStatus { const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING); } -pub(crate) fn from_cg_point(view: &UIView, node: &Node, point: CGPoint) -> Point { - let local_point = unsafe { view.convertPoint_fromView(point, None) }; +pub(crate) fn from_cg_point(view: &UIView, node: &Node, point: CGPoint) -> Option { + let window = view.window()?; + let screen_space = window.screen().coordinateSpace(); + let local_point = view.convertPoint_fromCoordinateSpace(point, &screen_space); let insets = view.safeAreaInsets(); let factor = view.contentScaleFactor(); let point = Point::new( (local_point.x - insets.left) * factor, (local_point.y - insets.top) * factor, ); - node.transform().inverse() * point + Some(node.transform().inverse() * point) +} + +pub(crate) fn to_screen_rect(view: &UIView, rect: CGRect) -> CGRect { + unsafe { UIAccessibilityConvertFrameToScreenCoordinates(rect, view) } } pub(crate) fn to_cg_rect(view: &UIView, rect: accesskit::Rect) -> CGRect { @@ -53,5 +59,5 @@ pub(crate) fn to_cg_rect(view: &UIView, rect: accesskit::Rect) -> CGRect { height: rect.height() / factor, }, }; - unsafe { view.convertRect_toView(local_rect, None) } + to_screen_rect(view, local_rect) } From ef279956aa1762939d8ef0fd6bdda356ed358062 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Wed, 22 Apr 2026 23:11:08 +0200 Subject: [PATCH 18/23] Don't automatically compute the safe area --- platforms/ios/src/util.rs | 11 ++----- platforms/winit/examples/mixed_handlers.rs | 35 ++++++++++++++++++--- platforms/winit/examples/simple.rs | 36 ++++++++++++++++++---- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/platforms/ios/src/util.rs b/platforms/ios/src/util.rs index 6d917ae06..67ee07aa1 100644 --- a/platforms/ios/src/util.rs +++ b/platforms/ios/src/util.rs @@ -33,12 +33,8 @@ pub(crate) fn from_cg_point(view: &UIView, node: &Node, point: CGPoint) -> Optio let window = view.window()?; let screen_space = window.screen().coordinateSpace(); let local_point = view.convertPoint_fromCoordinateSpace(point, &screen_space); - let insets = view.safeAreaInsets(); let factor = view.contentScaleFactor(); - let point = Point::new( - (local_point.x - insets.left) * factor, - (local_point.y - insets.top) * factor, - ); + let point = Point::new(local_point.x * factor, local_point.y * factor); Some(node.transform().inverse() * point) } @@ -47,12 +43,11 @@ pub(crate) fn to_screen_rect(view: &UIView, rect: CGRect) -> CGRect { } pub(crate) fn to_cg_rect(view: &UIView, rect: accesskit::Rect) -> CGRect { - let insets = view.safeAreaInsets(); let factor = view.contentScaleFactor(); let local_rect = CGRect { origin: CGPoint { - x: rect.x0 / factor + insets.left, - y: rect.y0 / factor + insets.top, + x: rect.x0 / factor, + y: rect.y0 / factor, }, size: CGSize { width: rect.width() / factor, diff --git a/platforms/winit/examples/mixed_handlers.rs b/platforms/winit/examples/mixed_handlers.rs index 493192317..eefeab868 100644 --- a/platforms/winit/examples/mixed_handlers.rs +++ b/platforms/winit/examples/mixed_handlers.rs @@ -49,13 +49,34 @@ const BUTTON_2_RECT: Rect = Rect { y1: 128.0, }; -fn build_button(id: NodeId, label: &str) -> Node { +#[cfg(target_os = "ios")] +fn safe_area_inset(window: &Window) -> (f64, f64) { + let Ok(outer) = window.outer_position() else { + return (0.0, 0.0); + }; + let Ok(inner) = window.inner_position() else { + return (0.0, 0.0); + }; + ((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) +} + +#[cfg(not(target_os = "ios"))] +fn safe_area_inset(_: &Window) -> (f64, f64) { + (0.0, 0.0) +} + +fn build_button(id: NodeId, label: &str, inset: (f64, f64)) -> Node { let rect = match id { BUTTON_1_ID => BUTTON_1_RECT, BUTTON_2_ID => BUTTON_2_RECT, _ => unreachable!(), }; - + let rect = Rect { + x0: rect.x0 + inset.0, + y0: rect.y0 + inset.1, + x1: rect.x1 + inset.0, + y1: rect.y1 + inset.1, + }; let mut node = Node::new(Role::Button); node.set_bounds(rect); node.set_label(label); @@ -77,6 +98,7 @@ struct UiState { focus: NodeId, announcement: Option, pending_announcement: Option<(String, Instant)>, + safe_area_inset: (f64, f64), } impl UiState { @@ -85,6 +107,7 @@ impl UiState { focus: INITIAL_FOCUS, announcement: None, pending_announcement: None, + safe_area_inset: (0.0, 0.0), })) } @@ -101,8 +124,8 @@ impl UiState { fn build_initial_tree(&mut self) -> TreeUpdate { let root = self.build_root(); - let button_1 = build_button(BUTTON_1_ID, "Button 1"); - let button_2 = build_button(BUTTON_2_ID, "Button 2"); + let button_1 = build_button(BUTTON_1_ID, "Button 1", self.safe_area_inset); + let button_2 = build_button(BUTTON_2_ID, "Button 2", self.safe_area_inset); let tree = Tree::new(WINDOW_ID); let mut result = TreeUpdate { nodes: vec![ @@ -245,6 +268,10 @@ impl ApplicationHandler for Application { self.window = None; } WindowEvent::Resized(_) => { + let inset = safe_area_inset(&window.window); + let mut state = state.lock().unwrap(); + state.safe_area_inset = inset; + adapter.update_if_active(|| state.build_initial_tree()); window.window.request_redraw(); } WindowEvent::RedrawRequested => { diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index ea9e76175..f36eb2b91 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -43,13 +43,34 @@ const BUTTON_2_RECT: Rect = Rect { y1: 128.0, }; -fn build_button(id: NodeId, label: &str) -> Node { +#[cfg(target_os = "ios")] +fn safe_area_inset(window: &Window) -> (f64, f64) { + let Ok(outer) = window.outer_position() else { + return (0.0, 0.0); + }; + let Ok(inner) = window.inner_position() else { + return (0.0, 0.0); + }; + ((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) +} + +#[cfg(not(target_os = "ios"))] +fn safe_area_inset(_: &Window) -> (f64, f64) { + (0.0, 0.0) +} + +fn build_button(id: NodeId, label: &str, inset: (f64, f64)) -> Node { let rect = match id { BUTTON_1_ID => BUTTON_1_RECT, BUTTON_2_ID => BUTTON_2_RECT, _ => unreachable!(), }; - + let rect = Rect { + x0: rect.x0 + inset.0, + y0: rect.y0 + inset.1, + x1: rect.x1 + inset.0, + y1: rect.y1 + inset.1, + }; let mut node = Node::new(Role::Button); node.set_bounds(rect); node.set_label(label); @@ -93,10 +114,10 @@ impl UiState { node } - fn build_initial_tree(&mut self) -> TreeUpdate { + fn build_initial_tree(&mut self, inset: (f64, f64)) -> TreeUpdate { let root = self.build_root(); - let button_1 = build_button(BUTTON_1_ID, "Button 1"); - let button_2 = build_button(BUTTON_2_ID, "Button 2"); + let button_1 = build_button(BUTTON_1_ID, "Button 1", inset); + let button_2 = build_button(BUTTON_2_ID, "Button 2", inset); let tree = Tree::new(WINDOW_ID); let mut result = TreeUpdate { nodes: vec![ @@ -221,6 +242,8 @@ impl ApplicationHandler for Application { self.window = None; } WindowEvent::Resized(_) => { + let inset = safe_area_inset(&window.window); + adapter.update_if_active(|| state.build_initial_tree(inset)); window.window.request_redraw(); } WindowEvent::RedrawRequested => { @@ -265,7 +288,8 @@ impl ApplicationHandler for Application { match user_event.window_event { AccessKitWindowEvent::InitialTreeRequested => { - adapter.update_if_active(|| state.build_initial_tree()); + let inset = safe_area_inset(&window.window); + adapter.update_if_active(|| state.build_initial_tree(inset)); } AccessKitWindowEvent::ActionRequested(ActionRequest { action, From 16481bca15fc776e94ccab4c02f2339287b69707 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Thu, 23 Apr 2026 20:05:02 +0200 Subject: [PATCH 19/23] Optimize inset computation in simple example --- platforms/winit/examples/simple.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index f36eb2b91..9c67087b7 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -92,6 +92,7 @@ struct UiState { focus: NodeId, announcement: Option, pending_announcement: Option<(String, Instant)>, + inset: (f64, f64), } impl UiState { @@ -100,6 +101,7 @@ impl UiState { focus: INITIAL_FOCUS, announcement: None, pending_announcement: None, + inset: (0.0, 0.0), } } @@ -114,10 +116,10 @@ impl UiState { node } - fn build_initial_tree(&mut self, inset: (f64, f64)) -> TreeUpdate { + fn build_initial_tree(&mut self) -> TreeUpdate { let root = self.build_root(); - let button_1 = build_button(BUTTON_1_ID, "Button 1", inset); - let button_2 = build_button(BUTTON_2_ID, "Button 2", inset); + let button_1 = build_button(BUTTON_1_ID, "Button 1", self.inset); + let button_2 = build_button(BUTTON_2_ID, "Button 2", self.inset); let tree = Tree::new(WINDOW_ID); let mut result = TreeUpdate { nodes: vec![ @@ -242,8 +244,8 @@ impl ApplicationHandler for Application { self.window = None; } WindowEvent::Resized(_) => { - let inset = safe_area_inset(&window.window); - adapter.update_if_active(|| state.build_initial_tree(inset)); + state.inset = safe_area_inset(&window.window); + adapter.update_if_active(|| state.build_initial_tree()); window.window.request_redraw(); } WindowEvent::RedrawRequested => { @@ -288,8 +290,7 @@ impl ApplicationHandler for Application { match user_event.window_event { AccessKitWindowEvent::InitialTreeRequested => { - let inset = safe_area_inset(&window.window); - adapter.update_if_active(|| state.build_initial_tree(inset)); + adapter.update_if_active(|| state.build_initial_tree()); } AccessKitWindowEvent::ActionRequested(ActionRequest { action, From ab5d535c0cee1d5317100b4740cf140c6f951db3 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Thu, 23 Apr 2026 20:06:30 +0200 Subject: [PATCH 20/23] Don't enable the iOS adapter on targets not yet supported by winit --- platforms/winit/Cargo.toml | 6 +++--- platforms/winit/examples/apple/build_with_cargo.bash | 8 -------- platforms/winit/src/platform_impl/mod.rs | 6 ++---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/platforms/winit/Cargo.toml b/platforms/winit/Cargo.toml index c40e33287..984264b58 100644 --- a/platforms/winit/Cargo.toml +++ b/platforms/winit/Cargo.toml @@ -36,7 +36,7 @@ accesskit_unix = { version = "0.21.0", path = "../unix", optional = true, defaul [target.'cfg(target_os = "android")'.dependencies] accesskit_android = { version = "0.7.2", path = "../android", optional = true, features = ["embedded-dex"] } -[target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))'.dependencies] +[target.'cfg(target_os = "ios")'.dependencies] accesskit_ios = { version = "0.1.0", path = "../ios" } [dev-dependencies.winit] @@ -44,7 +44,7 @@ version = "0.30.5" default-features = false features = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] -[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "tvos", target_os = "visionos")))'.dev-dependencies.softbuffer] +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dev-dependencies.softbuffer] version = "0.4.8" default-features = false features = [ @@ -54,6 +54,6 @@ features = [ "wayland-dlopen", ] -[target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))'.dev-dependencies.softbuffer] +[target.'cfg(target_os = "ios")'.dev-dependencies.softbuffer] version = "0.4.8" default-features = false diff --git a/platforms/winit/examples/apple/build_with_cargo.bash b/platforms/winit/examples/apple/build_with_cargo.bash index 1905e2fb9..4b1f06f74 100755 --- a/platforms/winit/examples/apple/build_with_cargo.bash +++ b/platforms/winit/examples/apple/build_with_cargo.bash @@ -18,10 +18,6 @@ export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo" case "$PLATFORM_NAME" in iphoneos) CARGO_OS=ios; BUILD_KIND=device ;; iphonesimulator) CARGO_OS=ios; BUILD_KIND=simulator ;; - appletvos) CARGO_OS=tvos; BUILD_KIND=device ;; - appletvsimulator) CARGO_OS=tvos; BUILD_KIND=simulator ;; - xros) CARGO_OS=visionos; BUILD_KIND=device ;; - xrsimulator) CARGO_OS=visionos; BUILD_KIND=simulator ;; macosx) if [[ "${IS_MACCATALYST:-NO}" != "YES" ]]; then echo "non-Catalyst macOS builds are not supported" >&2 @@ -59,10 +55,6 @@ for arch in $ARCHS; do ;; simulator) if [[ "$RUST_ARCH" = "x86_64" ]]; then - if [[ "$CARGO_OS" = "visionos" ]]; then - echo "x86_64 is not supported for visionOS" >&2 - exit 1 - fi # Rust names the x86_64 simulator target without a -sim suffix, # but clang still needs the -simulator triple. CARGO_TARGET="${RUST_ARCH}-apple-${CARGO_OS}" diff --git a/platforms/winit/src/platform_impl/mod.rs b/platforms/winit/src/platform_impl/mod.rs index 297ecc17e..a926ee563 100644 --- a/platforms/winit/src/platform_impl/mod.rs +++ b/platforms/winit/src/platform_impl/mod.rs @@ -31,7 +31,7 @@ mod platform; #[path = "android.rs"] mod platform; -#[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] +#[cfg(target_os = "ios")] #[path = "ios.rs"] mod platform; @@ -49,9 +49,7 @@ mod platform; ) ), all(feature = "accesskit_android", target_os = "android"), - target_os = "ios", - target_os = "tvos", - target_os = "visionos" + target_os = "ios" )))] #[path = "null.rs"] mod platform; From 24e8c92ef8e83d7552bfb884344ccde7fe9c43f4 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 24 Apr 2026 11:48:35 +0200 Subject: [PATCH 21/23] Set transform on root and apply scale factor in examples --- platforms/winit/examples/mixed_handlers.rs | 43 +++++++++++----------- platforms/winit/examples/simple.rs | 41 +++++++++++---------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/platforms/winit/examples/mixed_handlers.rs b/platforms/winit/examples/mixed_handlers.rs index eefeab868..2f242a7bc 100644 --- a/platforms/winit/examples/mixed_handlers.rs +++ b/platforms/winit/examples/mixed_handlers.rs @@ -2,8 +2,8 @@ mod fill; use accesskit::{ - Action, ActionRequest, ActivationHandler, Live, Node, NodeId, Rect, Role, Tree, TreeId, - TreeUpdate, + Action, ActionRequest, ActivationHandler, Affine, Live, Node, NodeId, Rect, Role, Tree, TreeId, + TreeUpdate, Vec2, }; use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; use std::{ @@ -50,33 +50,27 @@ const BUTTON_2_RECT: Rect = Rect { }; #[cfg(target_os = "ios")] -fn safe_area_inset(window: &Window) -> (f64, f64) { +fn safe_area_inset(window: &Window) -> Vec2 { let Ok(outer) = window.outer_position() else { - return (0.0, 0.0); + return Vec2::ZERO; }; let Ok(inner) = window.inner_position() else { - return (0.0, 0.0); + return Vec2::ZERO; }; - ((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) + Vec2::new((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) } #[cfg(not(target_os = "ios"))] -fn safe_area_inset(_: &Window) -> (f64, f64) { - (0.0, 0.0) +fn safe_area_inset(_: &Window) -> Vec2 { + Vec2::ZERO } -fn build_button(id: NodeId, label: &str, inset: (f64, f64)) -> Node { +fn build_button(id: NodeId, label: &str) -> Node { let rect = match id { BUTTON_1_ID => BUTTON_1_RECT, BUTTON_2_ID => BUTTON_2_RECT, _ => unreachable!(), }; - let rect = Rect { - x0: rect.x0 + inset.0, - y0: rect.y0 + inset.1, - x1: rect.x1 + inset.0, - y1: rect.y1 + inset.1, - }; let mut node = Node::new(Role::Button); node.set_bounds(rect); node.set_label(label); @@ -98,22 +92,27 @@ struct UiState { focus: NodeId, announcement: Option, pending_announcement: Option<(String, Instant)>, - safe_area_inset: (f64, f64), + scale_factor: f64, + safe_area_inset: Vec2, } impl UiState { - fn new() -> Arc> { + fn new(scale_factor: f64, safe_area_inset: Vec2) -> Arc> { Arc::new(Mutex::new(Self { focus: INITIAL_FOCUS, announcement: None, pending_announcement: None, - safe_area_inset: (0.0, 0.0), + scale_factor, + safe_area_inset, })) } fn build_root(&mut self) -> Node { let mut node = Node::new(Role::Window); node.set_bounds(WINDOW_RECT); + node.set_transform( + Affine::translate(self.safe_area_inset) * Affine::scale(self.scale_factor), + ); node.set_children(vec![BUTTON_1_ID, BUTTON_2_ID]); if self.announcement.is_some() { node.push_child(ANNOUNCEMENT_ID); @@ -124,8 +123,8 @@ impl UiState { fn build_initial_tree(&mut self) -> TreeUpdate { let root = self.build_root(); - let button_1 = build_button(BUTTON_1_ID, "Button 1", self.safe_area_inset); - let button_2 = build_button(BUTTON_2_ID, "Button 2", self.safe_area_inset); + let button_1 = build_button(BUTTON_1_ID, "Button 1"); + let button_2 = build_button(BUTTON_2_ID, "Button 2"); let tree = Tree::new(WINDOW_ID); let mut result = TreeUpdate { nodes: vec![ @@ -235,7 +234,7 @@ impl Application { .with_visible(false); let window = event_loop.create_window(window_attributes)?; - let ui = UiState::new(); + let ui = UiState::new(window.scale_factor(), safe_area_inset(&window)); let activation_handler = TearoffActivationHandler { state: Arc::clone(&ui), }; @@ -268,8 +267,10 @@ impl ApplicationHandler for Application { self.window = None; } WindowEvent::Resized(_) => { + let factor = window.window.scale_factor(); let inset = safe_area_inset(&window.window); let mut state = state.lock().unwrap(); + state.scale_factor = factor; state.safe_area_inset = inset; adapter.update_if_active(|| state.build_initial_tree()); window.window.request_redraw(); diff --git a/platforms/winit/examples/simple.rs b/platforms/winit/examples/simple.rs index 9c67087b7..d5132ca59 100644 --- a/platforms/winit/examples/simple.rs +++ b/platforms/winit/examples/simple.rs @@ -1,7 +1,9 @@ #[path = "util/fill.rs"] mod fill; -use accesskit::{Action, ActionRequest, Live, Node, NodeId, Rect, Role, Tree, TreeId, TreeUpdate}; +use accesskit::{ + Action, ActionRequest, Affine, Live, Node, NodeId, Rect, Role, Tree, TreeId, TreeUpdate, Vec2, +}; use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; use std::error::Error; use std::time::{Duration, Instant}; @@ -44,33 +46,27 @@ const BUTTON_2_RECT: Rect = Rect { }; #[cfg(target_os = "ios")] -fn safe_area_inset(window: &Window) -> (f64, f64) { +fn safe_area_inset(window: &Window) -> Vec2 { let Ok(outer) = window.outer_position() else { - return (0.0, 0.0); + return Vec2::ZERO; }; let Ok(inner) = window.inner_position() else { - return (0.0, 0.0); + return Vec2::ZERO; }; - ((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) + Vec2::new((inner.x - outer.x) as f64, (inner.y - outer.y) as f64) } #[cfg(not(target_os = "ios"))] -fn safe_area_inset(_: &Window) -> (f64, f64) { - (0.0, 0.0) +fn safe_area_inset(_: &Window) -> Vec2 { + Vec2::ZERO } -fn build_button(id: NodeId, label: &str, inset: (f64, f64)) -> Node { +fn build_button(id: NodeId, label: &str) -> Node { let rect = match id { BUTTON_1_ID => BUTTON_1_RECT, BUTTON_2_ID => BUTTON_2_RECT, _ => unreachable!(), }; - let rect = Rect { - x0: rect.x0 + inset.0, - y0: rect.y0 + inset.1, - x1: rect.x1 + inset.0, - y1: rect.y1 + inset.1, - }; let mut node = Node::new(Role::Button); node.set_bounds(rect); node.set_label(label); @@ -92,22 +88,25 @@ struct UiState { focus: NodeId, announcement: Option, pending_announcement: Option<(String, Instant)>, - inset: (f64, f64), + scale_factor: f64, + inset: Vec2, } impl UiState { - fn new() -> Self { + fn new(scale_factor: f64, inset: Vec2) -> Self { Self { focus: INITIAL_FOCUS, announcement: None, pending_announcement: None, - inset: (0.0, 0.0), + scale_factor, + inset, } } fn build_root(&mut self) -> Node { let mut node = Node::new(Role::Window); node.set_bounds(WINDOW_RECT); + node.set_transform(Affine::translate(self.inset) * Affine::scale(self.scale_factor)); node.set_children(vec![BUTTON_1_ID, BUTTON_2_ID]); if self.announcement.is_some() { node.push_child(ANNOUNCEMENT_ID); @@ -118,8 +117,8 @@ impl UiState { fn build_initial_tree(&mut self) -> TreeUpdate { let root = self.build_root(); - let button_1 = build_button(BUTTON_1_ID, "Button 1", self.inset); - let button_2 = build_button(BUTTON_2_ID, "Button 2", self.inset); + let button_1 = build_button(BUTTON_1_ID, "Button 1"); + let button_2 = build_button(BUTTON_2_ID, "Button 2"); let tree = Tree::new(WINDOW_ID); let mut result = TreeUpdate { nodes: vec![ @@ -219,11 +218,12 @@ impl Application { .with_visible(false); let window = event_loop.create_window(window_attributes)?; + let ui = UiState::new(window.scale_factor(), safe_area_inset(&window)); let adapter = Adapter::with_event_loop_proxy(event_loop, &window, self.event_loop_proxy.clone()); window.set_visible(true); - self.window = Some(WindowState::new(window, adapter, UiState::new())); + self.window = Some(WindowState::new(window, adapter, ui)); Ok(()) } } @@ -244,6 +244,7 @@ impl ApplicationHandler for Application { self.window = None; } WindowEvent::Resized(_) => { + state.scale_factor = window.window.scale_factor(); state.inset = safe_area_inset(&window.window); adapter.update_if_active(|| state.build_initial_tree()); window.window.request_redraw(); From dc69001fcbb562076a6ba5214017af60ac39e0d4 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 24 Apr 2026 12:19:21 +0200 Subject: [PATCH 22/23] Reduce startup delay by activating if ATs are enabled --- platforms/ios/Cargo.toml | 1 + platforms/ios/src/adapter.rs | 144 ++++++++++++++++++++++++++++++++-- platforms/ios/src/subclass.rs | 53 ++++++++++++- 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml index 3e0500be4..396491d52 100644 --- a/platforms/ios/Cargo.toml +++ b/platforms/ios/Cargo.toml @@ -23,6 +23,7 @@ objc2-foundation = { version = "0.2.0", features = [ "NSArray", "NSAttributedString", "NSDictionary", + "NSNotification", "NSString", "NSValue", "NSThread", diff --git a/platforms/ios/src/adapter.rs b/platforms/ios/src/adapter.rs index 91d5d1b07..5cca5895d 100644 --- a/platforms/ios/src/adapter.rs +++ b/platforms/ios/src/adapter.rs @@ -13,17 +13,36 @@ use accesskit::{ Tree as TreeData, TreeId, TreeUpdate, }; use accesskit_consumer::{FilterResult, Tree}; -use objc2::rc::{Retained, WeakId}; -use objc2_foundation::{CGPoint, MainThreadMarker, NSArray, NSObject}; +use objc2::{ + ClassType, DeclaredClass, declare_class, msg_send_id, + mutability::MainThreadOnly, + rc::{Retained, WeakId}, + runtime::AnyObject, + sel, +}; +use objc2_foundation::{ + CGPoint, MainThreadMarker, NSArray, NSNotification, NSNotificationCenter, NSNotificationName, + NSObject, +}; use objc2_ui_kit::{ - UIAccessibilityPostNotification, UIAccessibilityScreenChangedNotification, UIView, + UIAccessibilityBoldTextStatusDidChangeNotification, + UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + UIAccessibilityInvertColorsStatusDidChangeNotification, UIAccessibilityIsSpeakScreenEnabled, + UIAccessibilityIsSwitchControlRunning, UIAccessibilityIsVoiceOverRunning, + UIAccessibilityOnOffSwitchLabelsDidChangeNotification, UIAccessibilityPostNotification, + UIAccessibilityReduceMotionStatusDidChangeNotification, + UIAccessibilityScreenChangedNotification, + UIAccessibilitySpeakScreenStatusDidChangeNotification, + UIAccessibilitySwitchControlStatusDidChangeNotification, + UIAccessibilityVideoAutoplayStatusDidChangeNotification, + UIAccessibilityVoiceOverStatusDidChangeNotification, UIView, }; use std::fmt::{Debug, Formatter}; use std::{ffi::c_void, ptr::null_mut, rc::Rc}; use crate::{ context::{ActionHandlerNoMut, ActionHandlerWrapper, Context}, - event::{EventGenerator, QueuedEvents, screen_changed_event}, + event::{EventGenerator, QueuedEvents, layout_event, screen_changed_event}, filters::filter, node::PlatformNode, util::from_cg_point, @@ -74,6 +93,71 @@ impl ActionHandler for PlaceholderActionHandler { fn do_action(&mut self, _request: ActionRequest) {} } +fn any_assistive_tech_running() -> bool { + unsafe { + UIAccessibilityIsVoiceOverRunning().as_bool() + || UIAccessibilityIsSwitchControlRunning().as_bool() + || UIAccessibilityIsSpeakScreenEnabled().as_bool() + } +} + +fn observed_notification_names() -> [&'static NSNotificationName; 9] { + unsafe { + [ + UIAccessibilityVoiceOverStatusDidChangeNotification, + UIAccessibilitySwitchControlStatusDidChangeNotification, + UIAccessibilitySpeakScreenStatusDidChangeNotification, + UIAccessibilityInvertColorsStatusDidChangeNotification, + UIAccessibilityReduceMotionStatusDidChangeNotification, + UIAccessibilityBoldTextStatusDidChangeNotification, + UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + UIAccessibilityOnOffSwitchLabelsDidChangeNotification, + UIAccessibilityVideoAutoplayStatusDidChangeNotification, + ] + } +} + +struct StatusObserverIvars { + view: WeakId, +} + +declare_class!( + #[derive(Debug)] + struct StatusObserver; + + unsafe impl ClassType for StatusObserver { + type Super = NSObject; + type Mutability = MainThreadOnly; + const NAME: &'static str = "AccessKitAccessibilityStatusObserver"; + } + + impl DeclaredClass for StatusObserver { + type Ivars = StatusObserverIvars; + } + + unsafe impl StatusObserver { + #[method(accessibilityStatusChanged:)] + fn accessibility_status_changed(&self, _notification: &NSNotification) { + if !any_assistive_tech_running() { + return; + } + if self.ivars().view.load().is_none() { + return; + } + unsafe { + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, None); + } + } + } +); + +impl StatusObserver { + fn new(view: WeakId, mtm: MainThreadMarker) -> Retained { + let this = mtm.alloc::().set_ivars(StatusObserverIvars { view }); + unsafe { msg_send_id![super(this), init] } + } +} + /// An AccessKit adapter for an owned `UIView`. /// /// The adapter bridges an AccessKit tree to UIKit's informal accessibility @@ -94,13 +178,16 @@ impl ActionHandler for PlaceholderActionHandler { /// [`Adapter::accessibility_elements`]. /// 4. Override `accessibilityHitTest:` to return the result of /// [`Adapter::hit_test`]. -/// 5. Whenever the application's accessibility tree changes, call +/// 5. In `viewDidAppear:`, call [`Adapter::view_did_appear`] and raise +/// the returned events. +/// 6. Whenever the application's accessibility tree changes, call /// [`Adapter::update_if_active`] and raise the returned events. /// /// All adapter methods must be called on the main thread. #[derive(Debug)] pub struct Adapter { state: State, + status_observer: Retained, } impl Adapter { @@ -116,12 +203,29 @@ impl Adapter { let view = unsafe { Retained::retain(view as *mut UIView) }.unwrap(); let view = WeakId::from_retained(&view); let mtm = MainThreadMarker::new().unwrap(); + + let status_observer = StatusObserver::new(view.clone(), mtm); + let center = unsafe { NSNotificationCenter::defaultCenter() }; + for name in observed_notification_names() { + unsafe { + center.addObserver_selector_name_object( + status_observer.as_ref().as_ref(), + sel!(accessibilityStatusChanged:), + Some(name), + None, + ); + } + } + let state = State::Inactive { view, action_handler: Rc::new(ActionHandlerWrapper::new(action_handler)), mtm, }; - Self { state } + Self { + state, + status_observer, + } } /// If and only if the tree has been initialized, call the provided function @@ -166,6 +270,29 @@ impl Adapter { } } + /// Called when the host view has just appeared on screen. If an assistive + /// technology is running, this proactively builds the accessibility tree. + /// + /// If a [`QueuedEvents`] instance is returned, the caller must call + /// [`QueuedEvents::raise`] on it. + pub fn view_did_appear( + &mut self, + activation_handler: &mut H, + ) -> Option { + if !any_assistive_tech_running() { + return None; + } + if !matches!(self.state, State::Inactive { .. }) { + return None; + } + let context = self.get_or_init_context(activation_handler); + if !matches!(self.state, State::Active(_)) { + return None; + } + let focus_id = context.tree.borrow().state().focus().map(|node| node.id()); + focus_id.map(|id| QueuedEvents::new(context, vec![layout_event(Some(id))])) + } + fn get_or_init_context( &mut self, activation_handler: &mut H, @@ -297,6 +424,11 @@ impl Adapter { impl Drop for Adapter { fn drop(&mut self) { + let center = unsafe { NSNotificationCenter::defaultCenter() }; + unsafe { + let observer: &AnyObject = self.status_observer.as_ref().as_ref(); + center.removeObserver(observer); + } if !matches!(self.state, State::Inactive { .. }) { unsafe { UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, None); diff --git a/platforms/ios/src/subclass.rs b/platforms/ios/src/subclass.rs index 8027d2e22..7486ea30e 100644 --- a/platforms/ios/src/subclass.rs +++ b/platforms/ios/src/subclass.rs @@ -12,7 +12,7 @@ use objc2::{ OBJC_ASSOCIATION_RETAIN_NONATOMIC, objc_getAssociatedObject, objc_setAssociatedObject, object_setClass, }, - msg_send_id, + msg_send, msg_send_id, mutability::MainThreadOnly, rc::Retained, runtime::{AnyClass, AnyObject, Bool, Sel}, @@ -134,6 +134,36 @@ unsafe extern "C" fn accessibility_hit_test( .hit_test(point, &mut *state_mut.activation_handler) as *mut AnyObject } +// UIView lifecycle + +unsafe extern "C" fn did_move_to_window(this: &UIView, _cmd: Sel) { + let Some(associated) = associated_object(this) else { + return; + }; + let prev_class = associated.ivars().prev_class; + unsafe { + let _: () = msg_send![super(this, prev_class), didMoveToWindow]; + } + + if this.window().is_none() { + return; + } + + let Some(associated) = associated_object(this) else { + return; + }; + let events = { + let mut state = associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .view_did_appear(&mut *state_mut.activation_handler) + }; + if let Some(events) = events { + events.raise(); + } +} + /// Uses dynamic Objective-C subclassing to implement the `UIView` /// accessibility methods when normal subclassing isn't an option. pub struct SubclassingAdapter { @@ -212,6 +242,10 @@ impl SubclassingAdapter { sel!(accessibilityHitTest:), accessibility_hit_test as unsafe extern "C" fn(_, _, _) -> _, ); + builder.add_method( + sel!(didMoveToWindow), + did_move_to_window as unsafe extern "C" fn(_, _), + ); } let class = builder.register(); subclasses.push((prev_class, class)); @@ -222,10 +256,25 @@ impl SubclassingAdapter { // the subclass doesn't add any instance variables; // it uses an associated object instead. unsafe { object_setClass(view as *mut _, (subclass as *const AnyClass).cast()) }; - Self { + let result = Self { view: retained_view, associated, + }; + // UIKit won't replay `didMoveToWindow` for a view that is already + // attached to its window; catch up manually. + if result.view.window().is_some() { + let events = { + let mut state = result.associated.ivars().state.borrow_mut(); + let state_mut = &mut *state; + state_mut + .adapter + .view_did_appear(&mut *state_mut.activation_handler) + }; + if let Some(events) = events { + events.raise(); + } } + result } /// Create an adapter that dynamically subclasses the root view From 7afe4de44bad8b2e9e188e0b604dd79dfbbdf56e Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Fri, 24 Apr 2026 14:38:00 +0200 Subject: [PATCH 23/23] Support deactivation --- platforms/ios/src/adapter.rs | 369 +++++++++++++---------- platforms/ios/src/subclass.rs | 97 +++--- platforms/winit/src/platform_impl/ios.rs | 11 +- 3 files changed, 256 insertions(+), 221 deletions(-) diff --git a/platforms/ios/src/adapter.rs b/platforms/ios/src/adapter.rs index 5cca5895d..3ea6dcbd4 100644 --- a/platforms/ios/src/adapter.rs +++ b/platforms/ios/src/adapter.rs @@ -9,8 +9,8 @@ // found in the LICENSE.chromium file. use accesskit::{ - ActionHandler, ActionRequest, ActivationHandler, Node as NodeProvider, NodeId, Role, - Tree as TreeData, TreeId, TreeUpdate, + ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, Node as NodeProvider, + NodeId, Role, Tree as TreeData, TreeId, TreeUpdate, }; use accesskit_consumer::{FilterResult, Tree}; use objc2::{ @@ -29,16 +29,16 @@ use objc2_ui_kit::{ UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, UIAccessibilityInvertColorsStatusDidChangeNotification, UIAccessibilityIsSpeakScreenEnabled, UIAccessibilityIsSwitchControlRunning, UIAccessibilityIsVoiceOverRunning, - UIAccessibilityOnOffSwitchLabelsDidChangeNotification, UIAccessibilityPostNotification, + UIAccessibilityOnOffSwitchLabelsDidChangeNotification, UIAccessibilityReduceMotionStatusDidChangeNotification, - UIAccessibilityScreenChangedNotification, UIAccessibilitySpeakScreenStatusDidChangeNotification, UIAccessibilitySwitchControlStatusDidChangeNotification, UIAccessibilityVideoAutoplayStatusDidChangeNotification, UIAccessibilityVoiceOverStatusDidChangeNotification, UIView, }; -use std::fmt::{Debug, Formatter}; -use std::{ffi::c_void, ptr::null_mut, rc::Rc}; +use std::cell::RefCell; +use std::rc::{Rc, Weak}; +use std::{ffi::c_void, ptr::null_mut}; use crate::{ context::{ActionHandlerNoMut, ActionHandlerWrapper, Context}, @@ -63,30 +63,6 @@ enum State { Active(Rc), } -impl Debug for State { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - State::Inactive { - view, - action_handler: _, - mtm, - } => f - .debug_struct("Inactive") - .field("view", view) - .field("mtm", mtm) - .finish(), - State::Placeholder { - placeholder_context, - action_handler: _, - } => f - .debug_struct("Placeholder") - .field("placeholder_context", placeholder_context) - .finish(), - State::Active(context) => f.debug_struct("Active").field("context", context).finish(), - } - } -} - struct PlaceholderActionHandler; impl ActionHandler for PlaceholderActionHandler { @@ -117,12 +93,131 @@ fn observed_notification_names() -> [&'static NSNotificationName; 9] { } } +type ActivationHandlerCell = RefCell>; +type DeactivationHandlerCell = RefCell>; + +/// Ensure `state` is `Active` or `Placeholder`. If currently `Inactive`, +/// call the activation handler outside the state borrow and commit the +/// resulting transition on re-borrow. If a re-entrant path moved us out +/// of `Inactive` while the handler was running, the existing state wins. +fn get_or_init_context( + state: &RefCell, + activation_handler: &ActivationHandlerCell, +) -> Rc { + let (view, action_handler, mtm) = { + let state = state.borrow(); + match &*state { + State::Active(context) => return Rc::clone(context), + State::Placeholder { + placeholder_context, + .. + } => return Rc::clone(placeholder_context), + State::Inactive { + view, + action_handler, + mtm, + } => (view.clone(), Rc::clone(action_handler), *mtm), + } + }; + + let initial = activation_handler.borrow_mut().request_initial_tree(); + + let mut state = state.borrow_mut(); + match &*state { + State::Active(context) => Rc::clone(context), + State::Placeholder { + placeholder_context, + .. + } => Rc::clone(placeholder_context), + State::Inactive { .. } => match initial { + Some(initial_state) => { + let tree = Tree::new(initial_state, true); + let context = Context::new(view, tree, action_handler, mtm); + let result = Rc::clone(&context); + *state = State::Active(context); + result + } + None => { + let placeholder_update = TreeUpdate { + nodes: vec![(PLACEHOLDER_ROOT_ID, NodeProvider::new(Role::Window))], + tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + tree_id: TreeId::ROOT, + focus: PLACEHOLDER_ROOT_ID, + }; + let placeholder_tree = Tree::new(placeholder_update, true); + let placeholder_context = Context::new( + view, + placeholder_tree, + Rc::new(ActionHandlerWrapper::new(PlaceholderActionHandler {})), + mtm, + ); + let result = Rc::clone(&placeholder_context); + *state = State::Placeholder { + placeholder_context, + action_handler, + }; + result + } + }, + } +} + +fn try_activate( + state: &RefCell, + activation_handler: &ActivationHandlerCell, +) -> Option { + if !any_assistive_tech_running() { + return None; + } + if !matches!(&*state.borrow(), State::Inactive { .. }) { + return None; + } + let context = get_or_init_context(state, activation_handler); + if !matches!(&*state.borrow(), State::Active(_)) { + return None; + } + let focus_id = context.tree.borrow().state().focus().map(|node| node.id()); + focus_id.map(|id| QueuedEvents::new(context, vec![layout_event(Some(id))])) +} + +fn try_deactivate(state: &RefCell, deactivation_handler: &DeactivationHandlerCell) { + let transitioned = { + let mut state = state.borrow_mut(); + let (view, action_handler, mtm) = match &*state { + State::Inactive { .. } => return, + State::Placeholder { + placeholder_context, + action_handler, + } => ( + placeholder_context.view.clone(), + Rc::clone(action_handler), + placeholder_context.mtm, + ), + State::Active(context) => ( + context.view.clone(), + Rc::clone(&context.action_handler), + context.mtm, + ), + }; + *state = State::Inactive { + view, + action_handler, + mtm, + }; + true + }; + if transitioned { + deactivation_handler.borrow_mut().deactivate_accessibility(); + } +} + struct StatusObserverIvars { - view: WeakId, + state: Weak>, + activation_handler: Weak, + deactivation_handler: Weak, } declare_class!( - #[derive(Debug)] struct StatusObserver; unsafe impl ClassType for StatusObserver { @@ -138,22 +233,34 @@ declare_class!( unsafe impl StatusObserver { #[method(accessibilityStatusChanged:)] fn accessibility_status_changed(&self, _notification: &NSNotification) { - if !any_assistive_tech_running() { - return; - } - if self.ivars().view.load().is_none() { - return; - } - unsafe { - UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, None); + let ivars = self.ivars(); + if any_assistive_tech_running() { + if let (Some(state), Some(activation_handler)) = + (ivars.state.upgrade(), ivars.activation_handler.upgrade()) + { + let _ = get_or_init_context(&state, &activation_handler); + } + } else if let (Some(state), Some(deactivation_handler)) = + (ivars.state.upgrade(), ivars.deactivation_handler.upgrade()) + { + try_deactivate(&state, &deactivation_handler); } } } ); impl StatusObserver { - fn new(view: WeakId, mtm: MainThreadMarker) -> Retained { - let this = mtm.alloc::().set_ivars(StatusObserverIvars { view }); + fn new( + state: Weak>, + activation_handler: Weak, + deactivation_handler: Weak, + mtm: MainThreadMarker, + ) -> Retained { + let this = mtm.alloc::().set_ivars(StatusObserverIvars { + state, + activation_handler, + deactivation_handler, + }); unsafe { msg_send_id![super(this), init] } } } @@ -169,9 +276,9 @@ impl StatusObserver { /// A typical setup looks like this: /// /// 1. In the view's initializer, create an `Adapter` with -/// [`Adapter::new`], passing a pointer to the view and an -/// [`ActionHandler`]. Store the adapter alongside the view (e.g. in -/// an associated object or a Rust-side wrapper). +/// [`Adapter::new`], passing a pointer to the view and the required +/// handlers. Store the adapter alongside the view (e.g. in an +/// associated object or a Rust-side wrapper). /// 2. Override `isAccessibilityElement` to return the result of /// [`Adapter::is_accessibility_element`]. /// 3. Override `accessibilityElements` to return the result of @@ -184,9 +291,11 @@ impl StatusObserver { /// [`Adapter::update_if_active`] and raise the returned events. /// /// All adapter methods must be called on the main thread. -#[derive(Debug)] pub struct Adapter { - state: State, + state: Rc>, + activation_handler: Rc, + #[allow(dead_code)] + deactivation_handler: Rc, status_observer: Retained, } @@ -194,17 +303,37 @@ impl Adapter { /// Create a new iOS adapter. This function must be called on /// the main thread. /// - /// The action handler will always be called on the main thread. + /// All handlers will always be called on the main thread. /// /// # Safety /// /// `view` must be a valid, unreleased pointer to a `UIView`. - pub unsafe fn new(view: *mut c_void, action_handler: impl 'static + ActionHandler) -> Self { + pub unsafe fn new( + view: *mut c_void, + activation_handler: impl 'static + ActivationHandler, + action_handler: impl 'static + ActionHandler, + deactivation_handler: impl 'static + DeactivationHandler, + ) -> Self { let view = unsafe { Retained::retain(view as *mut UIView) }.unwrap(); let view = WeakId::from_retained(&view); let mtm = MainThreadMarker::new().unwrap(); - let status_observer = StatusObserver::new(view.clone(), mtm); + let state = Rc::new(RefCell::new(State::Inactive { + view, + action_handler: Rc::new(ActionHandlerWrapper::new(action_handler)), + mtm, + })); + let activation_handler: Rc = + Rc::new(RefCell::new(Box::new(activation_handler))); + let deactivation_handler: Rc = + Rc::new(RefCell::new(Box::new(deactivation_handler))); + + let status_observer = StatusObserver::new( + Rc::downgrade(&state), + Rc::downgrade(&activation_handler), + Rc::downgrade(&deactivation_handler), + mtm, + ); let center = unsafe { NSNotificationCenter::defaultCenter() }; for name in observed_notification_names() { unsafe { @@ -217,13 +346,10 @@ impl Adapter { } } - let state = State::Inactive { - view, - action_handler: Rc::new(ActionHandlerWrapper::new(action_handler)), - mtm, - }; Self { state, + activation_handler, + deactivation_handler, status_observer, } } @@ -237,16 +363,21 @@ impl Adapter { /// If a [`QueuedEvents`] instance is returned, the caller must call /// [`QueuedEvents::raise`] on it. pub fn update_if_active( - &mut self, + &self, update_factory: impl FnOnce() -> TreeUpdate, ) -> Option { - match &self.state { + if matches!(&*self.state.borrow(), State::Inactive { .. }) { + return None; + } + let update = update_factory(); + let mut state = self.state.borrow_mut(); + match &mut *state { State::Inactive { .. } => None, State::Placeholder { placeholder_context, action_handler, } => { - let tree = Tree::new(update_factory(), true); + let tree = Tree::new(update, true); let context = Context::new( placeholder_context.view.clone(), tree, @@ -258,13 +389,15 @@ impl Adapter { let events = vec![screen_changed_event(Some(id))]; QueuedEvents::new(Rc::clone(&context), events) }); - self.state = State::Active(context); + *state = State::Active(context); queued_events } 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); + { + let mut tree = context.tree.borrow_mut(); + tree.update_and_process_changes(update, &mut event_generator); + } Some(event_generator.into_result()) } } @@ -275,101 +408,23 @@ impl Adapter { /// /// If a [`QueuedEvents`] instance is returned, the caller must call /// [`QueuedEvents::raise`] on it. - pub fn view_did_appear( - &mut self, - activation_handler: &mut H, - ) -> Option { - if !any_assistive_tech_running() { - return None; - } - if !matches!(self.state, State::Inactive { .. }) { - return None; - } - let context = self.get_or_init_context(activation_handler); - if !matches!(self.state, State::Active(_)) { - return None; - } - let focus_id = context.tree.borrow().state().focus().map(|node| node.id()); - focus_id.map(|id| QueuedEvents::new(context, vec![layout_event(Some(id))])) - } - - fn get_or_init_context( - &mut self, - activation_handler: &mut H, - ) -> Rc { - match &self.state { - State::Inactive { - view, - action_handler, - mtm, - } => match activation_handler.request_initial_tree() { - Some(initial_state) => { - let tree = Tree::new(initial_state, true); - let context = Context::new(view.clone(), tree, Rc::clone(action_handler), *mtm); - let result = Rc::clone(&context); - self.state = State::Active(context); - result - } - None => { - let placeholder_update = TreeUpdate { - nodes: vec![(PLACEHOLDER_ROOT_ID, NodeProvider::new(Role::Window))], - tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), - tree_id: TreeId::ROOT, - focus: PLACEHOLDER_ROOT_ID, - }; - let placeholder_tree = Tree::new(placeholder_update, true); - let placeholder_context = Context::new( - view.clone(), - placeholder_tree, - Rc::new(ActionHandlerWrapper::new(PlaceholderActionHandler {})), - *mtm, - ); - let result = Rc::clone(&placeholder_context); - self.state = State::Placeholder { - placeholder_context, - action_handler: Rc::clone(action_handler), - }; - result - } - }, - State::Placeholder { - placeholder_context, - .. - } => Rc::clone(placeholder_context), - State::Active(context) => Rc::clone(context), - } - } - - fn weak_view(&self) -> &WeakId { - match &self.state { - State::Inactive { view, .. } => view, - State::Placeholder { - placeholder_context, - .. - } => &placeholder_context.view, - State::Active(context) => &context.view, - } + pub fn view_did_appear(&self) -> Option { + try_activate(&self.state, &self.activation_handler) } // UIAccessibilityContainer methods /// Indicates whether the view itself is an accessibility element. /// This corresponds to `isAccessibilityElement`. - pub fn is_accessibility_element( - &mut self, - activation_handler: &mut H, - ) -> bool { - let _ = self.get_or_init_context(activation_handler); + pub fn is_accessibility_element(&self) -> bool { + let _ = get_or_init_context(&self.state, &self.activation_handler); false } /// Returns all accessibility elements in the container. /// This corresponds to `accessibilityElements`. - pub fn accessibility_elements( - &mut self, - activation_handler: &mut H, - ) -> *mut NSArray { - let context = self.get_or_init_context(activation_handler); + pub fn accessibility_elements(&self) -> *mut NSArray { + let context = get_or_init_context(&self.state, &self.activation_handler); let tree = context.tree.borrow(); let state = tree.state(); let node = state.root(); @@ -395,22 +450,15 @@ impl Adapter { /// Returns the accessibility element at the specified point. /// This corresponds to `accessibilityHitTest:`. - pub fn hit_test( - &mut self, - point: CGPoint, - activation_handler: &mut H, - ) -> *mut NSObject { - let view = match self.weak_view().load() { + pub fn hit_test(&self, point: CGPoint) -> *mut NSObject { + let context = get_or_init_context(&self.state, &self.activation_handler); + let view = match context.view.load() { Some(view) => view, - None => { - return null_mut(); - } + None => return null_mut(), }; - - let context = self.get_or_init_context(activation_handler); let tree = context.tree.borrow(); - let state = tree.state(); - let root = state.root(); + let tree_state = tree.state(); + let root = tree_state.root(); let Some(point) = from_cg_point(&view, &root, point) else { return null_mut(); }; @@ -429,10 +477,5 @@ impl Drop for Adapter { let observer: &AnyObject = self.status_observer.as_ref().as_ref(); center.removeObserver(observer); } - if !matches!(self.state, State::Inactive { .. }) { - unsafe { - UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, None); - } - } } } diff --git a/platforms/ios/src/subclass.rs b/platforms/ios/src/subclass.rs index 7486ea30e..e3b0e00f6 100644 --- a/platforms/ios/src/subclass.rs +++ b/platforms/ios/src/subclass.rs @@ -3,7 +3,7 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. -use accesskit::{ActionHandler, ActivationHandler, TreeUpdate}; +use accesskit::{ActionHandler, ActivationHandler, DeactivationHandler, TreeUpdate}; use objc2::{ ClassType, DeclaredClass, declare::ClassBuilder, @@ -20,7 +20,7 @@ use objc2::{ }; use objc2_foundation::{CGPoint, MainThreadMarker, NSArray, NSObject}; use objc2_ui_kit::{UIView, UIWindow}; -use std::{cell::RefCell, ffi::c_void, ptr::null_mut, sync::Mutex}; +use std::{ffi::c_void, ptr::null_mut, sync::Mutex}; use crate::{Adapter, event::QueuedEvents}; @@ -32,13 +32,8 @@ fn associated_object_key() -> *const c_void { (&ASSOCIATED_OBJECT_KEY as *const u8).cast() } -struct AssociatedObjectState { - adapter: Adapter, - activation_handler: Box, -} - struct AssociatedObjectIvars { - state: RefCell, + adapter: Adapter, prev_class: &'static AnyClass, } @@ -59,17 +54,13 @@ declare_class!( impl AssociatedObject { fn new( adapter: Adapter, - activation_handler: impl 'static + ActivationHandler, prev_class: &'static AnyClass, mtm: MainThreadMarker, ) -> Retained { - let state = RefCell::new(AssociatedObjectState { + let this = mtm.alloc::().set_ivars(AssociatedObjectIvars { adapter, - activation_handler: Box::new(activation_handler), + prev_class, }); - let this = mtm - .alloc::() - .set_ivars(AssociatedObjectIvars { state, prev_class }); unsafe { msg_send_id![super(this), init] } } @@ -97,24 +88,14 @@ unsafe extern "C" fn is_accessibility_element(this: &UIView, _cmd: Sel) -> Bool let Some(associated) = associated_object(this) else { return Bool::YES; }; - let mut state = associated.ivars().state.borrow_mut(); - let state_mut = &mut *state; - Bool::new( - state_mut - .adapter - .is_accessibility_element(&mut *state_mut.activation_handler), - ) + Bool::new(associated.ivars().adapter.is_accessibility_element()) } unsafe extern "C" fn accessibility_elements(this: &UIView, _cmd: Sel) -> *mut NSArray { let Some(associated) = associated_object(this) else { return Retained::autorelease_return(NSArray::new()); }; - let mut state = associated.ivars().state.borrow_mut(); - let state_mut = &mut *state; - state_mut - .adapter - .accessibility_elements(&mut *state_mut.activation_handler) + associated.ivars().adapter.accessibility_elements() } // UIAccessibilityHitTest methods @@ -127,11 +108,7 @@ unsafe extern "C" fn accessibility_hit_test( let Some(associated) = associated_object(this) else { return null_mut(); }; - let mut state = associated.ivars().state.borrow_mut(); - let state_mut = &mut *state; - state_mut - .adapter - .hit_test(point, &mut *state_mut.activation_handler) as *mut AnyObject + associated.ivars().adapter.hit_test(point) as *mut AnyObject } // UIView lifecycle @@ -152,14 +129,7 @@ unsafe extern "C" fn did_move_to_window(this: &UIView, _cmd: Sel) { let Some(associated) = associated_object(this) else { return; }; - let events = { - let mut state = associated.ivars().state.borrow_mut(); - let state_mut = &mut *state; - state_mut - .adapter - .view_did_appear(&mut *state_mut.activation_handler) - }; - if let Some(events) = events { + if let Some(events) = associated.ivars().adapter.view_did_appear() { events.raise(); } } @@ -176,7 +146,7 @@ impl SubclassingAdapter { /// This must be done before the view is shown or focused for /// the first time. /// - /// The action handler will always be called on the main thread. + /// All handlers will always be called on the main thread. /// /// # Safety /// @@ -185,16 +155,23 @@ impl SubclassingAdapter { view: *mut c_void, activation_handler: impl 'static + ActivationHandler, action_handler: impl 'static + ActionHandler, + deactivation_handler: impl 'static + DeactivationHandler, ) -> Self { let view = view as *mut UIView; let retained_view = unsafe { Retained::retain(view) }.unwrap(); - Self::new_internal(retained_view, activation_handler, action_handler) + Self::new_internal( + retained_view, + activation_handler, + action_handler, + deactivation_handler, + ) } fn new_internal( retained_view: Retained, activation_handler: impl 'static + ActivationHandler, action_handler: impl 'static + ActionHandler, + deactivation_handler: impl 'static + DeactivationHandler, ) -> Self { let mtm = MainThreadMarker::new().unwrap(); let view = Retained::as_ptr(&retained_view) as *mut UIView; @@ -205,12 +182,19 @@ impl SubclassingAdapter { { panic!("subclassing adapter already instantiated on view {view:?}"); } - let adapter = unsafe { Adapter::new(view as *mut c_void, action_handler) }; + let adapter = unsafe { + Adapter::new( + view as *mut c_void, + activation_handler, + action_handler, + deactivation_handler, + ) + }; // Cast to a pointer and back to force the lifetime to 'static // SAFETY: We know the class will live as long as the instance, // and we only use this reference while the instance is alive. let prev_class = unsafe { &*((*view).class() as *const AnyClass) }; - let associated = AssociatedObject::new(adapter, activation_handler, prev_class, mtm); + let associated = AssociatedObject::new(adapter, prev_class, mtm); unsafe { objc_setAssociatedObject( view as *mut _, @@ -263,14 +247,7 @@ impl SubclassingAdapter { // UIKit won't replay `didMoveToWindow` for a view that is already // attached to its window; catch up manually. if result.view.window().is_some() { - let events = { - let mut state = result.associated.ivars().state.borrow_mut(); - let state_mut = &mut *state; - state_mut - .adapter - .view_did_appear(&mut *state_mut.activation_handler) - }; - if let Some(events) = events { + if let Some(events) = result.associated.ivars().adapter.view_did_appear() { events.raise(); } } @@ -280,7 +257,7 @@ impl SubclassingAdapter { /// Create an adapter that dynamically subclasses the root view /// of the specified window. /// - /// The action handler will always be called on the main thread. + /// All handlers will always be called on the main thread. /// /// # Safety /// @@ -294,6 +271,7 @@ impl SubclassingAdapter { window: *mut c_void, activation_handler: impl 'static + ActivationHandler, action_handler: impl 'static + ActionHandler, + deactivation_handler: impl 'static + DeactivationHandler, ) -> Self { let window = unsafe { &*(window as *const UIWindow) }; let root_view_controller = window @@ -302,7 +280,12 @@ impl SubclassingAdapter { let retained_view = root_view_controller .view() .expect("root view controller has no view"); - Self::new_internal(retained_view, activation_handler, action_handler) + Self::new_internal( + retained_view, + activation_handler, + action_handler, + deactivation_handler, + ) } /// If and only if the tree has been initialized, call the provided function @@ -314,11 +297,13 @@ impl SubclassingAdapter { /// If a [`QueuedEvents`] instance is returned, the caller must call /// [`QueuedEvents::raise`] on it. pub fn update_if_active( - &mut self, + &self, update_factory: impl FnOnce() -> TreeUpdate, ) -> Option { - let mut state = self.associated.ivars().state.borrow_mut(); - state.adapter.update_if_active(update_factory) + self.associated + .ivars() + .adapter + .update_if_active(update_factory) } } diff --git a/platforms/winit/src/platform_impl/ios.rs b/platforms/winit/src/platform_impl/ios.rs index 42c14dcc8..c9d8eedd9 100644 --- a/platforms/winit/src/platform_impl/ios.rs +++ b/platforms/winit/src/platform_impl/ios.rs @@ -21,7 +21,7 @@ impl Adapter { window: &Window, activation_handler: impl 'static + ActivationHandler, action_handler: impl 'static + ActionHandler, - _deactivation_handler: impl 'static + DeactivationHandler, + deactivation_handler: impl 'static + DeactivationHandler, ) -> Self { #[cfg(feature = "rwh_05")] let view = match window.raw_window_handle() { @@ -34,7 +34,14 @@ impl Adapter { _ => unreachable!(), }; - let adapter = unsafe { SubclassingAdapter::new(view, activation_handler, action_handler) }; + let adapter = unsafe { + SubclassingAdapter::new( + view, + activation_handler, + action_handler, + deactivation_handler, + ) + }; Self { adapter } }