1use 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
25pub 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 tab_bar_y: u16,
39 last_health_check: std::time::Instant,
41}
42
43impl App {
44 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 Box::new(screens::conformance::ConformanceScreen::new()),
78 ];
79
80 debug_assert_eq!(
87 screens.len(),
88 ScreenId::ALL.len(),
89 "screens vec must match ScreenId::ALL length"
90 );
91
92 let active_tab = if initial_tab < screens.len() {
93 initial_tab
94 } else {
95 0
96 };
97
98 Self {
99 config,
100 admin_url,
101 client,
102 screens,
103 active_tab,
104 show_help: false,
105 command_palette: CommandPalette::new(),
106 connected: false,
107 error_count: 0,
108 should_quit: false,
109 tab_bar_y: 1,
110 last_health_check: std::time::Instant::now(),
111 }
112 }
113
114 pub async fn run(mut self) -> Result<()> {
116 let mut terminal = tui::init()?;
117 let tick_rate = Duration::from_millis(250);
118 let mut events = EventHandler::new(tick_rate);
119 let tx = events.sender();
120
121 self.connected = self.client.ping().await;
123
124 loop {
125 terminal.draw(|frame| self.render(frame))?;
127
128 let event = events.next().await?;
130
131 match event {
132 Event::Key(key) => {
133 if key.modifiers.contains(KeyModifiers::CONTROL)
135 && matches!(key.code, KeyCode::Char('c'))
136 {
137 self.should_quit = true;
138 } else if self.command_palette.visible {
139 if let Some(action) = self.command_palette.handle_key(key) {
140 self.execute_palette_action(action);
141 }
142 } else if self.show_help {
143 if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
144 self.show_help = false;
145 }
146 } else {
147 let consumed = self.screens[self.active_tab].handle_key(key);
149 if !consumed {
150 self.handle_global_key(key);
151 }
152 }
153 }
154 Event::Tick => {
155 self.screens[self.active_tab].tick(&self.client, &tx);
156
157 if self.last_health_check.elapsed() >= Duration::from_secs(10) {
159 self.last_health_check = std::time::Instant::now();
160 let client = self.client.clone();
161 let health_tx = tx.clone();
162 tokio::spawn(async move {
163 let ok = client.ping().await;
164 if ok {
166 let _ = health_tx.send(Event::Data {
167 screen: "_health_check",
168 payload: String::new(),
169 });
170 } else {
171 let _ = health_tx.send(Event::ApiError {
172 screen: "_health_check",
173 message: "Server unreachable".into(),
174 });
175 }
176 });
177 }
178 }
179 Event::Data { screen, payload } => {
180 self.route_data(screen, &payload);
181 }
182 Event::ApiError { screen, message } => {
183 self.error_count = (self.error_count + 1).min(999);
184 self.route_error(screen, &message);
185 }
186 Event::LogLine(line) => {
187 self.connected = true;
188 if let Some(logs) = self.screens.get_mut(1) {
189 logs.push_log_line(line);
190 }
191 }
192 Event::Resize(_, _) => {}
193 Event::Mouse(mouse) => {
194 self.handle_mouse(mouse);
195 }
196 }
197
198 if self.should_quit {
199 break;
200 }
201 }
202
203 self.config.last_tab = Some(self.active_tab);
205 let _ = self.config.save();
206
207 tui::restore()?;
208 Ok(())
209 }
210
211 fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
212 if matches!(key.code, KeyCode::Char(':')) {
214 self.command_palette.open();
215 return;
216 }
217
218 if let Some(action) = keybindings::resolve(key) {
219 match action {
220 Action::Quit => self.should_quit = true,
221 Action::ToggleHelp => self.show_help = !self.show_help,
222 Action::NextTab => {
223 self.active_tab = (self.active_tab + 1) % self.screens.len();
224 }
225 Action::PrevTab => {
226 self.active_tab = if self.active_tab == 0 {
227 self.screens.len() - 1
228 } else {
229 self.active_tab - 1
230 };
231 }
232 Action::JumpTab(idx) => {
233 if idx < self.screens.len() {
234 self.active_tab = idx;
235 }
236 }
237 Action::Refresh => {
238 self.screens[self.active_tab].force_refresh();
239 }
240 _ => {}
241 }
242 }
243 }
244
245 fn execute_palette_action(&mut self, action: PaletteAction) {
246 match action {
247 PaletteAction::GoToScreen(idx) => {
248 if idx < self.screens.len() {
249 self.active_tab = idx;
250 }
251 }
252 PaletteAction::Refresh => {
253 self.screens[self.active_tab].force_refresh();
254 }
255 PaletteAction::ToggleHelp => {
256 self.show_help = !self.show_help;
257 }
258 PaletteAction::Quit => {
259 self.should_quit = true;
260 }
261 }
262 }
263
264 fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
265 use crossterm::event::{MouseButton, MouseEventKind};
266
267 match mouse.kind {
268 MouseEventKind::Down(MouseButton::Left) => {
269 if mouse.row == self.tab_bar_y {
271 self.handle_tab_click(mouse.column);
272 }
273 }
274 MouseEventKind::ScrollUp => {
275 let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
277 self.screens[self.active_tab].handle_key(key);
278 }
279 MouseEventKind::ScrollDown => {
280 let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
281 self.screens[self.active_tab].handle_key(key);
282 }
283 _ => {}
284 }
285 }
286
287 fn handle_tab_click(&mut self, column: u16) {
288 let mut x: u16 = 0;
290 for (i, screen) in self.screens.iter().enumerate() {
291 let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
292 let label_len: u16 = if i <= 9 {
293 title_len.saturating_add(4)
295 } else {
296 title_len.saturating_add(3)
298 };
299 if column >= x && column < x.saturating_add(label_len) {
300 self.active_tab = i;
301 return;
302 }
303 x = x.saturating_add(label_len);
304 }
305 }
306
307 fn route_data(&mut self, screen_key: &str, payload: &str) {
308 self.connected = true;
309
310 if screen_key == "_health_check" {
312 return;
313 }
314
315 for (i, sid) in ScreenId::ALL.iter().enumerate() {
316 if sid.data_key() == screen_key {
317 if let Some(screen) = self.screens.get_mut(i) {
318 screen.on_data(payload);
319 }
320 return;
321 }
322 }
323 }
324
325 fn route_error(&mut self, screen_key: &str, message: &str) {
326 if screen_key == "_health_check" {
329 self.connected = false;
330 return;
331 }
332
333 for (i, sid) in ScreenId::ALL.iter().enumerate() {
334 if sid.data_key() == screen_key {
335 if let Some(screen) = self.screens.get_mut(i) {
336 screen.on_error(message);
337 }
338 return;
339 }
340 }
341 }
342
343 fn render(&self, frame: &mut Frame) {
344 let area = frame.area();
345
346 if area.width < 80 || area.height < 24 {
348 let msg = Paragraph::new(format!(
349 "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
350 area.width, area.height
351 ))
352 .style(Style::default().fg(Theme::RED))
353 .alignment(Alignment::Center);
354 let centered = Rect {
355 y: area.height / 2,
356 height: 1,
357 ..area
358 };
359 frame.render_widget(msg, centered);
360 return;
361 }
362
363 let chunks = Layout::vertical([
364 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
368 .split(area);
369
370 self.render_header(frame, chunks[0]);
371
372 let content_area = if let Some(err) = self.screens[self.active_tab].error() {
375 let parts =
376 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
377 let banner = Paragraph::new(format!(" Error: {err}"))
378 .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
379 frame.render_widget(banner, parts[0]);
380 parts[1]
381 } else {
382 chunks[1]
383 };
384
385 self.screens[self.active_tab].render(frame, content_area);
386
387 status_bar::render(
388 frame,
389 chunks[2],
390 self.connected,
391 self.screens[self.active_tab].status_hint(),
392 self.error_count,
393 &self.admin_url,
394 );
395
396 if self.show_help {
397 help::render(frame);
398 }
399
400 if self.command_palette.visible {
401 self.command_palette.render(frame);
402 }
403 }
404
405 fn render_header(&self, frame: &mut Frame, area: Rect) {
406 let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
407
408 let conn_status = if self.connected {
410 "Connected"
411 } else {
412 "Disconnected"
413 };
414 let conn_style = if self.connected {
415 Theme::success()
416 } else {
417 Theme::error()
418 };
419 let title = Line::from(vec![
420 Span::styled(" MockForge TUI ", Theme::title()),
421 Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
422 Span::raw(" "),
423 Span::styled(conn_status, conn_style),
424 Span::styled(format!(" {}", self.admin_url), Theme::dim()),
425 ]);
426 frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
427
428 let mut tab_spans = Vec::new();
430 for (i, screen) in self.screens.iter().enumerate() {
431 let style = if i == self.active_tab {
432 Theme::tab_active()
433 } else {
434 Theme::tab_inactive()
435 };
436 let label = if i < 9 {
437 format!(" {}:{} ", i + 1, screen.title())
438 } else if i == 9 {
439 format!(" 0:{} ", screen.title())
440 } else {
441 format!(" {} ", screen.title())
442 };
443 tab_spans.push(Span::styled(label, style));
444 tab_spans.push(Span::raw(" "));
445 }
446 let tabs = Line::from(tab_spans);
447 frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::config::TuiConfig;
455
456 #[test]
462 fn app_screens_match_screen_id_all() {
463 let app = App::new(TuiConfig::default(), None);
464 assert_eq!(
465 app.screens.len(),
466 ScreenId::ALL.len(),
467 "App::new must instantiate a Screen for every ScreenId::ALL entry"
468 );
469 }
470}