⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/desktop/
a11y.rs

1//! Accessibility (a11y) Framework
2//!
3//! Provides an accessibility tree, screen reader support, high contrast
4//! themes, and keyboard-driven navigation. Implements a subset of WAI-ARIA
5//! roles and properties for desktop widget accessibility.
6//!
7//! All coordinates and sizes use integer types.
8
9#![allow(dead_code)]
10
11use alloc::{string::String, vec::Vec};
12
13// ---------------------------------------------------------------------------
14// Roles and actions
15// ---------------------------------------------------------------------------
16
17/// Accessibility role for a UI element (WAI-ARIA subset).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum A11yRole {
20    /// Top-level window.
21    Window,
22    /// Clickable button.
23    Button,
24    /// Static text label.
25    Label,
26    /// Text input field.
27    TextInput,
28    /// Pop-up or pull-down menu.
29    Menu,
30    /// An item within a menu.
31    MenuItem,
32    /// Toolbar container.
33    Toolbar,
34    /// Scroll bar control.
35    Scrollbar,
36    /// A list container.
37    List,
38    /// An item within a list.
39    ListItem,
40    /// Modal or non-modal dialog.
41    Dialog,
42    /// Alert or notification.
43    Alert,
44    /// Visual separator.
45    Separator,
46    /// Image or icon.
47    Image,
48}
49
50/// Actions that can be performed on an accessible element.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum A11yAction {
53    /// Activate (click/press).
54    Click,
55    /// Set keyboard focus.
56    Focus,
57    /// Expand a collapsed node.
58    Expand,
59    /// Collapse an expanded node.
60    Collapse,
61    /// Scroll content upward.
62    ScrollUp,
63    /// Scroll content downward.
64    ScrollDown,
65}
66
67// ---------------------------------------------------------------------------
68// Accessibility node
69// ---------------------------------------------------------------------------
70
71/// Unique identifier for an a11y node.
72pub type A11yNodeId = u32;
73
74/// A single node in the accessibility tree.
75#[derive(Debug, Clone)]
76pub struct A11yNode {
77    /// Unique identifier.
78    pub id: A11yNodeId,
79    /// Role of this element.
80    pub role: A11yRole,
81    /// Human-readable name.
82    pub name: String,
83    /// Optional longer description.
84    pub description: String,
85    /// Bounding rectangle (x, y, width, height).
86    pub bounds_x: i32,
87    pub bounds_y: i32,
88    pub bounds_w: u32,
89    pub bounds_h: u32,
90    /// Child node IDs.
91    pub children: Vec<A11yNodeId>,
92    /// Whether this node can receive keyboard focus.
93    pub focusable: bool,
94    /// Whether this node currently has focus.
95    pub focused: bool,
96    /// Available actions.
97    pub actions: Vec<A11yAction>,
98    /// Current value (for sliders, text fields, etc.).
99    pub value: String,
100    /// Whether this node is expanded (for tree items, menus).
101    pub expanded: bool,
102}
103
104impl A11yNode {
105    /// Create a new node with the given role and name.
106    pub fn new(id: A11yNodeId, role: A11yRole, name: &str) -> Self {
107        Self {
108            id,
109            role,
110            name: String::from(name),
111            description: String::new(),
112            bounds_x: 0,
113            bounds_y: 0,
114            bounds_w: 0,
115            bounds_h: 0,
116            children: Vec::new(),
117            focusable: matches!(
118                role,
119                A11yRole::Button | A11yRole::TextInput | A11yRole::MenuItem | A11yRole::ListItem
120            ),
121            focused: false,
122            actions: Vec::new(),
123            value: String::new(),
124            expanded: false,
125        }
126    }
127
128    /// Set the bounding rectangle.
129    pub fn set_bounds(&mut self, x: i32, y: i32, w: u32, h: u32) {
130        self.bounds_x = x;
131        self.bounds_y = y;
132        self.bounds_w = w;
133        self.bounds_h = h;
134    }
135
136    /// Add a child node ID.
137    pub fn add_child(&mut self, child_id: A11yNodeId) {
138        self.children.push(child_id);
139    }
140
141    /// Add an available action.
142    pub fn add_action(&mut self, action: A11yAction) {
143        if !self.actions.contains(&action) {
144            self.actions.push(action);
145        }
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Accessibility tree
151// ---------------------------------------------------------------------------
152
153/// The complete accessibility tree for the desktop.
154#[derive(Debug)]
155pub struct A11yTree {
156    /// All nodes, indexed by ID.
157    nodes: Vec<A11yNode>,
158    /// ID of the root node (if any).
159    root_id: Option<A11yNodeId>,
160    /// ID of the currently focused node.
161    focused_node_id: Option<A11yNodeId>,
162    /// Next ID to assign.
163    next_id: A11yNodeId,
164}
165
166impl Default for A11yTree {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl A11yTree {
173    /// Create a new empty tree.
174    pub fn new() -> Self {
175        Self {
176            nodes: Vec::new(),
177            root_id: None,
178            focused_node_id: None,
179            next_id: 1,
180        }
181    }
182
183    /// Add a node and return its ID.
184    pub fn add_node(&mut self, mut node: A11yNode) -> A11yNodeId {
185        let id = self.next_id;
186        self.next_id += 1;
187        node.id = id;
188        if self.root_id.is_none() {
189            self.root_id = Some(id);
190        }
191        self.nodes.push(node);
192        id
193    }
194
195    /// Find a node by ID.
196    pub fn find_by_id(&self, id: A11yNodeId) -> Option<&A11yNode> {
197        self.nodes.iter().find(|n| n.id == id)
198    }
199
200    /// Find a mutable node by ID.
201    pub fn find_by_id_mut(&mut self, id: A11yNodeId) -> Option<&mut A11yNode> {
202        self.nodes.iter_mut().find(|n| n.id == id)
203    }
204
205    /// Get the currently focused node.
206    pub fn focused_node(&self) -> Option<&A11yNode> {
207        self.focused_node_id.and_then(|id| self.find_by_id(id))
208    }
209
210    /// Set focus to a specific node.
211    pub fn set_focus(&mut self, id: A11yNodeId) -> bool {
212        // Unfocus current
213        if let Some(old_id) = self.focused_node_id {
214            if let Some(node) = self.find_by_id_mut(old_id) {
215                node.focused = false;
216            }
217        }
218
219        // Focus new
220        if let Some(node) = self.find_by_id_mut(id) {
221            if node.focusable {
222                node.focused = true;
223                self.focused_node_id = Some(id);
224                return true;
225            }
226        }
227        false
228    }
229
230    /// Move focus to the next focusable node.
231    pub fn next_focusable(&mut self) -> Option<A11yNodeId> {
232        let focusable: Vec<A11yNodeId> = self
233            .nodes
234            .iter()
235            .filter(|n| n.focusable)
236            .map(|n| n.id)
237            .collect();
238
239        if focusable.is_empty() {
240            return None;
241        }
242
243        let current_idx = self
244            .focused_node_id
245            .and_then(|id| focusable.iter().position(|&fid| fid == id))
246            .unwrap_or(focusable.len().wrapping_sub(1));
247
248        let next_idx = (current_idx + 1) % focusable.len();
249        let next_id = focusable[next_idx];
250        self.set_focus(next_id);
251        Some(next_id)
252    }
253
254    /// Move focus to the previous focusable node.
255    pub fn prev_focusable(&mut self) -> Option<A11yNodeId> {
256        let focusable: Vec<A11yNodeId> = self
257            .nodes
258            .iter()
259            .filter(|n| n.focusable)
260            .map(|n| n.id)
261            .collect();
262
263        if focusable.is_empty() {
264            return None;
265        }
266
267        let current_idx = self
268            .focused_node_id
269            .and_then(|id| focusable.iter().position(|&fid| fid == id))
270            .unwrap_or(1);
271
272        let prev_idx = if current_idx == 0 {
273            focusable.len() - 1
274        } else {
275            current_idx - 1
276        };
277        let prev_id = focusable[prev_idx];
278        self.set_focus(prev_id);
279        Some(prev_id)
280    }
281
282    /// Number of nodes in the tree.
283    pub fn node_count(&self) -> usize {
284        self.nodes.len()
285    }
286
287    /// Build a tree from a list of window descriptions.
288    pub fn build_from_windows(windows: &[(&str, i32, i32, u32, u32)]) -> Self {
289        let mut tree = Self::new();
290
291        for (name, x, y, w, h) in windows {
292            let mut node = A11yNode::new(0, A11yRole::Window, name);
293            node.set_bounds(*x, *y, *w, *h);
294            node.focusable = true;
295            node.add_action(A11yAction::Focus);
296            tree.add_node(node);
297        }
298
299        tree
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Screen reader
305// ---------------------------------------------------------------------------
306
307/// Screen reader that generates text announcements from the a11y tree.
308#[derive(Debug)]
309pub struct ScreenReader {
310    /// Queue of pending announcements.
311    announcements: Vec<String>,
312    /// Whether the screen reader is enabled.
313    pub enabled: bool,
314    /// Maximum announcements to buffer.
315    max_queue: usize,
316}
317
318impl Default for ScreenReader {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324impl ScreenReader {
325    /// Create a new screen reader.
326    pub fn new() -> Self {
327        Self {
328            announcements: Vec::new(),
329            enabled: false,
330            max_queue: 64,
331        }
332    }
333
334    /// Announce a message.
335    pub fn announce(&mut self, message: &str) {
336        if !self.enabled {
337            return;
338        }
339        if self.announcements.len() < self.max_queue {
340            self.announcements.push(String::from(message));
341        }
342    }
343
344    /// Read the focused element and announce its name and role.
345    pub fn read_focused(&mut self, tree: &A11yTree) {
346        if !self.enabled {
347            return;
348        }
349        if let Some(node) = tree.focused_node() {
350            let role_str = match node.role {
351                A11yRole::Window => "window",
352                A11yRole::Button => "button",
353                A11yRole::Label => "label",
354                A11yRole::TextInput => "text input",
355                A11yRole::Menu => "menu",
356                A11yRole::MenuItem => "menu item",
357                A11yRole::Toolbar => "toolbar",
358                A11yRole::Scrollbar => "scrollbar",
359                A11yRole::List => "list",
360                A11yRole::ListItem => "list item",
361                A11yRole::Dialog => "dialog",
362                A11yRole::Alert => "alert",
363                A11yRole::Separator => "separator",
364                A11yRole::Image => "image",
365            };
366            let mut msg = String::new();
367            msg.push_str(&node.name);
368            msg.push_str(", ");
369            msg.push_str(role_str);
370            if !node.value.is_empty() {
371                msg.push_str(", value: ");
372                msg.push_str(&node.value);
373            }
374            self.announce(&msg);
375        }
376    }
377
378    /// Read all children of a node.
379    pub fn read_all_children(&mut self, tree: &A11yTree, parent_id: A11yNodeId) {
380        if !self.enabled {
381            return;
382        }
383        if let Some(parent) = tree.find_by_id(parent_id) {
384            let children = parent.children.clone();
385            for child_id in &children {
386                if let Some(child) = tree.find_by_id(*child_id) {
387                    self.announce(&child.name);
388                }
389            }
390        }
391    }
392
393    /// Drain all pending announcements.
394    pub fn drain_announcements(&mut self) -> Vec<String> {
395        let mut drained = Vec::new();
396        core::mem::swap(&mut self.announcements, &mut drained);
397        drained
398    }
399
400    /// Number of pending announcements.
401    pub fn pending_count(&self) -> usize {
402        self.announcements.len()
403    }
404}
405
406// ---------------------------------------------------------------------------
407// High contrast theme
408// ---------------------------------------------------------------------------
409
410/// High contrast colour theme for accessibility.
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub struct HighContrastTheme {
413    /// Background colour (ARGB8888).
414    pub bg_color: u32,
415    /// Foreground / text colour.
416    pub fg_color: u32,
417    /// Accent colour (focused items, links).
418    pub accent_color: u32,
419    /// Border colour.
420    pub border_color: u32,
421}
422
423impl HighContrastTheme {
424    /// Black background, white text (classic high contrast).
425    pub const DARK: Self = Self {
426        bg_color: 0xFF000000,
427        fg_color: 0xFFFFFFFF,
428        accent_color: 0xFF00FFFF,
429        border_color: 0xFFFFFF00,
430    };
431
432    /// White background, black text.
433    pub const LIGHT: Self = Self {
434        bg_color: 0xFFFFFFFF,
435        fg_color: 0xFF000000,
436        accent_color: 0xFF0000FF,
437        border_color: 0xFF000000,
438    };
439
440    /// Apply the theme colours to a pixel buffer region (fills with bg_color).
441    pub fn apply_background(&self, buf: &mut [u32], start: usize, count: usize) {
442        let end = (start + count).min(buf.len());
443        for px in &mut buf[start..end] {
444            *px = self.bg_color;
445        }
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Accessibility settings
451// ---------------------------------------------------------------------------
452
453/// Global accessibility settings.
454#[derive(Debug, Clone)]
455pub struct AccessibilitySettings {
456    /// Whether the screen reader is enabled.
457    pub screen_reader_enabled: bool,
458    /// Whether high contrast mode is active.
459    pub high_contrast: bool,
460    /// High contrast theme to use.
461    pub contrast_theme: HighContrastTheme,
462    /// Whether to use larger text (2x glyph scaling).
463    pub large_text: bool,
464    /// Whether to reduce motion/animations.
465    pub reduce_motion: bool,
466    /// Whether sticky keys are enabled.
467    pub sticky_keys: bool,
468    /// Keyboard repeat delay in ticks.
469    pub key_repeat_delay: u32,
470    /// Keyboard repeat rate in ticks per character.
471    pub key_repeat_rate: u32,
472}
473
474impl Default for AccessibilitySettings {
475    fn default() -> Self {
476        Self {
477            screen_reader_enabled: false,
478            high_contrast: false,
479            contrast_theme: HighContrastTheme::DARK,
480            large_text: false,
481            reduce_motion: false,
482            sticky_keys: false,
483            key_repeat_delay: 500,
484            key_repeat_rate: 50,
485        }
486    }
487}
488
489impl AccessibilitySettings {
490    /// Whether any accessibility feature is active.
491    pub fn any_active(&self) -> bool {
492        self.screen_reader_enabled
493            || self.high_contrast
494            || self.large_text
495            || self.reduce_motion
496            || self.sticky_keys
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Keyboard navigator
502// ---------------------------------------------------------------------------
503
504/// Navigation area (a group of focusable elements).
505#[derive(Debug, Clone)]
506pub struct NavigationArea {
507    /// Area name (for screen reader).
508    pub name: String,
509    /// Node IDs belonging to this area.
510    pub node_ids: Vec<A11yNodeId>,
511    /// Currently focused index within this area.
512    pub focus_index: usize,
513}
514
515impl NavigationArea {
516    /// Create a new area.
517    pub fn new(name: &str) -> Self {
518        Self {
519            name: String::from(name),
520            node_ids: Vec::new(),
521            focus_index: 0,
522        }
523    }
524
525    /// Add a node to this area.
526    pub fn add_node(&mut self, id: A11yNodeId) {
527        self.node_ids.push(id);
528    }
529}
530
531/// Keyboard-driven navigation controller.
532///
533/// Supports F6 for area cycling, Tab/Shift-Tab for intra-area focus,
534/// Arrow keys for directional movement, Enter for activation, Escape for
535/// cancel.
536#[derive(Debug)]
537pub struct KeyboardNavigator {
538    /// Navigation areas.
539    pub areas: Vec<NavigationArea>,
540    /// Index of the currently active area.
541    pub current_area: usize,
542}
543
544impl Default for KeyboardNavigator {
545    fn default() -> Self {
546        Self::new()
547    }
548}
549
550/// Key events understood by the keyboard navigator.
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub enum NavKey {
553    /// F6: cycle between areas.
554    CycleArea,
555    /// Tab: next focusable in area.
556    Tab,
557    /// Shift+Tab: previous focusable in area.
558    ShiftTab,
559    /// Arrow up within area.
560    Up,
561    /// Arrow down within area.
562    Down,
563    /// Enter: activate focused element.
564    Enter,
565    /// Escape: cancel or close.
566    Escape,
567}
568
569/// Result of handling a navigation key.
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub enum NavResult {
572    /// Focus moved to a node.
573    FocusChanged(A11yNodeId),
574    /// An action was triggered on a node.
575    Activated(A11yNodeId),
576    /// Escape was pressed (dismiss/cancel).
577    Cancelled,
578    /// No change.
579    NoOp,
580}
581
582impl KeyboardNavigator {
583    /// Create a new navigator.
584    pub fn new() -> Self {
585        Self {
586            areas: Vec::new(),
587            current_area: 0,
588        }
589    }
590
591    /// Add a navigation area.
592    pub fn add_area(&mut self, area: NavigationArea) {
593        self.areas.push(area);
594    }
595
596    /// Cycle to the next area (F6).
597    pub fn cycle_area(&mut self) -> NavResult {
598        if self.areas.is_empty() {
599            return NavResult::NoOp;
600        }
601        self.current_area = (self.current_area + 1) % self.areas.len();
602        self.current_focused_node()
603            .map(NavResult::FocusChanged)
604            .unwrap_or(NavResult::NoOp)
605    }
606
607    /// Move focus to the next node in the current area.
608    pub fn focus_next(&mut self) -> NavResult {
609        if let Some(area) = self.areas.get_mut(self.current_area) {
610            if area.node_ids.is_empty() {
611                return NavResult::NoOp;
612            }
613            area.focus_index = (area.focus_index + 1) % area.node_ids.len();
614            NavResult::FocusChanged(area.node_ids[area.focus_index])
615        } else {
616            NavResult::NoOp
617        }
618    }
619
620    /// Move focus to the previous node in the current area.
621    pub fn focus_prev(&mut self) -> NavResult {
622        if let Some(area) = self.areas.get_mut(self.current_area) {
623            if area.node_ids.is_empty() {
624                return NavResult::NoOp;
625            }
626            area.focus_index = if area.focus_index == 0 {
627                area.node_ids.len() - 1
628            } else {
629                area.focus_index - 1
630            };
631            NavResult::FocusChanged(area.node_ids[area.focus_index])
632        } else {
633            NavResult::NoOp
634        }
635    }
636
637    /// Handle a navigation key event.
638    pub fn handle_key(&mut self, key: NavKey) -> NavResult {
639        match key {
640            NavKey::CycleArea => self.cycle_area(),
641            NavKey::Tab | NavKey::Down => self.focus_next(),
642            NavKey::ShiftTab | NavKey::Up => self.focus_prev(),
643            NavKey::Enter => self
644                .current_focused_node()
645                .map(NavResult::Activated)
646                .unwrap_or(NavResult::NoOp),
647            NavKey::Escape => NavResult::Cancelled,
648        }
649    }
650
651    /// Get the currently focused node ID.
652    pub fn current_focused_node(&self) -> Option<A11yNodeId> {
653        let area = self.areas.get(self.current_area)?;
654        area.node_ids.get(area.focus_index).copied()
655    }
656}
657
658// ---------------------------------------------------------------------------
659// Tests
660// ---------------------------------------------------------------------------
661
662#[cfg(test)]
663mod tests {
664    #[allow(unused_imports)]
665    use alloc::vec;
666
667    use super::*;
668
669    #[test]
670    fn test_a11y_node_new() {
671        let node = A11yNode::new(1, A11yRole::Button, "OK");
672        assert_eq!(node.name, "OK");
673        assert!(node.focusable);
674        assert!(!node.focused);
675    }
676
677    #[test]
678    fn test_a11y_node_label_not_focusable() {
679        let node = A11yNode::new(1, A11yRole::Label, "Status");
680        assert!(!node.focusable);
681    }
682
683    #[test]
684    fn test_a11y_tree_add_and_find() {
685        let mut tree = A11yTree::new();
686        let node = A11yNode::new(0, A11yRole::Window, "Main");
687        let id = tree.add_node(node);
688        assert!(tree.find_by_id(id).is_some());
689        assert_eq!(tree.node_count(), 1);
690    }
691
692    #[test]
693    fn test_a11y_tree_focus() {
694        let mut tree = A11yTree::new();
695        let mut btn = A11yNode::new(0, A11yRole::Button, "Click Me");
696        btn.focusable = true;
697        let id = tree.add_node(btn);
698        assert!(tree.set_focus(id));
699        assert!(tree.focused_node().is_some());
700        assert_eq!(tree.focused_node().unwrap().name, "Click Me");
701    }
702
703    #[test]
704    fn test_a11y_tree_next_focusable() {
705        let mut tree = A11yTree::new();
706        let btn1 = A11yNode::new(0, A11yRole::Button, "A");
707        let btn2 = A11yNode::new(0, A11yRole::Button, "B");
708        tree.add_node(btn1);
709        tree.add_node(btn2);
710        let next = tree.next_focusable();
711        assert!(next.is_some());
712    }
713
714    #[test]
715    fn test_a11y_tree_prev_focusable() {
716        let mut tree = A11yTree::new();
717        let btn1 = A11yNode::new(0, A11yRole::Button, "A");
718        let btn2 = A11yNode::new(0, A11yRole::Button, "B");
719        let id1 = tree.add_node(btn1);
720        let _id2 = tree.add_node(btn2);
721        tree.set_focus(id1);
722        let prev = tree.prev_focusable();
723        assert!(prev.is_some());
724    }
725
726    #[test]
727    fn test_build_from_windows() {
728        let tree = A11yTree::build_from_windows(&[("Terminal", 0, 0, 800, 600)]);
729        assert_eq!(tree.node_count(), 1);
730    }
731
732    #[test]
733    fn test_screen_reader_announce() {
734        let mut reader = ScreenReader::new();
735        reader.enabled = true;
736        reader.announce("Hello");
737        assert_eq!(reader.pending_count(), 1);
738        let msgs = reader.drain_announcements();
739        assert_eq!(msgs.len(), 1);
740        assert_eq!(msgs[0], "Hello");
741    }
742
743    #[test]
744    fn test_screen_reader_disabled() {
745        let mut reader = ScreenReader::new();
746        reader.announce("Should not be added");
747        assert_eq!(reader.pending_count(), 0);
748    }
749
750    #[test]
751    fn test_screen_reader_read_focused() {
752        let mut tree = A11yTree::new();
753        let btn = A11yNode::new(0, A11yRole::Button, "Save");
754        let id = tree.add_node(btn);
755        tree.set_focus(id);
756        let mut reader = ScreenReader::new();
757        reader.enabled = true;
758        reader.read_focused(&tree);
759        assert_eq!(reader.pending_count(), 1);
760    }
761
762    #[test]
763    fn test_high_contrast_theme() {
764        let theme = HighContrastTheme::DARK;
765        assert_eq!(theme.bg_color, 0xFF000000);
766        assert_eq!(theme.fg_color, 0xFFFFFFFF);
767        let mut buf = vec![0xFFFFFFFF_u32; 10];
768        theme.apply_background(&mut buf, 0, 5);
769        assert_eq!(buf[0], 0xFF000000);
770        assert_eq!(buf[5], 0xFFFFFFFF);
771    }
772
773    #[test]
774    fn test_accessibility_settings_default() {
775        let settings = AccessibilitySettings::default();
776        assert!(!settings.any_active());
777    }
778
779    #[test]
780    fn test_keyboard_navigator_cycle() {
781        let mut nav = KeyboardNavigator::new();
782        let mut area1 = NavigationArea::new("Menu");
783        area1.add_node(10);
784        area1.add_node(11);
785        let mut area2 = NavigationArea::new("Content");
786        area2.add_node(20);
787        nav.add_area(area1);
788        nav.add_area(area2);
789        assert_eq!(nav.current_area, 0);
790        nav.cycle_area();
791        assert_eq!(nav.current_area, 1);
792        nav.cycle_area();
793        assert_eq!(nav.current_area, 0);
794    }
795
796    #[test]
797    fn test_keyboard_navigator_focus_next_prev() {
798        let mut nav = KeyboardNavigator::new();
799        let mut area = NavigationArea::new("Toolbar");
800        area.add_node(1);
801        area.add_node(2);
802        area.add_node(3);
803        nav.add_area(area);
804        let result = nav.focus_next();
805        assert_eq!(result, NavResult::FocusChanged(2));
806        let result = nav.focus_prev();
807        assert_eq!(result, NavResult::FocusChanged(1));
808    }
809
810    #[test]
811    fn test_keyboard_navigator_handle_key() {
812        let mut nav = KeyboardNavigator::new();
813        let mut area = NavigationArea::new("Test");
814        area.add_node(5);
815        nav.add_area(area);
816        let result = nav.handle_key(NavKey::Enter);
817        assert_eq!(result, NavResult::Activated(5));
818        let result = nav.handle_key(NavKey::Escape);
819        assert_eq!(result, NavResult::Cancelled);
820    }
821
822    #[test]
823    fn test_nav_result_no_op_empty() {
824        let mut nav = KeyboardNavigator::new();
825        assert_eq!(nav.handle_key(NavKey::Tab), NavResult::NoOp);
826    }
827}