Skip to main content

virtuoso_cli/tui/
mod.rs

1//! Interactive terminal UI — entry point and event loop.
2//!
3//! Architecture (borrowed from cc-switch-cli):
4//! - `app/`  — App state, overlay enum, event cascade, action handler
5//! - `ui/`   — pure rendering: chrome (header), content tabs, overlays, footer
6//! - `theme` — colors + no_color accessibility mode
7//!
8//! Input priority cascade: overlay → globals → tab content. An active overlay
9//! suppresses all other keys, so vim motions inside a log viewer never leak
10//! into tab switching.
11
12pub mod app;
13pub mod theme;
14pub mod ui;
15
16use crate::error::Result;
17use crossterm::event::{self, Event, KeyEventKind};
18use crossterm::terminal::{
19    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use crossterm::ExecutableCommand;
22use ratatui::backend::CrosstermBackend;
23use ratatui::Terminal;
24use std::io::stdout;
25use std::time::Duration;
26
27fn err(e: impl std::fmt::Display) -> crate::error::VirtuosoError {
28    crate::error::VirtuosoError::Execution(e.to_string())
29}
30
31pub fn run_tui() -> Result<()> {
32    let mut app = app::state::App::new();
33    let theme = theme::Theme::detect();
34
35    enable_raw_mode().map_err(err)?;
36    stdout().execute(EnterAlternateScreen).map_err(err)?;
37    let backend = CrosstermBackend::new(stdout());
38    let mut terminal = Terminal::new(backend).map_err(err)?;
39
40    let result = run_loop(&mut terminal, &mut app, &theme);
41
42    let _ = disable_raw_mode();
43    let _ = stdout().execute(LeaveAlternateScreen);
44    result
45}
46
47fn run_loop(
48    terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
49    app: &mut app::state::App,
50    theme: &theme::Theme,
51) -> Result<()> {
52    loop {
53        terminal
54            .draw(|frame| ui::draw(frame, app, theme))
55            .map_err(err)?;
56
57        if !event::poll(Duration::from_millis(500)).map_err(err)? {
58            app.spinner_frame = app.spinner_frame.wrapping_add(1);
59            app.clear_expired_status();
60            continue;
61        }
62
63        if let Event::Key(key) = event::read().map_err(err)? {
64            if key.kind != KeyEventKind::Press {
65                continue;
66            }
67            let action = app::on_key(app, key);
68            app::handle_action(app, action);
69            if app.should_quit {
70                break;
71            }
72        }
73    }
74    Ok(())
75}