Skip to main content

rskit_cli/prompt/terminal/
mod.rs

1//! The interactive medium a [`Prompter`](crate::prompt::Prompter) speaks through.
2//!
3//! A `Terminal` abstracts *how* a prompt reads input and renders — decoupled
4//! from *what* a prompt asks — so one set of prompt-kind logic drives three very
5//! different realities:
6//!
7//! - [`mod@line`] — [`LineTerminal`]: plain cooked stdio (type a line, press Enter);
8//!   no raw mode, works over pipes, dependency-free. The always-available default.
9//! - `rich` — `RichTerminal`: a raw-mode terminal (behind the `interactive`
10//!   feature) that reads individual [`Key`]s so widgets can offer arrow-key
11//!   navigation with live-highlighted radio and checkbox lists.
12//! - [`mod@scripted`] — [`ScriptedTerminal`]: a deterministic test double that feeds
13//!   canned keys or lines and captures rendered output, so both the key-driven
14//!   and line-driven paths are unit-testable without a real terminal.
15//!
16//! A terminal advertises whether it is key-driven via [`Capabilities`]; prompt
17//! kinds branch on that once and never touch a concrete terminal type.
18
19pub mod line;
20pub mod scripted;
21
22#[cfg(feature = "interactive")]
23pub mod rich;
24
25use rskit_errors::AppResult;
26
27use super::key::Key;
28
29pub use line::LineTerminal;
30pub use scripted::ScriptedTerminal;
31
32#[cfg(feature = "interactive")]
33pub use rich::RichTerminal;
34
35/// What interaction model a [`Terminal`] supports.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Capabilities {
38    keys: bool,
39}
40
41impl Default for Capabilities {
42    /// Line-driven, matching the default (cooked-stdio) terminal.
43    fn default() -> Self {
44        Self::line_driven()
45    }
46}
47
48impl Capabilities {
49    /// A key-driven terminal: reads individual [`Key`]s and can redraw frames,
50    /// enabling live arrow-key navigation.
51    #[must_use]
52    pub const fn key_driven() -> Self {
53        Self { keys: true }
54    }
55
56    /// A line-driven terminal: reads a whole line at a time (cooked stdio).
57    #[must_use]
58    pub const fn line_driven() -> Self {
59        Self { keys: false }
60    }
61
62    /// Whether the terminal reads individual keys (live widgets) rather than
63    /// whole lines.
64    #[must_use]
65    pub const fn is_key_driven(self) -> bool {
66        self.keys
67    }
68}
69
70/// The interactive medium a prompter reads from and renders to.
71///
72/// Implementations own the I/O device and, for key-driven terminals, the
73/// raw-mode lifecycle and cursor movement needed to redraw a live frame. The
74/// prompter builds already-styled strings; the terminal only writes them and,
75/// between frames, clears the lines it previously drew.
76pub trait Terminal {
77    /// The interaction model this terminal supports.
78    fn capabilities(&self) -> Capabilities;
79
80    /// Read one whole line (line-driven terminals).
81    ///
82    /// Returns `Ok(None)` at end of input so callers surface a typed "input
83    /// closed" error instead of hanging.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error when the underlying reader fails, or when the terminal
88    /// is key-driven and does not support line reads.
89    fn read_line(&mut self) -> AppResult<Option<String>>;
90
91    /// Read one decoded keystroke (key-driven terminals).
92    ///
93    /// # Errors
94    ///
95    /// Returns an error when the underlying device fails, at end of input, or
96    /// when the terminal is line-driven and does not support key reads.
97    fn read_key(&mut self) -> AppResult<Key>;
98
99    /// Write `text` verbatim (no trailing newline).
100    ///
101    /// # Errors
102    ///
103    /// Returns an error when the underlying writer fails.
104    fn write(&mut self, text: &str) -> AppResult<()>;
105
106    /// Write `text` followed by a newline (a carriage return + line feed in raw
107    /// mode).
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when the underlying writer fails.
112    fn write_line(&mut self, text: &str) -> AppResult<()>;
113
114    /// Flush any buffered output.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error when the underlying writer fails.
119    fn flush(&mut self) -> AppResult<()>;
120
121    /// Move the cursor up `count` lines and clear from there down, so the next
122    /// frame overwrites the previous one. A no-op for line-driven terminals.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error when the underlying device fails.
127    fn clear_last_lines(&mut self, count: u16) -> AppResult<()>;
128
129    /// Enter interactive (raw) mode before a key-driven loop. A no-op for
130    /// line-driven terminals.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error when raw mode cannot be entered.
135    fn begin_interactive(&mut self) -> AppResult<()>;
136
137    /// Leave interactive (raw) mode after a key-driven loop. A no-op for
138    /// line-driven terminals.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error when raw mode cannot be restored.
143    fn end_interactive(&mut self) -> AppResult<()>;
144}
145
146impl Terminal for Box<dyn Terminal> {
147    fn capabilities(&self) -> Capabilities {
148        (**self).capabilities()
149    }
150
151    fn read_line(&mut self) -> AppResult<Option<String>> {
152        (**self).read_line()
153    }
154
155    fn read_key(&mut self) -> AppResult<Key> {
156        (**self).read_key()
157    }
158
159    fn write(&mut self, text: &str) -> AppResult<()> {
160        (**self).write(text)
161    }
162
163    fn write_line(&mut self, text: &str) -> AppResult<()> {
164        (**self).write_line(text)
165    }
166
167    fn flush(&mut self) -> AppResult<()> {
168        (**self).flush()
169    }
170
171    fn clear_last_lines(&mut self, count: u16) -> AppResult<()> {
172        (**self).clear_last_lines(count)
173    }
174
175    fn begin_interactive(&mut self) -> AppResult<()> {
176        (**self).begin_interactive()
177    }
178
179    fn end_interactive(&mut self) -> AppResult<()> {
180        (**self).end_interactive()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{Capabilities, Terminal};
187    use crate::prompt::key::Key;
188    use crate::prompt::terminal::ScriptedTerminal;
189
190    #[test]
191    fn boxed_terminal_forwards_every_method_to_the_inner_terminal() {
192        let scripted = ScriptedTerminal::key_driven()
193            .with_line("typed")
194            .with_key(Key::Enter);
195        let mut boxed: Box<dyn Terminal> = Box::new(scripted);
196
197        assert_eq!(boxed.capabilities(), Capabilities::key_driven());
198        boxed.write("prompt").expect("write");
199        boxed.write_line("line").expect("write_line");
200        boxed.flush().expect("flush");
201        boxed.begin_interactive().expect("begin");
202        boxed.clear_last_lines(1).expect("clear");
203        assert_eq!(boxed.read_line().expect("line"), Some("typed".to_string()));
204        assert_eq!(boxed.read_key().expect("key"), Key::Enter);
205        boxed.end_interactive().expect("end");
206    }
207}