Skip to main content

virtuoso_cli/tui/
mod.rs

1mod input;
2mod render;
3mod state;
4mod theme;
5
6use crate::command_log;
7use crate::error::Result;
8use crossterm::event::{self, Event, KeyEventKind};
9use crossterm::terminal::{
10    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
11};
12use crossterm::ExecutableCommand;
13use ratatui::backend::CrosstermBackend;
14use ratatui::Terminal;
15use std::io::stdout;
16use std::time::Duration;
17
18fn err(e: impl std::fmt::Display) -> crate::error::VirtuosoError {
19    crate::error::VirtuosoError::Execution(e.to_string())
20}
21
22fn load_log(state: &mut state::TuiState) {
23    if let Ok(content) = std::fs::read_to_string(command_log::log_path()) {
24        state.log_lines = content.lines().map(|l| l.to_string()).collect();
25        state.log_scroll = state.log_lines.len().saturating_sub(1);
26    }
27}
28
29pub fn run_tui() -> Result<()> {
30    let mut state = state::TuiState::new();
31    let theme = theme::Theme::default();
32    load_log(&mut state);
33
34    enable_raw_mode().map_err(err)?;
35    stdout().execute(EnterAlternateScreen).map_err(err)?;
36    let backend = CrosstermBackend::new(stdout());
37    let mut terminal = Terminal::new(backend).map_err(err)?;
38
39    let result = run_loop(&mut terminal, &mut state, &theme);
40
41    let _ = disable_raw_mode();
42    let _ = stdout().execute(LeaveAlternateScreen);
43
44    result
45}
46
47fn run_loop(
48    terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
49    state: &mut state::TuiState,
50    theme: &theme::Theme,
51) -> Result<()> {
52    loop {
53        terminal
54            .draw(|frame| render::render(frame, state, theme))
55            .map_err(err)?;
56
57        if !event::poll(Duration::from_millis(500)).map_err(err)? {
58            state.spinner_frame = state.spinner_frame.wrapping_add(1);
59            if let Some((_, at)) = &state.status_msg {
60                if at.elapsed().as_secs() >= 3 {
61                    state.status_msg = None;
62                }
63            }
64            continue;
65        }
66
67        let ev = event::read().map_err(err)?;
68
69        if let Event::Key(key) = ev {
70            if key.kind != KeyEventKind::Press {
71                continue;
72            }
73            match input::handle_key(state, key) {
74                input::EventAction::Quit => break,
75                input::EventAction::Refresh => {
76                    state.refresh();
77                    load_log(state);
78                    state.set_status("Refreshed");
79                }
80                input::EventAction::ShowLog => {
81                    state.show_log = true;
82                }
83                input::EventAction::CancelJob => {
84                    let idx = state.selected_job;
85                    if let Some(job) = state.jobs.get_mut(idx) {
86                        if job.status == crate::spectre::jobs::JobStatus::Running {
87                            let _ = job.cancel();
88                        }
89                    }
90                    if let Some(job) = state.jobs.get(idx) {
91                        state.set_status(&format!("Cancelled job {}", job.id));
92                    }
93                }
94                input::EventAction::SaveConfig => match state.save_config() {
95                    Ok(_) => state.set_status("Config saved to .env"),
96                    Err(e) => state.set_status(&format!("Save failed: {e}")),
97                },
98                input::EventAction::Continue => {}
99            }
100        }
101    }
102
103    Ok(())
104}