//! The console shell: the frame, the event loop, and the reserved-key handling //! every `alloy` subcommand shares. //! //! docs/CONSOLE.md commits each subcommand to the same navigation model, the //! same status area, and the same command-log pane. Those live here rather //! than in each view, so a new subcommand supplies only its body and its //! hints and inherits the rest. use std::process::Command; use std::time::Duration; use alloy_tui::keys::{Action, classify}; use alloy_tui::{AlloyLog, AlloyModal, AlloyStatusBar, Hint, Severity, Theme, hint, layout}; use anyhow::Result; use ratatui::Frame; use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind}; use ratatui::layout::Rect; use crate::cli::CommandLog; /// What a view wants the shell to do after handling a key. /// /// Not `Copy` or `PartialEq`: two variants carry owned data. Callers match /// rather than compare. /// /// `Exit`, `Confirm`, and `Suspend` have no production consumer yet. The first /// view with a destructive action and a shell-out is `alloy pkg`, which is /// blocked on Fedora hardware; the shell side landed first because both /// mechanisms get more expensive to retrofit once `alloy config` has form state /// to lose. Covered by tests in this module. Drop the allow when `alloy pkg` /// lands. #[allow(dead_code)] #[derive(Debug)] pub enum Flow { Continue, Exit, /// Open a confirmation modal. The view keeps whatever it was about to do /// and performs it in [`View::confirmed`] if the user agrees. Confirm(Confirm), /// Hand the terminal to an interactive program, then come back. /// /// For children that want a real TTY (`distrobox enter`, an editor). The /// shell tears the TUI down, runs it to completion, and re-initializes. Suspend(Command), } /// A confirmation prompt raised by a view. /// /// Carries only what to display. The pending action stays in the view, which /// keeps [`View`] object-safe and means the shell never has to understand what /// it is confirming. #[derive(Debug)] pub struct Confirm { pub title: String, pub message: String, pub severity: Severity, } impl Confirm { /// A destructive confirm: the common case, and the reason this exists. /// /// Unused until `alloy pkg` gains its remove action; see [`Flow`]. #[allow(dead_code)] pub fn destructive(title: impl Into, message: impl Into) -> Self { Self { title: title.into(), message: message.into(), severity: Severity::Error, } } } /// A console screen. Views own their data and their body; the shell owns the /// frame around it. pub trait View { /// Title for the body block. fn title(&self) -> String; /// Key hints for the footer. The shell appends the reserved global hints, /// so a view lists only its own keys. fn hints(&self) -> Vec; /// Transient status for the right end of the footer, if any. fn status(&self) -> Option<(Severity, String)> { None } /// Draw the body into `area`. fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme); /// Handle a key the shell did not claim. fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow; /// The user confirmed the modal this view raised with [`Flow::Confirm`]. /// /// Default is nothing, so a view with no destructive actions ignores the /// whole mechanism. fn confirmed(&mut self, _log: &mut CommandLog) {} /// The user dismissed the modal this view raised. /// /// Views clear their pending action here. Default is nothing. fn cancelled(&mut self) {} /// Called roughly every [`TICK`] while no key is pressed. /// /// For views onto state that changes without the user (an app starting /// playback, an interface coming up). Default is nothing, so a view onto /// state that only changes when acted on costs no background work. /// /// Anything run from here is console bookkeeping, not a user action, so it /// belongs inside [`CommandLog::quiet`]. fn tick(&mut self, _log: &mut CommandLog) {} } /// How long the loop waits for a key before ticking. /// /// This bounds tick latency, not input latency: a keypress wakes the poll /// immediately. One second is slow enough that a view polling a couple of /// commands per tick stays cheap, and fast enough that an app starting /// playback shows up before the user wonders whether the console noticed. pub const TICK: Duration = Duration::from_secs(1); /// Run a view to completion: set up the terminal, loop, and restore. /// /// The terminal is restored even when the loop fails, so a backend error does /// not strand the user in raw mode with no echo. pub fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> { let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, theme, view, log); ratatui::restore(); result } fn event_loop( terminal: &mut ratatui::DefaultTerminal, theme: &Theme, view: &mut dyn View, log: &mut CommandLog, ) -> Result<()> { // The one modal slot. Deliberately not a stack: a confirm raised from a // confirm is a design smell, and an unbounded stack turns Esc into "how // many times do I press this" rather than "back out". let mut modal: Option = None; loop { terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref()))?; // Poll rather than block, so a view can refresh state that changes // without the user. `poll` returns as soon as an event arrives, so // this costs nothing in input responsiveness. if !event::poll(TICK)? { view.tick(log); continue; } let Event::Key(key) = event::read()? else { continue; }; // Windows terminals report press *and* release for every key; acting on // both runs each action twice. if key.kind != KeyEventKind::Press { continue; } let action = classify(key); // A modal owns every key while it is open. Without this the reserved // keys still reach the shell, so `q` would quit the console out from // under a "delete this?" prompt. if modal.is_some() { match modal_key(action) { ModalOutcome::Confirmed => { modal = None; view.confirmed(log); } ModalOutcome::Cancelled => { modal = None; view.cancelled(); } ModalOutcome::Ignored => {} } continue; } match action { Action::Quit | Action::Cancel => return Ok(()), _ => match view.handle(key, log) { Flow::Exit => return Ok(()), Flow::Continue => {} Flow::Confirm(confirm) => modal = Some(confirm), Flow::Suspend(command) => suspend(terminal, view, log, command)?, }, } } } /// What a key does to an open modal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ModalOutcome { Confirmed, Cancelled, Ignored, } /// Route a reserved action against an open modal. /// /// Pure, and separate from the event loop, because this is the piece with the /// consequences: it decides whether `q` closes a destructive prompt or the /// whole console. A loop needing a live terminal is a bad place to keep a rule /// that wants testing. /// /// Everything unrecognized is ignored rather than passed through to the view. /// A modal is a question, and a view acting on keys while the user is being /// asked something is how a confirm ends up applying to a different row than /// the one it named. const fn modal_key(action: Action) -> ModalOutcome { match action { Action::Activate => ModalOutcome::Confirmed, // `q` cancels rather than quitting. It is the console's quit key // everywhere else, so a user reaching for it mid-prompt means "get me // out of this", not "close the application". Action::Cancel | Action::Quit => ModalOutcome::Cancelled, _ => ModalOutcome::Ignored, } } /// Hand the terminal to an interactive child, then take it back. /// /// The TUI is torn down before the child runs and rebuilt after, so the child /// gets a normal terminal: raw mode off, alternate screen exited, cursor /// visible. `distrobox enter` and anything else expecting a real TTY needs /// that; run underneath a live ratatui it would draw into the alternate screen /// and fight the event loop for input. /// /// The terminal is rebuilt whether or not the child succeeded. A child exiting /// nonzero is ordinary (the user typed `exit 1`, the box was gone) and must not /// strand them in a torn-down TUI. fn suspend( terminal: &mut ratatui::DefaultTerminal, view: &mut dyn View, log: &mut CommandLog, mut command: Command, ) -> Result<()> { ratatui::restore(); let status = command.status(); *terminal = ratatui::init(); terminal.clear()?; // The child usually changed what the view is looking at — entering a box // starts it. Refresh through the tick path, which is where console // bookkeeping belongs. view.tick(log); status?; Ok(()) } fn draw( frame: &mut Frame, theme: &Theme, view: &dyn View, log: &mut CommandLog, modal: Option<&Confirm>, ) { let areas = layout::console(frame.area()); view.render(frame, areas.body, theme); frame.render_widget(AlloyLog::new(theme, log.entries()), areas.log); let mut hints = view.hints(); hints.push(hint("q", "quit")); let mut status_bar = AlloyStatusBar::new(theme, hints); if let Some((severity, message)) = view.status() { status_bar = status_bar.status(severity, message); } frame.render_widget(status_bar, areas.footer); // Drawn last so it sits over the view. The modal covers the body only: the // command log stays readable underneath, which matters when the thing being // confirmed is the command shown on the log's last line. if let Some(confirm) = modal { let area = layout::centered(areas.body, MODAL_WIDTH, MODAL_HEIGHT); frame.render_widget( AlloyModal::new(theme, &confirm.title, &confirm.message).severity(confirm.severity), area, ); } } /// Modal box size. Wide enough for a package name plus a sentence about what /// removing it does, short enough to leave the view visible around it. const MODAL_WIDTH: u16 = 54; const MODAL_HEIGHT: u16 = 7; /// Title text for a view's body block, padded so it does not sit flush against /// the border corner. pub fn block_title(title: &str) -> String { format!(" {title} ") } #[cfg(test)] mod tests { use super::*; use ratatui::layout::Rect; /// A view that records what the shell called on it. Stands in for the real /// views, none of which have destructive actions yet. #[derive(Default)] struct StubView { confirmed: usize, cancelled: usize, } impl View for StubView { fn title(&self) -> String { "stub".into() } fn hints(&self) -> Vec { Vec::new() } fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {} fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow { Flow::Continue } fn confirmed(&mut self, _log: &mut CommandLog) { self.confirmed += 1; } fn cancelled(&mut self) { self.cancelled += 1; } } #[test] fn enter_confirms_and_esc_cancels() { assert_eq!(modal_key(Action::Activate), ModalOutcome::Confirmed); assert_eq!(modal_key(Action::Cancel), ModalOutcome::Cancelled); } // The whole reason this mechanism exists. Before it, Cancel and Quit both // returned from the event loop, so there was no way to ask a question the // user could decline without also closing the console. #[test] fn q_cancels_the_modal_rather_than_quitting_the_console() { assert_eq!(modal_key(Action::Quit), ModalOutcome::Cancelled); } // A modal is a question. Keys that would otherwise act on the view behind // it must not reach it, or a confirm can be answered against a row the user // moved off while the prompt was up. #[test] fn keys_that_are_not_an_answer_do_nothing() { for action in [ Action::NextFocus, Action::PrevFocus, Action::NextTab, Action::PrevTab, Action::Save, Action::Filter, Action::Command, Action::Help, Action::Passthrough, ] { assert_eq!( modal_key(action), ModalOutcome::Ignored, "{action:?} must not answer a modal" ); } } #[test] fn confirm_and_cancel_reach_the_view() { let mut view = StubView::default(); let mut log = CommandLog::new(); view.confirmed(&mut log); view.cancelled(); assert_eq!(view.confirmed, 1); assert_eq!(view.cancelled, 1); } // Views with no destructive actions must not have to know this exists. #[test] fn confirm_hooks_default_to_nothing() { struct Bare; impl View for Bare { fn title(&self) -> String { "bare".into() } fn hints(&self) -> Vec { Vec::new() } fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {} fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow { Flow::Continue } } let mut log = CommandLog::new(); Bare.confirmed(&mut log); Bare.cancelled(); } #[test] fn a_destructive_confirm_carries_the_error_accent() { let confirm = Confirm::destructive("remove", "Remove tailscale?"); assert_eq!(confirm.severity, Severity::Error); assert_eq!(confirm.title, "remove"); } // Flow's data-carrying variants exist for `alloy pkg`, which is blocked on // Fedora hardware. Constructing them here keeps them compiled and checked // rather than sitting behind an allow(dead_code) until that lands. #[test] fn flow_carries_a_confirm_and_a_suspendable_command() { let flow = Flow::Confirm(Confirm::destructive("remove", "Remove tailscale?")); assert!(matches!(flow, Flow::Confirm(_))); let flow = Flow::Suspend(Command::new("distrobox")); assert!(matches!(flow, Flow::Suspend(_))); assert!(matches!(Flow::Exit, Flow::Exit)); } }