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}