Skip to main content

foundry_tui/
lib.rs

1//! Shared terminal UI utilities for Foundry.
2
3use crossterm::{
4    event::{DisableMouseCapture, EnableMouseCapture, Event, read},
5    execute,
6    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
7};
8use ratatui::{
9    Frame, Terminal,
10    backend::{Backend, CrosstermBackend},
11};
12use std::{
13    env,
14    io::{IsTerminal, Result as IoResult, Stdout, Write, stdin, stdout},
15    ops::ControlFlow,
16    panic::{PanicHookInfo, set_hook, take_hook},
17    sync::Arc,
18    thread::panicking,
19};
20
21/// The default terminal backend used by Foundry TUIs.
22pub type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;
23
24type PanicHandler = Box<dyn Fn(&PanicHookInfo<'_>) + 'static + Sync + Send>;
25
26/// Runs a closure with the default Foundry terminal setup.
27pub fn with_terminal<T>(f: impl FnMut(&mut CrosstermTerminal) -> T) -> IoResult<T> {
28    let backend = CrosstermBackend::new(stdout());
29    let terminal = Terminal::new(backend)?;
30    Ok(TerminalGuard::with(terminal, f))
31}
32
33/// The resolved mode for a requested TUI run.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum TuiMode {
36    /// The process can open an interactive TUI.
37    Interactive,
38    /// The process should use a line-oriented fallback.
39    Fallback(TuiFallbackReason),
40}
41
42impl TuiMode {
43    /// Returns whether the mode can run an interactive TUI.
44    pub const fn is_interactive(self) -> bool {
45        matches!(self, Self::Interactive)
46    }
47}
48
49/// Why an interactive TUI should not be opened.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum TuiFallbackReason {
52    /// Foundry is running in a CI environment.
53    Ci,
54    /// Standard input is not connected to a terminal.
55    StdinNotTerminal,
56    /// Standard output is not connected to a terminal.
57    StdoutNotTerminal,
58}
59
60impl TuiFallbackReason {
61    /// Returns a short stable description of the fallback reason.
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::Ci => "running in CI",
65            Self::StdinNotTerminal => "stdin is not a terminal",
66            Self::StdoutNotTerminal => "stdout is not a terminal",
67        }
68    }
69}
70
71/// Runtime environment details used to decide whether a TUI can run interactively.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct TuiEnvironment {
74    /// Whether standard input is connected to a terminal.
75    pub stdin_is_terminal: bool,
76    /// Whether standard output is connected to a terminal.
77    pub stdout_is_terminal: bool,
78    /// Whether Foundry appears to be running in CI.
79    pub is_ci: bool,
80}
81
82impl TuiEnvironment {
83    /// Creates a new environment descriptor.
84    pub const fn new(stdin_is_terminal: bool, stdout_is_terminal: bool, is_ci: bool) -> Self {
85        Self { stdin_is_terminal, stdout_is_terminal, is_ci }
86    }
87
88    /// Detects the current process environment.
89    pub fn detect() -> Self {
90        Self::new(stdin().is_terminal(), stdout().is_terminal(), env::var_os("CI").is_some())
91    }
92
93    /// Resolves the TUI mode for this environment.
94    pub const fn mode(self) -> TuiMode {
95        if self.is_ci {
96            TuiMode::Fallback(TuiFallbackReason::Ci)
97        } else if !self.stdin_is_terminal {
98            TuiMode::Fallback(TuiFallbackReason::StdinNotTerminal)
99        } else if !self.stdout_is_terminal {
100            TuiMode::Fallback(TuiFallbackReason::StdoutNotTerminal)
101        } else {
102            TuiMode::Interactive
103        }
104    }
105}
106
107/// Detects whether a requested TUI should run interactively or fall back to line output.
108pub fn tui_mode() -> TuiMode {
109    TuiEnvironment::detect().mode()
110}
111
112/// An interactive terminal application.
113pub trait TuiApp {
114    /// The reason the application exited.
115    type Exit;
116
117    /// Draws one frame.
118    fn draw(&mut self, frame: &mut Frame<'_>);
119
120    /// Handles one terminal event.
121    fn handle_event(&mut self, event: Event) -> ControlFlow<Self::Exit>;
122}
123
124/// Runs an interactive terminal application with the default Foundry terminal setup.
125pub fn run_app<App: TuiApp>(app: &mut App) -> IoResult<App::Exit> {
126    with_terminal(|terminal| run_app_inner(terminal, app))?
127}
128
129/// Runs an app only when the current environment supports an interactive TUI.
130pub fn run_app_if_interactive<App: TuiApp>(app: &mut App) -> IoResult<Option<App::Exit>> {
131    match tui_mode() {
132        TuiMode::Interactive => run_app(app).map(Some),
133        TuiMode::Fallback(_) => Ok(None),
134    }
135}
136
137fn run_app_inner<App: TuiApp>(
138    terminal: &mut CrosstermTerminal,
139    app: &mut App,
140) -> IoResult<App::Exit> {
141    loop {
142        terminal.draw(|frame| app.draw(frame))?;
143        match app.handle_event(read()?) {
144            ControlFlow::Continue(()) => {}
145            ControlFlow::Break(reason) => return Ok(reason),
146        }
147    }
148}
149
150/// Handles terminal setup and teardown for interactive TUIs.
151#[must_use]
152pub struct TerminalGuard<B: Backend + Write> {
153    terminal: Terminal<B>,
154    hook: Option<Arc<PanicHandler>>,
155}
156
157impl<B: Backend + Write> TerminalGuard<B> {
158    /// Runs a closure while the terminal is in alternate-screen raw mode.
159    pub fn with<T>(terminal: Terminal<B>, mut f: impl FnMut(&mut Terminal<B>) -> T) -> T {
160        let mut guard = Self { terminal, hook: None };
161        guard.setup();
162        f(&mut guard.terminal)
163    }
164
165    fn setup(&mut self) {
166        let previous = Arc::new(take_hook());
167        self.hook = Some(previous.clone());
168        // Restore terminal state before displaying the panic message.
169        set_hook(Box::new(move |info| {
170            Self::half_restore(&mut stdout());
171            (previous)(info)
172        }));
173
174        let _ = enable_raw_mode();
175        let _ = execute!(*self.terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture);
176        let _ = self.terminal.hide_cursor();
177        let _ = self.terminal.clear();
178    }
179
180    fn restore(&mut self) {
181        if !panicking() {
182            let _ = take_hook();
183            let prev = self.hook.take().unwrap();
184            let prev = match Arc::try_unwrap(prev) {
185                Ok(prev) => prev,
186                Err(_) => unreachable!("`self.hook` is not the only reference to the panic hook"),
187            };
188            set_hook(prev);
189
190            Self::half_restore(self.terminal.backend_mut());
191        }
192
193        let _ = self.terminal.show_cursor();
194    }
195
196    fn half_restore(w: &mut impl Write) {
197        let _ = disable_raw_mode();
198        let _ = execute!(*w, LeaveAlternateScreen, DisableMouseCapture);
199    }
200}
201
202impl<B: Backend + Write> Drop for TerminalGuard<B> {
203    #[inline]
204    fn drop(&mut self) {
205        self.restore();
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::{TuiEnvironment, TuiFallbackReason, TuiMode};
212
213    #[test]
214    fn detects_interactive_mode() {
215        let env = TuiEnvironment::new(true, true, false);
216
217        assert_eq!(env.mode(), TuiMode::Interactive);
218        assert!(env.mode().is_interactive());
219    }
220
221    #[test]
222    fn ci_forces_fallback() {
223        let env = TuiEnvironment::new(true, true, true);
224
225        assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::Ci));
226        assert!(!env.mode().is_interactive());
227    }
228
229    #[test]
230    fn stdin_must_be_terminal() {
231        let env = TuiEnvironment::new(false, true, false);
232
233        assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::StdinNotTerminal));
234    }
235
236    #[test]
237    fn stdout_must_be_terminal() {
238        let env = TuiEnvironment::new(true, false, false);
239
240        assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::StdoutNotTerminal));
241    }
242
243    #[test]
244    fn ci_reason_takes_precedence() {
245        let env = TuiEnvironment::new(false, false, true);
246
247        assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::Ci));
248    }
249
250    #[test]
251    fn fallback_reasons_have_stable_descriptions() {
252        assert_eq!(TuiFallbackReason::Ci.as_str(), "running in CI");
253        assert_eq!(TuiFallbackReason::StdinNotTerminal.as_str(), "stdin is not a terminal");
254        assert_eq!(TuiFallbackReason::StdoutNotTerminal.as_str(), "stdout is not a terminal");
255    }
256}