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, 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
24/// Sentinel NodeId for the root window node.
25/// We use u64::MAX to avoid collision with element IDs (which are u32 hash values).
26const ROOT_NODE_ID: NodeId = NodeId(u64::MAX);
27
28/// Sentinel NodeId for the document container node.
29/// Placed as the sole child of the root window, all accessible elements are its children.
30/// This enables structural navigation in screen readers (e.g. Orca's Insert+Z).
31const 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    // Label
68    if !config.label.is_empty() {
69        node.set_label(config.label.as_str());
70    }
71
72    // Description
73    if !config.description.is_empty() {
74        node.set_description(config.description.as_str());
75    }
76
77    // Value (text value for sliders, progress bars, etc.)
78    if !config.value.is_empty() {
79        node.set_value(config.value.as_str());
80    }
81
82    // Numeric value bounds (for sliders)
83    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 we have a numeric value, try to parse it
91    if !config.value.is_empty() {
92        if let Ok(num) = config.value.parse::<f64>() {
93            node.set_numeric_value(num);
94        }
95    }
96
97    // Heading level
98    if let AccessibilityRole::Heading { level } = &config.role {
99        node.set_level(*level as usize);
100    }
101
102    // Checked/toggled state (checkboxes, radio buttons)
103    if let Some(checked) = config.checked {
104        node.set_toggled(if checked {
105            Toggled::True
106        } else {
107            Toggled::False
108        });
109    }
110
111    // Live region
112    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    // Declare supported actions based on role
123    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    // Collect child NodeIds for the document container
153    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    // Root window → Document → accessible elements
160    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    // Document container enables structural navigation in screen readers
166    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    // Build child nodes
171    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    // Determine focus: if focused_id is 0 (no focus), point to root
179    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
202/// ActivationHandler: called when an assistive technology activates.
203/// Holds a pre-built initial tree so the adapter is immediately ready.
204struct 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
217/// ActionHandler: queues incoming screen reader action requests for processing
218/// on the main thread during the next eval() cycle.
219struct 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/// DeactivationHandler: called when the assistive technology disconnects.
232/// Only used on Linux (AT-SPI); macOS and Windows adapters don't require one.
233#[cfg(target_os = "linux")]
234struct PlyDeactivationHandler;
235
236#[cfg(target_os = "linux")]
237impl DeactivationHandler for PlyDeactivationHandler {
238    fn deactivate_accessibility(&mut self) {
239        // Nothing to clean up
240    }
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    /// Marker — actual adapter lives in the `WINDOWS_A11Y` static so the
250    /// wndproc hook (a plain `fn` pointer) can access it for `WM_GETOBJECT`.
251    Windows,
252    #[cfg(target_os = "android")]
253    Android(accesskit_android::InjectingAdapter),
254    /// Fallback for platforms without an adapter (e.g. iOS in the future).
255    None,
256}
257
258#[cfg(target_os = "windows")]
259struct WindowsA11yState {
260    adapter: accesskit_windows::Adapter,
261    activation_handler: PlyActivationHandler,
262}
263
264/// The Windows AccessKit adapter must be accessible from the window subclass
265/// procedure (a plain `extern "system"` callback), so we store it in a static.
266/// The `Mutex` is released *before* calling `.into()` on `handle_wm_getobject`'s
267/// return value, which may trigger nested `WM_GETOBJECT` — avoiding deadlock.
268#[cfg(target_os = "windows")]
269static WINDOWS_A11Y: std::sync::Mutex<Option<WindowsA11yState>> = std::sync::Mutex::new(None);
270
271// Win32 FFI for window subclassing (comctl32.dll).
272#[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/// Subclass procedure installed on miniquad's window. Forwards
285/// `WM_GETOBJECT` to AccessKit and relays focus changes to the adapter.
286/// All other messages are passed through to the original window procedure.
287#[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            // Acquire lock → call handle_wm_getobject → release lock → .into()
303            // The .into() may trigger a nested WM_GETOBJECT, so the lock must
304            // be released first.
305            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            // Lock released — safe to call .into() which may trigger nested WM_GETOBJECT
321            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            // Always pass focus messages to the original wndproc
344            DefSubclassProc(hwnd, msg, wparam, lparam)
345        }
346        _ => DefSubclassProc(hwnd, msg, wparam, lparam),
347    }
348}
349
350/// On some non-GNOME Wayland compositors, Orca does not set the
351/// `org.a11y.Status.ScreenReaderEnabled` property on the session D-Bus bus.
352/// AccessKit only creates its AT-SPI bus connection when this property is `true`.
353/// This function checks the property and sets it if `IsEnabled` is `true` but
354/// `ScreenReaderEnabled` is `false`.
355#[cfg(target_os = "linux")]
356fn ensure_screen_reader_enabled() {
357    use std::process::Command;
358
359    // Check current value of ScreenReaderEnabled
360    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, // busctl not available — nothing we can do
377    };
378
379    if sr_enabled {
380        // Already true — AccessKit should activate on its own
381        return;
382    }
383
384    // Check if AT-SPI is enabled at all (IsEnabled)
385    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        // AT-SPI is not enabled; don't force ScreenReaderEnabled
406        return;
407    }
408
409    // IsEnabled=true but ScreenReaderEnabled=false.
410    // Set ScreenReaderEnabled=true to trigger AccessKit activation.
411    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            // Tell the adapter our window currently has focus
462            adapter.update_window_focus_state(true);
463            self.adapter = PlatformAdapter::Unix(adapter);
464
465            // Workaround: On some Wayland compositors (e.g. Hyprland), Orca does
466            // not set the `ScreenReaderEnabled` D-Bus property to `true` even when
467            // running. AccessKit only activates its AT-SPI adapter when this
468            // property is `true`. We spawn a background thread that checks the
469            // property and sets it if needed, which triggers AccessKit's internal
470            // PropertyChanged listener to activate.
471            std::thread::spawn(|| {
472                // Give AccessKit's event loop time to subscribe to PropertyChanged
473                std::thread::sleep(std::time::Duration::from_millis(200));
474                ensure_screen_reader_enabled();
475            });
476        }
477
478        #[cfg(target_os = "macos")]
479        {
480            // macOS: Use SubclassingAdapter which dynamically subclasses the miniquad
481            // NSView to handle NSAccessibility protocol callbacks from VoiceOver.
482            //
483            // `apple_view()` returns an ObjcId (*mut c_void) for the miniquad view.
484            // SubclassingAdapter overrides accessibilityChildren, accessibilityFocusedUIElement,
485            // and accessibilityHitTest: on the view's class.
486            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            // Notify the adapter that our view currently has focus
498            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            // Windows: Use the raw Adapter with a window subclass to intercept
507            // WM_GETOBJECT messages sent by screen readers (Narrator/NVDA).
508            //
509            // We cannot use AccessKit's SubclassingAdapter because miniquad
510            // calls ShowWindow() before our code runs, and SubclassingAdapter
511            // panics if the window is already visible (IsWindowVisible check).
512            //
513            // Instead we use SetWindowSubclass (comctl32) to install our own
514            // subclass procedure that forwards WM_GETOBJECT to AccessKit.
515            // The adapter and activation handler live in the WINDOWS_A11Y static
516            // so the subclass proc (a plain extern "system" fn) can access them.
517            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, // window starts focused
522                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            // Install the subclass so WM_GETOBJECT is forwarded to AccessKit
532            unsafe {
533                SetWindowSubclass(
534                    hwnd_ptr as isize,
535                    a11y_subclass_proc,
536                    0xA11E, // arbitrary subclass ID
537                    0,
538                );
539            }
540            self.adapter = PlatformAdapter::Windows;
541        }
542
543        #[cfg(target_os = "android")]
544        {
545            // Android: Use InjectingAdapter which sets an accessibility delegate
546            // on the View, letting TalkBack discover our accessibility tree.
547            //
548            // We retrieve the View via JNI by calling:
549            //   activity.getWindow().getDecorView()
550            // using miniquad's existing ACTIVITY global ref and attach_jni_env().
551            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                // activity.getWindow() → android.view.Window
563                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                // window.getDecorView() → android.view.View
570                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
598/// An action requested by the screen reader, to be processed by the engine.
599pub enum PendingA11yAction {
600    /// Set keyboard focus to the element with this u32 ID.
601    Focus(u32),
602    /// Fire the on_press callback for the element with this u32 ID.
603    Click(u32),
604}
605
606/// Synchronise Ply's accessibility state with the platform screen reader.
607///
608/// This is the native equivalent of `accessibility_web::sync_accessibility_tree`.
609/// It is called from `Ply::eval()` on every frame when the `a11y` feature
610/// is enabled and we are **not** compiling for WASM.
611///
612/// Returns a list of actions requested by the screen reader (focus changes,
613/// clicks) that the engine should process.
614pub 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    // Lazy-initialize the platform adapter on first call
621    if !state.initialized {
622        state.initialize(accessibility_configs, accessibility_element_order, focused_element_id);
623    }
624
625    // Process any queued action requests from the screen reader
626    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    // Convert AccessKit actions into engine-level actions
635    let mut result = Vec::new();
636    for action in &pending_actions {
637        // Skip sentinel nodes (root window, document container)
638        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    // Build and push the tree update to the platform adapter
655    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            // Access the adapter through the static (same one the wndproc hook uses)
676            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        // Ensure every AccessibilityRole maps to a non-panicking Role
718        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        // Should have root + document + 2 children = 4 nodes
815        assert_eq!(update.nodes.len(), 4);
816
817        // Root should be first
818        assert_eq!(update.nodes[0].0, ROOT_NODE_ID);
819        assert_eq!(update.nodes[0].1.role(), Role::Window);
820
821        // Document container should be second
822        assert_eq!(update.nodes[1].0, DOCUMENT_NODE_ID);
823        assert_eq!(update.nodes[1].1.role(), Role::Document);
824
825        // Focus should be on element 101
826        assert_eq!(update.focus, NodeId(101));
827
828        // Tree metadata
829        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        // Only root + document nodes
841        assert_eq!(update.nodes.len(), 2);
842        // Focus falls back to root
843        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}