Skip to main content

langcodec_cli/tui/
terminal.rs

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}