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/.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 diff --git a/.gitignore b/.gitignore index 8794fd73b..2b462b369 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,12 @@ target #gedit *~ +# macOS +.DS_Store + #KDE .directory + +# Xcode (generated by xcodegen) +*.xcodeproj +xcuserdata diff --git a/Cargo.lock b/Cargo.lock index 179d72dec..7a9cec28a 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 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + [[package]] name = "accesskit_macos" version = "0.26.0" @@ -67,9 +79,9 @@ dependencies = [ "accesskit", "accesskit_consumer", "hashbrown", - "objc2", + "objc2 0.5.2", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -112,6 +124,7 @@ version = "0.32.2" dependencies = [ "accesskit", "accesskit_android", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -419,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]] @@ -592,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" @@ -1117,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" @@ -1126,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]] @@ -1141,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]] @@ -1153,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]] @@ -1165,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]] @@ -1176,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", ] @@ -1188,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]] @@ -1209,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]] @@ -1219,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]] @@ -1232,8 +1310,8 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.8.0", "block2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1244,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]] @@ -1267,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", @@ -1287,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]] @@ -1299,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]] @@ -1903,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", ] @@ -2089,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", @@ -2100,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", @@ -2111,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", ] @@ -2552,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" @@ -2762,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/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/consumer/src/node.rs b/consumer/src/node.rs index 36b527b0d..10bde0dbe 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() } @@ -887,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/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 diff --git a/platforms/ios/Cargo.toml b/platforms/ios/Cargo.toml new file mode 100644 index 000000000..3e0500be4 --- /dev/null +++ b/platforms/ios/Cargo.toml @@ -0,0 +1,42 @@ +[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", + "NSAttributedString", + "NSDictionary", + "NSString", + "NSValue", + "NSThread", +] } +objc2-ui-kit = { version = "0.2.0", features = [ + "UIAccessibility", + "UIAccessibilityConstants", + "UIAccessibilityContainer", + "UIAccessibilityElement", + "UIAccessibilityIdentification", + "UIGeometry", + "UIResponder", + "UIScreen", + "UIView", + "UIViewController", + "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/adapter.rs b/platforms/ios/src/adapter.rs new file mode 100644 index 000000000..91d5d1b07 --- /dev/null +++ b/platforms/ios/src/adapter.rs @@ -0,0 +1,306 @@ +// 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, screen_changed_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![screen_changed_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 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 _, + 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..1f9945efb --- /dev/null +++ b/platforms/ios/src/event.rs @@ -0,0 +1,257 @@ +// 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::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::{ + UIAccessibilityAnnouncementNotification, UIAccessibilityLayoutChangedNotification, + UIAccessibilityNotifications, UIAccessibilityPostNotification, UIAccessibilityPriority, + UIAccessibilityPriorityHigh, UIAccessibilityPriorityLow, + UIAccessibilityScreenChangedNotification, UIAccessibilitySpeechAttributeAnnouncementPriority, + UIAccessibilitySpeechAttributeQueueAnnouncement, +}; +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), + 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 { + 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); + } + 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), + ); + } + } +} + +/// 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. + /// + /// 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) fn screen_changed_event(node_id: Option) -> QueuedEvent { + QueuedEvent::Generic { + node_id, + notification: unsafe { UIAccessibilityScreenChangedNotification }, + } +} + +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.platform_focus != 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_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); + + while let Some(node) = to_remove.pop_front() { + for child in node.filtered_children(&filter) { + to_remove.push_back(child); + } + + self.remove_node(node.id()); + } + } + + pub(crate) fn into_result(self) -> QueuedEvents { + let mut events = self.events; + if let Some(focus) = self.layout_event_focus { + events.push(layout_event(focus)); + } + 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(); + if let Some(value) = node.value() { + if 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_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; + + if old_included && !new_included { + if new_filter_result == FilterResult::ExcludeSubtree { + self.remove_subtree(old_node); + } else { + self.remove_node(new_node.id()); + } + return; + } + + 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() { + 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(), + )); + } + } + } + + 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.remove_node(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 new file mode 100644 index 000000000..a32aa4ab0 --- /dev/null +++ b/platforms/ios/src/lib.rs @@ -0,0 +1,41 @@ +// 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. + +//! 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; +mod filters; +mod node; +mod util; + +mod adapter; +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/node.rs b/platforms/ios/src/node.rs new file mode 100644 index 000000000..c7e317f1f --- /dev/null +++ b/platforms/ios/src/node.rs @@ -0,0 +1,1015 @@ +// 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, Live, 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, Bool}, +}; +use objc2_foundation::{CGRect, NSArray, NSObject, NSObjectProtocol, NSString}; +use objc2_ui_kit::{ + UIAccessibilityContainerType, UIAccessibilityElement, UIAccessibilityScrollDirection, + UIAccessibilityTraitAdjustable, UIAccessibilityTraitAllowsDirectInteraction, + UIAccessibilityTraitButton, UIAccessibilityTraitHeader, UIAccessibilityTraitImage, + UIAccessibilityTraitLink, UIAccessibilityTraitNone, UIAccessibilityTraitNotEnabled, + UIAccessibilityTraitSearchField, UIAccessibilityTraitSelected, UIAccessibilityTraitStaticText, + UIAccessibilityTraitToggleButton, UIAccessibilityTraitUpdatesFrequently, UIAccessibilityTraits, +}; +use std::rc::{Rc, Weak}; + +use crate::{ + context::Context, + filters::{filter, filter_for_is_accessibility_element}, + util::{UIAccessibilityExpandedStatus, to_cg_rect, to_screen_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 + | 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 }, + }; + + if self.0.is_disabled() { + 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 + } + + 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 { + 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 => to_screen_rect(&view, view.bounds()), + 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(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(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| { + // 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(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| { + 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, + }); + } + }); + } + + #[method_id(accessibilityIdentifier)] + fn identifier(&self) -> Option> { + self.resolve(|node| { + node.author_id().map(NSString::from_str) + }) + .flatten() + } + } +); + +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_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(); + 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_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); + 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_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); + 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); + assert!(t & unsafe { UIAccessibilityTraitToggleButton } == 0); + assert!(t & unsafe { UIAccessibilityTraitUpdatesFrequently } == 0); + assert!(t & unsafe { UIAccessibilityTraitAllowsDirectInteraction } == 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_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); + 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 { + 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/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, + ) + }; + } +} diff --git a/platforms/ios/src/util.rs b/platforms/ios/src/util.rs new file mode 100644 index 000000000..67ee07aa1 --- /dev/null +++ b/platforms/ios/src/util.rs @@ -0,0 +1,58 @@ +// 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::encode::{Encode, Encoding, RefEncode}; +use objc2_foundation::{CGPoint, CGRect, CGSize, NSInteger}; +use objc2_ui_kit::{UIAccessibilityConvertFrameToScreenCoordinates, UICoordinateSpace, 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) -> Option { + let window = view.window()?; + let screen_space = window.screen().coordinateSpace(); + let local_point = view.convertPoint_fromCoordinateSpace(point, &screen_space); + let factor = view.contentScaleFactor(); + let point = Point::new(local_point.x * factor, local_point.y * factor); + 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 { + let factor = view.contentScaleFactor(); + let local_rect = CGRect { + origin: CGPoint { + x: rect.x0 / factor, + y: rect.y0 / factor, + }, + size: CGSize { + width: rect.width() / factor, + height: rect.height() / factor, + }, + }; + to_screen_rect(view, local_rect) +} diff --git a/platforms/winit/Cargo.toml b/platforms/winit/Cargo.toml index 94ae7621c..c40e33287 100644 --- a/platforms/winit/Cargo.toml +++ b/platforms/winit/Cargo.toml @@ -36,16 +36,24 @@ 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] -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..eefeab868 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}, @@ -26,27 +28,55 @@ 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 { +#[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); @@ -62,9 +92,13 @@ 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)>, + safe_area_inset: (f64, f64), } impl UiState { @@ -72,11 +106,14 @@ impl UiState { Arc::new(Mutex::new(Self { focus: INITIAL_FOCUS, announcement: None, + pending_announcement: None, + safe_area_inset: (0.0, 0.0), })) } 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); @@ -87,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![ @@ -118,23 +155,38 @@ 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, - } - }); + // 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())); + } + + 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 } } @@ -216,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 => { @@ -243,7 +299,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(); } _ => (), @@ -274,27 +330,38 @@ impl ApplicationHandler for Application { state.set_focus(adapter, target_node); } Action::Click => { - state.press_button(adapter, target_node); + state.press_button(target_node); } _ => (), } } 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(); } } 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 388072582..f36eb2b91 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}, @@ -20,27 +22,55 @@ 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 { +#[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); @@ -56,9 +86,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 { @@ -66,11 +99,13 @@ impl UiState { Self { focus: INITIAL_FOCUS, announcement: None, + pending_announcement: None, } } 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); @@ -79,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![ @@ -112,23 +147,38 @@ 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, - } - }); + // 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())); + } + + 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 } } @@ -192,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 => { @@ -217,7 +269,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(); } _ => (), @@ -236,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, @@ -249,7 +302,7 @@ impl ApplicationHandler for Application { state.set_focus(adapter, target_node); } Action::Click => { - state.press_button(adapter, target_node); + state.press_button(target_node); } _ => (), } @@ -261,15 +314,21 @@ 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(); } } 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(); } } 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. } } 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; 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": {},