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}