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            Box::new(screens::verification::VerificationScreen::new()),
69            Box::new(screens::analytics::AnalyticsScreen::new()),
70            Box::new(screens::recorder::RecorderScreen::new()),
71            Box::new(screens::import::ImportScreen::new()),
72            Box::new(screens::audit::AuditScreen::new()),
73            Box::new(screens::world_state::WorldStateScreen::new()),
74            Box::new(screens::contract_diff::ContractDiffScreen::new()),
75            Box::new(screens::federation::FederationScreen::new()),
76            Box::new(screens::behavioral_cloning::BehavioralCloningScreen::new()),
77        ];
78
79        let active_tab = if initial_tab < screens.len() {
80            initial_tab
81        } else {
82            0
83        };
84
85        Self {
86            config,
87            admin_url,
88            client,
89            screens,
90            active_tab,
91            show_help: false,
92            command_palette: CommandPalette::new(),
93            connected: false,
94            error_count: 0,
95            should_quit: false,
96            tab_bar_y: 1,
97            last_health_check: std::time::Instant::now(),
98        }
99    }
100
101    /// Run the terminal UI event loop.
102    pub async fn run(mut self) -> Result<()> {
103        let mut terminal = tui::init()?;
104        let tick_rate = Duration::from_millis(250);
105        let mut events = EventHandler::new(tick_rate);
106        let tx = events.sender();
107
108        // Initial connectivity check.
109        self.connected = self.client.ping().await;
110
111        loop {
112            // Render.
113            terminal.draw(|frame| self.render(frame))?;
114
115            // Wait for next event.
116            let event = events.next().await?;
117
118            match event {
119                Event::Key(key) => {
120                    // Ctrl+C always quits.
121                    if key.modifiers.contains(KeyModifiers::CONTROL)
122                        && matches!(key.code, KeyCode::Char('c'))
123                    {
124                        self.should_quit = true;
125                    } else if self.command_palette.visible {
126                        if let Some(action) = self.command_palette.handle_key(key) {
127                            self.execute_palette_action(action);
128                        }
129                    } else if self.show_help {
130                        if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
131                            self.show_help = false;
132                        }
133                    } else {
134                        // Try screen-specific handling first.
135                        let consumed = self.screens[self.active_tab].handle_key(key);
136                        if !consumed {
137                            self.handle_global_key(key);
138                        }
139                    }
140                }
141                Event::Tick => {
142                    self.screens[self.active_tab].tick(&self.client, &tx);
143
144                    // Periodic connectivity check every 10 seconds.
145                    if self.last_health_check.elapsed() >= Duration::from_secs(10) {
146                        self.last_health_check = std::time::Instant::now();
147                        let client = self.client.clone();
148                        let health_tx = tx.clone();
149                        tokio::spawn(async move {
150                            let ok = client.ping().await;
151                            // Use a special screen key for health check routing.
152                            if ok {
153                                let _ = health_tx.send(Event::Data {
154                                    screen: "_health_check",
155                                    payload: String::new(),
156                                });
157                            } else {
158                                let _ = health_tx.send(Event::ApiError {
159                                    screen: "_health_check",
160                                    message: "Server unreachable".into(),
161                                });
162                            }
163                        });
164                    }
165                }
166                Event::Data { screen, payload } => {
167                    self.route_data(screen, &payload);
168                }
169                Event::ApiError { screen, message } => {
170                    self.error_count = (self.error_count + 1).min(999);
171                    self.route_error(screen, &message);
172                }
173                Event::LogLine(line) => {
174                    self.connected = true;
175                    if let Some(logs) = self.screens.get_mut(1) {
176                        logs.push_log_line(line);
177                    }
178                }
179                Event::Resize(_, _) => {}
180                Event::Mouse(mouse) => {
181                    self.handle_mouse(mouse);
182                }
183            }
184
185            if self.should_quit {
186                break;
187            }
188        }
189
190        // Save last-used tab to config file (best-effort).
191        self.config.last_tab = Some(self.active_tab);
192        let _ = self.config.save();
193
194        tui::restore()?;
195        Ok(())
196    }
197
198    fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
199        // `:` opens the command palette (not in keybindings since it's a modal trigger).
200        if matches!(key.code, KeyCode::Char(':')) {
201            self.command_palette.open();
202            return;
203        }
204
205        if let Some(action) = keybindings::resolve(key) {
206            match action {
207                Action::Quit => self.should_quit = true,
208                Action::ToggleHelp => self.show_help = !self.show_help,
209                Action::NextTab => {
210                    self.active_tab = (self.active_tab + 1) % self.screens.len();
211                }
212                Action::PrevTab => {
213                    self.active_tab = if self.active_tab == 0 {
214                        self.screens.len() - 1
215                    } else {
216                        self.active_tab - 1
217                    };
218                }
219                Action::JumpTab(idx) => {
220                    if idx < self.screens.len() {
221                        self.active_tab = idx;
222                    }
223                }
224                Action::Refresh => {
225                    self.screens[self.active_tab].force_refresh();
226                }
227                _ => {}
228            }
229        }
230    }
231
232    fn execute_palette_action(&mut self, action: PaletteAction) {
233        match action {
234            PaletteAction::GoToScreen(idx) => {
235                if idx < self.screens.len() {
236                    self.active_tab = idx;
237                }
238            }
239            PaletteAction::Refresh => {
240                self.screens[self.active_tab].force_refresh();
241            }
242            PaletteAction::ToggleHelp => {
243                self.show_help = !self.show_help;
244            }
245            PaletteAction::Quit => {
246                self.should_quit = true;
247            }
248        }
249    }
250
251    fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
252        use crossterm::event::{MouseButton, MouseEventKind};
253
254        match mouse.kind {
255            MouseEventKind::Down(MouseButton::Left) => {
256                // Check if click is on the tab bar row.
257                if mouse.row == self.tab_bar_y {
258                    self.handle_tab_click(mouse.column);
259                }
260            }
261            MouseEventKind::ScrollUp => {
262                // Forward as Up key to the active screen.
263                let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
264                self.screens[self.active_tab].handle_key(key);
265            }
266            MouseEventKind::ScrollDown => {
267                let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
268                self.screens[self.active_tab].handle_key(key);
269            }
270            _ => {}
271        }
272    }
273
274    fn handle_tab_click(&mut self, column: u16) {
275        // Calculate tab boundaries based on rendered tab labels.
276        let mut x: u16 = 0;
277        for (i, screen) in self.screens.iter().enumerate() {
278            let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
279            let label_len: u16 = if i <= 9 {
280                // " N:Title " + " " separator
281                title_len.saturating_add(4)
282            } else {
283                // " Title " + " "
284                title_len.saturating_add(3)
285            };
286            if column >= x && column < x.saturating_add(label_len) {
287                self.active_tab = i;
288                return;
289            }
290            x = x.saturating_add(label_len);
291        }
292    }
293
294    fn route_data(&mut self, screen_key: &str, payload: &str) {
295        self.connected = true;
296
297        // Internal health check — not routed to any screen.
298        if screen_key == "_health_check" {
299            return;
300        }
301
302        for (i, sid) in ScreenId::ALL.iter().enumerate() {
303            if sid.data_key() == screen_key {
304                if let Some(screen) = self.screens.get_mut(i) {
305                    screen.on_data(payload);
306                }
307                return;
308            }
309        }
310    }
311
312    fn route_error(&mut self, screen_key: &str, message: &str) {
313        // Internal health check failure — mark disconnected but don't
314        // propagate to any screen.
315        if screen_key == "_health_check" {
316            self.connected = false;
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_error(message);
324                }
325                return;
326            }
327        }
328    }
329
330    fn render(&self, frame: &mut Frame) {
331        let area = frame.area();
332
333        // Minimum terminal size check (80x24).
334        if area.width < 80 || area.height < 24 {
335            let msg = Paragraph::new(format!(
336                "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
337                area.width, area.height
338            ))
339            .style(Style::default().fg(Theme::RED))
340            .alignment(Alignment::Center);
341            let centered = Rect {
342                y: area.height / 2,
343                height: 1,
344                ..area
345            };
346            frame.render_widget(msg, centered);
347            return;
348        }
349
350        let chunks = Layout::vertical([
351            Constraint::Length(2), // title bar + tabs
352            Constraint::Min(0),    // main content
353            Constraint::Length(1), // status bar
354        ])
355        .split(area);
356
357        self.render_header(frame, chunks[0]);
358
359        // Error banner: show a persistent 1-line banner when the active screen
360        // has an error, while still rendering data underneath.
361        let content_area = if let Some(err) = self.screens[self.active_tab].error() {
362            let parts =
363                Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
364            let banner = Paragraph::new(format!(" Error: {err}"))
365                .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
366            frame.render_widget(banner, parts[0]);
367            parts[1]
368        } else {
369            chunks[1]
370        };
371
372        self.screens[self.active_tab].render(frame, content_area);
373
374        status_bar::render(
375            frame,
376            chunks[2],
377            self.connected,
378            self.screens[self.active_tab].status_hint(),
379            self.error_count,
380            &self.admin_url,
381        );
382
383        if self.show_help {
384            help::render(frame);
385        }
386
387        if self.command_palette.visible {
388            self.command_palette.render(frame);
389        }
390    }
391
392    fn render_header(&self, frame: &mut Frame, area: Rect) {
393        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
394
395        // Title bar
396        let conn_status = if self.connected {
397            "Connected"
398        } else {
399            "Disconnected"
400        };
401        let conn_style = if self.connected {
402            Theme::success()
403        } else {
404            Theme::error()
405        };
406        let title = Line::from(vec![
407            Span::styled(" MockForge TUI ", Theme::title()),
408            Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
409            Span::raw("  "),
410            Span::styled(conn_status, conn_style),
411            Span::styled(format!("  {}", self.admin_url), Theme::dim()),
412        ]);
413        frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
414
415        // Tab bar
416        let mut tab_spans = Vec::new();
417        for (i, screen) in self.screens.iter().enumerate() {
418            let style = if i == self.active_tab {
419                Theme::tab_active()
420            } else {
421                Theme::tab_inactive()
422            };
423            let label = if i < 9 {
424                format!(" {}:{} ", i + 1, screen.title())
425            } else if i == 9 {
426                format!(" 0:{} ", screen.title())
427            } else {
428                format!(" {} ", screen.title())
429            };
430            tab_spans.push(Span::styled(label, style));
431            tab_spans.push(Span::raw(" "));
432        }
433        let tabs = Line::from(tab_spans);
434        frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
435    }
436}