- 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>
282 lines
9.5 KiB
Rust
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);
|
|
}
|