Skip to main content

max / alloy_tui

14.6 KB · 427 lines History Blame Raw
1 //! The console shell: the frame, the event loop, and the reserved-key handling
2 //! every `alloy` subcommand shares.
3 //!
4 //! docs/CONSOLE.md commits each subcommand to the same navigation model, the
5 //! same status area, and the same command-log pane. Those live here rather
6 //! than in each view, so a new subcommand supplies only its body and its
7 //! hints and inherits the rest.
8
9 use std::process::Command;
10 use std::time::Duration;
11
12 use alloy_tui::keys::{Action, classify};
13 use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
14 use anyhow::Result;
15 use ratatui::Frame;
16 use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
17 use ratatui::layout::Rect;
18
19 use crate::cli::CommandLog;
20
21 /// What a view wants the shell to do after handling a key.
22 ///
23 /// Not `Copy` or `PartialEq`: two variants carry owned data. Callers match
24 /// rather than compare.
25 ///
26 /// `Exit`, `Confirm`, and `Suspend` have no production consumer yet. The first
27 /// view with a destructive action and a shell-out is `alloy pkg`, which is
28 /// blocked on Fedora hardware; the shell side landed first because both
29 /// mechanisms get more expensive to retrofit once `alloy config` has form state
30 /// to lose. Covered by tests in this module. Drop the allow when `alloy pkg`
31 /// lands.
32 #[allow(dead_code)]
33 #[derive(Debug)]
34 pub enum Flow {
35 Continue,
36 Exit,
37 /// Open a confirmation modal. The view keeps whatever it was about to do
38 /// and performs it in [`View::confirmed`] if the user agrees.
39 Confirm(Confirm),
40 /// Hand the terminal to an interactive program, then come back.
41 ///
42 /// For children that want a real TTY (`distrobox enter`, an editor). The
43 /// shell tears the TUI down, runs it to completion, and re-initializes.
44 Suspend(Command),
45 }
46
47 /// A confirmation prompt raised by a view.
48 ///
49 /// Carries only what to display. The pending action stays in the view, which
50 /// keeps [`View`] object-safe and means the shell never has to understand what
51 /// it is confirming.
52 #[derive(Debug)]
53 pub struct Confirm {
54 pub title: String,
55 pub message: String,
56 pub severity: Severity,
57 }
58
59 impl Confirm {
60 /// A destructive confirm: the common case, and the reason this exists.
61 ///
62 /// Unused until `alloy pkg` gains its remove action; see [`Flow`].
63 #[allow(dead_code)]
64 pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
65 Self {
66 title: title.into(),
67 message: message.into(),
68 severity: Severity::Error,
69 }
70 }
71 }
72
73 /// A console screen. Views own their data and their body; the shell owns the
74 /// frame around it.
75 pub trait View {
76 /// Title for the body block.
77 fn title(&self) -> String;
78
79 /// Key hints for the footer. The shell appends the reserved global hints,
80 /// so a view lists only its own keys.
81 fn hints(&self) -> Vec<Hint>;
82
83 /// Transient status for the right end of the footer, if any.
84 fn status(&self) -> Option<(Severity, String)> {
85 None
86 }
87
88 /// Draw the body into `area`.
89 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme);
90
91 /// Handle a key the shell did not claim.
92 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow;
93
94 /// The user confirmed the modal this view raised with [`Flow::Confirm`].
95 ///
96 /// Default is nothing, so a view with no destructive actions ignores the
97 /// whole mechanism.
98 fn confirmed(&mut self, _log: &mut CommandLog) {}
99
100 /// The user dismissed the modal this view raised.
101 ///
102 /// Views clear their pending action here. Default is nothing.
103 fn cancelled(&mut self) {}
104
105 /// Called roughly every [`TICK`] while no key is pressed.
106 ///
107 /// For views onto state that changes without the user (an app starting
108 /// playback, an interface coming up). Default is nothing, so a view onto
109 /// state that only changes when acted on costs no background work.
110 ///
111 /// Anything run from here is console bookkeeping, not a user action, so it
112 /// belongs inside [`CommandLog::quiet`].
113 fn tick(&mut self, _log: &mut CommandLog) {}
114 }
115
116 /// How long the loop waits for a key before ticking.
117 ///
118 /// This bounds tick latency, not input latency: a keypress wakes the poll
119 /// immediately. One second is slow enough that a view polling a couple of
120 /// commands per tick stays cheap, and fast enough that an app starting
121 /// playback shows up before the user wonders whether the console noticed.
122 pub const TICK: Duration = Duration::from_secs(1);
123
124 /// Run a view to completion: set up the terminal, loop, and restore.
125 ///
126 /// The terminal is restored even when the loop fails, so a backend error does
127 /// not strand the user in raw mode with no echo.
128 pub fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> {
129 let mut terminal = ratatui::init();
130 let result = event_loop(&mut terminal, theme, view, log);
131 ratatui::restore();
132 result
133 }
134
135 fn event_loop(
136 terminal: &mut ratatui::DefaultTerminal,
137 theme: &Theme,
138 view: &mut dyn View,
139 log: &mut CommandLog,
140 ) -> Result<()> {
141 // The one modal slot. Deliberately not a stack: a confirm raised from a
142 // confirm is a design smell, and an unbounded stack turns Esc into "how
143 // many times do I press this" rather than "back out".
144 let mut modal: Option<Confirm> = None;
145
146 loop {
147 terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?;
148
149 // Poll rather than block, so a view can refresh state that changes
150 // without the user. `poll` returns as soon as an event arrives, so
151 // this costs nothing in input responsiveness.
152 if !event::poll(TICK)? {
153 view.tick(log);
154 continue;
155 }
156
157 let Event::Key(key) = event::read()? else {
158 continue;
159 };
160 // Windows terminals report press *and* release for every key; acting on
161 // both runs each action twice.
162 if key.kind != KeyEventKind::Press {
163 continue;
164 }
165
166 let action = classify(key);
167
168 // A modal owns every key while it is open. Without this the reserved
169 // keys still reach the shell, so `q` would quit the console out from
170 // under a "delete this?" prompt.
171 if modal.is_some() {
172 match modal_key(action) {
173 ModalOutcome::Confirmed => {
174 modal = None;
175 view.confirmed(log);
176 }
177 ModalOutcome::Cancelled => {
178 modal = None;
179 view.cancelled();
180 }
181 ModalOutcome::Ignored => {}
182 }
183 continue;
184 }
185
186 match action {
187 Action::Quit | Action::Cancel => return Ok(()),
188 _ => match view.handle(key, log) {
189 Flow::Exit => return Ok(()),
190 Flow::Continue => {}
191 Flow::Confirm(confirm) => modal = Some(confirm),
192 Flow::Suspend(command) => suspend(terminal, view, log, command)?,
193 },
194 }
195 }
196 }
197
198 /// What a key does to an open modal.
199 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
200 enum ModalOutcome {
201 Confirmed,
202 Cancelled,
203 Ignored,
204 }
205
206 /// Route a reserved action against an open modal.
207 ///
208 /// Pure, and separate from the event loop, because this is the piece with the
209 /// consequences: it decides whether `q` closes a destructive prompt or the
210 /// whole console. A loop needing a live terminal is a bad place to keep a rule
211 /// that wants testing.
212 ///
213 /// Everything unrecognized is ignored rather than passed through to the view.
214 /// A modal is a question, and a view acting on keys while the user is being
215 /// asked something is how a confirm ends up applying to a different row than
216 /// the one it named.
217 const fn modal_key(action: Action) -> ModalOutcome {
218 match action {
219 Action::Activate => ModalOutcome::Confirmed,
220 // `q` cancels rather than quitting. It is the console's quit key
221 // everywhere else, so a user reaching for it mid-prompt means "get me
222 // out of this", not "close the application".
223 Action::Cancel | Action::Quit => ModalOutcome::Cancelled,
224 _ => ModalOutcome::Ignored,
225 }
226 }
227
228 /// Hand the terminal to an interactive child, then take it back.
229 ///
230 /// The TUI is torn down before the child runs and rebuilt after, so the child
231 /// gets a normal terminal: raw mode off, alternate screen exited, cursor
232 /// visible. `distrobox enter` and anything else expecting a real TTY needs
233 /// that; run underneath a live ratatui it would draw into the alternate screen
234 /// and fight the event loop for input.
235 ///
236 /// The terminal is rebuilt whether or not the child succeeded. A child exiting
237 /// nonzero is ordinary (the user typed `exit 1`, the box was gone) and must not
238 /// strand them in a torn-down TUI.
239 fn suspend(
240 terminal: &mut ratatui::DefaultTerminal,
241 view: &mut dyn View,
242 log: &mut CommandLog,
243 mut command: Command,
244 ) -> Result<()> {
245 ratatui::restore();
246 let status = command.status();
247 *terminal = ratatui::init();
248 terminal.clear()?;
249
250 // The child usually changed what the view is looking at — entering a box
251 // starts it. Refresh through the tick path, which is where console
252 // bookkeeping belongs.
253 view.tick(log);
254
255 status?;
256 Ok(())
257 }
258
259 fn draw(
260 frame: &mut Frame,
261 theme: &Theme,
262 view: &dyn View,
263 log: &mut CommandLog,
264 modal: Option<&Confirm>,
265 ) {
266 let areas = layout::console(frame.area());
267
268 view.render(frame, areas.body, theme);
269 frame.render_widget(AlloyLog::new(theme, log.entries()), areas.log);
270
271 let mut hints = view.hints();
272 hints.push(hint("q", "quit"));
273 let mut status_bar = AlloyStatusBar::new(theme, hints);
274 if let Some((severity, message)) = view.status() {
275 status_bar = status_bar.status(severity, message);
276 }
277 frame.render_widget(status_bar, areas.footer);
278
279 // Drawn last so it sits over the view. The modal covers the body only: the
280 // command log stays readable underneath, which matters when the thing being
281 // confirmed is the command shown on the log's last line.
282 if let Some(confirm) = modal {
283 let area = layout::centered(areas.body, MODAL_WIDTH, MODAL_HEIGHT);
284 frame.render_widget(
285 AlloyModal::new(theme, &confirm.title, &confirm.message).severity(confirm.severity),
286 area,
287 );
288 }
289 }
290
291 /// Modal box size. Wide enough for a package name plus a sentence about what
292 /// removing it does, short enough to leave the view visible around it.
293 const MODAL_WIDTH: u16 = 54;
294 const MODAL_HEIGHT: u16 = 7;
295
296 /// Title text for a view's body block, padded so it does not sit flush against
297 /// the border corner.
298 pub fn block_title(title: &str) -> String {
299 format!(" {title} ")
300 }
301
302 #[cfg(test)]
303 mod tests {
304 use super::*;
305 use ratatui::layout::Rect;
306
307 /// A view that records what the shell called on it. Stands in for the real
308 /// views, none of which have destructive actions yet.
309 #[derive(Default)]
310 struct StubView {
311 confirmed: usize,
312 cancelled: usize,
313 }
314
315 impl View for StubView {
316 fn title(&self) -> String {
317 "stub".into()
318 }
319 fn hints(&self) -> Vec<Hint> {
320 Vec::new()
321 }
322 fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {}
323 fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
324 Flow::Continue
325 }
326 fn confirmed(&mut self, _log: &mut CommandLog) {
327 self.confirmed += 1;
328 }
329 fn cancelled(&mut self) {
330 self.cancelled += 1;
331 }
332 }
333
334 #[test]
335 fn enter_confirms_and_esc_cancels() {
336 assert_eq!(modal_key(Action::Activate), ModalOutcome::Confirmed);
337 assert_eq!(modal_key(Action::Cancel), ModalOutcome::Cancelled);
338 }
339
340 // The whole reason this mechanism exists. Before it, Cancel and Quit both
341 // returned from the event loop, so there was no way to ask a question the
342 // user could decline without also closing the console.
343 #[test]
344 fn q_cancels_the_modal_rather_than_quitting_the_console() {
345 assert_eq!(modal_key(Action::Quit), ModalOutcome::Cancelled);
346 }
347
348 // A modal is a question. Keys that would otherwise act on the view behind
349 // it must not reach it, or a confirm can be answered against a row the user
350 // moved off while the prompt was up.
351 #[test]
352 fn keys_that_are_not_an_answer_do_nothing() {
353 for action in [
354 Action::NextFocus,
355 Action::PrevFocus,
356 Action::NextTab,
357 Action::PrevTab,
358 Action::Save,
359 Action::Filter,
360 Action::Command,
361 Action::Help,
362 Action::Passthrough,
363 ] {
364 assert_eq!(
365 modal_key(action),
366 ModalOutcome::Ignored,
367 "{action:?} must not answer a modal"
368 );
369 }
370 }
371
372 #[test]
373 fn confirm_and_cancel_reach_the_view() {
374 let mut view = StubView::default();
375 let mut log = CommandLog::new();
376
377 view.confirmed(&mut log);
378 view.cancelled();
379
380 assert_eq!(view.confirmed, 1);
381 assert_eq!(view.cancelled, 1);
382 }
383
384 // Views with no destructive actions must not have to know this exists.
385 #[test]
386 fn confirm_hooks_default_to_nothing() {
387 struct Bare;
388 impl View for Bare {
389 fn title(&self) -> String {
390 "bare".into()
391 }
392 fn hints(&self) -> Vec<Hint> {
393 Vec::new()
394 }
395 fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {}
396 fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
397 Flow::Continue
398 }
399 }
400
401 let mut log = CommandLog::new();
402 Bare.confirmed(&mut log);
403 Bare.cancelled();
404 }
405
406 #[test]
407 fn a_destructive_confirm_carries_the_error_accent() {
408 let confirm = Confirm::destructive("remove", "Remove tailscale?");
409 assert_eq!(confirm.severity, Severity::Error);
410 assert_eq!(confirm.title, "remove");
411 }
412
413 // Flow's data-carrying variants exist for `alloy pkg`, which is blocked on
414 // Fedora hardware. Constructing them here keeps them compiled and checked
415 // rather than sitting behind an allow(dead_code) until that lands.
416 #[test]
417 fn flow_carries_a_confirm_and_a_suspendable_command() {
418 let flow = Flow::Confirm(Confirm::destructive("remove", "Remove tailscale?"));
419 assert!(matches!(flow, Flow::Confirm(_)));
420
421 let flow = Flow::Suspend(Command::new("distrobox"));
422 assert!(matches!(flow, Flow::Suspend(_)));
423
424 assert!(matches!(Flow::Exit, Flow::Exit));
425 }
426 }
427