use rustc_hash::FxHashMap;
use std::sync::{Arc, Mutex};
use accesskit::{
Action, ActionHandler, ActionRequest, ActivationHandler, Live, Node, NodeId, Rect, Role,
Toggled, Tree, TreeId, TreeUpdate,
};
#[cfg(target_os = "linux")]
use accesskit::DeactivationHandler;
#[allow(unused_imports)]
use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
use crate::math::{BoundingBox, Dimensions};
const ROOT_NODE_ID: NodeId = NodeId(u64::MAX);
const DOCUMENT_NODE_ID: NodeId = NodeId(u64::MAX - 1);
fn map_role(role: &AccessibilityRole) -> Role {
match role {
AccessibilityRole::None => Role::Unknown,
AccessibilityRole::Button => Role::Button,
AccessibilityRole::Link => Role::Link,
AccessibilityRole::Heading { .. } => Role::Heading,
AccessibilityRole::Label => Role::Label,
AccessibilityRole::StaticText => Role::Label,
AccessibilityRole::TextInput => Role::TextInput,
AccessibilityRole::TextArea => Role::MultilineTextInput,
AccessibilityRole::Checkbox => Role::CheckBox,
AccessibilityRole::RadioButton => Role::RadioButton,
AccessibilityRole::Slider => Role::Slider,
AccessibilityRole::Group => Role::Group,
AccessibilityRole::List => Role::List,
AccessibilityRole::ListItem => Role::ListItem,
AccessibilityRole::Menu => Role::Menu,
AccessibilityRole::MenuItem => Role::MenuItem,
AccessibilityRole::MenuBar => Role::MenuBar,
AccessibilityRole::Tab => Role::Tab,
AccessibilityRole::TabList => Role::TabList,
AccessibilityRole::TabPanel => Role::TabPanel,
AccessibilityRole::Dialog => Role::Dialog,
AccessibilityRole::AlertDialog => Role::AlertDialog,
AccessibilityRole::Toolbar => Role::Toolbar,
AccessibilityRole::Image => Role::Image,
AccessibilityRole::ProgressBar => Role::ProgressIndicator,
}
}
fn bounding_box_to_rect(bounds: BoundingBox) -> Rect {
Rect {
x0: bounds.x as f64,
y0: bounds.y as f64,
x1: (bounds.x + bounds.width) as f64,
y1: (bounds.y + bounds.height) as f64,
}
}
fn build_node(config: &AccessibilityConfig, bounds: BoundingBox) -> Node {
let role = map_role(&config.role);
let mut node = Node::new(role);
node.set_bounds(bounding_box_to_rect(bounds));
if !config.label.is_empty() {
node.set_label(config.label.as_str());
}
if !config.description.is_empty() {
node.set_description(config.description.as_str());
}
if !config.value.is_empty() {
node.set_value(config.value.as_str());
}
if let Some(min) = config.value_min {
node.set_min_numeric_value(min as f64);
}
if let Some(max) = config.value_max {
node.set_max_numeric_value(max as f64);
}
if !config.value.is_empty() {
if let Ok(num) = config.value.parse::<f64>() {
node.set_numeric_value(num);
}
}
if let AccessibilityRole::Heading { level } = &config.role {
node.set_level(*level as usize);
}
if let Some(checked) = config.checked {
node.set_toggled(if checked {
Toggled::True
} else {
Toggled::False
});
}
match config.live_region {
LiveRegionMode::Off => {}
LiveRegionMode::Polite => {
node.set_live(Live::Polite);
}
LiveRegionMode::Assertive => {
node.set_live(Live::Assertive);
}
}
if config.focusable {
node.add_action(Action::Focus);
}
match config.role {
AccessibilityRole::Button | AccessibilityRole::Link | AccessibilityRole::MenuItem => {
node.add_action(Action::Click);
}
AccessibilityRole::Checkbox | AccessibilityRole::RadioButton => {
node.add_action(Action::Click);
}
AccessibilityRole::Slider => {
node.add_action(Action::Increment);
node.add_action(Action::Decrement);
node.add_action(Action::SetValue);
}
_ => {}
}
node
}
fn build_tree_update(
configs: &FxHashMap<u32, AccessibilityConfig>,
bounds_by_id: &FxHashMap<u32, BoundingBox>,
element_order: &[u32],
focused_id: u32,
viewport: Dimensions,
include_tree: bool,
) -> TreeUpdate {
let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(element_order.len() + 2);
let child_ids: Vec<NodeId> = element_order
.iter()
.filter(|id| configs.contains_key(id) && bounds_by_id.contains_key(id))
.map(|&id| NodeId(id as u64))
.collect();
let viewport_bounds = BoundingBox::new(0.0, 0.0, viewport.width.max(0.0), viewport.height.max(0.0));
let mut root_node = Node::new(Role::Window);
root_node.set_label("Ply Application");
root_node.set_bounds(bounding_box_to_rect(viewport_bounds));
root_node.set_children(vec![DOCUMENT_NODE_ID]);
nodes.push((ROOT_NODE_ID, root_node));
let mut doc_node = Node::new(Role::Document);
doc_node.set_bounds(bounding_box_to_rect(viewport_bounds));
doc_node.set_children(child_ids);
nodes.push((DOCUMENT_NODE_ID, doc_node));
for &elem_id in element_order {
if let (Some(config), Some(bounds)) = (configs.get(&elem_id), bounds_by_id.get(&elem_id)) {
let node = build_node(config, *bounds);
nodes.push((NodeId(elem_id as u64), node));
}
}
let focus = if focused_id != 0 && configs.contains_key(&focused_id) && bounds_by_id.contains_key(&focused_id) {
NodeId(focused_id as u64)
} else {
ROOT_NODE_ID
};
let tree = if include_tree {
let mut t = Tree::new(ROOT_NODE_ID);
t.toolkit_name = Some("Ply Engine".to_string());
t.toolkit_version = Some(env!("CARGO_PKG_VERSION").to_string());
Some(t)
} else {
None
};
TreeUpdate {
nodes,
tree,
tree_id: TreeId::ROOT,
focus,
}
}
struct PlyActivationHandler {
initial_tree: Mutex<Option<TreeUpdate>>,
}
impl ActivationHandler for PlyActivationHandler {
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
self.initial_tree
.lock()
.ok()
.and_then(|mut t| t.take())
}
}
struct PlyActionHandler {
queue: Arc<Mutex<Vec<ActionRequest>>>,
}
impl ActionHandler for PlyActionHandler {
fn do_action(&mut self, request: ActionRequest) {
if let Ok(mut q) = self.queue.lock() {
q.push(request);
}
}
}
#[cfg(target_os = "linux")]
struct PlyDeactivationHandler;
#[cfg(target_os = "linux")]
impl DeactivationHandler for PlyDeactivationHandler {
fn deactivate_accessibility(&mut self) {
}
}
enum PlatformAdapter {
#[cfg(target_os = "linux")]
Unix(accesskit_unix::Adapter),
#[cfg(target_os = "macos")]
MacOs(accesskit_macos::SubclassingAdapter),
#[cfg(target_os = "windows")]
Windows,
#[cfg(target_os = "android")]
Android(accesskit_android::InjectingAdapter),
None,
}
#[cfg(target_os = "windows")]
struct WindowsA11yState {
adapter: accesskit_windows::Adapter,
activation_handler: PlyActivationHandler,
}
#[cfg(target_os = "windows")]
static WINDOWS_A11Y: std::sync::Mutex<Option<WindowsA11yState>> = std::sync::Mutex::new(None);
#[cfg(target_os = "windows")]
#[link(name = "comctl32")]
extern "system" {
fn SetWindowSubclass(
hwnd: isize,
pfn_subclass: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize,
uid_subclass: usize,
dw_ref_data: usize,
) -> i32;
fn DefSubclassProc(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
}
#[cfg(target_os = "windows")]
unsafe extern "system" fn a11y_subclass_proc(
hwnd: isize,
msg: u32,
wparam: usize,
lparam: isize,
_uid_subclass: usize,
_dw_ref_data: usize,
) -> isize {
const WM_GETOBJECT: u32 = 0x003D;
const WM_SETFOCUS: u32 = 0x0007;
const WM_KILLFOCUS: u32 = 0x0008;
match msg {
WM_GETOBJECT => {
let pending = {
if let Ok(mut guard) = WINDOWS_A11Y.lock() {
if let Some(state) = guard.as_mut() {
state.adapter.handle_wm_getobject(
accesskit_windows::WPARAM(wparam),
accesskit_windows::LPARAM(lparam),
&mut state.activation_handler,
)
} else {
None
}
} else {
None
}
};
if let Some(r) = pending {
let lresult: accesskit_windows::LRESULT = r.into();
return lresult.0;
}
DefSubclassProc(hwnd, msg, wparam, lparam)
}
WM_SETFOCUS | WM_KILLFOCUS => {
let is_focused = msg == WM_SETFOCUS;
let pending = {
if let Ok(mut guard) = WINDOWS_A11Y.lock() {
if let Some(state) = guard.as_mut() {
state.adapter.update_window_focus_state(is_focused)
} else {
None
}
} else {
None
}
};
if let Some(events) = pending {
events.raise();
}
DefSubclassProc(hwnd, msg, wparam, lparam)
}
_ => DefSubclassProc(hwnd, msg, wparam, lparam),
}
}
#[cfg(target_os = "linux")]
fn ensure_screen_reader_enabled() {
use std::process::Command;
let sr_output = Command::new("busctl")
.args([
"--user",
"get-property",
"org.a11y.Bus",
"/org/a11y/bus",
"org.a11y.Status",
"ScreenReaderEnabled",
])
.output();
let sr_enabled = match &sr_output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.trim() == "b true"
}
Err(_) => return, };
if sr_enabled {
return;
}
let is_output = Command::new("busctl")
.args([
"--user",
"get-property",
"org.a11y.Bus",
"/org/a11y/bus",
"org.a11y.Status",
"IsEnabled",
])
.output();
let is_enabled = match &is_output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.trim() == "b true"
}
Err(_) => return,
};
if !is_enabled {
return;
}
let _ = Command::new("busctl")
.args([
"--user",
"set-property",
"org.a11y.Bus",
"/org/a11y/bus",
"org.a11y.Status",
"ScreenReaderEnabled",
"b",
"true",
])
.output();
}
pub struct NativeAccessibilityState {
adapter: PlatformAdapter,
action_queue: Arc<Mutex<Vec<ActionRequest>>>,
initialized: bool,
}
impl Default for NativeAccessibilityState {
fn default() -> Self {
Self {
adapter: PlatformAdapter::None,
action_queue: Arc::new(Mutex::new(Vec::new())),
initialized: false,
}
}
}
impl NativeAccessibilityState {
fn initialize(
&mut self,
configs: &FxHashMap<u32, AccessibilityConfig>,
bounds_by_id: &FxHashMap<u32, BoundingBox>,
element_order: &[u32],
focused_id: u32,
viewport: Dimensions,
) {
let queue = self.action_queue.clone();
let initial_tree = build_tree_update(
configs,
bounds_by_id,
element_order,
focused_id,
viewport,
true,
);
#[cfg(target_os = "linux")]
{
let activation_handler = PlyActivationHandler {
initial_tree: Mutex::new(Some(initial_tree)),
};
let mut adapter = accesskit_unix::Adapter::new(
activation_handler,
PlyActionHandler { queue },
PlyDeactivationHandler,
);
adapter.update_window_focus_state(true);
self.adapter = PlatformAdapter::Unix(adapter);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(200));
ensure_screen_reader_enabled();
});
}
#[cfg(target_os = "macos")]
{
let view = macroquad::miniquad::window::apple_view() as *mut std::ffi::c_void;
let activation_handler = PlyActivationHandler {
initial_tree: Mutex::new(Some(initial_tree)),
};
let mut adapter = unsafe {
accesskit_macos::SubclassingAdapter::new(
view,
activation_handler,
PlyActionHandler { queue },
)
};
if let Some(events) = adapter.update_view_focus_state(true) {
events.raise();
}
self.adapter = PlatformAdapter::MacOs(adapter);
}
#[cfg(target_os = "windows")]
{
let hwnd_ptr = macroquad::miniquad::window::windows_hwnd();
let hwnd = accesskit_windows::HWND(hwnd_ptr);
let adapter = accesskit_windows::Adapter::new(
hwnd,
true, PlyActionHandler { queue },
);
let activation_handler = PlyActivationHandler {
initial_tree: Mutex::new(Some(initial_tree)),
};
*WINDOWS_A11Y.lock().unwrap() = Some(WindowsA11yState {
adapter,
activation_handler,
});
unsafe {
SetWindowSubclass(
hwnd_ptr as isize,
a11y_subclass_proc,
0xA11E, 0,
);
}
self.adapter = PlatformAdapter::Windows;
}
#[cfg(target_os = "android")]
{
use accesskit_android::jni::{self, objects::JValue};
let adapter = unsafe {
let raw_env = macroquad::miniquad::native::android::attach_jni_env();
let mut env = jni::JNIEnv::from_raw(raw_env as *mut _)
.expect("Failed to wrap JNIEnv");
let activity = jni::objects::JObject::from_raw(
macroquad::miniquad::native::android::ACTIVITY as _,
);
let focused_view = env
.call_method(&activity, "getCurrentFocus", "()Landroid/view/View;", &[])
.expect("getCurrentFocus() failed")
.l()
.unwrap_or(jni::objects::JObject::null());
let host_view = if !focused_view.is_null() {
focused_view
} else {
let content_view = env
.call_method(
&activity,
"findViewById",
"(I)Landroid/view/View;",
&[JValue::Int(16908290)],
)
.expect("findViewById(android.R.id.content) failed")
.l()
.expect("findViewById(android.R.id.content) did not return an object");
if !content_view.is_null() {
content_view
} else {
let window = env
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])
.expect("getWindow() failed")
.l()
.expect("getWindow() did not return an object");
env.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])
.expect("getDecorView() failed")
.l()
.expect("getDecorView() did not return an object")
}
};
accesskit_android::InjectingAdapter::new(
&mut env,
&host_view,
PlyActivationHandler {
initial_tree: Mutex::new(Some(initial_tree)),
},
PlyActionHandler { queue },
)
};
self.adapter = PlatformAdapter::Android(adapter);
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "android")))]
{
let _ = (queue, initial_tree);
self.adapter = PlatformAdapter::None;
}
self.initialized = true;
}
}
pub enum PendingA11yAction {
Focus(u32),
Click(u32),
}
pub fn sync_accessibility_tree(
state: &mut NativeAccessibilityState,
accessibility_configs: &FxHashMap<u32, AccessibilityConfig>,
accessibility_bounds: &FxHashMap<u32, BoundingBox>,
accessibility_element_order: &[u32],
focused_element_id: u32,
viewport: Dimensions,
) -> Vec<PendingA11yAction> {
if !state.initialized {
state.initialize(
accessibility_configs,
accessibility_bounds,
accessibility_element_order,
focused_element_id,
viewport,
);
}
let pending_actions: Vec<ActionRequest> = {
if let Ok(mut q) = state.action_queue.lock() {
q.drain(..).collect()
} else {
Vec::new()
}
};
let mut result = Vec::new();
for action in &pending_actions {
let target = action.target_node.0;
if target == ROOT_NODE_ID.0 || target == DOCUMENT_NODE_ID.0 {
continue;
}
let target_id = target as u32;
match action.action {
Action::Focus => {
result.push(PendingA11yAction::Focus(target_id));
}
Action::Click => {
result.push(PendingA11yAction::Click(target_id));
}
_ => {}
}
}
let update = build_tree_update(
accessibility_configs,
accessibility_bounds,
accessibility_element_order,
focused_element_id,
viewport,
false,
);
match &mut state.adapter {
#[cfg(target_os = "linux")]
PlatformAdapter::Unix(adapter) => {
adapter.update_if_active(|| update);
}
#[cfg(target_os = "macos")]
PlatformAdapter::MacOs(adapter) => {
if let Some(events) = adapter.update_if_active(|| update) {
events.raise();
}
}
#[cfg(target_os = "windows")]
PlatformAdapter::Windows => {
let pending = {
let mut guard = WINDOWS_A11Y.lock().unwrap();
if let Some(state) = guard.as_mut() {
state.adapter.update_if_active(|| update)
} else {
None
}
};
if let Some(events) = pending {
events.raise();
}
}
#[cfg(target_os = "android")]
PlatformAdapter::Android(adapter) => {
adapter.update_if_active(|| update);
}
PlatformAdapter::None => {
let _ = update;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
fn make_config(role: AccessibilityRole, label: &str) -> AccessibilityConfig {
AccessibilityConfig {
focusable: true,
role,
label: label.to_string(),
show_ring: true,
..Default::default()
}
}
#[test]
fn role_mapping_covers_all_variants() {
let roles = vec![
AccessibilityRole::None,
AccessibilityRole::Button,
AccessibilityRole::Link,
AccessibilityRole::Heading { level: 1 },
AccessibilityRole::Label,
AccessibilityRole::StaticText,
AccessibilityRole::TextInput,
AccessibilityRole::TextArea,
AccessibilityRole::Checkbox,
AccessibilityRole::RadioButton,
AccessibilityRole::Slider,
AccessibilityRole::Group,
AccessibilityRole::List,
AccessibilityRole::ListItem,
AccessibilityRole::Menu,
AccessibilityRole::MenuItem,
AccessibilityRole::MenuBar,
AccessibilityRole::Tab,
AccessibilityRole::TabList,
AccessibilityRole::TabPanel,
AccessibilityRole::Dialog,
AccessibilityRole::AlertDialog,
AccessibilityRole::Toolbar,
AccessibilityRole::Image,
AccessibilityRole::ProgressBar,
];
for role in roles {
let _ = map_role(&role);
}
}
#[test]
fn build_node_button() {
let config = make_config(AccessibilityRole::Button, "Click me");
let node = build_node(&config, BoundingBox::new(10.0, 20.0, 30.0, 40.0));
assert_eq!(node.role(), Role::Button);
assert_eq!(node.label(), Some("Click me"));
}
#[test]
fn build_node_heading_with_level() {
let config = make_config(AccessibilityRole::Heading { level: 2 }, "Section");
let node = build_node(&config, BoundingBox::new(0.0, 0.0, 100.0, 24.0));
assert_eq!(node.role(), Role::Heading);
assert_eq!(node.level(), Some(2));
assert_eq!(node.label(), Some("Section"));
}
#[test]
fn build_node_checkbox_toggled() {
let mut config = make_config(AccessibilityRole::Checkbox, "Agree");
config.checked = Some(true);
let node = build_node(&config, BoundingBox::new(0.0, 0.0, 20.0, 20.0));
assert_eq!(node.role(), Role::CheckBox);
assert_eq!(node.toggled(), Some(Toggled::True));
}
#[test]
fn build_node_slider_values() {
let mut config = make_config(AccessibilityRole::Slider, "Volume");
config.value = "50".to_string();
config.value_min = Some(0.0);
config.value_max = Some(100.0);
let node = build_node(&config, BoundingBox::new(0.0, 0.0, 120.0, 24.0));
assert_eq!(node.role(), Role::Slider);
assert_eq!(node.numeric_value(), Some(50.0));
assert_eq!(node.min_numeric_value(), Some(0.0));
assert_eq!(node.max_numeric_value(), Some(100.0));
}
#[test]
fn build_node_live_region() {
let mut config = make_config(AccessibilityRole::Label, "Status");
config.live_region = LiveRegionMode::Polite;
let node = build_node(&config, BoundingBox::new(0.0, 0.0, 80.0, 24.0));
assert_eq!(node.live(), Some(Live::Polite));
}
#[test]
fn build_node_description() {
let mut config = make_config(AccessibilityRole::Button, "Submit");
config.description = "Submit the form".to_string();
let node = build_node(&config, BoundingBox::new(0.0, 0.0, 80.0, 24.0));
assert_eq!(node.description(), Some("Submit the form"));
}
#[test]
fn build_tree_update_structure() {
let mut configs = FxHashMap::default();
let mut bounds = FxHashMap::default();
configs.insert(101, make_config(AccessibilityRole::Button, "OK"));
configs.insert(102, make_config(AccessibilityRole::Button, "Cancel"));
bounds.insert(101, BoundingBox::new(10.0, 10.0, 80.0, 32.0));
bounds.insert(102, BoundingBox::new(100.0, 10.0, 80.0, 32.0));
let order = vec![101, 102];
let update = build_tree_update(&configs, &bounds, &order, 101, Dimensions::new(320.0, 240.0), true);
assert_eq!(update.nodes.len(), 4);
assert_eq!(update.nodes[0].0, ROOT_NODE_ID);
assert_eq!(update.nodes[0].1.role(), Role::Window);
assert_eq!(update.nodes[1].0, DOCUMENT_NODE_ID);
assert_eq!(update.nodes[1].1.role(), Role::Document);
assert_eq!(update.focus, NodeId(101));
let tree = update.tree.as_ref().unwrap();
assert_eq!(tree.root, ROOT_NODE_ID);
assert_eq!(tree.toolkit_name, Some("Ply Engine".to_string()));
}
#[test]
fn build_tree_update_no_focus() {
let configs = FxHashMap::default();
let bounds = FxHashMap::default();
let order = vec![];
let update = build_tree_update(&configs, &bounds, &order, 0, Dimensions::new(320.0, 240.0), true);
assert_eq!(update.nodes.len(), 2);
assert_eq!(update.focus, ROOT_NODE_ID);
}
#[test]
fn default_state_is_uninitialized() {
let state = NativeAccessibilityState::default();
assert!(!state.initialized);
}
}