Skip to main content

wt/tui/
terminal.rs

1//! Terminal lifecycle for the TUI (spec §10): raw mode + alternate screen on
2//! stderr (stdout stays reserved for the chosen path, §5), a panic hook that
3//! restores the terminal, and suspend/resume for the foreground editor.
4//!
5//! This module is the deliberately-thin, terminal-touching shell of the TUI;
6//! all decisions live in the tested [`crate::tui::app`]/[`crate::tui::event`].
7
8use std::io::{IsTerminal, Stderr, stderr};
9
10use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
11use crossterm::execute;
12use crossterm::terminal::{
13    Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use ratatui::Terminal;
16use ratatui::backend::CrosstermBackend;
17
18use crate::error::{Error, Result};
19use crate::tui::App;
20use crate::tui::view;
21
22/// The ratatui backend over stderr.
23type Backend = CrosstermBackend<Stderr>;
24
25/// An owned terminal in raw mode + alternate screen, restored on drop.
26pub struct Tui {
27    terminal: Terminal<Backend>,
28    mouse: bool,
29}
30
31impl Tui {
32    /// Enters raw mode and the alternate screen (with mouse capture if enabled).
33    ///
34    /// Errors (without touching the terminal) when stderr is not a real
35    /// terminal. The TUI draws to stderr, and `enable_raw_mode` performs a
36    /// `tcsetattr` on the controlling terminal — so taking it over is only safe
37    /// when stderr is genuinely a terminal. Enforcing that precondition *here*,
38    /// at the irreversible boundary, rather than relying solely on the
39    /// higher-level [`crate::cx::Cx`] TTY gate, keeps any non-TTY run (tests,
40    /// pipes, or a `cargo mutants` child in a background process group) from
41    /// being stopped by `SIGTTOU` and wedging indefinitely.
42    pub fn enter(mouse: bool) -> Result<Tui> {
43        if !stderr().is_terminal() {
44            return Err(Error::operation(
45                "refusing to start the TUI: stderr is not a terminal",
46            ));
47        }
48        enable_raw_mode()?;
49        // Raw mode is now on, but no `Tui` exists yet — so if the alternate
50        // screen, mouse capture, or backend setup fails, `Tui::drop` will never
51        // run to undo it. Restore by hand on any such failure to avoid leaving
52        // the shell wedged in raw mode / the alternate screen.
53        match build_terminal(mouse) {
54            Ok(terminal) => Ok(Tui { terminal, mouse }),
55            Err(e) => {
56                let _ = restore(mouse);
57                Err(e)
58            }
59        }
60    }
61
62    /// Draws the current app state.
63    pub fn draw(&mut self, app: &App) -> Result<()> {
64        self.terminal.draw(|frame| view::render(app, frame))?;
65        Ok(())
66    }
67
68    /// Leaves raw mode / alt screen to run a foreground program (e.g. editor).
69    pub fn suspend(&mut self) -> Result<()> {
70        restore(self.mouse)
71    }
72
73    /// Re-enters raw mode / alt screen after [`Tui::suspend`].
74    pub fn resume(&mut self) -> Result<()> {
75        enable_raw_mode()?;
76        // Clear the alternate screen with a plain escape (no cursor read).
77        execute!(stderr(), EnterAlternateScreen, Clear(ClearType::All))?;
78        if self.mouse {
79            execute!(stderr(), EnableMouseCapture)?;
80        }
81        // Recreate the terminal to force a full repaint on the next draw without
82        // a cursor-position query: ratatui ≥0.30.1's `Terminal::clear` reads the
83        // cursor (ESC[6n) on stdout, but `wt`'s stdout is captured by the shell
84        // wrapper, so the reply never arrives and crossterm times out (#36). A
85        // fresh fullscreen `Terminal` resets the diff buffers via `backend.size()`
86        // (ioctl) only — no cursor read.
87        self.terminal = Terminal::new(CrosstermBackend::new(stderr()))?;
88        Ok(())
89    }
90
91    /// The current terminal size (cols, rows).
92    pub fn size(&self) -> (u16, u16) {
93        self.terminal
94            .size()
95            .map(|s| (s.width, s.height))
96            .unwrap_or((100, 30))
97    }
98}
99
100impl Drop for Tui {
101    fn drop(&mut self) {
102        let _ = restore(self.mouse);
103    }
104}
105
106/// Enters the alternate screen (with mouse capture if enabled) and builds the
107/// ratatui backend over stderr. Kept separate from [`Tui::enter`] so a failure
108/// here can be unwound by the caller before any `Tui` exists to restore on drop.
109fn build_terminal(mouse: bool) -> Result<Terminal<Backend>> {
110    execute!(stderr(), EnterAlternateScreen)?;
111    if mouse {
112        execute!(stderr(), EnableMouseCapture)?;
113    }
114    Ok(Terminal::new(CrosstermBackend::new(stderr()))?)
115}
116
117/// Restores the terminal to its normal state (idempotent, best-effort).
118fn restore(mouse: bool) -> Result<()> {
119    if mouse {
120        let _ = execute!(stderr(), DisableMouseCapture);
121    }
122    let _ = execute!(stderr(), LeaveAlternateScreen);
123    disable_raw_mode()?;
124    Ok(())
125}
126
127/// Installs a panic hook that restores the terminal before the default hook
128/// runs, so a panic never leaves the terminal in raw mode (spec §10).
129pub fn install_panic_hook() {
130    let original = std::panic::take_hook();
131    std::panic::set_hook(Box::new(move |info| {
132        let _ = restore(true);
133        original(info);
134    }));
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn enter_refuses_when_stderr_is_not_a_terminal() {
143        // `cargo test` captures stderr, so it is not a terminal: entering the
144        // TUI must fail fast instead of driving raw mode on a non-terminal —
145        // which under a background process group (e.g. a `cargo mutants` child)
146        // would raise SIGTTOU and hang the run. Guard against the rare case of
147        // running attached to a real terminal (e.g. `--nocapture` from a tty),
148        // where grabbing it would be both unwanted and disruptive.
149        if stderr().is_terminal() {
150            return;
151        }
152        assert!(Tui::enter(false).is_err());
153    }
154}