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
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum TuiMode {
29 Interactive,
31 Fallback(TuiFallbackReason),
33}
34
35impl TuiMode {
36 pub const fn is_interactive(self) -> bool {
38 matches!(self, Self::Interactive)
39 }
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum TuiFallbackReason {
45 Ci,
47 StdinNotTerminal,
49 StdoutNotTerminal,
51}
52
53impl TuiFallbackReason {
54 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct TuiEnvironment {
67 pub stdin_is_terminal: bool,
69 pub stdout_is_terminal: bool,
71 pub is_ci: bool,
73}
74
75impl TuiEnvironment {
76 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 pub fn detect() -> Self {
83 Self::new(stdin().is_terminal(), stdout().is_terminal(), env::var_os("CI").is_some())
84 }
85
86 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
100pub fn tui_mode() -> TuiMode {
102 TuiEnvironment::detect().mode()
103}
104
105pub trait TuiApp {
107 type Exit;
109
110 fn draw(&mut self, frame: &mut Frame<'_>);
112
113 fn handle_event(&mut self, event: Event) -> ControlFlow<Self::Exit>;
115}
116
117pub fn run_app<App: TuiApp>(app: &mut App) -> IoResult<App::Exit> {
119 with_terminal(|terminal| run_app_inner(terminal, app))?
120}
121
122pub 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#[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 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 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 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
205pub 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}