1use rustc_hash::FxHashMap;
12use std::sync::{Arc, Mutex};
13
14use accesskit::{
15 Action, ActionHandler, ActionRequest, ActivationHandler, Live, Node, NodeId, Role, Toggled,
16 Tree, TreeId, TreeUpdate,
17};
18#[cfg(target_os = "linux")]
19use accesskit::DeactivationHandler;
20
21#[allow(unused_imports)]
22use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
23
24const ROOT_NODE_ID: NodeId = NodeId(u64::MAX);
27
28const DOCUMENT_NODE_ID: NodeId = NodeId(u64::MAX - 1);
32
33fn map_role(role: &AccessibilityRole) -> Role {
34 match role {
35 AccessibilityRole::None => Role::Unknown,
36 AccessibilityRole::Button => Role::Button,
37 AccessibilityRole::Link => Role::Link,
38 AccessibilityRole::Heading { .. } => Role::Heading,
39 AccessibilityRole::Label => Role::Label,
40 AccessibilityRole::StaticText => Role::Label,
41 AccessibilityRole::TextInput => Role::TextInput,
42 AccessibilityRole::TextArea => Role::MultilineTextInput,
43 AccessibilityRole::Checkbox => Role::CheckBox,
44 AccessibilityRole::RadioButton => Role::RadioButton,
45 AccessibilityRole::Slider => Role::Slider,
46 AccessibilityRole::Group => Role::Group,
47 AccessibilityRole::List => Role::List,
48 AccessibilityRole::ListItem => Role::ListItem,
49 AccessibilityRole::Menu => Role::Menu,
50 AccessibilityRole::MenuItem => Role::MenuItem,
51 AccessibilityRole::MenuBar => Role::MenuBar,
52 AccessibilityRole::Tab => Role::Tab,
53 AccessibilityRole::TabList => Role::TabList,
54 AccessibilityRole::TabPanel => Role::TabPanel,
55 AccessibilityRole::Dialog => Role::Dialog,
56 AccessibilityRole::AlertDialog => Role::AlertDialog,
57 AccessibilityRole::Toolbar => Role::Toolbar,
58 AccessibilityRole::Image => Role::Image,
59 AccessibilityRole::ProgressBar => Role::ProgressIndicator,
60 }
61}
62
63fn build_node(config: &AccessibilityConfig) -> Node {
64 let role = map_role(&config.role);
65 let mut node = Node::new(role);
66
67 if !config.label.is_empty() {
69 node.set_label(config.label.as_str());
70 }
71
72 if !config.description.is_empty() {
74 node.set_description(config.description.as_str());
75 }
76
77 if !config.value.is_empty() {
79 node.set_value(config.value.as_str());
80 }
81
82 if let Some(min) = config.value_min {
84 node.set_min_numeric_value(min as f64);
85 }
86 if let Some(max) = config.value_max {
87 node.set_max_numeric_value(max as f64);
88 }
89
90 if !config.value.is_empty() {
92 if let Ok(num) = config.value.parse::<f64>() {
93 node.set_numeric_value(num);
94 }
95 }
96
97 if let AccessibilityRole::Heading { level } = &config.role {
99 node.set_level(*level as usize);
100 }
101
102 if let Some(checked) = config.checked {
104 node.set_toggled(if checked {
105 Toggled::True
106 } else {
107 Toggled::False
108 });
109 }
110
111 match config.live_region {
113 LiveRegionMode::Off => {}
114 LiveRegionMode::Polite => {
115 node.set_live(Live::Polite);
116 }
117 LiveRegionMode::Assertive => {
118 node.set_live(Live::Assertive);
119 }
120 }
121
122 if config.focusable {
124 node.add_action(Action::Focus);
125 }
126 match config.role {
127 AccessibilityRole::Button | AccessibilityRole::Link | AccessibilityRole::MenuItem => {
128 node.add_action(Action::Click);
129 }
130 AccessibilityRole::Checkbox | AccessibilityRole::RadioButton => {
131 node.add_action(Action::Click);
132 }
133 AccessibilityRole::Slider => {
134 node.add_action(Action::Increment);
135 node.add_action(Action::Decrement);
136 node.add_action(Action::SetValue);
137 }
138 _ => {}
139 }
140
141 node
142}
143
144fn build_tree_update(
145 configs: &FxHashMap<u32, AccessibilityConfig>,
146 element_order: &[u32],
147 focused_id: u32,
148 include_tree: bool,
149) -> TreeUpdate {
150 let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(element_order.len() + 2);
151
152 let child_ids: Vec<NodeId> = element_order
154 .iter()
155 .filter(|id| configs.contains_key(id))
156 .map(|&id| NodeId(id as u64))
157 .collect();
158
159 let mut root_node = Node::new(Role::Window);
161 root_node.set_label("Ply Application");
162 root_node.set_children(vec![DOCUMENT_NODE_ID]);
163 nodes.push((ROOT_NODE_ID, root_node));
164
165 let mut doc_node = Node::new(Role::Document);
167 doc_node.set_children(child_ids);
168 nodes.push((DOCUMENT_NODE_ID, doc_node));
169
170 for &elem_id in element_order {
172 if let Some(config) = configs.get(&elem_id) {
173 let node = build_node(config);
174 nodes.push((NodeId(elem_id as u64), node));
175 }
176 }
177
178 let focus = if focused_id != 0 && configs.contains_key(&focused_id) {
180 NodeId(focused_id as u64)
181 } else {
182 ROOT_NODE_ID
183 };
184
185 let tree = if include_tree {
186 let mut t = Tree::new(ROOT_NODE_ID);
187 t.toolkit_name = Some("Ply Engine".to_string());
188 t.toolkit_version = Some(env!("CARGO_PKG_VERSION").to_string());
189 Some(t)
190 } else {
191 None
192 };
193
194 TreeUpdate {
195 nodes,
196 tree,
197 tree_id: TreeId::ROOT,
198 focus,
199 }
200}
201
202struct PlyActivationHandler {
205 initial_tree: Mutex<Option<TreeUpdate>>,
206}
207
208impl ActivationHandler for PlyActivationHandler {
209 fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
210 self.initial_tree
211 .lock()
212 .ok()
213 .and_then(|mut t| t.take())
214 }
215}
216
217struct PlyActionHandler {
220 queue: Arc<Mutex<Vec<ActionRequest>>>,
221}
222
223impl ActionHandler for PlyActionHandler {
224 fn do_action(&mut self, request: ActionRequest) {
225 if let Ok(mut q) = self.queue.lock() {
226 q.push(request);
227 }
228 }
229}
230
231#[cfg(target_os = "linux")]
234struct PlyDeactivationHandler;
235
236#[cfg(target_os = "linux")]
237impl DeactivationHandler for PlyDeactivationHandler {
238 fn deactivate_accessibility(&mut self) {
239 }
241}
242
243enum PlatformAdapter {
244 #[cfg(target_os = "linux")]
245 Unix(accesskit_unix::Adapter),
246 #[cfg(target_os = "macos")]
247 MacOs(accesskit_macos::SubclassingAdapter),
248 #[cfg(target_os = "windows")]
249 Windows,
252 #[cfg(target_os = "android")]
253 Android(accesskit_android::InjectingAdapter),
254 None,
256}
257
258#[cfg(target_os = "windows")]
259struct WindowsA11yState {
260 adapter: accesskit_windows::Adapter,
261 activation_handler: PlyActivationHandler,
262}
263
264#[cfg(target_os = "windows")]
269static WINDOWS_A11Y: std::sync::Mutex<Option<WindowsA11yState>> = std::sync::Mutex::new(None);
270
271#[cfg(target_os = "windows")]
273#[link(name = "comctl32")]
274extern "system" {
275 fn SetWindowSubclass(
276 hwnd: isize,
277 pfn_subclass: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize,
278 uid_subclass: usize,
279 dw_ref_data: usize,
280 ) -> i32;
281 fn DefSubclassProc(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
282}
283
284#[cfg(target_os = "windows")]
288unsafe extern "system" fn a11y_subclass_proc(
289 hwnd: isize,
290 msg: u32,
291 wparam: usize,
292 lparam: isize,
293 _uid_subclass: usize,
294 _dw_ref_data: usize,
295) -> isize {
296 const WM_GETOBJECT: u32 = 0x003D;
297 const WM_SETFOCUS: u32 = 0x0007;
298 const WM_KILLFOCUS: u32 = 0x0008;
299
300 match msg {
301 WM_GETOBJECT => {
302 let pending = {
306 if let Ok(mut guard) = WINDOWS_A11Y.lock() {
307 if let Some(state) = guard.as_mut() {
308 state.adapter.handle_wm_getobject(
309 accesskit_windows::WPARAM(wparam),
310 accesskit_windows::LPARAM(lparam),
311 &mut state.activation_handler,
312 )
313 } else {
314 None
315 }
316 } else {
317 None
318 }
319 };
320 if let Some(r) = pending {
322 let lresult: accesskit_windows::LRESULT = r.into();
323 return lresult.0;
324 }
325 DefSubclassProc(hwnd, msg, wparam, lparam)
326 }
327 WM_SETFOCUS | WM_KILLFOCUS => {
328 let is_focused = msg == WM_SETFOCUS;
329 let pending = {
330 if let Ok(mut guard) = WINDOWS_A11Y.lock() {
331 if let Some(state) = guard.as_mut() {
332 state.adapter.update_window_focus_state(is_focused)
333 } else {
334 None
335 }
336 } else {
337 None
338 }
339 };
340 if let Some(events) = pending {
341 events.raise();
342 }
343 DefSubclassProc(hwnd, msg, wparam, lparam)
345 }
346 _ => DefSubclassProc(hwnd, msg, wparam, lparam),
347 }
348}
349
350#[cfg(target_os = "linux")]
356fn ensure_screen_reader_enabled() {
357 use std::process::Command;
358
359 let sr_output = Command::new("busctl")
361 .args([
362 "--user",
363 "get-property",
364 "org.a11y.Bus",
365 "/org/a11y/bus",
366 "org.a11y.Status",
367 "ScreenReaderEnabled",
368 ])
369 .output();
370
371 let sr_enabled = match &sr_output {
372 Ok(out) => {
373 let stdout = String::from_utf8_lossy(&out.stdout);
374 stdout.trim() == "b true"
375 }
376 Err(_) => return, };
378
379 if sr_enabled {
380 return;
382 }
383
384 let is_output = Command::new("busctl")
386 .args([
387 "--user",
388 "get-property",
389 "org.a11y.Bus",
390 "/org/a11y/bus",
391 "org.a11y.Status",
392 "IsEnabled",
393 ])
394 .output();
395
396 let is_enabled = match &is_output {
397 Ok(out) => {
398 let stdout = String::from_utf8_lossy(&out.stdout);
399 stdout.trim() == "b true"
400 }
401 Err(_) => return,
402 };
403
404 if !is_enabled {
405 return;
407 }
408
409 let _ = Command::new("busctl")
412 .args([
413 "--user",
414 "set-property",
415 "org.a11y.Bus",
416 "/org/a11y/bus",
417 "org.a11y.Status",
418 "ScreenReaderEnabled",
419 "b",
420 "true",
421 ])
422 .output();
423}
424
425pub struct NativeAccessibilityState {
426 adapter: PlatformAdapter,
427 action_queue: Arc<Mutex<Vec<ActionRequest>>>,
428 initialized: bool,
429}
430
431impl Default for NativeAccessibilityState {
432 fn default() -> Self {
433 Self {
434 adapter: PlatformAdapter::None,
435 action_queue: Arc::new(Mutex::new(Vec::new())),
436 initialized: false,
437 }
438 }
439}
440
441impl NativeAccessibilityState {
442 fn initialize(
443 &mut self,
444 configs: &FxHashMap<u32, AccessibilityConfig>,
445 element_order: &[u32],
446 focused_id: u32,
447 ) {
448 let queue = self.action_queue.clone();
449 let initial_tree = build_tree_update(configs, element_order, focused_id, true);
450
451 #[cfg(target_os = "linux")]
452 {
453 let activation_handler = PlyActivationHandler {
454 initial_tree: Mutex::new(Some(initial_tree)),
455 };
456 let mut adapter = accesskit_unix::Adapter::new(
457 activation_handler,
458 PlyActionHandler { queue },
459 PlyDeactivationHandler,
460 );
461 adapter.update_window_focus_state(true);
463 self.adapter = PlatformAdapter::Unix(adapter);
464
465 std::thread::spawn(|| {
472 std::thread::sleep(std::time::Duration::from_millis(200));
474 ensure_screen_reader_enabled();
475 });
476 }
477
478 #[cfg(target_os = "macos")]
479 {
480 let view = macroquad::miniquad::window::apple_view() as *mut std::ffi::c_void;
487 let activation_handler = PlyActivationHandler {
488 initial_tree: Mutex::new(Some(initial_tree)),
489 };
490 let mut adapter = unsafe {
491 accesskit_macos::SubclassingAdapter::new(
492 view,
493 activation_handler,
494 PlyActionHandler { queue },
495 )
496 };
497 if let Some(events) = adapter.update_view_focus_state(true) {
499 events.raise();
500 }
501 self.adapter = PlatformAdapter::MacOs(adapter);
502 }
503
504 #[cfg(target_os = "windows")]
505 {
506 let hwnd_ptr = macroquad::miniquad::window::windows_hwnd();
518 let hwnd = accesskit_windows::HWND(hwnd_ptr);
519 let adapter = accesskit_windows::Adapter::new(
520 hwnd,
521 true, PlyActionHandler { queue },
523 );
524 let activation_handler = PlyActivationHandler {
525 initial_tree: Mutex::new(Some(initial_tree)),
526 };
527 *WINDOWS_A11Y.lock().unwrap() = Some(WindowsA11yState {
528 adapter,
529 activation_handler,
530 });
531 unsafe {
533 SetWindowSubclass(
534 hwnd_ptr as isize,
535 a11y_subclass_proc,
536 0xA11E, 0,
538 );
539 }
540 self.adapter = PlatformAdapter::Windows;
541 }
542
543 #[cfg(target_os = "android")]
544 {
545 use accesskit_android::jni;
552
553 let adapter = unsafe {
554 let raw_env = macroquad::miniquad::native::android::attach_jni_env();
555 let mut env = jni::JNIEnv::from_raw(raw_env as *mut _)
556 .expect("Failed to wrap JNIEnv");
557
558 let activity = jni::objects::JObject::from_raw(
559 macroquad::miniquad::native::android::ACTIVITY as _,
560 );
561
562 let window = env
564 .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])
565 .expect("getWindow() failed")
566 .l()
567 .expect("getWindow() did not return an object");
568
569 let decor_view = env
571 .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])
572 .expect("getDecorView() failed")
573 .l()
574 .expect("getDecorView() did not return an object");
575
576 accesskit_android::InjectingAdapter::new(
577 &mut env,
578 &decor_view,
579 PlyActivationHandler {
580 initial_tree: Mutex::new(Some(initial_tree)),
581 },
582 PlyActionHandler { queue },
583 )
584 };
585 self.adapter = PlatformAdapter::Android(adapter);
586 }
587
588 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "android")))]
589 {
590 let _ = (queue, initial_tree);
591 self.adapter = PlatformAdapter::None;
592 }
593
594 self.initialized = true;
595 }
596}
597
598pub enum PendingA11yAction {
600 Focus(u32),
602 Click(u32),
604}
605
606pub fn sync_accessibility_tree(
615 state: &mut NativeAccessibilityState,
616 accessibility_configs: &FxHashMap<u32, AccessibilityConfig>,
617 accessibility_element_order: &[u32],
618 focused_element_id: u32,
619) -> Vec<PendingA11yAction> {
620 if !state.initialized {
622 state.initialize(accessibility_configs, accessibility_element_order, focused_element_id);
623 }
624
625 let pending_actions: Vec<ActionRequest> = {
627 if let Ok(mut q) = state.action_queue.lock() {
628 q.drain(..).collect()
629 } else {
630 Vec::new()
631 }
632 };
633
634 let mut result = Vec::new();
636 for action in &pending_actions {
637 let target = action.target_node.0;
639 if target == ROOT_NODE_ID.0 || target == DOCUMENT_NODE_ID.0 {
640 continue;
641 }
642 let target_id = target as u32;
643 match action.action {
644 Action::Focus => {
645 result.push(PendingA11yAction::Focus(target_id));
646 }
647 Action::Click => {
648 result.push(PendingA11yAction::Click(target_id));
649 }
650 _ => {}
651 }
652 }
653
654 let update = build_tree_update(
656 accessibility_configs,
657 accessibility_element_order,
658 focused_element_id,
659 false,
660 );
661
662 match &mut state.adapter {
663 #[cfg(target_os = "linux")]
664 PlatformAdapter::Unix(adapter) => {
665 adapter.update_if_active(|| update);
666 }
667 #[cfg(target_os = "macos")]
668 PlatformAdapter::MacOs(adapter) => {
669 if let Some(events) = adapter.update_if_active(|| update) {
670 events.raise();
671 }
672 }
673 #[cfg(target_os = "windows")]
674 PlatformAdapter::Windows => {
675 let pending = {
677 let mut guard = WINDOWS_A11Y.lock().unwrap();
678 if let Some(state) = guard.as_mut() {
679 state.adapter.update_if_active(|| update)
680 } else {
681 None
682 }
683 };
684 if let Some(events) = pending {
685 events.raise();
686 }
687 }
688 #[cfg(target_os = "android")]
689 PlatformAdapter::Android(adapter) => {
690 adapter.update_if_active(|| update);
691 }
692 PlatformAdapter::None => {
693 let _ = update;
694 }
695 }
696
697 result
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
704
705 fn make_config(role: AccessibilityRole, label: &str) -> AccessibilityConfig {
706 AccessibilityConfig {
707 focusable: true,
708 role,
709 label: label.to_string(),
710 show_ring: true,
711 ..Default::default()
712 }
713 }
714
715 #[test]
716 fn role_mapping_covers_all_variants() {
717 let roles = vec![
719 AccessibilityRole::None,
720 AccessibilityRole::Button,
721 AccessibilityRole::Link,
722 AccessibilityRole::Heading { level: 1 },
723 AccessibilityRole::Label,
724 AccessibilityRole::StaticText,
725 AccessibilityRole::TextInput,
726 AccessibilityRole::TextArea,
727 AccessibilityRole::Checkbox,
728 AccessibilityRole::RadioButton,
729 AccessibilityRole::Slider,
730 AccessibilityRole::Group,
731 AccessibilityRole::List,
732 AccessibilityRole::ListItem,
733 AccessibilityRole::Menu,
734 AccessibilityRole::MenuItem,
735 AccessibilityRole::MenuBar,
736 AccessibilityRole::Tab,
737 AccessibilityRole::TabList,
738 AccessibilityRole::TabPanel,
739 AccessibilityRole::Dialog,
740 AccessibilityRole::AlertDialog,
741 AccessibilityRole::Toolbar,
742 AccessibilityRole::Image,
743 AccessibilityRole::ProgressBar,
744 ];
745 for role in roles {
746 let _ = map_role(&role);
747 }
748 }
749
750 #[test]
751 fn build_node_button() {
752 let config = make_config(AccessibilityRole::Button, "Click me");
753 let node = build_node(&config);
754 assert_eq!(node.role(), Role::Button);
755 assert_eq!(node.label(), Some("Click me"));
756 }
757
758 #[test]
759 fn build_node_heading_with_level() {
760 let config = make_config(AccessibilityRole::Heading { level: 2 }, "Section");
761 let node = build_node(&config);
762 assert_eq!(node.role(), Role::Heading);
763 assert_eq!(node.level(), Some(2));
764 assert_eq!(node.label(), Some("Section"));
765 }
766
767 #[test]
768 fn build_node_checkbox_toggled() {
769 let mut config = make_config(AccessibilityRole::Checkbox, "Agree");
770 config.checked = Some(true);
771 let node = build_node(&config);
772 assert_eq!(node.role(), Role::CheckBox);
773 assert_eq!(node.toggled(), Some(Toggled::True));
774 }
775
776 #[test]
777 fn build_node_slider_values() {
778 let mut config = make_config(AccessibilityRole::Slider, "Volume");
779 config.value = "50".to_string();
780 config.value_min = Some(0.0);
781 config.value_max = Some(100.0);
782 let node = build_node(&config);
783 assert_eq!(node.role(), Role::Slider);
784 assert_eq!(node.numeric_value(), Some(50.0));
785 assert_eq!(node.min_numeric_value(), Some(0.0));
786 assert_eq!(node.max_numeric_value(), Some(100.0));
787 }
788
789 #[test]
790 fn build_node_live_region() {
791 let mut config = make_config(AccessibilityRole::Label, "Status");
792 config.live_region = LiveRegionMode::Polite;
793 let node = build_node(&config);
794 assert_eq!(node.live(), Some(Live::Polite));
795 }
796
797 #[test]
798 fn build_node_description() {
799 let mut config = make_config(AccessibilityRole::Button, "Submit");
800 config.description = "Submit the form".to_string();
801 let node = build_node(&config);
802 assert_eq!(node.description(), Some("Submit the form"));
803 }
804
805 #[test]
806 fn build_tree_update_structure() {
807 let mut configs = FxHashMap::default();
808 configs.insert(101, make_config(AccessibilityRole::Button, "OK"));
809 configs.insert(102, make_config(AccessibilityRole::Button, "Cancel"));
810
811 let order = vec![101, 102];
812 let update = build_tree_update(&configs, &order, 101, true);
813
814 assert_eq!(update.nodes.len(), 4);
816
817 assert_eq!(update.nodes[0].0, ROOT_NODE_ID);
819 assert_eq!(update.nodes[0].1.role(), Role::Window);
820
821 assert_eq!(update.nodes[1].0, DOCUMENT_NODE_ID);
823 assert_eq!(update.nodes[1].1.role(), Role::Document);
824
825 assert_eq!(update.focus, NodeId(101));
827
828 let tree = update.tree.as_ref().unwrap();
830 assert_eq!(tree.root, ROOT_NODE_ID);
831 assert_eq!(tree.toolkit_name, Some("Ply Engine".to_string()));
832 }
833
834 #[test]
835 fn build_tree_update_no_focus() {
836 let configs = FxHashMap::default();
837 let order = vec![];
838 let update = build_tree_update(&configs, &order, 0, true);
839
840 assert_eq!(update.nodes.len(), 2);
842 assert_eq!(update.focus, ROOT_NODE_ID);
844 }
845
846 #[test]
847 fn default_state_is_uninitialized() {
848 let state = NativeAccessibilityState::default();
849 assert!(!state.initialized);
850 }
851}