1use 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
21pub type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;
23
24type PanicHandler = Box<dyn Fn(&PanicHookInfo<'_>) + 'static + Sync + Send>;
25
26pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum TuiMode {
36 Interactive,
38 Fallback(TuiFallbackReason),
40}
41
42impl TuiMode {
43 pub const fn is_interactive(self) -> bool {
45 matches!(self, Self::Interactive)
46 }
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum TuiFallbackReason {
52 Ci,
54 StdinNotTerminal,
56 StdoutNotTerminal,
58}
59
60impl TuiFallbackReason {
61 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct TuiEnvironment {
74 pub stdin_is_terminal: bool,
76 pub stdout_is_terminal: bool,
78 pub is_ci: bool,
80}
81
82impl TuiEnvironment {
83 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 pub fn detect() -> Self {
90 Self::new(stdin().is_terminal(), stdout().is_terminal(), env::var_os("CI").is_some())
91 }
92
93 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
107pub fn tui_mode() -> TuiMode {
109 TuiEnvironment::detect().mode()
110}
111
112pub trait TuiApp {
114 type Exit;
116
117 fn draw(&mut self, frame: &mut Frame<'_>);
119
120 fn handle_event(&mut self, event: Event) -> ControlFlow<Self::Exit>;
122}
123
124pub fn run_app<App: TuiApp>(app: &mut App) -> IoResult<App::Exit> {
126 with_terminal(|terminal| run_app_inner(terminal, app))?
127}
128
129pub 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#[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 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 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}