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