rskit_cli/prompt/kinds/
mod.rs1pub mod confirm;
17pub mod multi_select;
18pub mod select;
19pub mod text;
20
21use rskit_errors::AppError;
22
23use super::render::Style;
24use super::terminal::Terminal;
25
26fn non_interactive_error(prompt: &str) -> AppError {
28 AppError::invalid_input(
29 "prompt",
30 format!("non-interactive mode requires a default for: {prompt}"),
31 )
32}
33
34fn closed_input(prompt: &str) -> AppError {
36 AppError::invalid_input("prompt", format!("input closed before answering: {prompt}"))
37}
38
39fn cancelled(prompt: &str) -> AppError {
41 AppError::cancelled(format!("prompt cancelled: {prompt}"))
42}
43
44fn with_raw_mode<T, R>(
51 terminal: &mut T,
52 body: impl FnOnce(&mut T) -> rskit_errors::AppResult<R>,
53) -> rskit_errors::AppResult<R>
54where
55 T: Terminal + ?Sized,
56{
57 terminal.begin_interactive()?;
58 let mut guard = RawModeGuard {
59 terminal,
60 armed: true,
61 };
62 let result = body(&mut *guard.terminal);
63 let teardown = guard.disarm();
66 match result {
67 Ok(value) => teardown.map(|()| value),
68 Err(error) => Err(error),
69 }
70}
71
72struct RawModeGuard<'a, T: Terminal + ?Sized> {
74 terminal: &'a mut T,
75 armed: bool,
76}
77
78impl<T: Terminal + ?Sized> RawModeGuard<'_, T> {
79 fn disarm(&mut self) -> rskit_errors::AppResult<()> {
84 let result = self.terminal.end_interactive();
85 if result.is_ok() {
86 self.armed = false;
87 }
88 result
89 }
90}
91
92impl<T: Terminal + ?Sized> Drop for RawModeGuard<'_, T> {
93 fn drop(&mut self) {
94 if self.armed {
95 let _ = self.terminal.end_interactive();
96 }
97 }
98}
99
100const fn focus_up(cursor: usize, len: usize) -> usize {
102 if cursor == 0 { len - 1 } else { cursor - 1 }
103}
104
105const fn focus_down(cursor: usize, len: usize) -> usize {
107 if cursor + 1 >= len { 0 } else { cursor + 1 }
108}
109
110fn write_answer(
113 terminal: &mut (impl Terminal + ?Sized),
114 style: Style,
115 hint: Option<&str>,
116) -> rskit_errors::AppResult<()> {
117 let marker = style.glyphs().answer();
118 let text = hint.map_or_else(
119 || format!(" {marker} "),
120 |hint| format!(" {marker} {hint}: "),
121 );
122 terminal.write(&text)?;
123 terminal.flush()
124}
125
126fn notice(
128 terminal: &mut (impl Terminal + ?Sized),
129 style: Style,
130 text: &str,
131) -> rskit_errors::AppResult<()> {
132 let styled = style.palette().warn(text).into_owned();
133 terminal.write_line(&format!(" {styled}"))
134}
135
136fn parse_index(input: &str, len: usize) -> Option<usize> {
138 let number: usize = input.parse().ok()?;
139 (1..=len).contains(&number).then(|| number - 1)
140}
141
142#[cfg(test)]
143mod tests {
144 use std::cell::Cell;
145
146 use rskit_errors::{AppError, AppResult};
147
148 use super::{focus_down, focus_up, with_raw_mode};
149 use crate::prompt::terminal::{Capabilities, ScriptedTerminal, Terminal};
150
151 struct FlakyTeardown {
155 fail_remaining: Cell<u32>,
156 teardown_calls: Cell<u32>,
157 raw: Cell<bool>,
158 }
159
160 impl FlakyTeardown {
161 fn failing(times: u32) -> Self {
162 Self {
163 fail_remaining: Cell::new(times),
164 teardown_calls: Cell::new(0),
165 raw: Cell::new(false),
166 }
167 }
168 }
169
170 impl Terminal for FlakyTeardown {
171 fn capabilities(&self) -> Capabilities {
172 Capabilities::key_driven()
173 }
174 fn read_line(&mut self) -> AppResult<Option<String>> {
175 Ok(None)
176 }
177 fn read_key(&mut self) -> AppResult<crate::prompt::Key> {
178 Err(AppError::invalid_input("terminal", "no keys scripted"))
179 }
180 fn write(&mut self, _text: &str) -> AppResult<()> {
181 Ok(())
182 }
183 fn write_line(&mut self, _text: &str) -> AppResult<()> {
184 Ok(())
185 }
186 fn flush(&mut self) -> AppResult<()> {
187 Ok(())
188 }
189 fn clear_last_lines(&mut self, _count: u16) -> AppResult<()> {
190 Ok(())
191 }
192 fn begin_interactive(&mut self) -> AppResult<()> {
193 self.raw.set(true);
194 Ok(())
195 }
196 fn end_interactive(&mut self) -> AppResult<()> {
197 self.teardown_calls.set(self.teardown_calls.get() + 1);
198 let remaining = self.fail_remaining.get();
199 if remaining > 0 {
200 self.fail_remaining.set(remaining - 1);
201 return Err(AppError::invalid_input("terminal", "teardown failed"));
202 }
203 self.raw.set(false);
204 Ok(())
205 }
206 }
207
208 #[test]
209 fn focus_wraps_both_ends() {
210 assert_eq!(focus_up(0, 3), 2);
211 assert_eq!(focus_up(2, 3), 1);
212 assert_eq!(focus_down(2, 3), 0);
213 assert_eq!(focus_down(0, 3), 1);
214 }
215
216 #[test]
217 fn raw_mode_restored_on_normal_return() {
218 let mut term = ScriptedTerminal::key_driven();
219 let out = with_raw_mode(&mut term, |t| {
220 assert!(t.is_interactive());
221 Ok(7)
222 });
223 assert_eq!(out.expect("ok"), 7);
224 assert!(!term.is_interactive());
225 }
226
227 #[test]
228 fn raw_mode_restored_when_body_panics() {
229 let mut term = ScriptedTerminal::key_driven();
233 let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
234 with_raw_mode(&mut term, |_| -> rskit_errors::AppResult<()> {
235 panic!("body blew up mid-prompt");
236 })
237 }));
238 assert!(unwound.is_err());
239 assert!(!term.is_interactive());
240 }
241
242 #[test]
243 fn failed_teardown_is_retried_by_drop_net() {
244 let mut term = FlakyTeardown::failing(1);
247 let out = with_raw_mode(&mut term, |t| {
248 assert!(t.capabilities().is_key_driven());
251 t.write("x")?;
252 t.write_line("y")?;
253 t.flush()?;
254 t.clear_last_lines(1)?;
255 assert_eq!(t.read_line()?, None);
256 assert!(t.read_key().is_err());
257 Ok(())
258 });
259 assert!(out.is_err());
261 assert_eq!(term.teardown_calls.get(), 2);
263 assert!(!term.raw.get());
264 }
265}