Skip to main content

rskit_cli/prompt/terminal/
rich.rs

1//! A raw-mode terminal over [crossterm], enabling live arrow-key navigation.
2//!
3//! `RichTerminal` is compiled only when the `interactive` feature is enabled. It
4//! puts the controlling terminal into raw mode for the duration of a key-driven
5//! prompt, decodes platform key events into rskit's own [`Key`] vocabulary, and
6//! redraws frames in place via cursor movement. Raw mode is restored on
7//! [`Terminal::end_interactive`] and, as a panic-safety net, on drop.
8//!
9//! Its raw-mode I/O path needs a real TTY, so that path is not exercised by
10//! unit tests; the shared prompt-kind logic is covered through
11//! [`ScriptedTerminal`](super::ScriptedTerminal). The pure key decoder is
12//! unit-tested at its boundary (see the `map_key` tests below).
13
14use std::io::{self, IsTerminal, Write};
15
16use crossterm::cursor::MoveToPreviousLine;
17use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, read};
18use crossterm::terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode};
19use crossterm::{execute, queue};
20use rskit_errors::{AppError, AppResult};
21
22use super::{Capabilities, Terminal};
23use crate::prompt::key::Key;
24
25/// A raw-mode [`Terminal`] that reads individual keys and redraws frames.
26///
27/// Render output is written to stderr, matching the stream prompts style against.
28pub struct RichTerminal {
29    writer: io::Stderr,
30    raw: bool,
31}
32
33impl RichTerminal {
34    /// Build a rich terminal rendering to stderr.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error unless both stderr and stdin are terminals: frames are
39    /// drawn to stderr, while raw-mode key input is read from stdin, so both
40    /// streams must be a real TTY.
41    pub fn stderr() -> AppResult<Self> {
42        let writer = io::stderr();
43        if !writer.is_terminal() {
44            return Err(AppError::invalid_input(
45                "prompt",
46                "rich terminal requires an interactive stderr",
47            ));
48        }
49        if !io::stdin().is_terminal() {
50            return Err(AppError::invalid_input(
51                "prompt",
52                "rich terminal requires an interactive stdin for key input",
53            ));
54        }
55        Ok(Self { writer, raw: false })
56    }
57}
58
59impl Drop for RichTerminal {
60    fn drop(&mut self) {
61        if self.raw {
62            let _ = disable_raw_mode();
63        }
64    }
65}
66
67const fn map_key(event: KeyEvent) -> Key {
68    let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
69    match event.code {
70        KeyCode::Char('c' | 'd') if ctrl => Key::Interrupt,
71        KeyCode::Char(' ') => Key::Space,
72        KeyCode::Char(c) => Key::Char(c),
73        KeyCode::Enter => Key::Enter,
74        KeyCode::Backspace => Key::Backspace,
75        KeyCode::Esc => Key::Escape,
76        KeyCode::Tab => Key::Tab,
77        KeyCode::Up => Key::Up,
78        KeyCode::Down => Key::Down,
79        KeyCode::Left => Key::Left,
80        KeyCode::Right => Key::Right,
81        KeyCode::Home => Key::Home,
82        KeyCode::End => Key::End,
83        _ => Key::Unknown,
84    }
85}
86
87impl Terminal for RichTerminal {
88    fn capabilities(&self) -> Capabilities {
89        Capabilities::key_driven()
90    }
91
92    fn read_line(&mut self) -> AppResult<Option<String>> {
93        Err(AppError::invalid_input(
94            "prompt",
95            "rich terminal reads keys, not lines",
96        ))
97    }
98
99    fn read_key(&mut self) -> AppResult<Key> {
100        loop {
101            match read().map_err(AppError::internal)? {
102                Event::Key(event) if event.kind == KeyEventKind::Press => {
103                    return Ok(map_key(event));
104                }
105                _ => {}
106            }
107        }
108    }
109
110    fn write(&mut self, text: &str) -> AppResult<()> {
111        write!(self.writer, "{text}").map_err(AppError::internal)
112    }
113
114    fn write_line(&mut self, text: &str) -> AppResult<()> {
115        // Raw mode disables the kernel's LF->CRLF translation, so emit an
116        // explicit carriage return; in cooked mode a bare LF is correct (and a
117        // stray CR would misplace the cursor).
118        let newline = if self.raw { "\r\n" } else { "\n" };
119        write!(self.writer, "{text}{newline}").map_err(AppError::internal)
120    }
121
122    fn flush(&mut self) -> AppResult<()> {
123        self.writer.flush().map_err(AppError::internal)
124    }
125
126    fn clear_last_lines(&mut self, count: u16) -> AppResult<()> {
127        if count == 0 {
128            return Ok(());
129        }
130        queue!(
131            self.writer,
132            MoveToPreviousLine(count),
133            Clear(ClearType::FromCursorDown)
134        )
135        .map_err(AppError::internal)?;
136        self.flush()
137    }
138
139    fn begin_interactive(&mut self) -> AppResult<()> {
140        if !self.raw {
141            enable_raw_mode().map_err(AppError::internal)?;
142            self.raw = true;
143        }
144        Ok(())
145    }
146
147    fn end_interactive(&mut self) -> AppResult<()> {
148        // Idempotent: only tear down (and clear the current line) when we were
149        // actually in raw mode, so a stray or repeated call can't clobber
150        // terminal output that we never rendered over.
151        if !self.raw {
152            return Ok(());
153        }
154        disable_raw_mode().map_err(AppError::internal)?;
155        self.raw = false;
156        execute!(self.writer, Clear(ClearType::CurrentLine)).map_err(AppError::internal)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{Key, KeyCode, KeyEvent, KeyModifiers, RichTerminal, map_key};
163
164    #[test]
165    fn requires_a_terminal_stderr() {
166        // RichTerminal needs both stdin (key input) and stderr (rendering) to be
167        // a TTY. The test harness usually redirects both, but under --nocapture
168        // or some CI runners they can be real terminals, so gate the assertion on
169        // the actual stream status to stay deterministic.
170        use std::io::{IsTerminal, stderr, stdin};
171
172        let result = RichTerminal::stderr();
173        if stdin().is_terminal() && stderr().is_terminal() {
174            assert!(result.is_ok());
175        } else {
176            assert!(result.is_err());
177        }
178    }
179
180    #[test]
181    fn maps_control_keys_to_interrupt() {
182        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
183        assert_eq!(map_key(ctrl_c), Key::Interrupt);
184        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
185        assert_eq!(map_key(plain_c), Key::Char('c'));
186    }
187
188    #[test]
189    fn maps_space_bar_to_space_not_char() {
190        // Regression: the space bar arrives as KeyCode::Char(' '); it must decode
191        // to Key::Space so multi-select's toggle arm is reachable on a real TTY.
192        // Feeding Key::Space from a scripted double is not enough — the decoder
193        // itself must emit it.
194        let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
195        assert_eq!(map_key(space), Key::Space);
196    }
197
198    #[test]
199    fn maps_navigation_keys() {
200        assert_eq!(map_key(KeyEvent::from(KeyCode::Up)), Key::Up);
201        assert_eq!(map_key(KeyEvent::from(KeyCode::Enter)), Key::Enter);
202        assert_eq!(map_key(KeyEvent::from(KeyCode::Backspace)), Key::Backspace);
203        assert_eq!(map_key(KeyEvent::from(KeyCode::Esc)), Key::Escape);
204        assert_eq!(map_key(KeyEvent::from(KeyCode::F(5))), Key::Unknown);
205    }
206}