Files
foxhunt/bin/fxt/src/tui/event_loop.rs
jgrusewski 0fdcc496be fix: type-safe proto enums, MCP protocol compliance, TUI teardown
- Replace hardcoded i32 enum matches with ProtoEnum::try_from() in
  8 command files (cluster, config, data, model, risk, service, train, tune)
- Replace hardcoded i32 literals with enum variants (EmergencyStopType,
  ExportFormat, TrainingMode)
- Add JSON-RPC 2.0 PARSE_ERROR (-32700) for malformed JSON input
- Validate jsonrpc:"2.0" version on incoming MCP requests
- Change unreachable execute_tool catch-all from Ok to Err
- Fix TUI teardown to not mask original event_loop error
- Rename config_cmd module to config (commands::config)
- 77 tests pass, 0 clippy warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:29:13 +01:00

282 lines
9.5 KiB
Rust

#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints
//! TUI event loop -- terminal setup, input handling, rendering.
use std::io::{self, Stdout};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use crossterm::ExecutableCommand;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::cockpit::Cockpit;
use super::cockpits::data::DataCockpit;
use super::cockpits::overview::OverviewCockpit;
use super::cockpits::risk::RiskCockpit;
use super::cockpits::services::ServicesCockpit;
use super::cockpits::trading::TradingCockpit;
use super::cockpits::training::TrainingCockpit;
use super::state::AppState;
use super::theme;
/// Number of cockpit views.
const COCKPIT_COUNT: usize = 6;
/// Tick interval for the event loop (1 second).
const TICK_RATE: Duration = Duration::from_secs(1);
/// Run the TUI event loop.
///
/// `_api_url` is accepted for future gRPC streaming but is unused today
/// (mock data only).
pub async fn run(_api_url: &str) -> anyhow::Result<()> {
// Setup terminal
let mut terminal = setup_terminal()?;
let result = event_loop(&mut terminal).await;
// Always restore terminal, even on error
if let Err(e) = teardown_terminal(&mut terminal) {
eprintln!("terminal teardown failed: {e}");
}
result
}
// ---------------------------------------------------------------------------
// Terminal lifecycle
// ---------------------------------------------------------------------------
fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> anyhow::Result<()> {
disable_raw_mode()?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
// ---------------------------------------------------------------------------
// Event loop
// ---------------------------------------------------------------------------
async fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> anyhow::Result<()> {
let mut state = AppState::default();
let cockpits: Vec<Box<dyn Cockpit>> = vec![
Box::new(OverviewCockpit),
Box::new(TrainingCockpit),
Box::new(TradingCockpit),
Box::new(ServicesCockpit),
Box::new(RiskCockpit),
Box::new(DataCockpit),
];
loop {
// Render
terminal.draw(|frame| {
let size = frame.area();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // tab bar
Constraint::Min(0), // cockpit body
Constraint::Length(1), // status bar
])
.split(size);
render_tab_bar(frame, layout[0], &cockpits, state.current_cockpit);
if let Some(cockpit) = cockpits.get(state.current_cockpit) {
cockpit.render(frame, layout[1], &state);
}
render_status_bar(frame, layout[2], &state);
if state.show_help {
render_help_overlay(frame, size);
}
})?;
// Poll for events with timeout
if crossterm::event::poll(TICK_RATE)? {
if let Event::Key(key) = event::read()? {
#[allow(clippy::wildcard_enum_match_arm)]
match key.code {
// Quit
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break;
}
// Cockpit switching (1-6)
KeyCode::Char(c @ '1'..='6') => {
let idx = (c as usize).saturating_sub('1' as usize);
if idx < COCKPIT_COUNT {
state.current_cockpit = idx;
}
}
// Help toggle
KeyCode::Char('?') => {
state.show_help = !state.show_help;
}
// Refresh
KeyCode::Char('r') => {
state.last_update = std::time::Instant::now();
}
_ => {}
}
}
}
// Tick -- update timestamp (data refresh will happen here later)
state.last_update = std::time::Instant::now();
}
Ok(())
}
// ---------------------------------------------------------------------------
// Chrome renderers
// ---------------------------------------------------------------------------
fn render_tab_bar(
frame: &mut ratatui::Frame,
area: Rect,
cockpits: &[Box<dyn Cockpit>],
active: usize,
) {
let mut spans = Vec::new();
spans.push(Span::styled(" FXT ", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(" | ", theme::muted_style()));
for (i, cockpit) in cockpits.iter().enumerate() {
let num = format!("{}", i + 1);
if i == active {
spans.push(Span::styled(
format!("[{num}] {}", cockpit.name()),
Style::default()
.fg(theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
format!(" {num} {}", cockpit.name()),
theme::muted_style(),
));
}
if i + 1 < cockpits.len() {
spans.push(Span::styled(" ", theme::muted_style()));
}
}
let tab_line = Line::from(spans);
let paragraph = Paragraph::new(tab_line);
frame.render_widget(paragraph, area);
}
fn render_status_bar(frame: &mut ratatui::Frame, area: Rect, state: &AppState) {
let elapsed = state.last_update.elapsed();
let ago = if elapsed.as_secs() < 2 {
"just now".to_owned()
} else {
format!("{}s ago", elapsed.as_secs())
};
let line = Line::from(vec![
Span::styled(" q", Style::default().fg(theme::SECONDARY)),
Span::styled(" quit ", theme::muted_style()),
Span::styled("?", Style::default().fg(theme::SECONDARY)),
Span::styled(" help ", theme::muted_style()),
Span::styled("r", Style::default().fg(theme::SECONDARY)),
Span::styled(" refresh ", theme::muted_style()),
Span::styled("1-6", Style::default().fg(theme::SECONDARY)),
Span::styled(" switch cockpit ", theme::muted_style()),
Span::styled(
format!("Updated: {ago}"),
theme::muted_style(),
),
]);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
fn render_help_overlay(frame: &mut ratatui::Frame, area: Rect) {
// Center a help box in the terminal
let width = 50_u16.min(area.width.saturating_sub(4));
let height = 14_u16.min(area.height.saturating_sub(4));
#[allow(clippy::integer_division)]
let x = area.x + (area.width.saturating_sub(width)) / 2;
#[allow(clippy::integer_division)]
let y = area.y + (area.height.saturating_sub(height)) / 2;
let help_area = Rect::new(x, y, width, height);
// Clear behind the overlay
frame.render_widget(Clear, help_area);
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(" 1-6 ", Style::default().fg(theme::SECONDARY)),
Span::styled("Switch cockpit view", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(theme::SECONDARY)),
Span::styled("Toggle this help", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(theme::SECONDARY)),
Span::styled("Force refresh", Style::default().fg(theme::TEXT)),
]),
Line::from(vec![
Span::styled(" C-c ", Style::default().fg(theme::SECONDARY)),
Span::styled("Quit (Ctrl+C)", Style::default().fg(theme::TEXT)),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Cockpits: Overview | Training | Trading",
theme::muted_style(),
)]),
Line::from(vec![Span::styled(
" Services | Risk | Data",
theme::muted_style(),
)]),
];
let help_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::PRIMARY))
.title(" Keyboard Shortcuts ")
.title_style(theme::title_style());
let help = Paragraph::new(help_text).block(help_block).wrap(Wrap { trim: false });
frame.render_widget(help, help_area);
}