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}