Skip to main content

appscale_core/
accessibility.rs

1//! Accessibility Layer — semantic roles, focus management, screen reader mapping.
2//!
3//! Every native widget framework already has accessibility built into its widgets.
4//! Our job is to ensure the right semantics reach the platform:
5//! - iOS: UIAccessibility (VoiceOver)
6//! - Android: AccessibilityNodeInfo (TalkBack)
7//! - macOS: NSAccessibility (VoiceOver)
8//! - Windows: UI Automation (Narrator)
9//! - Web: ARIA attributes
10//!
11//! The accessibility tree is a parallel structure to the shadow tree.
12//! Not every visual node is an accessibility node — we merge/prune to match
13//! how screen readers expect to navigate.
14
15use crate::tree::NodeId;
16use crate::platform::NativeHandle;
17use std::collections::HashMap;
18
19/// Semantic role of a UI element (maps to platform accessibility APIs).
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AccessibilityRole {
22    /// No semantic role — decorative or container (pruned from a11y tree).
23    None,
24    /// Interactive button.
25    Button,
26    /// Text content (heading, paragraph, label).
27    Text,
28    /// Heading (h1-h6 equivalent). Level 1-6.
29    Heading,
30    /// Text input field.
31    TextField,
32    /// Image with description.
33    Image,
34    /// Checkable toggle (switch, checkbox).
35    Switch,
36    /// Adjustable value (slider, stepper).
37    Adjustable,
38    /// Link (navigates somewhere).
39    Link,
40    /// Search field.
41    SearchField,
42    /// Tab bar or segmented control.
43    TabBar,
44    /// Individual tab.
45    Tab,
46    /// List (scrollable collection).
47    List,
48    /// List item.
49    ListItem,
50    /// Modal/dialog.
51    Alert,
52    /// Progress indicator.
53    ProgressBar,
54    /// Menu.
55    Menu,
56    /// Menu item.
57    MenuItem,
58}
59
60/// Accessibility state for a node.
61#[derive(Debug, Clone, Default)]
62pub struct AccessibilityState {
63    pub disabled: bool,
64    pub selected: bool,
65    pub checked: Option<bool>,  // None = not checkable, Some(true/false) = checkable
66    pub expanded: Option<bool>, // None = not expandable
67    pub busy: bool,
68}
69
70/// Accessibility value (for adjustable elements like sliders).
71#[derive(Debug, Clone, Default)]
72pub struct AccessibilityValue {
73    pub min: Option<f64>,
74    pub max: Option<f64>,
75    pub now: Option<f64>,
76    pub text: Option<String>,  // e.g., "50%" or "Medium"
77}
78
79/// Complete accessibility info for a node.
80#[derive(Debug, Clone)]
81pub struct AccessibilityInfo {
82    pub role: AccessibilityRole,
83    pub label: Option<String>,           // What screen reader announces
84    pub hint: Option<String>,            // Usage hint ("double tap to activate")
85    pub state: AccessibilityState,
86    pub value: AccessibilityValue,
87    pub heading_level: Option<u8>,       // 1-6 for Heading role
88    pub live_region: LiveRegion,         // For dynamic content updates
89    pub actions: Vec<AccessibilityAction>,
90    pub is_modal: bool,                  // Traps focus within this subtree
91    pub hides_descendants: bool,         // Children hidden from a11y tree
92}
93
94impl Default for AccessibilityInfo {
95    fn default() -> Self {
96        Self {
97            role: AccessibilityRole::None,
98            label: None,
99            hint: None,
100            state: AccessibilityState::default(),
101            value: AccessibilityValue::default(),
102            heading_level: None,
103            live_region: LiveRegion::Off,
104            actions: Vec::new(),
105            is_modal: false,
106            hides_descendants: false,
107        }
108    }
109}
110
111/// Live region announcement policy.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub enum LiveRegion {
114    /// No automatic announcements.
115    #[default]
116    Off,
117    /// Announce changes when convenient (polite).
118    Polite,
119    /// Interrupt current announcement to announce changes.
120    Assertive,
121}
122
123/// Custom accessibility actions.
124#[derive(Debug, Clone)]
125pub struct AccessibilityAction {
126    pub name: String,
127    pub label: String,  // Human-readable label for the action
128}
129
130// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
131// Focus management
132// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
133
134/// Manages keyboard/accessibility focus across the application.
135pub struct FocusManager {
136    /// Currently focused node.
137    focused: Option<NodeId>,
138
139    /// Focus order (tab index). Nodes not in this list use tree order.
140    focus_order: Vec<NodeId>,
141
142    /// Focus trap stack (for modals — focus stays within the top trap).
143    focus_traps: Vec<NodeId>,
144
145    /// Nodes that are focusable.
146    focusable: HashMap<NodeId, FocusConfig>,
147}
148
149#[derive(Debug, Clone)]
150pub struct FocusConfig {
151    /// Tab index: -1 = not tabbable, 0 = natural order, >0 = explicit order.
152    pub tab_index: i32,
153    /// Whether this node should auto-focus when mounted.
154    pub auto_focus: bool,
155}
156
157impl FocusManager {
158    pub fn new() -> Self {
159        Self {
160            focused: None,
161            focus_order: Vec::new(),
162            focus_traps: Vec::new(),
163            focusable: HashMap::new(),
164        }
165    }
166
167    /// Register a node as focusable.
168    pub fn register(&mut self, node_id: NodeId, config: FocusConfig) {
169        if config.tab_index >= 0 {
170            // Insert into focus order
171            if config.tab_index > 0 {
172                // Explicit order: insert at the right position
173                let pos = self.focus_order.iter()
174                    .position(|&id| {
175                        self.focusable.get(&id)
176                            .map(|c| c.tab_index > config.tab_index)
177                            .unwrap_or(true)
178                    })
179                    .unwrap_or(self.focus_order.len());
180                self.focus_order.insert(pos, node_id);
181            } else {
182                // Natural order (tab_index = 0): append
183                self.focus_order.push(node_id);
184            }
185        }
186        self.focusable.insert(node_id, config);
187    }
188
189    /// Unregister a node (when removed from tree).
190    pub fn unregister(&mut self, node_id: NodeId) {
191        self.focusable.remove(&node_id);
192        self.focus_order.retain(|&id| id != node_id);
193        if self.focused == Some(node_id) {
194            self.focused = None;
195        }
196    }
197
198    /// Move focus to a specific node.
199    pub fn focus(&mut self, node_id: NodeId) -> Option<FocusChange> {
200        let previous = self.focused;
201        self.focused = Some(node_id);
202
203        Some(FocusChange {
204            previous,
205            current: node_id,
206        })
207    }
208
209    /// Move focus to the next focusable node (Tab key).
210    pub fn focus_next(&mut self) -> Option<FocusChange> {
211        let candidates = self.get_candidates();
212        if candidates.is_empty() { return None; }
213
214        let current_index = self.focused
215            .and_then(|id| candidates.iter().position(|&c| c == id))
216            .unwrap_or(candidates.len().wrapping_sub(1));
217
218        let next_index = (current_index + 1) % candidates.len();
219        self.focus(candidates[next_index])
220    }
221
222    /// Move focus to the previous focusable node (Shift+Tab).
223    pub fn focus_previous(&mut self) -> Option<FocusChange> {
224        let candidates = self.get_candidates();
225        if candidates.is_empty() { return None; }
226
227        let current_index = self.focused
228            .and_then(|id| candidates.iter().position(|&c| c == id))
229            .unwrap_or(0);
230
231        let prev_index = if current_index == 0 {
232            candidates.len() - 1
233        } else {
234            current_index - 1
235        };
236
237        self.focus(candidates[prev_index])
238    }
239
240    /// Push a focus trap (for modals). Focus cannot leave the trap subtree.
241    pub fn push_trap(&mut self, root_node: NodeId) {
242        self.focus_traps.push(root_node);
243    }
244
245    /// Pop a focus trap (when modal is dismissed).
246    pub fn pop_trap(&mut self) -> Option<NodeId> {
247        self.focus_traps.pop()
248    }
249
250    /// Get the currently focused node.
251    pub fn focused(&self) -> Option<NodeId> {
252        self.focused
253    }
254
255    /// Get focusable candidates, respecting the active focus trap.
256    fn get_candidates(&self) -> Vec<NodeId> {
257        if let Some(&_trap_root) = self.focus_traps.last() {
258            // When a focus trap is active, only nodes within the trap are candidates.
259            // TODO: filter by tree ancestry (requires tree reference).
260            // For now, return all focusable nodes (trap filtering happens at dispatch).
261            self.focus_order.clone()
262        } else {
263            self.focus_order.clone()
264        }
265    }
266}
267
268#[derive(Debug, Clone)]
269pub struct FocusChange {
270    pub previous: Option<NodeId>,
271    pub current: NodeId,
272}
273
274// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
275// Platform accessibility bridge
276// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
277
278/// Platform bridges implement this to push accessibility info to the OS.
279pub trait AccessibilityBridge {
280    /// Update the accessibility info for a native view.
281    fn update_accessibility(
282        &self,
283        handle: NativeHandle,
284        info: &AccessibilityInfo,
285    );
286
287    /// Announce a message to the screen reader.
288    fn announce(&self, message: &str, priority: LiveRegion);
289
290    /// Move accessibility focus to a specific element.
291    fn set_accessibility_focus(&self, handle: NativeHandle);
292}
293
294/// Maps our AccessibilityRole to platform-specific values.
295/// Each platform bridge uses this in its update_accessibility implementation.
296impl AccessibilityRole {
297    /// iOS UIAccessibilityTraits mapping.
298    pub fn ios_traits(&self) -> &'static str {
299        match self {
300            Self::None => "none",
301            Self::Button => "button",
302            Self::Text => "staticText",
303            Self::Heading => "header",
304            Self::TextField => "none", // UITextField is inherently accessible
305            Self::Image => "image",
306            Self::Switch => "button", // + UISwitch is inherently accessible
307            Self::Adjustable => "adjustable",
308            Self::Link => "link",
309            Self::SearchField => "searchField",
310            Self::TabBar => "tabBar",
311            Self::Tab => "button", // + selected state
312            Self::List => "none", // UITableView handles this
313            Self::ListItem => "none",
314            Self::Alert => "none", // UIAlertController handles this
315            Self::ProgressBar => "none", // UIProgressView handles this
316            Self::Menu => "none",
317            Self::MenuItem => "button",
318        }
319    }
320
321    /// Web ARIA role mapping.
322    pub fn aria_role(&self) -> &'static str {
323        match self {
324            Self::None => "presentation",
325            Self::Button => "button",
326            Self::Text => "",           // No role needed for text
327            Self::Heading => "heading",
328            Self::TextField => "textbox",
329            Self::Image => "img",
330            Self::Switch => "switch",
331            Self::Adjustable => "slider",
332            Self::Link => "link",
333            Self::SearchField => "searchbox",
334            Self::TabBar => "tablist",
335            Self::Tab => "tab",
336            Self::List => "list",
337            Self::ListItem => "listitem",
338            Self::Alert => "alert",
339            Self::ProgressBar => "progressbar",
340            Self::Menu => "menu",
341            Self::MenuItem => "menuitem",
342        }
343    }
344
345    /// Android AccessibilityNodeInfo className mapping.
346    pub fn android_class(&self) -> &'static str {
347        match self {
348            Self::None => "android.view.View",
349            Self::Button => "android.widget.Button",
350            Self::Text => "android.widget.TextView",
351            Self::Heading => "android.widget.TextView", // + heading flag
352            Self::TextField => "android.widget.EditText",
353            Self::Image => "android.widget.ImageView",
354            Self::Switch => "android.widget.Switch",
355            Self::Adjustable => "android.widget.SeekBar",
356            Self::Link => "android.widget.TextView", // + clickable
357            Self::SearchField => "android.widget.EditText",
358            Self::TabBar => "android.widget.TabWidget",
359            Self::Tab => "android.widget.TabWidget",
360            Self::List => "android.widget.ListView",
361            Self::ListItem => "android.widget.ListView",
362            Self::Alert => "android.app.AlertDialog",
363            Self::ProgressBar => "android.widget.ProgressBar",
364            Self::Menu => "android.widget.PopupMenu",
365            Self::MenuItem => "android.widget.PopupMenu",
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_focus_cycle() {
376        let mut fm = FocusManager::new();
377        fm.register(NodeId(1), FocusConfig { tab_index: 0, auto_focus: false });
378        fm.register(NodeId(2), FocusConfig { tab_index: 0, auto_focus: false });
379        fm.register(NodeId(3), FocusConfig { tab_index: 0, auto_focus: false });
380
381        // Tab through all nodes
382        let c = fm.focus_next().unwrap();
383        assert_eq!(c.current, NodeId(1));
384
385        let c = fm.focus_next().unwrap();
386        assert_eq!(c.current, NodeId(2));
387
388        let c = fm.focus_next().unwrap();
389        assert_eq!(c.current, NodeId(3));
390
391        // Wraps around
392        let c = fm.focus_next().unwrap();
393        assert_eq!(c.current, NodeId(1));
394    }
395
396    #[test]
397    fn test_focus_reverse() {
398        let mut fm = FocusManager::new();
399        fm.register(NodeId(1), FocusConfig { tab_index: 0, auto_focus: false });
400        fm.register(NodeId(2), FocusConfig { tab_index: 0, auto_focus: false });
401
402        fm.focus(NodeId(2));
403        let c = fm.focus_previous().unwrap();
404        assert_eq!(c.current, NodeId(1));
405    }
406
407    #[test]
408    fn test_role_mappings() {
409        assert_eq!(AccessibilityRole::Button.aria_role(), "button");
410        assert_eq!(AccessibilityRole::Switch.ios_traits(), "button");
411        assert_eq!(AccessibilityRole::TextField.android_class(), "android.widget.EditText");
412    }
413}