teapot/runtime/
accessible.rs

1//! Accessible mode support for screen readers and non-visual environments.
2//!
3//! When accessible mode is enabled, components render as plain text prompts
4//! with numbered options instead of visual TUI elements. Input is read from
5//! stdin line-by-line without requiring raw terminal mode.
6//!
7//! # Environment Variables
8//!
9//! - `ACCESSIBLE=1` - Enable accessible mode
10//! - `NO_COLOR=1` - Disable colors (always disabled in accessible mode)
11//! - `REDUCE_MOTION=1` - Disable animations (always disabled in accessible mode)
12//!
13//! # Example
14//!
15//! ```text
16//! ? What is your name?
17//! > Alice
18//!
19//! ? Choose a color:
20//! 1) Red
21//! 2) Green
22//! 3) Blue
23//! > 2
24//!
25//! ? Are you sure? (yes/no)
26//! > yes
27//! ```
28
29use std::io::{self, BufRead, Write};
30
31/// Result of parsing accessible input.
32#[derive(Debug, Clone)]
33pub enum AccessibleInput {
34    /// Text input.
35    Text(String),
36    /// Numeric selection (1-based).
37    Selection(usize),
38    /// Multiple selections (1-based).
39    MultiSelection(Vec<usize>),
40    /// Yes/true response.
41    Yes,
42    /// No/false response.
43    No,
44    /// Cancel/quit.
45    Cancel,
46    /// Empty input (use default).
47    Empty,
48}
49
50impl AccessibleInput {
51    /// Parse input for a text prompt.
52    pub fn parse_text(input: &str) -> Self {
53        let trimmed = input.trim();
54        if trimmed.is_empty() {
55            AccessibleInput::Empty
56        } else {
57            AccessibleInput::Text(trimmed.to_string())
58        }
59    }
60
61    /// Parse input for a selection prompt (1-based number).
62    pub fn parse_selection(input: &str, max: usize) -> Self {
63        let trimmed = input.trim().to_lowercase();
64
65        if trimmed.is_empty() {
66            return AccessibleInput::Empty;
67        }
68
69        if trimmed == "q" || trimmed == "quit" || trimmed == "cancel" {
70            return AccessibleInput::Cancel;
71        }
72
73        // Try parsing as number
74        if let Ok(n) = trimmed.parse::<usize>() {
75            if n >= 1 && n <= max {
76                return AccessibleInput::Selection(n);
77            }
78        }
79
80        // Invalid input - return empty to re-prompt
81        AccessibleInput::Empty
82    }
83
84    /// Parse input for a multi-selection prompt (comma-separated 1-based numbers).
85    pub fn parse_multi_selection(input: &str, max: usize) -> Self {
86        let trimmed = input.trim().to_lowercase();
87
88        if trimmed.is_empty() {
89            return AccessibleInput::Empty;
90        }
91
92        if trimmed == "q" || trimmed == "quit" || trimmed == "cancel" {
93            return AccessibleInput::Cancel;
94        }
95
96        if trimmed == "done" || trimmed == "d" {
97            return AccessibleInput::MultiSelection(vec![]);
98        }
99
100        // Parse comma-separated numbers
101        let mut selections = Vec::new();
102        for part in trimmed.split([',', ' ']) {
103            let part = part.trim();
104            if part.is_empty() {
105                continue;
106            }
107            if let Ok(n) = part.parse::<usize>() {
108                if n >= 1 && n <= max && !selections.contains(&n) {
109                    selections.push(n);
110                }
111            }
112        }
113
114        if selections.is_empty() {
115            AccessibleInput::Empty
116        } else {
117            AccessibleInput::MultiSelection(selections)
118        }
119    }
120
121    /// Parse input for a yes/no prompt.
122    pub fn parse_confirm(input: &str, default: Option<bool>) -> Self {
123        let trimmed = input.trim().to_lowercase();
124
125        if trimmed.is_empty() {
126            return match default {
127                Some(true) => AccessibleInput::Yes,
128                Some(false) => AccessibleInput::No,
129                None => AccessibleInput::Empty,
130            };
131        }
132
133        match trimmed.as_str() {
134            "y" | "yes" | "true" | "1" => AccessibleInput::Yes,
135            "n" | "no" | "false" | "0" => AccessibleInput::No,
136            "q" | "quit" | "cancel" => AccessibleInput::Cancel,
137            _ => AccessibleInput::Empty,
138        }
139    }
140}
141
142/// Trait for components that support accessible mode.
143///
144/// Components implementing this trait can render themselves as plain text
145/// prompts suitable for screen readers and text-only environments.
146pub trait Accessible {
147    /// The message type for accessible input.
148    type Message;
149
150    /// Render the accessible prompt.
151    ///
152    /// Returns a plain text prompt without ANSI codes or visual formatting.
153    /// The prompt should clearly describe what input is expected.
154    fn accessible_prompt(&self) -> String;
155
156    /// Parse accessible input and return a message.
157    ///
158    /// Takes the raw input line from the user and converts it to a message
159    /// that can be passed to `update()`.
160    fn parse_accessible_input(&self, input: &str) -> Option<Self::Message>;
161
162    /// Check if the component is complete (submitted or cancelled).
163    fn is_accessible_complete(&self) -> bool;
164}
165
166/// Read a line of input from stdin.
167pub fn read_line() -> io::Result<String> {
168    let stdin = io::stdin();
169    let mut line = String::new();
170    stdin.lock().read_line(&mut line)?;
171    Ok(line)
172}
173
174/// Print a prompt and flush stdout.
175pub fn print_prompt(prompt: &str) -> io::Result<()> {
176    let mut stdout = io::stdout();
177    write!(stdout, "{}", prompt)?;
178    stdout.flush()
179}
180
181/// Print a line to stdout.
182pub fn println_accessible(line: &str) -> io::Result<()> {
183    println!("{}", line);
184    Ok(())
185}
186
187/// Strip ANSI escape codes from a string.
188///
189/// Handles both CSI sequences (`\x1b[...m`) and OSC sequences (`\x1b]...\x07`),
190/// including OSC 8 hyperlinks.
191pub fn strip_ansi(s: &str) -> String {
192    let mut result = String::with_capacity(s.len());
193    let mut chars = s.chars().peekable();
194
195    while let Some(c) = chars.next() {
196        if c == '\x1b' {
197            match chars.peek() {
198                Some('[') => {
199                    // CSI sequence: ESC [ ... (letter)
200                    chars.next();
201                    while let Some(&next) = chars.peek() {
202                        chars.next();
203                        if next.is_ascii_alphabetic() {
204                            break;
205                        }
206                    }
207                },
208                Some(']') => {
209                    // OSC sequence: ESC ] ... (BEL or ESC \)
210                    chars.next();
211                    while let Some(next) = chars.next() {
212                        if next == '\x07' {
213                            break;
214                        } else if next == '\x1b' && chars.peek() == Some(&'\\') {
215                            chars.next();
216                            break;
217                        }
218                    }
219                },
220                _ => {},
221            }
222        } else {
223            result.push(c);
224        }
225    }
226
227    result
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_parse_text() {
236        assert!(matches!(
237            AccessibleInput::parse_text("hello"),
238            AccessibleInput::Text(s) if s == "hello"
239        ));
240        assert!(matches!(
241            AccessibleInput::parse_text("  hello  "),
242            AccessibleInput::Text(s) if s == "hello"
243        ));
244        assert!(matches!(AccessibleInput::parse_text(""), AccessibleInput::Empty));
245    }
246
247    #[test]
248    fn test_parse_selection() {
249        assert!(matches!(AccessibleInput::parse_selection("1", 3), AccessibleInput::Selection(1)));
250        assert!(matches!(AccessibleInput::parse_selection("3", 3), AccessibleInput::Selection(3)));
251        assert!(matches!(AccessibleInput::parse_selection("4", 3), AccessibleInput::Empty)); // Out of range
252        assert!(matches!(AccessibleInput::parse_selection("0", 3), AccessibleInput::Empty)); // Out of range
253        assert!(matches!(AccessibleInput::parse_selection("q", 3), AccessibleInput::Cancel));
254    }
255
256    #[test]
257    fn test_parse_multi_selection() {
258        match AccessibleInput::parse_multi_selection("1, 3", 5) {
259            AccessibleInput::MultiSelection(v) => assert_eq!(v, vec![1, 3]),
260            _ => panic!("Expected MultiSelection"),
261        }
262        match AccessibleInput::parse_multi_selection("1 2 3", 5) {
263            AccessibleInput::MultiSelection(v) => assert_eq!(v, vec![1, 2, 3]),
264            _ => panic!("Expected MultiSelection"),
265        }
266        assert!(matches!(
267            AccessibleInput::parse_multi_selection("done", 5),
268            AccessibleInput::MultiSelection(v) if v.is_empty()
269        ));
270    }
271
272    #[test]
273    fn test_parse_confirm() {
274        assert!(matches!(AccessibleInput::parse_confirm("yes", None), AccessibleInput::Yes));
275        assert!(matches!(AccessibleInput::parse_confirm("y", None), AccessibleInput::Yes));
276        assert!(matches!(AccessibleInput::parse_confirm("no", None), AccessibleInput::No));
277        assert!(matches!(AccessibleInput::parse_confirm("n", None), AccessibleInput::No));
278        assert!(matches!(AccessibleInput::parse_confirm("", Some(true)), AccessibleInput::Yes));
279        assert!(matches!(AccessibleInput::parse_confirm("", Some(false)), AccessibleInput::No));
280        assert!(matches!(AccessibleInput::parse_confirm("", None), AccessibleInput::Empty));
281    }
282
283    #[test]
284    fn test_strip_ansi() {
285        // CSI sequences
286        assert_eq!(strip_ansi("\x1b[31mRed\x1b[0m"), "Red");
287        assert_eq!(strip_ansi("\x1b[1;32mBold Green\x1b[0m"), "Bold Green");
288        assert_eq!(strip_ansi("No codes"), "No codes");
289        assert_eq!(strip_ansi("\x1b[38;2;255;0;0mTruecolor\x1b[0m"), "Truecolor");
290
291        // OSC 8 hyperlinks
292        assert_eq!(strip_ansi("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), "link");
293    }
294}