diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 74ff461cb..81a2b2dd2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"accesskit":"0.25.1","accesskit_consumer":"0.39.1","adapters/macos":"0.27.1","adapters/windows":"0.35.1","adapters/winit":"0.34.1","adapters/unix":"0.24.0","adapters/atspi-common":"0.21.0","adapters/android":"0.9.0","adapters/ios":"0.2.1","example_common":"0.0.1"} +{"accesskit":"0.25.1","accesskit_consumer":"0.39.1","adapters/macos":"0.27.1","adapters/ohos":"0.1.0","adapters/windows":"0.35.1","adapters/winit":"0.34.1","adapters/unix":"0.24.0","adapters/atspi-common":"0.21.0","adapters/android":"0.9.0","adapters/ios":"0.2.1","example_common":"0.0.1"} diff --git a/Cargo.lock b/Cargo.lock index d2278584e..f4f909b5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "accesskit_ohos" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_consumer", + "ohos-accessibility-binding", +] + [[package]] name = "accesskit_unix" version = "0.24.0" @@ -529,6 +538,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -1384,6 +1402,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohos-accessibility-binding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627d99fc7c5e585c3a482a6b8d54cf8e6f7c737117ede592100089f65bfe6e44" +dependencies = [ + "bitflags 2.8.0", + "ohos-accessibility-sys", + "ohos-arkui-input-sys", + "ohos-enum-derive", +] + +[[package]] +name = "ohos-accessibility-sys" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b0c98fcf8ba4538109d272a1136c39ecb5e71fffaeed85a9c03f0a6636a1c68" +dependencies = [ + "ohos-arkui-input-sys", +] + +[[package]] +name = "ohos-arkui-input-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d658c88d3692ded92e2dc17912483950b8f3abee189c8a215a8e6e63dbdf29" + +[[package]] +name = "ohos-enum-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2988e3067c0c79d34254847f0ecce89ff7fea9a6bf45aa3874e192662ae1581" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "once_cell" version = "1.21.3" diff --git a/Cargo.toml b/Cargo.toml index 327918112..94188c037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "adapters/atspi-common", "adapters/ios", "adapters/macos", + "adapters/ohos", "adapters/unix", "adapters/windows", "adapters/winit", @@ -28,6 +29,7 @@ rust-version = "1.87" [workspace.dependencies] hashbrown = { version = "0.17", default-features = false, features = ["default-hasher"] } +ohos-accessibility-binding = { version = "0.2.0", default-features = false } [profile.release] lto = true diff --git a/README.md b/README.md index 22f5ca4b9..5ecb235a7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ 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. +* [OpenHarmony adapter](https://crates.io/crates/accesskit_ohos): This adapter implements the ArkUI native accessibility provider 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. diff --git a/adapters/ohos/CHANGELOG.md b/adapters/ohos/CHANGELOG.md new file mode 100644 index 000000000..a6729f0e7 --- /dev/null +++ b/adapters/ohos/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +- Require `ohos-accessibility-binding` 0.2 so the adapter shares provider and + event types with applications using the new accessibility binding release. + +- Add the initial OpenHarmony ArkUI accessibility adapter. +- Default the adapter to API 13 and make API 24 component identifiers opt-in. diff --git a/adapters/ohos/Cargo.toml b/adapters/ohos/Cargo.toml new file mode 100644 index 000000000..a4d8fc7e0 --- /dev/null +++ b/adapters/ohos/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "accesskit_ohos" +version = "0.1.0" +authors.workspace = true +license.workspace = true +description = "AccessKit UI accessibility infrastructure: OpenHarmony adapter" +categories.workspace = true +keywords = ["gui", "ui", "accessibility", "openharmony"] +repository.workspace = true +readme = "README.md" +edition.workspace = true +rust-version = "1.88" + +[features] +default = ["api-13"] +api-13 = ["ohos-accessibility-binding/api-13"] +api-14 = ["api-13", "ohos-accessibility-binding/api-14"] +api-15 = ["api-14", "ohos-accessibility-binding/api-15"] +api-16 = ["api-15", "ohos-accessibility-binding/api-16"] +api-17 = ["api-16", "ohos-accessibility-binding/api-17"] +api-18 = ["api-17", "ohos-accessibility-binding/api-18"] +api-19 = ["api-18", "ohos-accessibility-binding/api-19"] +api-20 = ["api-19", "ohos-accessibility-binding/api-20"] +api-21 = ["api-20", "ohos-accessibility-binding/api-21"] +api-22 = ["api-21", "ohos-accessibility-binding/api-22"] +api-23 = ["api-22", "ohos-accessibility-binding/api-23"] +api-24 = ["api-23", "ohos-accessibility-binding/api-24"] + +[dependencies] +accesskit = { version = "0.25.0", path = "../../accesskit" } +accesskit_consumer = { version = "0.39.0", path = "../../accesskit_consumer" } +ohos-accessibility-binding = { workspace = true } diff --git a/adapters/ohos/README.md b/adapters/ohos/README.md new file mode 100644 index 000000000..bd062080b --- /dev/null +++ b/adapters/ohos/README.md @@ -0,0 +1,58 @@ +# accesskit_ohos + +AccessKit adapter for the ArkUI native accessibility provider API on +OpenHarmony/HarmonyOS NEXT. + +The adapter is attached to an accessibility `Provider` obtained from an +XComponent or an `ARKUI_NODE_CUSTOM`. It retains the AccessKit tree, answers +ArkUI's synchronous node queries, forwards native actions to AccessKit, and +reports tree/focus changes back to ArkUI. + +```rust,ignore +let provider = xcomponent.accessibility_provider()?; +let adapter = accesskit_ohos::Adapter::new( + provider, + activation_handler, + action_handler, +)?; + +adapter.update_if_active(|| next_tree_update()); +``` + +`Adapter::new` uses the standard callback registration for a single ArkUI custom +node or XComponent. The binding permits only one such registration per process. +For multiple XComponent windows or recreated surfaces, enable `api-15` and use +`Adapter::new_with_instance` with a unique ID for each registration, so late +callbacks cannot reach another window or a replacement surface's tree. + +Drop the adapter before destroying the XComponent or custom node that owns its +provider. + +The OpenHarmony native API does not expose an unregister function. Dropping +the adapter disables its Rust callback target, so any late native callback +returns a failure without touching released application state. + +Action descriptions use static C strings because ArkUI snapshots retain their +description pointers. Event submission keeps its attached element alive through +the binding's borrowed event lifetime. + +## API levels + +The adapter defaults to the `api-13` feature, which is sufficient for an +XComponent accessibility provider and for all core tree, action, and event +operations. Higher API features form an additive ladder: + +- API 15 adds multi-instance callback registration and next/previous HTML item + action handling. +- An ArkUI `ARKUI_NODE_CUSTOM` host needs API 23 to obtain its accessibility + provider. This requirement belongs to the ArkUI host integration; the + adapter still only consumes the resulting `Provider`. +- Enable `api-24` when AccessKit `author_id` values must be exposed through + ArkUI's `componentIdentifier` property. + +Applications should select the highest API feature supported by their minimum +deployment target. Enabling `api-24` is not required for the core adapter. + +## License + +MIT OR Apache-2.0 diff --git a/adapters/ohos/src/adapter.rs b/adapters/ohos/src/adapter.rs new file mode 100644 index 000000000..33e664bff --- /dev/null +++ b/adapters/ohos/src/adapter.rs @@ -0,0 +1,787 @@ +// 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 std::{ + collections::{HashMap, HashSet}, + ffi::CStr, + sync::{Arc, Mutex, MutexGuard}, +}; + +use accesskit::{ + Action, ActionData, ActionHandler, ActionRequest, ActivationHandler, Node as NodeData, + NodeId as LocalNodeId, Role, TextSelection, TreeId, TreeInfo as TreeData, TreeUpdate, +}; +use accesskit_consumer::{ + FilterResult, FullNodeId as NodeId, NodeRef as Node, Tree, TreeChangeHandler, +}; +use ohos_accessibility_binding::{ + AccessibilityError, ActionArguments, ActionType, ElementInfo, ElementInfoList, EventInfo, + EventType, FocusMoveDirection, FocusType, Provider, ProviderCallbacks, ProviderRegistration, + Result, SearchMode, +}; + +use crate::node::{filter, populate_element, usize_to_i32}; + +const ROOT_QUERY_ID: i64 = -1; +const ROOT_ELEMENT_ID: i32 = 0; +pub(crate) const ROOT_PARENT_ID: i32 = -2_100_000; +const PLACEHOLDER_ROOT_ID: LocalNodeId = LocalNodeId(0); + +struct IgnoreChanges; + +impl TreeChangeHandler for IgnoreChanges { + fn node_added(&mut self, _node: &Node) {} + fn node_updated(&mut self, _old_node: &Node, _new_node: &Node) {} + fn focus_moved(&mut self, _old_node: Option<&Node>, _new_node: Option<&Node>) {} + fn node_removed(&mut self, _node: &Node) {} +} + +#[derive(Debug, Default)] +pub(crate) struct NodeIdMap { + platform_to_accesskit: HashMap, + accesskit_to_platform: HashMap, + next_platform_id: i32, +} + +impl NodeIdMap { + pub(crate) fn get_or_create(&mut self, node: &Node) -> i32 { + if node.is_root() { + self.platform_to_accesskit + .insert(ROOT_ELEMENT_ID, node.id()); + self.accesskit_to_platform + .insert(node.id(), ROOT_ELEMENT_ID); + self.next_platform_id = self.next_platform_id.max(1); + return ROOT_ELEMENT_ID; + } + if let Some(id) = self.accesskit_to_platform.get(&node.id()) { + return *id; + } + let id = self.next_platform_id.max(1); + self.next_platform_id = id.saturating_add(1); + self.platform_to_accesskit.insert(id, node.id()); + self.accesskit_to_platform.insert(node.id(), id); + id + } + + fn get_accesskit(&self, platform_id: i64, root: NodeId) -> Option { + if platform_id == ROOT_QUERY_ID || platform_id == i64::from(ROOT_ELEMENT_ID) { + return Some(root); + } + let platform_id = i32::try_from(platform_id).ok()?; + self.platform_to_accesskit.get(&platform_id).copied() + } +} + +enum TreeLifecycle { + Inactive, + Placeholder(Tree), + Active(Tree), +} + +impl TreeLifecycle { + fn get(&self) -> Option<&Tree> { + match self { + Self::Inactive => None, + Self::Placeholder(tree) | Self::Active(tree) => Some(tree), + } + } + + fn get_active_mut(&mut self) -> Option<&mut Tree> { + match self { + Self::Active(tree) => Some(tree), + Self::Inactive | Self::Placeholder(_) => None, + } + } +} + +#[derive(Clone, Copy)] +struct ProviderAddress(usize); + +impl ProviderAddress { + fn send( + self, + event_type: EventType, + node: Option<&Node>, + ids: &mut NodeIdMap, + accessibility_focus: Option, + screen_offset: (i32, i32), + ) -> Result<()> { + let element = if let Some(node) = node { + let mut value = ElementInfo::new()?; + populate_element(node, ids, accessibility_focus, screen_offset, &mut value)?; + Some(value) + } else { + None + }; + // The event borrows this final element allocation until it is dropped. + let mut event = EventInfo::new()?; + event.set_event_type(event_type)?; + if let Some(element) = element.as_ref() { + event.set_element_info(element)?; + } + + let provider = unsafe { Provider::from_raw(self.0 as *mut _) }?; + provider.send_event(&event, None); + Ok(()) + } +} + +struct State { + tree: TreeLifecycle, + activation_handler: Box, + ids: NodeIdMap, + accessibility_focus: Option, + host_is_focused: bool, + screen_offset: (i32, i32), + provider: ProviderAddress, +} + +impl State { + fn initialize_tree(&mut self) { + if !matches!(self.tree, TreeLifecycle::Inactive) { + return; + } + self.tree = match self.activation_handler.request_initial_tree() { + Some(update) => TreeLifecycle::Active(Tree::new(update, self.host_is_focused)), + None => { + let update = TreeUpdate { + nodes: vec![(PLACEHOLDER_ROOT_ID, NodeData::new(Role::Window))], + tree: Some(TreeData::new(PLACEHOLDER_ROOT_ID)), + tree_id: TreeId::ROOT, + focus: PLACEHOLDER_ROOT_ID, + }; + TreeLifecycle::Placeholder(Tree::new(update, self.host_is_focused)) + } + }; + } +} + +struct Callbacks { + state: Arc>, + action_handler: Arc>>, +} + +impl Callbacks { + fn lock(&self) -> Result> { + self.state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned) + } +} + +impl ProviderCallbacks for Callbacks { + fn find_node_infos_by_id( + &self, + element_id: i64, + mode: SearchMode, + _request_id: i32, + elements: &mut ElementInfoList<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .and_then(|id| tree.state().node_by_id(id)) + .ok_or(AccessibilityError::BadParameter)?; + let node_ids = search_node_ids(&target, mode); + for id in node_ids { + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + let mut element = elements.add()?; + populate_element( + &node, + ids, + *accessibility_focus, + *screen_offset, + &mut element, + )?; + } + Ok(()) + } + + fn find_node_infos_by_text( + &self, + element_id: i64, + text: &CStr, + _request_id: i32, + elements: &mut ElementInfoList<'_>, + ) -> Result<()> { + let needle = text.to_string_lossy().to_lowercase(); + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .and_then(|id| tree.state().node_by_id(id)) + .ok_or(AccessibilityError::BadParameter)?; + let node_ids = subtree_node_ids(&target); + for id in node_ids { + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + if !node_search_text(&node).to_lowercase().contains(&needle) { + continue; + } + let mut element = elements.add()?; + populate_element( + &node, + ids, + *accessibility_focus, + *screen_offset, + &mut element, + )?; + } + Ok(()) + } + + fn find_focused_node( + &self, + _element_id: i64, + focus_type: FocusType, + _request_id: i32, + element: &mut ElementInfo<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let id = match focus_type { + FocusType::Input => tree.state().focus_id().ok_or(AccessibilityError::Failed)?, + FocusType::Accessibility => accessibility_focus.ok_or(AccessibilityError::Failed)?, + FocusType::Invalid => return Err(AccessibilityError::BadParameter), + }; + let node = tree + .state() + .node_by_id(id) + .ok_or(AccessibilityError::Failed)?; + populate_element(&node, ids, *accessibility_focus, *screen_offset, element) + } + + fn find_next_focus_node( + &self, + element_id: i64, + direction: FocusMoveDirection, + _request_id: i32, + element: &mut ElementInfo<'_>, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let State { + tree, + ids, + accessibility_focus, + screen_offset, + .. + } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let current = ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + let focusable = subtree_node_ids(&tree.state().root()) + .into_iter() + .filter(|id| { + tree.state() + .node_by_id(*id) + .is_some_and(|node| node.is_focusable(&filter)) + }) + .collect::>(); + let current_index = focusable.iter().position(|id| *id == current); + let next = match direction { + FocusMoveDirection::Forward | FocusMoveDirection::Right | FocusMoveDirection::Down => { + current_index.map_or_else(|| focusable.first(), |index| focusable.get(index + 1)) + } + FocusMoveDirection::Backward | FocusMoveDirection::Left | FocusMoveDirection::Up => { + current_index.map_or_else( + || focusable.last(), + |index| index.checked_sub(1).and_then(|index| focusable.get(index)), + ) + } + FocusMoveDirection::Invalid => None, + } + .copied() + .ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(next) + .ok_or(AccessibilityError::Failed)?; + populate_element(&node, ids, *accessibility_focus, *screen_offset, element) + } + + fn execute_action( + &self, + element_id: i64, + action: ActionType, + arguments: &ActionArguments<'_>, + _request_id: i32, + ) -> Result<()> { + let mut state = self.lock()?; + state.initialize_tree(); + let root = state + .tree + .get() + .expect("tree initialized above") + .state() + .root_id(); + let target = state + .ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + + match action { + ActionType::GainAccessibilityFocus => { + state.accessibility_focus = Some(target); + return send_target_event(&mut state, target, EventType::AccessibilityFocused); + } + ActionType::ClearAccessibilityFocus => { + state.accessibility_focus = None; + return send_target_event(&mut state, target, EventType::AccessibilityFocusCleared); + } + #[cfg(feature = "api-15")] + ActionType::NextHtmlItem | ActionType::PreviousHtmlItem => { + let forward = action == ActionType::NextHtmlItem; + let next = adjacent_focusable_node(&state, target, forward) + .ok_or(AccessibilityError::Failed)?; + state.accessibility_focus = Some(next); + return send_target_event(&mut state, next, EventType::AccessibilityFocused); + } + _ => {} + } + + let request = action_request(&state, target, action, arguments)?; + drop(state); + self.action_handler + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)? + .do_action(request); + Ok(()) + } + + fn clear_focused_node(&self) -> Result<()> { + let mut state = self.lock()?; + let Some(target) = state.accessibility_focus.take() else { + return Ok(()); + }; + send_target_event(&mut state, target, EventType::AccessibilityFocusCleared) + } + + fn cursor_position(&self, element_id: i64, _request_id: i32) -> Result { + let mut state = self.lock()?; + state.initialize_tree(); + let State { tree, ids, .. } = &mut *state; + let tree = tree.get().expect("tree initialized above"); + let root = tree.state().root_id(); + let target = ids + .get_accesskit(element_id, root) + .ok_or(AccessibilityError::BadParameter)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + node.text_selection_focus() + .map(|position| position.to_global_utf16_index()) + .map(usize_to_i32) + .transpose()? + .ok_or(AccessibilityError::Failed) + } +} + +/// AccessKit adapter for ArkUI native accessibility providers. +pub struct Adapter<'a> { + _registration: ProviderRegistration<'a>, + state: Arc>, +} + +impl<'a> Adapter<'a> { + pub fn new( + provider: Provider<'a>, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + ) -> Result { + Self::new_with_registrar( + provider, + activation_handler, + action_handler, + |provider, callbacks| provider.register_callbacks(callbacks), + ) + } + + /// Register the multi-instance callback shape introduced in API 15. + /// + /// Give each registration a unique ID. This keeps callbacks for multiple + /// XComponent windows and recreated surfaces isolated in the Rust binding. + #[cfg(feature = "api-15")] + pub fn new_with_instance( + provider: Provider<'a>, + instance_id: &str, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + ) -> Result { + Self::new_with_registrar( + provider, + activation_handler, + action_handler, + |provider, callbacks| provider.register_callbacks_with_instance(instance_id, callbacks), + ) + } + + fn new_with_registrar( + provider: Provider<'a>, + activation_handler: impl ActivationHandler + Send + 'static, + action_handler: impl ActionHandler + Send + 'static, + register: impl FnOnce(Provider<'a>, Callbacks) -> Result>, + ) -> Result { + let action_handler: Arc>> = + Arc::new(Mutex::new(Box::new(action_handler))); + let state = Arc::new(Mutex::new(State { + tree: TreeLifecycle::Inactive, + activation_handler: Box::new(activation_handler), + ids: NodeIdMap::default(), + accessibility_focus: None, + host_is_focused: true, + screen_offset: (0, 0), + provider: ProviderAddress(provider.as_raw() as usize), + })); + let callbacks = Callbacks { + state: state.clone(), + action_handler, + }; + let registration = register(provider, callbacks)?; + Ok(Self { + _registration: registration, + state, + }) + } + + /// Apply an update only after ArkUI has activated this adapter by querying + /// its tree. + pub fn update_if_active(&self, update_factory: impl FnOnce() -> TreeUpdate) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + let old_focus = state.tree.get().map(|tree| tree.state().focus_id_in_tree()); + match &mut state.tree { + TreeLifecycle::Inactive => return Ok(()), + TreeLifecycle::Placeholder(_) => { + state.tree = + TreeLifecycle::Active(Tree::new(update_factory(), state.host_is_focused)); + } + TreeLifecycle::Active(tree) => { + tree.update_and_process_changes(update_factory(), &mut IgnoreChanges) + } + } + let new_focus = state.tree.get().map(|tree| tree.state().focus_id_in_tree()); + if state.accessibility_focus.is_some_and(|id| { + state + .tree + .get() + .is_none_or(|tree| tree.state().node_by_id(id).is_none()) + }) { + state.accessibility_focus = None; + } + send_root_event(&mut state, EventType::PageContentUpdate)?; + if old_focus != new_focus + && let Some(new_focus) = new_focus + { + send_target_event(&mut state, new_focus, EventType::FocusNodeUpdate)?; + } + Ok(()) + } + + pub fn set_host_focus_state(&self, is_focused: bool) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + state.host_is_focused = is_focused; + if let Some(tree) = state.tree.get_active_mut() { + tree.update_host_focus_state_and_process_changes(is_focused, &mut IgnoreChanges); + } + Ok(()) + } + + /// Apply an additional provider-local pixel offset to AccessKit bounds. + /// + /// ArkUI positions the provider's child tree at the XComponent/custom-node + /// origin automatically, so the default `(0, 0)` is correct for normal + /// hosts. Use this only when the AccessKit tree has a nested local origin. + pub fn set_screen_offset(&self, x: i32, y: i32) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + if state.screen_offset == (x, y) { + return Ok(()); + } + state.screen_offset = (x, y); + if state.tree.get().is_some() { + send_root_event(&mut state, EventType::PageStateUpdate)?; + } + Ok(()) + } + + /// Drop the retained tree. It will be requested again on the next native + /// accessibility query. + pub fn deactivate(&self) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|_| AccessibilityError::LockPoisoned)?; + state.tree = TreeLifecycle::Inactive; + state.accessibility_focus = None; + Ok(()) + } +} + +fn search_node_ids(target: &Node, mode: SearchMode) -> Vec { + let mut result = Vec::new(); + let mut seen = HashSet::new(); + let push = |node: Node, result: &mut Vec, seen: &mut HashSet| { + if filter(&node) == FilterResult::Include && seen.insert(node.id()) { + result.push(node.id()); + } + }; + + // CURRENT is represented by zero. ArkUI's recursive example also expects + // the queried node itself in the result. + push(*target, &mut result, &mut seen); + if mode.contains(SearchMode::PREDECESSORS) { + let mut parent = target.filtered_parent(&filter); + while let Some(node) = parent { + push(node, &mut result, &mut seen); + parent = node.filtered_parent(&filter); + } + } + if mode.contains(SearchMode::SIBLINGS) + && let Some(parent) = target.filtered_parent(&filter) + { + for node in parent.filtered_children(&filter) { + push(node, &mut result, &mut seen); + } + } + if mode.contains(SearchMode::CHILDREN) { + for node in target.filtered_children(&filter) { + push(node, &mut result, &mut seen); + } + } + if mode.contains(SearchMode::RECURSIVE_CHILDREN) { + for id in subtree_node_ids(target) { + if seen.insert(id) { + result.push(id); + } + } + } + result +} + +fn subtree_node_ids(root: &Node) -> Vec { + let mut result = Vec::new(); + let mut stack = vec![*root]; + while let Some(node) = stack.pop() { + match filter(&node) { + FilterResult::Include => result.push(node.id()), + FilterResult::ExcludeSubtree => continue, + FilterResult::ExcludeNode => {} + } + let children = node.children().collect::>(); + stack.extend(children.into_iter().rev()); + } + result +} + +fn node_search_text(node: &Node) -> String { + [node.label(), node.value(), node.description()] + .into_iter() + .flatten() + .collect::>() + .join(" ") +} + +#[cfg(feature = "api-15")] +fn adjacent_focusable_node(state: &State, target: NodeId, forward: bool) -> Option { + let tree = state.tree.get()?; + let nodes = subtree_node_ids(&tree.state().root()) + .into_iter() + .filter(|id| { + tree.state() + .node_by_id(*id) + .is_some_and(|node| node.is_focusable(&filter)) + }) + .collect::>(); + let index = nodes.iter().position(|id| *id == target)?; + if forward { + nodes.get(index + 1).copied() + } else { + index + .checked_sub(1) + .and_then(|index| nodes.get(index).copied()) + } +} + +fn action_request( + state: &State, + target: NodeId, + action: ActionType, + arguments: &ActionArguments<'_>, +) -> Result { + let tree = state.tree.get().ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + let (action, data) = match action { + ActionType::Click => (Action::Click, None), + ActionType::ScrollForward => { + let action = [Action::ScrollDown, Action::ScrollRight] + .into_iter() + .find(|action| node.supports_action(*action, &filter)) + .ok_or(AccessibilityError::Unsupported)?; + (action, None) + } + ActionType::ScrollBackward => { + let action = [Action::ScrollUp, Action::ScrollLeft] + .into_iter() + .find(|action| node.supports_action(*action, &filter)) + .ok_or(AccessibilityError::Unsupported)?; + (action, None) + } + ActionType::SetText => { + let value = argument_string( + arguments, + &["ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE", "setText", "text"], + ) + .ok_or(AccessibilityError::BadParameter)?; + (Action::SetValue, Some(ActionData::Value(value.into()))) + } + ActionType::SelectText | ActionType::SetCursorPosition => { + let start = argument_usize( + arguments, + &["ACTION_ARGUMENT_SELECTION_START_INT", "selectTextBegin"], + ) + .ok_or(AccessibilityError::BadParameter)?; + let end = if action == ActionType::SetCursorPosition { + start + } else { + argument_usize( + arguments, + &[ + "ACTION_ARGUMENT_SELECTION_END_INT", + "selectTextEnd", + "TextEnd", + ], + ) + .ok_or(AccessibilityError::BadParameter)? + }; + let anchor = node + .text_position_from_global_utf16_index(start) + .ok_or(AccessibilityError::BadParameter)?; + let focus = node + .text_position_from_global_utf16_index(end) + .ok_or(AccessibilityError::BadParameter)?; + ( + Action::SetTextSelection, + Some(ActionData::SetTextSelection(TextSelection { + anchor: anchor.to_raw(), + focus: focus.to_raw(), + })), + ) + } + _ => return Err(AccessibilityError::Unsupported), + }; + if !node.supports_action(action, &filter) { + return Err(AccessibilityError::Unsupported); + } + let (target_node, target_tree) = node.locate(); + Ok(ActionRequest { + action, + target_tree, + target_node, + data, + }) +} + +fn argument_string(arguments: &ActionArguments<'_>, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + arguments + .get(key) + .ok() + .flatten() + .map(|value| value.to_string_lossy().into_owned()) + }) +} + +fn argument_usize(arguments: &ActionArguments<'_>, keys: &[&str]) -> Option { + argument_string(arguments, keys)?.parse().ok() +} + +fn send_target_event(state: &mut State, target: NodeId, event_type: EventType) -> Result<()> { + let State { + tree, + ids, + accessibility_focus, + screen_offset, + provider, + .. + } = state; + let tree = tree.get().ok_or(AccessibilityError::Failed)?; + let node = tree + .state() + .node_by_id(target) + .ok_or(AccessibilityError::BadParameter)?; + provider.send( + event_type, + Some(&node), + ids, + *accessibility_focus, + *screen_offset, + ) +} + +fn send_root_event(state: &mut State, event_type: EventType) -> Result<()> { + let State { + tree, + ids, + accessibility_focus, + screen_offset, + provider, + .. + } = state; + let tree = tree.get().ok_or(AccessibilityError::Failed)?; + let root = tree.state().root(); + provider.send( + event_type, + Some(&root), + ids, + *accessibility_focus, + *screen_offset, + ) +} diff --git a/adapters/ohos/src/lib.rs b/adapters/ohos/src/lib.rs new file mode 100644 index 000000000..90314cd98 --- /dev/null +++ b/adapters/ohos/src/lib.rs @@ -0,0 +1,11 @@ +// 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. + +#![cfg(target_env = "ohos")] + +mod adapter; +mod node; + +pub use adapter::Adapter; diff --git a/adapters/ohos/src/node.rs b/adapters/ohos/src/node.rs new file mode 100644 index 000000000..8bd6015b0 --- /dev/null +++ b/adapters/ohos/src/node.rs @@ -0,0 +1,234 @@ +// 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::{Action, Role, Toggled}; +use accesskit_consumer::{ + FilterResult, FullNodeId, NodeRef as Node, common_filter_with_root_exception, +}; +use ohos_accessibility_binding::{ + AccessibilityError, AccessibleRect, ActionType, ElementInfo, GridInfo, GridItemInfo, RangeInfo, + Result, +}; +use std::ffi::CStr; + +use crate::adapter::{NodeIdMap, ROOT_PARENT_ID}; + +pub(crate) fn filter(node: &Node) -> FilterResult { + common_filter_with_root_exception(node) +} + +pub(crate) fn populate_element( + node: &Node, + ids: &mut NodeIdMap, + accessibility_focus: Option, + screen_offset: (i32, i32), + element: &mut ElementInfo<'_>, +) -> Result<()> { + let element_id = ids.get_or_create(node); + let parent_id = node + .filtered_parent(&filter) + .map(|parent| ids.get_or_create(&parent)) + .unwrap_or(ROOT_PARENT_ID); + let child_ids = node + .filtered_children(&filter) + .map(|child| i64::from(ids.get_or_create(&child))) + .collect::>(); + + element + .set_element_id(element_id)? + .set_parent_id(parent_id)? + .set_component_type(component_type(node.role()))? + .set_enabled(!node.is_disabled())? + .set_visible(!node.is_hidden())? + .set_focusable(node.is_focusable(&filter))? + .set_focused(node.is_focused())? + .set_accessibility_focused(accessibility_focus == Some(node.id()))? + .set_selected(node.is_selected().unwrap_or(false))? + .set_clickable(node.is_clickable(&filter))? + .set_long_clickable(false)? + .set_scrollable(is_scrollable(node))? + .set_editable(node.is_text_input() && !node.is_read_only())? + .set_password(node.role() == Role::PasswordInput)? + .set_accessibility_level(if node.is_root() { "no" } else { "yes" })?; + + // ArkUI rejects a zero child count with BAD_PARAMETER. Leaf nodes already + // have an empty child list by default, so only call the setter when there + // are IDs to copy. + if !child_ids.is_empty() { + element.set_child_node_ids(&child_ids)?; + } + + if let Some(contents) = node.value() { + element.set_contents(&contents)?; + } + if let Some(label) = node.label() { + element.set_accessibility_text(&label)?; + } + if let Some(description) = node.description() { + element.set_accessibility_description(&description)?; + } + if let Some(hint) = node.placeholder() { + element.set_hint_text(hint)?.set_is_hint(true)?; + } + #[cfg(feature = "api-24")] + if let Some(identifier) = node.author_id() { + element.set_component_identifier(identifier)?; + } + if let Some(bounds) = node.bounding_box() { + element.set_screen_rect(AccessibleRect { + left: coordinate_to_i32(bounds.x0).saturating_add(screen_offset.0), + top: coordinate_to_i32(bounds.y0).saturating_add(screen_offset.1), + right: coordinate_to_i32(bounds.x1).saturating_add(screen_offset.0), + bottom: coordinate_to_i32(bounds.y1).saturating_add(screen_offset.1), + })?; + } + + if let Some(toggled) = node.toggled() { + element + .set_checkable(true)? + .set_checked(toggled != Toggled::False)?; + } + + if let (Some(min), Some(max), Some(current)) = ( + node.min_numeric_value(), + node.max_numeric_value(), + node.numeric_value(), + ) { + element.set_range_info(RangeInfo { min, max, current })?; + } + + let data = node.data(); + if data.row_count().is_some() || data.column_count().is_some() { + element.set_grid_info(GridInfo { + rows: usize_to_i32(data.row_count().unwrap_or(0))?, + columns: usize_to_i32(data.column_count().unwrap_or(0))?, + selection_mode: i32::from(data.is_multiselectable()), + })?; + } + if data.row_index().is_some() || data.column_index().is_some() { + element.set_grid_item_info(GridItemInfo { + heading: matches!( + node.role(), + Role::RowHeader | Role::ColumnHeader | Role::Heading + ), + selected: node.is_selected().unwrap_or(false), + row_index: usize_to_i32(data.row_index().unwrap_or(0))?, + column_index: usize_to_i32(data.column_index().unwrap_or(0))?, + row_span: usize_to_i32(data.row_span().unwrap_or(1))?, + column_span: usize_to_i32(data.column_span().unwrap_or(1))?, + })?; + } + + if let Some(selection) = node.text_selection() { + element + .set_selected_text_start(usize_to_i32(selection.start().to_global_utf16_index())?)? + .set_selected_text_end(usize_to_i32(selection.end().to_global_utf16_index())?)?; + } + if let Some(position) = node.position_in_set() { + element.set_current_item_index(usize_to_i32(position)?)?; + } + if let Some(count) = node.size_of_set() { + element.set_item_count(usize_to_i32(count)?)?; + } + + let actions = supported_actions(node); + if !actions.is_empty() { + element.set_operation_actions(&actions)?; + } + + Ok(()) +} + +fn supported_actions(node: &Node) -> Vec<(ActionType, &'static CStr)> { + let mut actions = vec![ + ( + ActionType::GainAccessibilityFocus, + c"gain accessibility focus", + ), + ( + ActionType::ClearAccessibilityFocus, + c"clear accessibility focus", + ), + ]; + if node.supports_action(Action::Click, &filter) { + actions.push((ActionType::Click, c"click")); + } + if node.supports_action(Action::ScrollDown, &filter) + || node.supports_action(Action::ScrollRight, &filter) + { + actions.push((ActionType::ScrollForward, c"scroll forward")); + } + if node.supports_action(Action::ScrollUp, &filter) + || node.supports_action(Action::ScrollLeft, &filter) + { + actions.push((ActionType::ScrollBackward, c"scroll backward")); + } + if node.supports_action(Action::SetTextSelection, &filter) { + actions.push((ActionType::SelectText, c"select text")); + actions.push((ActionType::SetCursorPosition, c"set cursor position")); + } + if node.supports_action(Action::SetValue, &filter) { + actions.push((ActionType::SetText, c"set text")); + } + actions +} + +fn is_scrollable(node: &Node) -> bool { + [ + Action::ScrollDown, + Action::ScrollLeft, + Action::ScrollRight, + Action::ScrollUp, + ] + .into_iter() + .any(|action| node.supports_action(action, &filter)) +} + +fn coordinate_to_i32(value: f64) -> i32 { + if value.is_nan() { + 0 + } else { + value.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32 + } +} + +pub(crate) fn usize_to_i32(value: usize) -> Result { + i32::try_from(value).map_err(|_| AccessibilityError::BadParameter) +} + +fn component_type(role: Role) -> &'static str { + match role { + Role::Button | Role::DefaultButton | Role::PdfActionableHighlight => "button", + Role::CheckBox => "checkbox", + Role::RadioButton => "radio", + Role::Switch => "switch", + Role::TextInput + | Role::MultilineTextInput + | Role::SearchInput + | Role::EmailInput + | Role::NumberInput + | Role::PasswordInput + | Role::PhoneNumberInput + | Role::UrlInput => "textInput", + Role::Label | Role::TextRun => "text", + Role::Image | Role::SvgRoot => "image", + Role::Link => "link", + Role::List | Role::ListBox => "list", + Role::ListItem | Role::ListBoxOption => "listItem", + Role::Grid | Role::Table | Role::TreeGrid => "grid", + Role::Row => "row", + Role::Cell | Role::LayoutTableCell => "cell", + Role::Slider => "slider", + Role::ProgressIndicator | Role::Meter => "progress", + Role::ScrollView | Role::ScrollBar => "scroll", + Role::Dialog | Role::AlertDialog => "dialog", + Role::Menu | Role::MenuBar | Role::MenuListPopup => "menu", + Role::MenuItem | Role::MenuItemCheckBox | Role::MenuItemRadio => "menuItem", + Role::TabList => "tabs", + Role::Tab => "tab", + Role::Window | Role::RootWebArea | Role::Document => "root", + _ => "container", + } +} diff --git a/release-please-config.json b/release-please-config.json index f117c84c5..1c28e1434 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,6 +10,7 @@ "adapters/atspi-common": {}, "adapters/ios": {}, "adapters/macos": {}, + "adapters/ohos": {}, "adapters/unix": {}, "adapters/windows": {}, "adapters/winit": {}