Skip to main content

virtuoso_cli/tui/app/
state.rs

1use crate::models::{SessionInfo, TunnelState};
2use crate::spectre::jobs::Job;
3use crate::tui::app::overlay::Overlay;
4use std::time::Instant;
5
6#[derive(Clone, Copy, PartialEq, Eq)]
7pub enum Tab {
8    Sessions,
9    Jobs,
10    Config,
11}
12
13impl Tab {
14    pub fn label(self) -> &'static str {
15        match self {
16            Tab::Sessions => "Sessions",
17            Tab::Jobs => "Jobs",
18            Tab::Config => "Config",
19        }
20    }
21
22    pub fn next(self) -> Self {
23        match self {
24            Tab::Sessions => Tab::Jobs,
25            Tab::Jobs => Tab::Config,
26            Tab::Config => Tab::Sessions,
27        }
28    }
29
30    pub fn prev(self) -> Self {
31        match self {
32            Tab::Sessions => Tab::Config,
33            Tab::Jobs => Tab::Sessions,
34            Tab::Config => Tab::Jobs,
35        }
36    }
37
38    pub fn all() -> [Tab; 3] {
39        [Tab::Sessions, Tab::Jobs, Tab::Config]
40    }
41}
42
43/// Pane focus. Currently the nav chips are always active via Tab, so Content
44/// is the default — kept as an enum so we can add a Nav-focused picker later
45/// without another state refactor.
46#[derive(Clone, Copy, PartialEq, Eq)]
47pub enum Focus {
48    Content,
49}
50
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub enum StatusKind {
53    Info,
54    Ok,
55    Warn,
56    Err,
57}
58
59pub struct StatusToast {
60    pub message: String,
61    pub kind: StatusKind,
62    pub at: Instant,
63}
64
65/// A single .env config field.
66pub struct ConfigField {
67    pub key: String,
68    pub value: String,
69    pub hint: &'static str,
70}
71
72pub struct App {
73    pub tab: Tab,
74    pub focus: Focus,
75    pub overlay: Overlay,
76
77    pub sessions: Vec<SessionInfo>,
78    pub jobs: Vec<Job>,
79    pub tunnel_state: Option<TunnelState>,
80    pub config_fields: Vec<ConfigField>,
81
82    pub selected_session: usize,
83    pub selected_job: usize,
84    pub selected_config: usize,
85
86    pub spinner_frame: usize,
87    pub status: Option<StatusToast>,
88    pub should_quit: bool,
89}
90
91impl App {
92    pub fn new() -> Self {
93        let mut app = Self {
94            tab: Tab::Sessions,
95            focus: Focus::Content,
96            overlay: Overlay::None,
97            sessions: Vec::new(),
98            jobs: Vec::new(),
99            tunnel_state: None,
100            config_fields: Vec::new(),
101            selected_session: 0,
102            selected_job: 0,
103            selected_config: 0,
104            spinner_frame: 0,
105            status: None,
106            should_quit: false,
107        };
108        crate::tui::app::data::initial_load(&mut app);
109        app
110    }
111
112    pub fn set_status(&mut self, message: impl Into<String>, kind: StatusKind) {
113        self.status = Some(StatusToast {
114            message: message.into(),
115            kind,
116            at: Instant::now(),
117        });
118    }
119
120    pub fn clear_expired_status(&mut self) {
121        if let Some(s) = &self.status {
122            if s.at.elapsed().as_secs() >= 3 {
123                self.status = None;
124            }
125        }
126    }
127
128    pub fn selected_session_info(&self) -> Option<&SessionInfo> {
129        self.sessions.get(self.selected_session)
130    }
131
132    pub fn selected_config_field(&self) -> Option<&ConfigField> {
133        self.config_fields.get(self.selected_config)
134    }
135
136    /// Wrap-safe cursor movement for the active tab's list.
137    pub fn move_selection(&mut self, delta: i64) {
138        let (cursor, len) = match self.tab {
139            Tab::Sessions => (&mut self.selected_session, self.sessions.len()),
140            Tab::Jobs => (&mut self.selected_job, self.jobs.len()),
141            Tab::Config => (&mut self.selected_config, self.config_fields.len()),
142        };
143        if len == 0 {
144            *cursor = 0;
145            return;
146        }
147        let new = (*cursor as i64 + delta).rem_euclid(len as i64);
148        *cursor = new as usize;
149    }
150}
151
152impl Default for App {
153    fn default() -> Self {
154        Self::new()
155    }
156}