Skip to main content

ply_engine/
accessibility_native.rs

1//! Native platform accessibility via AccessKit.
2//!
3//! On non-WASM platforms, this module creates an AccessKit adapter that exposes
4//! Ply's accessible elements to system screen readers (Orca on Linux, VoiceOver
5//! on macOS, Narrator/NVDA on Windows).
6//!
7//! Thread safety: AccessKit handler traits are called from another thread on
8//! some platforms (notably Linux/AT-SPI). We use `Arc<Mutex<>>` for shared
9//! state between the main loop and the adapter thread.
10
11use rustc_hash::FxHashMap;
12use std::sync::{Arc, Mutex};
13
14use accesskit::{
15    Action, ActionHandler, ActionRequest, ActivationHandler, Live, Node, NodeId, Rect, Role,
16    Toggled, Tree, TreeId, TreeUpdate,
17};
18#[cfg(target_os = "linux")]
19use accesskit::DeactivationHandler;
20
21#[allow(unused_imports)]
22use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
23use crate::math::{BoundingBox, Dimensions};
24
25/// Sentinel NodeId for the root window node.
26/// We use u64::MAX to avoid collision with element IDs (which are u32 hash values).
27const ROOT_NODE_ID: NodeId = NodeId(u64::MAX);
28
29/// Sentinel NodeId for the document container node.
30/// Placed as the sole child of the root window, all accessible elements are its children.
31/// This enables structural navigation in screen readers (e.g. Orca's Insert+Z).
32const DOCUMENT_NODE_ID: NodeId = NodeId(u64::MAX - 1);
33
34fn map_role(role: &AccessibilityRole) -> Role {
35    match role {
36        AccessibilityRole::None => Role::Unknown,
37        AccessibilityRole::Button => Role::Button,
38        AccessibilityRole::Link => Role::Link,
39        AccessibilityRole::Heading { .. } => Role::Heading,
40        AccessibilityRole::Label => Role::Label,
41        AccessibilityRole::StaticText => Role::Label,
42        AccessibilityRole::TextInput => Role::TextInput,
43        AccessibilityRole::TextArea => Role::MultilineTextInput,
44        AccessibilityRole::Checkbox => Role::CheckBox,
45        AccessibilityRole::RadioButton => Role::RadioButton,
46        AccessibilityRole::Slider => Role::Slider,
47        AccessibilityRole::Group => Role::Group,
48        AccessibilityRole::List => Role::List,
49        AccessibilityRole::ListItem => Role::ListItem,
50        AccessibilityRole::Menu => Role::Menu,
51        AccessibilityRole::MenuItem => Role::MenuItem,
52        AccessibilityRole::MenuBar => Role::MenuBar,
53        AccessibilityRole::Tab => Role::Tab,
54        AccessibilityRole::TabList => Role::TabList,
55        AccessibilityRole::TabPanel => Role::TabPanel,
56        AccessibilityRole::Dialog => Role::Dialog,
57        AccessibilityRole::AlertDialog => Role::AlertDialog,
58        AccessibilityRole::Toolbar => Role::Toolbar,
59        AccessibilityRole::Image => Role::Image,
60        AccessibilityRole::ProgressBar => Role::ProgressIndicator,
61    }
62}
63
64fn bounding_box_to_rect(bounds: BoundingBox) -> Rect {
65    Rect {
66        x0: bounds.x as f64,
67        y0: bounds.y as f64,
68        x1: (bounds.x + bounds.width) as f64,
69        y1: (bounds.y + bounds.height) as f64,
70    }
71}
72
73fn build_node(config: &AccessibilityConfig, bounds: BoundingBox) -> Node {
74    let role = map_role(&config.role);
75    let mut node = Node::new(role);
76    node.set_bounds(bounding_box_to_rect(bounds));
77
78    // Label
79    if !config.label.is_empty() {
80        node.set_label(config.label.as_str());
81    }
82
83    // Description
84    if !config.description.is_empty() {
85        node.set_description(config.description.as_str());
86    }
87
88    // Value (text value for sliders, progress bars, etc.)
89    if !config.value.is_empty() {
90        node.set_value(config.value.as_str());
91    }
92
93    // Numeric value bounds (for sliders)
94    if let Some(min) = config.value_min {
95        node.set_min_numeric_value(min as f64);
96    }
97    if let Some(max) = config.value_max {
98        node.set_max_numeric_value(max as f64);
99    }
100
101    // If we have a numeric value, try to parse it
102    if !config.value.is_empty() {
103        if let Ok(num) = config.value.parse::<f64>() {
104            node.set_numeric_value(num);
105        }
106    }
107
108    // Heading level
109    if let AccessibilityRole::Heading { level } = &config.role {
110        node.set_level(*level as usize);
111    }
112
113    // Checked/toggled state (checkboxes, radio buttons)
114    if let Some(checked) = config.checked {
115        node.set_toggled(if checked {
116            Toggled::True
117        } else {
118            Toggled::False
119        });
120    }
121
122    // Live region
123    match config.live_region {
124        LiveRegionMode::Off => {}
125        LiveRegionMode::Polite => {
126            node.set_live(Live::Polite);
127        }
128        LiveRegionMode::Assertive => {
129            node.set_live(Live::Assertive);
130        }
131    }
132
133    // Declare supported actions based on role
134    if config.focusable {
135        node.add_action(Action::Focus);
136    }
137    match config.role {
138        AccessibilityRole::Button | AccessibilityRole::Link | AccessibilityRole::MenuItem => {
139            node.add_action(Action::Click);
140        }
141        AccessibilityRole::Checkbox | AccessibilityRole::RadioButton => {
142            node.add_action(Action::Click);
143        }
144        AccessibilityRole::Slider => {
145            node.add_action(Action::Increment);
146            node.add_action(Action::Decrement);
147            node.add_action(Action::SetValue);
148        }
149        _ => {}
150    }
151
152    node
153}
154
155fn build_tree_update(
156    configs: &FxHashMap<u32, AccessibilityConfig>,
157    bounds_by_id: &FxHashMap<u32, BoundingBox>,
158    element_order: &[u32],
159    focused_id: u32,
160    viewport: Dimensions,
161    include_tree: bool,
162) -> TreeUpdate {
163    let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(element_order.len() + 2);
164
165    // Collect child NodeIds for the document container
166    let child_ids: Vec<NodeId> = element_order
167        .iter()
168        .filter(|id| configs.contains_key(id) && bounds_by_id.contains_key(id))
169        .map(|&id| NodeId(id as u64))
170        .collect();
171
172    let viewport_bounds = BoundingBox::new(0.0, 0.0, viewport.width.max(0.0), viewport.height.max(0.0));
173
174    // Root window → Document → accessible elements
175    let mut root_node = Node::new(Role::Window);
176    root_node.set_label("Ply Application");
177    root_node.set_bounds(bounding_box_to_rect(viewport_bounds));
178    root_node.set_children(vec![DOCUMENT_NODE_ID]);
179    nodes.push((ROOT_NODE_ID, root_node));
180
181    // Document container enables structural navigation in screen readers
182    let mut doc_node = Node::new(Role::Document);
183    doc_node.set_bounds(bounding_box_to_rect(viewport_bounds));
184    doc_node.set_children(child_ids);
185    nodes.push((DOCUMENT_NODE_ID, doc_node));
186
187    // Build child nodes
188    for &elem_id in element_order {
189        if let (Some(config), Some(bounds)) = (configs.get(&elem_id), bounds_by_id.get(&elem_id)) {
190            let node = build_node(config, *bounds);
191            nodes.push((NodeId(elem_id as u64), node));
192        }
193    }
194
195    // Determine focus: if focused_id is 0 (no focus), point to root
196    let focus = if focused_id != 0 && configs.contains_key(&focused_id) && bounds_by_id.contains_key(&focused_id) {
197        NodeId(focused_id as u64)
198    } else {
199        ROOT_NODE_ID
200    };
201
202    let tree = if include_tree {
203        let mut t = Tree::new(ROOT_NODE_ID);
204        t.toolkit_name = Some("Ply Engine".to_string());
205        t.toolkit_version = Some(env!("CARGO_PKG_VERSION").to_string());
206        Some(t)
207    } else {
208        None
209    };
210
211    TreeUpdate {
212        nodes,
213        tree,
214        tree_id: TreeId::ROOT,
215        focus,
216    }
217}
218
219/// ActivationHandler: called when an assistive technology activates.
220/// Holds a pre-built initial tree so the adapter is immediately ready.
221struct PlyActivationHandler {
222    initial_tree: Mutex<Option<TreeUpdate>>,
223}
224
225impl ActivationHandler for PlyActivationHandler {
226    fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
227        self.initial_tree
228            .lock()
229            .ok()
230            .and_then(|mut t| t.take())
231    }
232}
233
234/// ActionHandler: queues incoming screen reader action requests for processing
235/// on the main thread during the next eval() cycle.
236struct PlyActionHandler {
237    queue: Arc<Mutex<Vec<ActionRequest>>>,
238}
239
240impl ActionHandler for PlyActionHandler {
241    fn do_action(&mut self, request: ActionRequest) {
242        if let Ok(mut q) = self.queue.lock() {
243            q.push(request);
244        }
245    }
246}
247
248/// DeactivationHandler: called when the assistive technology disconnects.
249/// Only used on Linux (AT-SPI); macOS and Windows adapters don't require one.
250#[cfg(target_os = "linux")]
251struct PlyDeactivationHandler;
252
253#[cfg(target_os = "linux")]
254impl DeactivationHandler for PlyDeactivationHandler {
255    fn deactivate_accessibility(&mut self) {
256        // Nothing to clean up
257    }
258}
259
260enum PlatformAdapter {
261    #[cfg(target_os = "linux")]
262    Unix(accesskit_unix::Adapter),
263    #[cfg(target_os = "macos")]
264    MacOs(accesskit_macos::SubclassingAdapter),
265    #[cfg(target_os = "windows")]
266    /// Marker — actual adapter lives in the `WINDOWS_A11Y` static so the
267    /// wndproc hook (a plain `fn` pointer) can access it for `WM_GETOBJECT`.
268    Windows,
269    #[cfg(target_os = "android")]
270    Android(accesskit_android::InjectingAdapter),
271    /// Fallback for platforms without an adapter (e.g. iOS in the future).
272    None,
273}
274
275#[cfg(target_os = "windows")]
276struct WindowsA11yState {
277    adapter: accesskit_windows::Adapter,
278    activation_handler: PlyActivationHandler,
279}
280
281/// The Windows AccessKit adapter must be accessible from the window subclass
282/// procedure (a plain `extern "system"` callback), so we store it in a static.
283/// The `Mutex` is released *before* calling `.into()` on `handle_wm_getobject`'s
284/// return value, which may trigger nested `WM_GETOBJECT` — avoiding deadlock.
285#[cfg(target_os = "windows")]
286static WINDOWS_A11Y: std::sync::Mutex<Option<WindowsA11yState>> = std::sync::Mutex::new(None);
287
288// Win32 FFI for window subclassing (comctl32.dll).
289#[cfg(target_os = "windows")]
290#[link(name = "comctl32")]
291extern "system" {
292    fn SetWindowSubclass(
293        hwnd: isize,
294        pfn_subclass: unsafe extern "system" fn(isize, u32, usize, isize, usize, usize) -> isize,
295        uid_subclass: usize,
296        dw_ref_data: usize,
297    ) -> i32;
298    fn DefSubclassProc(hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
299}
300
301/// Subclass procedure installed on miniquad's window. Forwards
302/// `WM_GETOBJECT` to AccessKit and relays focus changes to the adapter.
303/// All other messages are passed through to the original window procedure.
304#[cfg(target_os = "windows")]
305unsafe extern "system" fn a11y_subclass_proc(
306    hwnd: isize,
307    msg: u32,
308    wparam: usize,
309    lparam: isize,
310    _uid_subclass: usize,
311    _dw_ref_data: usize,
312) -> isize {
313    const WM_GETOBJECT: u32 = 0x003D;
314    const WM_SETFOCUS: u32 = 0x0007;
315    const WM_KILLFOCUS: u32 = 0x0008;
316
317    match msg {
318        WM_GETOBJECT => {
319            // Acquire lock → call handle_wm_getobject → release lock → .into()
320            // The .into() may trigger a nested WM_GETOBJECT, so the lock must
321            // be released first.
322            let pending = {
323                if let Ok(mut guard) = WINDOWS_A11Y.lock() {
324                    if let Some(state) = guard.as_mut() {
325                        state.adapter.handle_wm_getobject(
326                            accesskit_windows::WPARAM(wparam),
327                            accesskit_windows::LPARAM(lparam),
328                            &mut state.activation_handler,
329                        )
330                    } else {
331                        None
332                    }
333                } else {
334                    None
335                }
336            };
337            // Lock released — safe to call .into() which may trigger nested WM_GETOBJECT
338            if let Some(r) = pending {
339                let lresult: accesskit_windows::LRESULT = r.into();
340                return lresult.0;
341            }
342            DefSubclassProc(hwnd, msg, wparam, lparam)
343        }
344        WM_SETFOCUS | WM_KILLFOCUS => {
345            let is_focused = msg == WM_SETFOCUS;
346            let pending = {
347                if let Ok(mut guard) = WINDOWS_A11Y.lock() {
348                    if let Some(state) = guard.as_mut() {
349                        state.adapter.update_window_focus_state(is_focused)
350                    } else {
351                        None
352                    }
353                } else {
354                    None
355                }
356            };
357            if let Some(events) = pending {
358                events.raise();
359            }
360            // Always pass focus messages to the original wndproc
361            DefSubclassProc(hwnd, msg, wparam, lparam)
362        }
363        _ => DefSubclassProc(hwnd, msg, wparam, lparam),
364    }
365}
366
367/// On some non-GNOME Wayland compositors, Orca does not set the
368/// `org.a11y.Status.ScreenReaderEnabled` property on the session D-Bus bus.
369/// AccessKit only creates its AT-SPI bus connection when this property is `true`.
370/// This function checks the property and sets it if `IsEnabled` is `true` but
371/// `ScreenReaderEnabled` is `false`.
372#[cfg(target_os = "linux")]
373fn ensure_screen_reader_enabled() {
374    use std::process::Command;
375
376    // Check current value of ScreenReaderEnabled
377    let sr_output = Command::new("busctl")
378        .args([
379            "--user",
380            "get-property",
381            "org.a11y.Bus",
382            "/org/a11y/bus",
383            "org.a11y.Status",
384            "ScreenReaderEnabled",
385        ])
386        .output();
387
388    let sr_enabled = match &sr_output {
389        Ok(out) => {
390            let stdout = String::from_utf8_lossy(&out.stdout);
391            stdout.trim() == "b true"
392        }
393        Err(_) => return, // busctl not available — nothing we can do
394    };
395
396    if sr_enabled {
397        // Already true — AccessKit should activate on its own
398        return;
399    }
400
401    // Check if AT-SPI is enabled at all (IsEnabled)
402    let is_output = Command::new("busctl")
403        .args([
404            "--user",
405            "get-property",
406            "org.a11y.Bus",
407            "/org/a11y/bus",
408            "org.a11y.Status",
409            "IsEnabled",
410        ])
411        .output();
412
413    let is_enabled = match &is_output {
414        Ok(out) => {
415            let stdout = String::from_utf8_lossy(&out.stdout);
416            stdout.trim() == "b true"
417        }
418        Err(_) => return,
419    };
420
421    if !is_enabled {
422        // AT-SPI is not enabled; don't force ScreenReaderEnabled
423        return;
424    }
425
426    // IsEnabled=true but ScreenReaderEnabled=false.
427    // Set ScreenReaderEnabled=true to trigger AccessKit activation.
428    let _ = Command::new("busctl")
429        .args([
430            "--user",
431            "set-property",
432            "org.a11y.Bus",
433            "/org/a11y/bus",
434            "org.a11y.Status",
435            "ScreenReaderEnabled",
436            "b",
437            "true",
438        ])
439        .output();
440}
441
442pub struct NativeAccessibilityState {
443    adapter: PlatformAdapter,
444    action_queue: Arc<Mutex<Vec<ActionRequest>>>,
445    initialized: bool,
446}
447
448impl Default for NativeAccessibilityState {
449    fn default() -> Self {
450        Self {
451            adapter: PlatformAdapter::None,
452            action_queue: Arc::new(Mutex::new(Vec::new())),
453            initialized: false,
454        }
455    }
456}
457
458impl NativeAccessibilityState {
459    fn initialize(
460        &mut self,
461        configs: &FxHashMap<u32, AccessibilityConfig>,
462        bounds_by_id: &FxHashMap<u32, BoundingBox>,
463        element_order: &[u32],
464        focused_id: u32,
465        viewport: Dimensions,
466    ) {
467        let queue = self.action_queue.clone();
468        let initial_tree = build_tree_update(
469            configs,
470            bounds_by_id,
471            element_order,
472            focused_id,
473            viewport,
474            true,
475        );
476
477        #[cfg(target_os = "linux")]
478        {
479            let activation_handler = PlyActivationHandler {
480                initial_tree: Mutex::new(Some(initial_tree)),
481            };
482            let mut adapter = accesskit_unix::Adapter::new(
483                activation_handler,
484                PlyActionHandler { queue },
485                PlyDeactivationHandler,
486            );
487            // Tell the adapter our window currently has focus
488            adapter.update_window_focus_state(true);
489            self.adapter = PlatformAdapter::Unix(adapter);
490
491            // Workaround: On some Wayland compositors (e.g. Hyprland), Orca does
492            // not set the `ScreenReaderEnabled` D-Bus property to `true` even when
493            // running. AccessKit only activates its AT-SPI adapter when this
494            // property is `true`. We spawn a background thread that checks the
495            // property and sets it if needed, which triggers AccessKit's internal
496            // PropertyChanged listener to activate.
497            std::thread::spawn(|| {
498                // Give AccessKit's event loop time to subscribe to PropertyChanged
499                std::thread::sleep(std::time::Duration::from_millis(200));
500                ensure_screen_reader_enabled();
501            });
502        }
503
504        #[cfg(target_os = "macos")]
505        {
506            // macOS: Use SubclassingAdapter which dynamically subclasses the miniquad
507            // NSView to handle NSAccessibility protocol callbacks from VoiceOver.
508            //
509            // `apple_view()` returns an ObjcId (*mut c_void) for the miniquad view.
510            // SubclassingAdapter overrides accessibilityChildren, accessibilityFocusedUIElement,
511            // and accessibilityHitTest: on the view's class.
512            let view = macroquad::miniquad::window::apple_view() as *mut std::ffi::c_void;
513            let activation_handler = PlyActivationHandler {
514                initial_tree: Mutex::new(Some(initial_tree)),
515            };
516            let mut adapter = unsafe {
517                accesskit_macos::SubclassingAdapter::new(
518                    view,
519                    activation_handler,
520                    PlyActionHandler { queue },
521                )
522            };
523            // Notify the adapter that our view currently has focus
524            if let Some(events) = adapter.update_view_focus_state(true) {
525                events.raise();
526            }
527            self.adapter = PlatformAdapter::MacOs(adapter);
528        }
529
530        #[cfg(target_os = "windows")]
531        {
532            // Windows: Use the raw Adapter with a window subclass to intercept
533            // WM_GETOBJECT messages sent by screen readers (Narrator/NVDA).
534            //
535            // We cannot use AccessKit's SubclassingAdapter because miniquad
536            // calls ShowWindow() before our code runs, and SubclassingAdapter
537            // panics if the window is already visible (IsWindowVisible check).
538            //
539            // Instead we use SetWindowSubclass (comctl32) to install our own
540            // subclass procedure that forwards WM_GETOBJECT to AccessKit.
541            // The adapter and activation handler live in the WINDOWS_A11Y static
542            // so the subclass proc (a plain extern "system" fn) can access them.
543            let hwnd_ptr = macroquad::miniquad::window::windows_hwnd();
544            let hwnd = accesskit_windows::HWND(hwnd_ptr);
545            let adapter = accesskit_windows::Adapter::new(
546                hwnd,
547                true, // window starts focused
548                PlyActionHandler { queue },
549            );
550            let activation_handler = PlyActivationHandler {
551                initial_tree: Mutex::new(Some(initial_tree)),
552            };
553            *WINDOWS_A11Y.lock().unwrap() = Some(WindowsA11yState {
554                adapter,
555                activation_handler,
556            });
557            // Install the subclass so WM_GETOBJECT is forwarded to AccessKit
558            unsafe {
559                SetWindowSubclass(
560                    hwnd_ptr as isize,
561                    a11y_subclass_proc,
562                    0xA11E, // arbitrary subclass ID
563                    0,
564                );
565            }
566            self.adapter = PlatformAdapter::Windows;
567        }
568
569        #[cfg(target_os = "android")]
570        {
571            // Android: Use InjectingAdapter which sets an accessibility delegate
572            // on the View, letting TalkBack discover our accessibility tree.
573            //
574            // We inject into the actual focused render surface when possible.
575            // Miniquad's Android activity wraps that surface in a parent layout
576            // with top padding equal to the status bar inset, so attaching the
577            // accessibility delegate to the parent layout can shift bounds.
578            use accesskit_android::jni::{self, objects::JValue};
579
580            let adapter = unsafe {
581                let raw_env = macroquad::miniquad::native::android::attach_jni_env();
582                let mut env = jni::JNIEnv::from_raw(raw_env as *mut _)
583                    .expect("Failed to wrap JNIEnv");
584
585                let activity = jni::objects::JObject::from_raw(
586                    macroquad::miniquad::native::android::ACTIVITY as _,
587                );
588
589                let focused_view = env
590                    .call_method(&activity, "getCurrentFocus", "()Landroid/view/View;", &[])
591                    .expect("getCurrentFocus() failed")
592                    .l()
593                    .unwrap_or(jni::objects::JObject::null());
594
595                let host_view = if !focused_view.is_null() {
596                    focused_view
597                } else {
598                    let content_view = env
599                        .call_method(
600                            &activity,
601                            "findViewById",
602                            "(I)Landroid/view/View;",
603                            &[JValue::Int(16908290)],
604                        )
605                        .expect("findViewById(android.R.id.content) failed")
606                        .l()
607                        .expect("findViewById(android.R.id.content) did not return an object");
608
609                    if !content_view.is_null() {
610                        content_view
611                    } else {
612                        let window = env
613                            .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])
614                            .expect("getWindow() failed")
615                            .l()
616                            .expect("getWindow() did not return an object");
617                        env.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])
618                            .expect("getDecorView() failed")
619                            .l()
620                            .expect("getDecorView() did not return an object")
621                    }
622                };
623
624                accesskit_android::InjectingAdapter::new(
625                    &mut env,
626                    &host_view,
627                    PlyActivationHandler {
628                        initial_tree: Mutex::new(Some(initial_tree)),
629                    },
630                    PlyActionHandler { queue },
631                )
632            };
633            self.adapter = PlatformAdapter::Android(adapter);
634        }
635
636        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "android")))]
637        {
638            let _ = (queue, initial_tree);
639            self.adapter = PlatformAdapter::None;
640        }
641
642        self.initialized = true;
643    }
644}
645
646/// An action requested by the screen reader, to be processed by the engine.
647pub enum PendingA11yAction {
648    /// Set keyboard focus to the element with this u32 ID.
649    Focus(u32),
650    /// Fire the on_press callback for the element with this u32 ID.
651    Click(u32),
652}
653
654/// Synchronise Ply's accessibility state with the platform screen reader.
655///
656/// This is the native equivalent of `accessibility_web::sync_accessibility_tree`.
657/// It is called from `Ply::eval()` on every frame when the `a11y` feature
658/// is enabled and we are **not** compiling for WASM.
659///
660/// Returns a list of actions requested by the screen reader (focus changes,
661/// clicks) that the engine should process.
662pub fn sync_accessibility_tree(
663    state: &mut NativeAccessibilityState,
664    accessibility_configs: &FxHashMap<u32, AccessibilityConfig>,
665    accessibility_bounds: &FxHashMap<u32, BoundingBox>,
666    accessibility_element_order: &[u32],
667    focused_element_id: u32,
668    viewport: Dimensions,
669) -> Vec<PendingA11yAction> {
670    // Lazy-initialize the platform adapter on first call
671    if !state.initialized {
672        state.initialize(
673            accessibility_configs,
674            accessibility_bounds,
675            accessibility_element_order,
676            focused_element_id,
677            viewport,
678        );
679    }
680
681    // Process any queued action requests from the screen reader
682    let pending_actions: Vec<ActionRequest> = {
683        if let Ok(mut q) = state.action_queue.lock() {
684            q.drain(..).collect()
685        } else {
686            Vec::new()
687        }
688    };
689
690    // Convert AccessKit actions into engine-level actions
691    let mut result = Vec::new();
692    for action in &pending_actions {
693        // Skip sentinel nodes (root window, document container)
694        let target = action.target_node.0;
695        if target == ROOT_NODE_ID.0 || target == DOCUMENT_NODE_ID.0 {
696            continue;
697        }
698        let target_id = target as u32;
699        match action.action {
700            Action::Focus => {
701                result.push(PendingA11yAction::Focus(target_id));
702            }
703            Action::Click => {
704                result.push(PendingA11yAction::Click(target_id));
705            }
706            _ => {}
707        }
708    }
709
710    // Build and push the tree update to the platform adapter
711    let update = build_tree_update(
712        accessibility_configs,
713        accessibility_bounds,
714        accessibility_element_order,
715        focused_element_id,
716        viewport,
717        false,
718    );
719
720    match &mut state.adapter {
721        #[cfg(target_os = "linux")]
722        PlatformAdapter::Unix(adapter) => {
723            adapter.update_if_active(|| update);
724        }
725        #[cfg(target_os = "macos")]
726        PlatformAdapter::MacOs(adapter) => {
727            if let Some(events) = adapter.update_if_active(|| update) {
728                events.raise();
729            }
730        }
731        #[cfg(target_os = "windows")]
732        PlatformAdapter::Windows => {
733            // Access the adapter through the static (same one the wndproc hook uses)
734            let pending = {
735                let mut guard = WINDOWS_A11Y.lock().unwrap();
736                if let Some(state) = guard.as_mut() {
737                    state.adapter.update_if_active(|| update)
738                } else {
739                    None
740                }
741            };
742            if let Some(events) = pending {
743                events.raise();
744            }
745        }
746        #[cfg(target_os = "android")]
747        PlatformAdapter::Android(adapter) => {
748            adapter.update_if_active(|| update);
749        }
750        PlatformAdapter::None => {
751            let _ = update;
752        }
753    }
754
755    result
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use crate::accessibility::{AccessibilityConfig, AccessibilityRole, LiveRegionMode};
762
763    fn make_config(role: AccessibilityRole, label: &str) -> AccessibilityConfig {
764        AccessibilityConfig {
765            focusable: true,
766            role,
767            label: label.to_string(),
768            show_ring: true,
769            ..Default::default()
770        }
771    }
772
773    #[test]
774    fn role_mapping_covers_all_variants() {
775        // Ensure every AccessibilityRole maps to a non-panicking Role
776        let roles = vec![
777            AccessibilityRole::None,
778            AccessibilityRole::Button,
779            AccessibilityRole::Link,
780            AccessibilityRole::Heading { level: 1 },
781            AccessibilityRole::Label,
782            AccessibilityRole::StaticText,
783            AccessibilityRole::TextInput,
784            AccessibilityRole::TextArea,
785            AccessibilityRole::Checkbox,
786            AccessibilityRole::RadioButton,
787            AccessibilityRole::Slider,
788            AccessibilityRole::Group,
789            AccessibilityRole::List,
790            AccessibilityRole::ListItem,
791            AccessibilityRole::Menu,
792            AccessibilityRole::MenuItem,
793            AccessibilityRole::MenuBar,
794            AccessibilityRole::Tab,
795            AccessibilityRole::TabList,
796            AccessibilityRole::TabPanel,
797            AccessibilityRole::Dialog,
798            AccessibilityRole::AlertDialog,
799            AccessibilityRole::Toolbar,
800            AccessibilityRole::Image,
801            AccessibilityRole::ProgressBar,
802        ];
803        for role in roles {
804            let _ = map_role(&role);
805        }
806    }
807
808    #[test]
809    fn build_node_button() {
810        let config = make_config(AccessibilityRole::Button, "Click me");
811        let node = build_node(&config, BoundingBox::new(10.0, 20.0, 30.0, 40.0));
812        assert_eq!(node.role(), Role::Button);
813        assert_eq!(node.label(), Some("Click me"));
814    }
815
816    #[test]
817    fn build_node_heading_with_level() {
818        let config = make_config(AccessibilityRole::Heading { level: 2 }, "Section");
819        let node = build_node(&config, BoundingBox::new(0.0, 0.0, 100.0, 24.0));
820        assert_eq!(node.role(), Role::Heading);
821        assert_eq!(node.level(), Some(2));
822        assert_eq!(node.label(), Some("Section"));
823    }
824
825    #[test]
826    fn build_node_checkbox_toggled() {
827        let mut config = make_config(AccessibilityRole::Checkbox, "Agree");
828        config.checked = Some(true);
829        let node = build_node(&config, BoundingBox::new(0.0, 0.0, 20.0, 20.0));
830        assert_eq!(node.role(), Role::CheckBox);
831        assert_eq!(node.toggled(), Some(Toggled::True));
832    }
833
834    #[test]
835    fn build_node_slider_values() {
836        let mut config = make_config(AccessibilityRole::Slider, "Volume");
837        config.value = "50".to_string();
838        config.value_min = Some(0.0);
839        config.value_max = Some(100.0);
840        let node = build_node(&config, BoundingBox::new(0.0, 0.0, 120.0, 24.0));
841        assert_eq!(node.role(), Role::Slider);
842        assert_eq!(node.numeric_value(), Some(50.0));
843        assert_eq!(node.min_numeric_value(), Some(0.0));
844        assert_eq!(node.max_numeric_value(), Some(100.0));
845    }
846
847    #[test]
848    fn build_node_live_region() {
849        let mut config = make_config(AccessibilityRole::Label, "Status");
850        config.live_region = LiveRegionMode::Polite;
851        let node = build_node(&config, BoundingBox::new(0.0, 0.0, 80.0, 24.0));
852        assert_eq!(node.live(), Some(Live::Polite));
853    }
854
855    #[test]
856    fn build_node_description() {
857        let mut config = make_config(AccessibilityRole::Button, "Submit");
858        config.description = "Submit the form".to_string();
859        let node = build_node(&config, BoundingBox::new(0.0, 0.0, 80.0, 24.0));
860        assert_eq!(node.description(), Some("Submit the form"));
861    }
862
863    #[test]
864    fn build_tree_update_structure() {
865        let mut configs = FxHashMap::default();
866        let mut bounds = FxHashMap::default();
867        configs.insert(101, make_config(AccessibilityRole::Button, "OK"));
868        configs.insert(102, make_config(AccessibilityRole::Button, "Cancel"));
869        bounds.insert(101, BoundingBox::new(10.0, 10.0, 80.0, 32.0));
870        bounds.insert(102, BoundingBox::new(100.0, 10.0, 80.0, 32.0));
871
872        let order = vec![101, 102];
873        let update = build_tree_update(&configs, &bounds, &order, 101, Dimensions::new(320.0, 240.0), true);
874
875        // Should have root + document + 2 children = 4 nodes
876        assert_eq!(update.nodes.len(), 4);
877
878        // Root should be first
879        assert_eq!(update.nodes[0].0, ROOT_NODE_ID);
880        assert_eq!(update.nodes[0].1.role(), Role::Window);
881
882        // Document container should be second
883        assert_eq!(update.nodes[1].0, DOCUMENT_NODE_ID);
884        assert_eq!(update.nodes[1].1.role(), Role::Document);
885
886        // Focus should be on element 101
887        assert_eq!(update.focus, NodeId(101));
888
889        // Tree metadata
890        let tree = update.tree.as_ref().unwrap();
891        assert_eq!(tree.root, ROOT_NODE_ID);
892        assert_eq!(tree.toolkit_name, Some("Ply Engine".to_string()));
893    }
894
895    #[test]
896    fn build_tree_update_no_focus() {
897        let configs = FxHashMap::default();
898        let bounds = FxHashMap::default();
899        let order = vec![];
900        let update = build_tree_update(&configs, &bounds, &order, 0, Dimensions::new(320.0, 240.0), true);
901
902        // Only root + document nodes
903        assert_eq!(update.nodes.len(), 2);
904        // Focus falls back to root
905        assert_eq!(update.focus, ROOT_NODE_ID);
906    }
907
908    #[test]
909    fn default_state_is_uninitialized() {
910        let state = NativeAccessibilityState::default();
911        assert!(!state.initialized);
912    }
913}