Skip to main content

mockforge_tui/
app.rs

1//! App state machine and main event loop.
2
3use std::time::Duration;
4
5use anyhow::Result;
6use crossterm::event::{KeyCode, KeyModifiers};
7use ratatui::{
8    layout::{Alignment, Constraint, Layout, Rect},
9    style::Style,
10    text::{Line, Span},
11    widgets::Paragraph,
12    Frame,
13};
14
15use crate::api::client::MockForgeClient;
16use crate::config::TuiConfig;
17use crate::event::{Event, EventHandler};
18use crate::keybindings::{self, Action};
19use crate::screens::{self, Screen, ScreenId};
20use crate::theme::Theme;
21use crate::tui;
22use crate::widgets::command_palette::{CommandPalette, PaletteAction};
23use crate::widgets::{help, status_bar};
24
25/// Top-level application state.
26pub struct App {
27    config: TuiConfig,
28    admin_url: String,
29    client: MockForgeClient,
30    screens: Vec<Box<dyn Screen>>,
31    active_tab: usize,
32    show_help: bool,
33    command_palette: CommandPalette,
34    connected: bool,
35    error_count: usize,
36    should_quit: bool,
37    /// Y-offset of the tab bar for mouse click detection.
38    tab_bar_y: u16,
39    /// Last time we checked connectivity.
40    last_health_check: std::time::Instant,
41}
42
43impl App {
44    /// Create the app from a config and optional auth token.
45    pub fn new(config: TuiConfig, token: Option<String>) -> Self {
46        Theme::init(config.is_light_theme());
47
48        let client =
49            MockForgeClient::new(config.admin_url.clone(), token).expect("failed to build client");
50
51        let admin_url = config.admin_url.clone();
52        let initial_tab = config.last_tab.unwrap_or(0);
53
54        let screens: Vec<Box<dyn Screen>> = vec![
55            Box::new(screens::dashboard::DashboardScreen::new()),
56            Box::new(screens::logs::LogsScreen::new()),
57            Box::new(screens::routes::RoutesScreen::new()),
58            Box::new(screens::metrics::MetricsScreen::new()),
59            Box::new(screens::config::ConfigScreen::new()),
60            Box::new(screens::chaos::ChaosScreen::new()),
61            Box::new(screens::workspaces::WorkspacesScreen::new()),
62            Box::new(screens::plugins::PluginsScreen::new()),
63            Box::new(screens::fixtures::FixturesScreen::new()),
64            Box::new(screens::health::HealthScreen::new()),
65            Box::new(screens::smoke_tests::SmokeTestsScreen::new()),
66            Box::new(screens::time_travel::TimeTravelScreen::new()),
67            Box::new(screens::chains::ChainsScreen::new()),
68            // Conformance is intentionally placed before Verification —
69            // Verification's `Tab` is consumed to cycle internal fields,
70            // so plain Tab nav gets stuck on it. Order must match
71            // `ScreenId::ALL`; the `app_screens_match_screen_id_all`
72            // test asserts the lengths align.
73            Box::new(screens::conformance::ConformanceScreen::new()),
74            Box::new(screens::verification::VerificationScreen::new()),
75            Box::new(screens::analytics::AnalyticsScreen::new()),
76            Box::new(screens::recorder::RecorderScreen::new()),
77            Box::new(screens::import::ImportScreen::new()),
78            Box::new(screens::audit::AuditScreen::new()),
79            Box::new(screens::world_state::WorldStateScreen::new()),
80            Box::new(screens::contract_diff::ContractDiffScreen::new()),
81            Box::new(screens::federation::FederationScreen::new()),
82            Box::new(screens::behavioral_cloning::BehavioralCloningScreen::new()),
83        ];
84
85        // Invariant: every entry in `ScreenId::ALL` must have a matching
86        // boxed Screen at the same index. The header renders tabs from
87        // `self.screens` and routing looks up `self.screens[i]` keyed off
88        // `ScreenId::ALL[i]` — if these go out of sync, a tab silently
89        // disappears (regression first hit in v0.3.145: Conformance was
90        // added to `ScreenId::ALL` but the Box was missed here).
91        debug_assert_eq!(
92            screens.len(),
93            ScreenId::ALL.len(),
94            "screens vec must match ScreenId::ALL length"
95        );
96
97        let active_tab = if initial_tab < screens.len() {
98            initial_tab
99        } else {
100            0
101        };
102
103        Self {
104            config,
105            admin_url,
106            client,
107            screens,
108            active_tab,
109            show_help: false,
110            command_palette: CommandPalette::new(),
111            connected: false,
112            error_count: 0,
113            should_quit: false,
114            tab_bar_y: 1,
115            last_health_check: std::time::Instant::now(),
116        }
117    }
118
119    /// Run the terminal UI event loop.
120    pub async fn run(mut self) -> Result<()> {
121        let mut terminal = tui::init()?;
122        let tick_rate = Duration::from_millis(250);
123        let mut events = EventHandler::new(tick_rate);
124        let tx = events.sender();
125
126        // Initial connectivity check.
127        self.connected = self.client.ping().await;
128
129        loop {
130            // Render.
131            terminal.draw(|frame| self.render(frame))?;
132
133            // Wait for next event.
134            let event = events.next().await?;
135
136            match event {
137                Event::Key(key) => {
138                    // Ctrl+C always quits.
139                    if key.modifiers.contains(KeyModifiers::CONTROL)
140                        && matches!(key.code, KeyCode::Char('c'))
141                    {
142                        self.should_quit = true;
143                    } else if self.command_palette.visible {
144                        if let Some(action) = self.command_palette.handle_key(key) {
145                            self.execute_palette_action(action);
146                        }
147                    } else if self.show_help {
148                        if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
149                            self.show_help = false;
150                        }
151                    } else {
152                        // Try screen-specific handling first.
153                        let consumed = self.screens[self.active_tab].handle_key(key);
154                        if !consumed {
155                            self.handle_global_key(key);
156                        }
157                    }
158                }
159                Event::Tick => {
160                    self.screens[self.active_tab].tick(&self.client, &tx);
161
162                    // Periodic connectivity check every 10 seconds.
163                    if self.last_health_check.elapsed() >= Duration::from_secs(10) {
164                        self.last_health_check = std::time::Instant::now();
165                        let client = self.client.clone();
166                        let health_tx = tx.clone();
167                        tokio::spawn(async move {
168                            let ok = client.ping().await;
169                            // Use a special screen key for health check routing.
170                            if ok {
171                                let _ = health_tx.send(Event::Data {
172                                    screen: "_health_check",
173                                    payload: String::new(),
174                                });
175                            } else {
176                                let _ = health_tx.send(Event::ApiError {
177                                    screen: "_health_check",
178                                    message: "Server unreachable".into(),
179                                });
180                            }
181                        });
182                    }
183                }
184                Event::Data { screen, payload } => {
185                    self.route_data(screen, &payload);
186                }
187                Event::ApiError { screen, message } => {
188                    self.error_count = (self.error_count + 1).min(999);
189                    self.route_error(screen, &message);
190                }
191                Event::LogLine(line) => {
192                    self.connected = true;
193                    if let Some(logs) = self.screens.get_mut(1) {
194                        logs.push_log_line(line);
195                    }
196                }
197                Event::Resize(_, _) => {}
198                Event::Mouse(mouse) => {
199                    self.handle_mouse(mouse);
200                }
201            }
202
203            if self.should_quit {
204                break;
205            }
206        }
207
208        // Save last-used tab to config file (best-effort).
209        self.config.last_tab = Some(self.active_tab);
210        let _ = self.config.save();
211
212        tui::restore()?;
213        Ok(())
214    }
215
216    fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
217        // `:` opens the command palette (not in keybindings since it's a modal trigger).
218        if matches!(key.code, KeyCode::Char(':')) {
219            self.command_palette.open();
220            return;
221        }
222
223        if let Some(action) = keybindings::resolve(key) {
224            match action {
225                Action::Quit => self.should_quit = true,
226                Action::ToggleHelp => self.show_help = !self.show_help,
227                Action::NextTab => {
228                    self.active_tab = (self.active_tab + 1) % self.screens.len();
229                }
230                Action::PrevTab => {
231                    self.active_tab = if self.active_tab == 0 {
232                        self.screens.len() - 1
233                    } else {
234                        self.active_tab - 1
235                    };
236                }
237                Action::JumpTab(idx) => {
238                    if idx < self.screens.len() {
239                        self.active_tab = idx;
240                    }
241                }
242                Action::Refresh => {
243                    self.screens[self.active_tab].force_refresh();
244                }
245                _ => {}
246            }
247        }
248    }
249
250    fn execute_palette_action(&mut self, action: PaletteAction) {
251        match action {
252            PaletteAction::GoToScreen(idx) => {
253                if idx < self.screens.len() {
254                    self.active_tab = idx;
255                }
256            }
257            PaletteAction::Refresh => {
258                self.screens[self.active_tab].force_refresh();
259            }
260            PaletteAction::ToggleHelp => {
261                self.show_help = !self.show_help;
262            }
263            PaletteAction::Quit => {
264                self.should_quit = true;
265            }
266        }
267    }
268
269    fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
270        use crossterm::event::{MouseButton, MouseEventKind};
271
272        match mouse.kind {
273            MouseEventKind::Down(MouseButton::Left) => {
274                // Check if click is on the tab bar row.
275                if mouse.row == self.tab_bar_y {
276                    self.handle_tab_click(mouse.column);
277                }
278            }
279            MouseEventKind::ScrollUp => {
280                // Forward as Up key to the active screen.
281                let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
282                self.screens[self.active_tab].handle_key(key);
283            }
284            MouseEventKind::ScrollDown => {
285                let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
286                self.screens[self.active_tab].handle_key(key);
287            }
288            _ => {}
289        }
290    }
291
292    fn handle_tab_click(&mut self, column: u16) {
293        // Calculate tab boundaries based on rendered tab labels.
294        let mut x: u16 = 0;
295        for (i, screen) in self.screens.iter().enumerate() {
296            let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
297            let label_len: u16 = if i <= 9 {
298                // " N:Title " + " " separator
299                title_len.saturating_add(4)
300            } else {
301                // " Title " + " "
302                title_len.saturating_add(3)
303            };
304            if column >= x && column < x.saturating_add(label_len) {
305                self.active_tab = i;
306                return;
307            }
308            x = x.saturating_add(label_len);
309        }
310    }
311
312    fn route_data(&mut self, screen_key: &str, payload: &str) {
313        self.connected = true;
314
315        // Internal health check — not routed to any screen.
316        if screen_key == "_health_check" {
317            return;
318        }
319
320        for (i, sid) in ScreenId::ALL.iter().enumerate() {
321            if sid.data_key() == screen_key {
322                if let Some(screen) = self.screens.get_mut(i) {
323                    screen.on_data(payload);
324                }
325                return;
326            }
327        }
328    }
329
330    fn route_error(&mut self, screen_key: &str, message: &str) {
331        // Internal health check failure — mark disconnected but don't
332        // propagate to any screen.
333        if screen_key == "_health_check" {
334            self.connected = false;
335            return;
336        }
337
338        for (i, sid) in ScreenId::ALL.iter().enumerate() {
339            if sid.data_key() == screen_key {
340                if let Some(screen) = self.screens.get_mut(i) {
341                    screen.on_error(message);
342                }
343                return;
344            }
345        }
346    }
347
348    fn render(&self, frame: &mut Frame) {
349        let area = frame.area();
350
351        // Minimum terminal size check (80x24).
352        if area.width < 80 || area.height < 24 {
353            let msg = Paragraph::new(format!(
354                "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
355                area.width, area.height
356            ))
357            .style(Style::default().fg(Theme::RED))
358            .alignment(Alignment::Center);
359            let centered = Rect {
360                y: area.height / 2,
361                height: 1,
362                ..area
363            };
364            frame.render_widget(msg, centered);
365            return;
366        }
367
368        let chunks = Layout::vertical([
369            Constraint::Length(2), // title bar + tabs
370            Constraint::Min(0),    // main content
371            Constraint::Length(1), // status bar
372        ])
373        .split(area);
374
375        self.render_header(frame, chunks[0]);
376
377        // Error banner: show a persistent 1-line banner when the active screen
378        // has an error, while still rendering data underneath.
379        let content_area = if let Some(err) = self.screens[self.active_tab].error() {
380            let parts =
381                Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
382            let banner = Paragraph::new(format!(" Error: {err}"))
383                .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
384            frame.render_widget(banner, parts[0]);
385            parts[1]
386        } else {
387            chunks[1]
388        };
389
390        self.screens[self.active_tab].render(frame, content_area);
391
392        status_bar::render(
393            frame,
394            chunks[2],
395            self.connected,
396            self.screens[self.active_tab].status_hint(),
397            self.error_count,
398            &self.admin_url,
399        );
400
401        if self.show_help {
402            help::render(frame);
403        }
404
405        if self.command_palette.visible {
406            self.command_palette.render(frame);
407        }
408    }
409
410    fn render_header(&self, frame: &mut Frame, area: Rect) {
411        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
412
413        // Title bar
414        let conn_status = if self.connected {
415            "Connected"
416        } else {
417            "Disconnected"
418        };
419        let conn_style = if self.connected {
420            Theme::success()
421        } else {
422            Theme::error()
423        };
424        let title = Line::from(vec![
425            Span::styled(" MockForge TUI ", Theme::title()),
426            Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
427            Span::raw("  "),
428            Span::styled(conn_status, conn_style),
429            Span::styled(format!("  {}", self.admin_url), Theme::dim()),
430        ]);
431        frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
432
433        // Tab bar
434        let mut tab_spans = Vec::new();
435        for (i, screen) in self.screens.iter().enumerate() {
436            let style = if i == self.active_tab {
437                Theme::tab_active()
438            } else {
439                Theme::tab_inactive()
440            };
441            let label = if i < 9 {
442                format!(" {}:{} ", i + 1, screen.title())
443            } else if i == 9 {
444                format!(" 0:{} ", screen.title())
445            } else {
446                format!(" {} ", screen.title())
447            };
448            tab_spans.push(Span::styled(label, style));
449            tab_spans.push(Span::raw(" "));
450        }
451        let tabs = Line::from(tab_spans);
452        frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::config::TuiConfig;
460
461    /// Regression test for v0.3.145 → v0.3.146 hotfix: every entry in
462    /// `ScreenId::ALL` must have a corresponding `Box<dyn Screen>` in
463    /// `App::new`. When they go out of sync, the missing tab silently
464    /// disappears from the header (`render_header` iterates `self.screens`
465    /// while routing iterates `ScreenId::ALL`).
466    #[test]
467    fn app_screens_match_screen_id_all() {
468        let app = App::new(TuiConfig::default(), None);
469        assert_eq!(
470            app.screens.len(),
471            ScreenId::ALL.len(),
472            "App::new must instantiate a Screen for every ScreenId::ALL entry"
473        );
474    }
475}