1use std::{
2 io::{self, IsTerminal, Stdout, stdout},
3 panic,
4 sync::mpsc::{Receiver, TryRecvError},
5 time::Duration,
6};
7
8use clap::ValueEnum;
9use crossterm::{
10 event::{
11 DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind, poll, read,
12 },
13 execute,
14 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
15};
16use ratatui::{Terminal, backend::CrosstermBackend};
17
18use crate::tui::{DashboardState, FocusPane, render_dashboard, reporter::DashboardMessage};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
21pub enum UiMode {
22 Auto,
23 Plain,
24 Tui,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ResolvedUiMode {
29 Plain,
30 Tui,
31}
32
33pub fn resolve_ui_mode(
34 requested: UiMode,
35 stdin_is_tty: bool,
36 stdout_is_tty: bool,
37 term: Option<&str>,
38) -> Result<ResolvedUiMode, String> {
39 match requested {
40 UiMode::Plain => Ok(ResolvedUiMode::Plain),
41 UiMode::Auto => {
42 if stdin_is_tty && stdout_is_tty && !matches!(term, Some("dumb")) {
43 Ok(ResolvedUiMode::Tui)
44 } else {
45 Ok(ResolvedUiMode::Plain)
46 }
47 }
48 UiMode::Tui => {
49 if !stdin_is_tty || !stdout_is_tty {
50 return Err(
51 "TUI mode requires an interactive terminal on stdin and stdout".to_string(),
52 );
53 }
54 if matches!(term, Some("dumb")) {
55 return Err("TUI mode is unavailable when TERM=dumb".to_string());
56 }
57 Ok(ResolvedUiMode::Tui)
58 }
59 }
60}
61
62pub fn resolve_ui_mode_for_current_terminal(requested: UiMode) -> Result<ResolvedUiMode, String> {
63 resolve_ui_mode(
64 requested,
65 io::stdin().is_terminal(),
66 io::stdout().is_terminal(),
67 std::env::var("TERM").ok().as_deref(),
68 )
69}
70
71struct TerminalGuard {
72 terminal: Terminal<CrosstermBackend<Stdout>>,
73}
74
75impl TerminalGuard {
76 fn new() -> Result<Self, String> {
77 enable_raw_mode().map_err(|e| format!("Failed to enable raw mode: {e}"))?;
78 let mut stdout = stdout();
79 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)
80 .map_err(|e| format!("Failed to enter alternate screen: {e}"))?;
81 let backend = CrosstermBackend::new(stdout);
82 let terminal =
83 Terminal::new(backend).map_err(|e| format!("Failed to initialize terminal: {e}"))?;
84 Ok(Self { terminal })
85 }
86}
87
88impl Drop for TerminalGuard {
89 fn drop(&mut self) {
90 let _ = disable_raw_mode();
91 let _ = execute!(
92 self.terminal.backend_mut(),
93 DisableBracketedPaste,
94 LeaveAlternateScreen
95 );
96 let _ = self.terminal.show_cursor();
97 }
98}
99
100pub fn run_dashboard(
101 mut state: DashboardState,
102 rx: Receiver<DashboardMessage>,
103) -> Result<(), String> {
104 let hook = panic::take_hook();
105 panic::set_hook(Box::new(move |info| {
106 let _ = disable_raw_mode();
107 let mut out = stdout();
108 let _ = execute!(out, DisableBracketedPaste, LeaveAlternateScreen);
109 hook(info);
110 }));
111
112 let mut terminal = TerminalGuard::new()?;
113 let mut show_help = false;
114 let mut should_close = false;
115
116 while !should_close {
117 terminal
118 .terminal
119 .draw(|frame| render_dashboard(frame, &state, show_help))
120 .map_err(|e| format!("Failed to render TUI: {e}"))?;
121
122 loop {
123 match rx.try_recv() {
124 Ok(DashboardMessage::Event(event)) => state.apply(event),
125 Err(TryRecvError::Empty) => break,
126 Err(TryRecvError::Disconnected) => {
127 should_close = true;
128 break;
129 }
130 }
131 }
132
133 if should_close {
134 break;
135 }
136
137 if poll(Duration::from_millis(50)).map_err(|e| format!("TUI input polling failed: {e}"))?
138 && let Event::Key(key) = read().map_err(|e| format!("TUI input read failed: {e}"))?
139 {
140 if key.kind != KeyEventKind::Press {
141 continue;
142 }
143 match key.code {
144 KeyCode::Char('?') => show_help = !show_help,
145 KeyCode::Tab => state.focus = state.focus.next(),
146 KeyCode::Up => state.select_previous(),
147 KeyCode::Down => state.select_next(),
148 KeyCode::PageUp => state.scroll_backward(8),
149 KeyCode::PageDown => state.scroll_forward(8),
150 KeyCode::Char('g') => state.jump_top(),
151 KeyCode::Char('G') => state.jump_bottom(),
152 KeyCode::Char('q') if state.completed => should_close = true,
153 KeyCode::Char('q') => {}
154 _ => {}
155 }
156 if state.focus == FocusPane::Table {
157 state.detail_scroll = 0;
158 }
159 }
160 }
161
162 Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{ResolvedUiMode, UiMode, resolve_ui_mode};
168
169 #[test]
170 fn auto_uses_plain_without_tty() {
171 let resolved = resolve_ui_mode(UiMode::Auto, false, false, Some("xterm-256color")).unwrap();
172 assert_eq!(resolved, ResolvedUiMode::Plain);
173 }
174
175 #[test]
176 fn auto_uses_plain_for_dumb_term() {
177 let resolved = resolve_ui_mode(UiMode::Auto, true, true, Some("dumb")).unwrap();
178 assert_eq!(resolved, ResolvedUiMode::Plain);
179 }
180
181 #[test]
182 fn forced_tui_errors_without_terminal() {
183 let err = resolve_ui_mode(UiMode::Tui, false, true, Some("xterm-256color")).unwrap_err();
184 assert!(err.contains("interactive terminal"));
185 }
186}