ricecoder_tui/
accessibility.rs

1//! Accessibility features for the TUI
2//!
3//! This module provides accessibility support including:
4//! - Screen reader support with text alternatives
5//! - Full keyboard navigation
6//! - High contrast mode
7//! - Animation controls
8//! - State announcements
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Animation configuration
14#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
15pub struct AnimationConfig {
16    /// Enable animations
17    pub enabled: bool,
18    /// Animation speed (0.1 to 2.0, where 1.0 is normal)
19    pub speed: f32,
20    /// Reduce motion for accessibility
21    pub reduce_motion: bool,
22}
23
24impl Default for AnimationConfig {
25    fn default() -> Self {
26        Self {
27            enabled: true,
28            speed: 1.0,
29            reduce_motion: false,
30        }
31    }
32}
33
34impl AnimationConfig {
35    /// Get the effective animation duration in milliseconds
36    pub fn duration_ms(&self, base_ms: u32) -> u32 {
37        if !self.enabled || self.reduce_motion {
38            return 0;
39        }
40        ((base_ms as f32) / self.speed) as u32
41    }
42
43    /// Check if animations should be shown
44    pub fn should_animate(&self) -> bool {
45        self.enabled && !self.reduce_motion
46    }
47}
48
49/// Accessibility configuration
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AccessibilityConfig {
52    /// Enable screen reader support
53    pub screen_reader_enabled: bool,
54    /// Enable high contrast mode
55    pub high_contrast_enabled: bool,
56    /// Disable animations
57    pub animations_disabled: bool,
58    /// Enable state announcements
59    pub announcements_enabled: bool,
60    /// Focus indicator style
61    pub focus_indicator: FocusIndicatorStyle,
62    /// Animation configuration
63    #[serde(default)]
64    pub animations: AnimationConfig,
65}
66
67impl Default for AccessibilityConfig {
68    fn default() -> Self {
69        Self {
70            screen_reader_enabled: false,
71            high_contrast_enabled: false,
72            animations_disabled: false,
73            announcements_enabled: true,
74            focus_indicator: FocusIndicatorStyle::Bracket,
75            animations: AnimationConfig::default(),
76        }
77    }
78}
79
80/// Focus indicator style for keyboard navigation
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum FocusIndicatorStyle {
83    /// Use brackets: [focused element]
84    Bracket,
85    /// Use asterisks: *focused element*
86    Asterisk,
87    /// Use underline: _focused element_
88    Underline,
89    /// Use arrow: > focused element
90    Arrow,
91}
92
93impl FocusIndicatorStyle {
94    /// Get the prefix for this style
95    pub fn prefix(&self) -> &'static str {
96        match self {
97            FocusIndicatorStyle::Bracket => "[",
98            FocusIndicatorStyle::Asterisk => "*",
99            FocusIndicatorStyle::Underline => "_",
100            FocusIndicatorStyle::Arrow => "> ",
101        }
102    }
103
104    /// Get the suffix for this style
105    pub fn suffix(&self) -> &'static str {
106        match self {
107            FocusIndicatorStyle::Bracket => "]",
108            FocusIndicatorStyle::Asterisk => "*",
109            FocusIndicatorStyle::Underline => "_",
110            FocusIndicatorStyle::Arrow => "",
111        }
112    }
113}
114
115/// Text alternative for visual elements
116#[derive(Debug, Clone)]
117pub struct TextAlternative {
118    /// Unique identifier for the element
119    pub id: String,
120    /// Short description (for screen readers)
121    pub short_description: String,
122    /// Long description (for detailed context)
123    pub long_description: Option<String>,
124    /// Element type (button, input, list, etc.)
125    pub element_type: ElementType,
126}
127
128impl TextAlternative {
129    /// Create a new text alternative
130    pub fn new(
131        id: impl Into<String>,
132        short_desc: impl Into<String>,
133        element_type: ElementType,
134    ) -> Self {
135        Self {
136            id: id.into(),
137            short_description: short_desc.into(),
138            long_description: None,
139            element_type,
140        }
141    }
142
143    /// Add a long description
144    pub fn with_long_description(mut self, desc: impl Into<String>) -> Self {
145        self.long_description = Some(desc.into());
146        self
147    }
148
149    /// Get the full description for screen readers
150    pub fn full_description(&self) -> String {
151        match &self.long_description {
152            Some(long) => format!("{}: {}", self.short_description, long),
153            None => self.short_description.clone(),
154        }
155    }
156}
157
158/// Element type for semantic structure
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ElementType {
161    /// Button element
162    Button,
163    /// Input field
164    Input,
165    /// List or menu
166    List,
167    /// List item
168    ListItem,
169    /// Tab
170    Tab,
171    /// Tab panel
172    TabPanel,
173    /// Dialog
174    Dialog,
175    /// Text content
176    Text,
177    /// Heading
178    Heading,
179    /// Code block
180    CodeBlock,
181    /// Message (chat)
182    Message,
183    /// Status indicator
184    Status,
185}
186
187impl ElementType {
188    /// Get the semantic role name
189    pub fn role(&self) -> &'static str {
190        match self {
191            ElementType::Button => "button",
192            ElementType::Input => "textbox",
193            ElementType::List => "list",
194            ElementType::ListItem => "listitem",
195            ElementType::Tab => "tab",
196            ElementType::TabPanel => "tabpanel",
197            ElementType::Dialog => "dialog",
198            ElementType::Text => "text",
199            ElementType::Heading => "heading",
200            ElementType::CodeBlock => "code",
201            ElementType::Message => "article",
202            ElementType::Status => "status",
203        }
204    }
205}
206
207/// Screen reader announcer for state changes
208#[derive(Debug, Clone)]
209pub struct ScreenReaderAnnouncer {
210    /// Whether announcements are enabled
211    enabled: bool,
212    /// Announcement history (for testing)
213    history: Vec<Announcement>,
214}
215
216/// An announcement for screen readers
217#[derive(Debug, Clone)]
218pub struct Announcement {
219    /// The announcement text
220    pub text: String,
221    /// Priority level
222    pub priority: AnnouncementPriority,
223}
224
225/// Priority level for announcements
226#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
227pub enum AnnouncementPriority {
228    /// Low priority (polite)
229    Low,
230    /// Normal priority (assertive)
231    Normal,
232    /// High priority (alert)
233    High,
234}
235
236impl ScreenReaderAnnouncer {
237    /// Create a new announcer
238    pub fn new(enabled: bool) -> Self {
239        Self {
240            enabled,
241            history: Vec::new(),
242        }
243    }
244
245    /// Announce a message
246    pub fn announce(&mut self, text: impl Into<String>, priority: AnnouncementPriority) {
247        if !self.enabled {
248            return;
249        }
250
251        let announcement = Announcement {
252            text: text.into(),
253            priority,
254        };
255
256        self.history.push(announcement);
257    }
258
259    /// Announce a state change
260    pub fn announce_state_change(&mut self, element: &str, state: &str) {
261        self.announce(
262            format!("{} {}", element, state),
263            AnnouncementPriority::Normal,
264        );
265    }
266
267    /// Announce an error
268    pub fn announce_error(&mut self, message: impl Into<String>) {
269        self.announce(message, AnnouncementPriority::High);
270    }
271
272    /// Announce a success
273    pub fn announce_success(&mut self, message: impl Into<String>) {
274        self.announce(message, AnnouncementPriority::Normal);
275    }
276
277    /// Get the last announcement
278    pub fn last_announcement(&self) -> Option<&Announcement> {
279        self.history.last()
280    }
281
282    /// Get all announcements
283    pub fn announcements(&self) -> &[Announcement] {
284        &self.history
285    }
286
287    /// Clear announcement history
288    pub fn clear_history(&mut self) {
289        self.history.clear();
290    }
291
292    /// Enable announcements
293    pub fn enable(&mut self) {
294        self.enabled = true;
295    }
296
297    /// Disable announcements
298    pub fn disable(&mut self) {
299        self.enabled = false;
300    }
301
302    /// Check if announcements are enabled
303    pub fn is_enabled(&self) -> bool {
304        self.enabled
305    }
306}
307
308/// State change event for announcements
309#[derive(Debug, Clone)]
310pub struct StateChangeEvent {
311    /// Component or element that changed
312    pub component: String,
313    /// Previous state
314    pub previous_state: String,
315    /// New state
316    pub new_state: String,
317    /// Priority of the announcement
318    pub priority: AnnouncementPriority,
319}
320
321impl StateChangeEvent {
322    /// Create a new state change event
323    pub fn new(
324        component: impl Into<String>,
325        previous: impl Into<String>,
326        new: impl Into<String>,
327        priority: AnnouncementPriority,
328    ) -> Self {
329        Self {
330            component: component.into(),
331            previous_state: previous.into(),
332            new_state: new.into(),
333            priority,
334        }
335    }
336
337    /// Get the announcement text
338    pub fn announcement_text(&self) -> String {
339        format!(
340            "{} changed from {} to {}",
341            self.component, self.previous_state, self.new_state
342        )
343    }
344}
345
346/// Focus management for accessibility
347#[derive(Debug, Clone, Default)]
348pub struct FocusManager {
349    /// Currently focused element
350    pub focused_element: Option<String>,
351    /// Focus history for restoration
352    pub focus_history: Vec<String>,
353}
354
355impl FocusManager {
356    /// Create a new focus manager
357    pub fn new() -> Self {
358        Self::default()
359    }
360
361    /// Set focus to an element
362    pub fn set_focus(&mut self, element_id: impl Into<String>) {
363        let id = element_id.into();
364        if let Some(current) = &self.focused_element {
365            self.focus_history.push(current.clone());
366        }
367        self.focused_element = Some(id);
368    }
369
370    /// Restore previous focus
371    pub fn restore_focus(&mut self) -> Option<String> {
372        self.focus_history.pop()
373    }
374
375    /// Clear focus
376    pub fn clear_focus(&mut self) {
377        self.focused_element = None;
378    }
379}
380
381/// Keyboard navigation manager
382#[derive(Debug, Clone, Default)]
383pub struct KeyboardNavigationManager {
384    /// Currently focused element ID
385    pub focused_element: Option<String>,
386    /// Tab order (list of element IDs in tab order)
387    pub tab_order: Vec<String>,
388    /// Element descriptions
389    pub element_descriptions: HashMap<String, TextAlternative>,
390}
391
392impl KeyboardNavigationManager {
393    /// Create a new keyboard navigation manager
394    pub fn new() -> Self {
395        Self::default()
396    }
397
398    /// Register an element for keyboard navigation
399    pub fn register_element(&mut self, alternative: TextAlternative) {
400        self.tab_order.push(alternative.id.clone());
401        self.element_descriptions
402            .insert(alternative.id.clone(), alternative);
403    }
404
405    /// Set focus to an element
406    pub fn focus(&mut self, element_id: &str) -> bool {
407        if self.element_descriptions.contains_key(element_id) {
408            self.focused_element = Some(element_id.to_string());
409            true
410        } else {
411            false
412        }
413    }
414
415    /// Move focus to the next element
416    pub fn focus_next(&mut self) -> Option<&TextAlternative> {
417        if self.tab_order.is_empty() {
418            return None;
419        }
420
421        let next_index = match &self.focused_element {
422            None => 0,
423            Some(current) => {
424                let current_index = self.tab_order.iter().position(|id| id == current)?;
425                (current_index + 1) % self.tab_order.len()
426            }
427        };
428
429        let next_id = self.tab_order[next_index].clone();
430        self.focused_element = Some(next_id.clone());
431        self.element_descriptions.get(&next_id)
432    }
433
434    /// Move focus to the previous element
435    pub fn focus_previous(&mut self) -> Option<&TextAlternative> {
436        if self.tab_order.is_empty() {
437            return None;
438        }
439
440        let prev_index = match &self.focused_element {
441            None => self.tab_order.len() - 1,
442            Some(current) => {
443                let current_index = self.tab_order.iter().position(|id| id == current)?;
444                if current_index == 0 {
445                    self.tab_order.len() - 1
446                } else {
447                    current_index - 1
448                }
449            }
450        };
451
452        let prev_id = self.tab_order[prev_index].clone();
453        self.focused_element = Some(prev_id.clone());
454        self.element_descriptions.get(&prev_id)
455    }
456
457    /// Get the currently focused element
458    pub fn current_focus(&self) -> Option<&TextAlternative> {
459        self.focused_element
460            .as_ref()
461            .and_then(|id| self.element_descriptions.get(id))
462    }
463
464    /// Clear all registered elements
465    pub fn clear(&mut self) {
466        self.focused_element = None;
467        self.tab_order.clear();
468        self.element_descriptions.clear();
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_accessibility_config_default() {
478        let config = AccessibilityConfig::default();
479        assert!(!config.screen_reader_enabled);
480        assert!(!config.high_contrast_enabled);
481        assert!(!config.animations_disabled);
482        assert!(config.announcements_enabled);
483    }
484
485    #[test]
486    fn test_focus_indicator_style() {
487        assert_eq!(FocusIndicatorStyle::Bracket.prefix(), "[");
488        assert_eq!(FocusIndicatorStyle::Bracket.suffix(), "]");
489        assert_eq!(FocusIndicatorStyle::Arrow.prefix(), "> ");
490        assert_eq!(FocusIndicatorStyle::Arrow.suffix(), "");
491    }
492
493    #[test]
494    fn test_text_alternative() {
495        let alt = TextAlternative::new("btn1", "Submit button", ElementType::Button)
496            .with_long_description("Click to submit the form");
497        assert_eq!(alt.id, "btn1");
498        assert_eq!(alt.short_description, "Submit button");
499        assert!(alt.long_description.is_some());
500    }
501
502    #[test]
503    fn test_element_type_role() {
504        assert_eq!(ElementType::Button.role(), "button");
505        assert_eq!(ElementType::Input.role(), "textbox");
506        assert_eq!(ElementType::List.role(), "list");
507    }
508
509    #[test]
510    fn test_screen_reader_announcer() {
511        let mut announcer = ScreenReaderAnnouncer::new(true);
512        announcer.announce("Test announcement", AnnouncementPriority::Normal);
513        assert_eq!(announcer.announcements().len(), 1);
514        assert_eq!(
515            announcer.last_announcement().unwrap().text,
516            "Test announcement"
517        );
518    }
519
520    #[test]
521    fn test_screen_reader_announcer_disabled() {
522        let mut announcer = ScreenReaderAnnouncer::new(false);
523        announcer.announce("Test", AnnouncementPriority::Normal);
524        assert_eq!(announcer.announcements().len(), 0);
525    }
526
527    #[test]
528    fn test_keyboard_navigation_manager() {
529        let mut manager = KeyboardNavigationManager::new();
530        let alt1 = TextAlternative::new("btn1", "Button 1", ElementType::Button);
531        let alt2 = TextAlternative::new("btn2", "Button 2", ElementType::Button);
532
533        manager.register_element(alt1);
534        manager.register_element(alt2);
535
536        assert!(manager.focus("btn1"));
537        assert_eq!(manager.focused_element, Some("btn1".to_string()));
538
539        let next = manager.focus_next();
540        assert!(next.is_some());
541        assert_eq!(manager.focused_element, Some("btn2".to_string()));
542    }
543
544    #[test]
545    fn test_keyboard_navigation_wrap_around() {
546        let mut manager = KeyboardNavigationManager::new();
547        manager.register_element(TextAlternative::new(
548            "btn1",
549            "Button 1",
550            ElementType::Button,
551        ));
552        manager.register_element(TextAlternative::new(
553            "btn2",
554            "Button 2",
555            ElementType::Button,
556        ));
557
558        manager.focus("btn2");
559        let _next = manager.focus_next();
560        assert_eq!(manager.focused_element, Some("btn1".to_string()));
561    }
562
563    #[test]
564    fn test_animation_config_default() {
565        let config = AnimationConfig::default();
566        assert!(config.enabled);
567        assert_eq!(config.speed, 1.0);
568        assert!(!config.reduce_motion);
569    }
570
571    #[test]
572    fn test_animation_duration_calculation() {
573        let config = AnimationConfig {
574            enabled: true,
575            speed: 2.0,
576            reduce_motion: false,
577        };
578        // Base 100ms at 2x speed should be 50ms
579        assert_eq!(config.duration_ms(100), 50);
580    }
581
582    #[test]
583    fn test_animation_disabled() {
584        let config = AnimationConfig {
585            enabled: false,
586            speed: 1.0,
587            reduce_motion: false,
588        };
589        // Disabled animations should return 0 duration
590        assert_eq!(config.duration_ms(100), 0);
591    }
592
593    #[test]
594    fn test_animation_reduce_motion() {
595        let config = AnimationConfig {
596            enabled: true,
597            speed: 1.0,
598            reduce_motion: true,
599        };
600        // Reduce motion should return 0 duration
601        assert_eq!(config.duration_ms(100), 0);
602        assert!(!config.should_animate());
603    }
604
605    #[test]
606    fn test_accessibility_config_animations() {
607        let config = AccessibilityConfig::default();
608        assert!(config.animations.enabled);
609        assert!(config.animations.should_animate());
610    }
611
612    #[test]
613    fn test_state_change_event() {
614        let event = StateChangeEvent::new(
615            "button",
616            "disabled",
617            "enabled",
618            AnnouncementPriority::Normal,
619        );
620        assert_eq!(event.component, "button");
621        assert_eq!(event.previous_state, "disabled");
622        assert_eq!(event.new_state, "enabled");
623        assert!(event.announcement_text().contains("button"));
624    }
625
626    #[test]
627    fn test_focus_manager() {
628        let mut manager = FocusManager::new();
629        assert!(manager.focused_element.is_none());
630
631        manager.set_focus("btn1");
632        assert_eq!(manager.focused_element, Some("btn1".to_string()));
633
634        manager.set_focus("btn2");
635        assert_eq!(manager.focused_element, Some("btn2".to_string()));
636
637        let restored = manager.restore_focus();
638        assert_eq!(restored, Some("btn1".to_string()));
639    }
640
641    #[test]
642    fn test_focus_manager_clear() {
643        let mut manager = FocusManager::new();
644        manager.set_focus("btn1");
645        manager.clear_focus();
646        assert!(manager.focused_element.is_none());
647    }
648}