use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; use crate::feed_state::{FeedHealth, FeedMode, FeedState, HandshakePhase}; use crate::telemetry_frame::TelemetryFrame; use std::sync::{Arc, Mutex}; #[derive(Clone)] pub struct FeedDisplayInfo { pub label: &'static str, pub port: u16, pub mode: FeedMode, } fn health_indicator(health: FeedHealth) -> Span<'static> { match health { FeedHealth::Alive => Span::styled( "*".to_string(), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), FeedHealth::Stale => Span::styled( "~".to_string(), Style::default().fg(Color::Yellow), ), FeedHealth::Waiting => Span::styled( ".".to_string(), Style::default().fg(Color::DarkGray), ), FeedHealth::Error => Span::styled( "!".to_string(), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ), } } fn freq_style(health: FeedHealth) -> Style { match health { FeedHealth::Alive => Style::default().fg(Color::Green), FeedHealth::Stale => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray), } } fn format_ago(ago: Option) -> String { match ago { None => "never".to_string(), Some(t) => format!("{:.2}s", t), } } fn feed_block_title(info: &FeedDisplayInfo, state: &FeedState) -> String { match info.mode { FeedMode::Handshake { target_port, recv_port, } => { let phase_str = match state.handshake_phase { HandshakePhase::NotStarted => "not started", HandshakePhase::WaitingForResponse => "waiting", HandshakePhase::Subscribing => "subscribing", HandshakePhase::Subscribed => "subscribed", }; format!( " {} -> :{} recv :{} [{}] ", info.label, target_port, recv_port, phase_str ) } FeedMode::BindListen => { format!(" {} Port :{} ", info.label, info.port) } FeedMode::Disabled => { format!(" {} Port :{} [optional, not available] ", info.label, info.port) } } } fn render_feed_panel(frame: &mut Frame, area: Rect, info: &FeedDisplayInfo, state: &FeedState) { let title = feed_block_title(info, state); let border_style = match state.health { FeedHealth::Alive => Style::default().fg(Color::Green), FeedHealth::Stale => Style::default().fg(Color::Yellow), FeedHealth::Error => Style::default().fg(Color::Red), FeedHealth::Waiting => { if info.mode == FeedMode::Disabled { Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) } else { Style::default().fg(Color::DarkGray) } } }; let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style); let inner = block.inner(area); frame.render_widget(block, area); let indicator = health_indicator(state.health); let status_text = Span::raw(format!(" {:<12}", state.status_text)); let line1 = Line::from(vec![indicator, status_text]); let freq_span = Span::styled( format!("{:>7.1} Hz", state.frequency_hz), freq_style(state.health), ); let packets_span = Span::raw(format!(" {:>8} packets", state.total_packets)); let rate_span = Span::raw(format!(" {:>7.1} KB/s", state.data_rate_kb_s)); let ago_span = Span::raw(format!(" last: {:>8}", format_ago(state.last_packet_ago_secs))); let line2 = Line::from(vec![freq_span, packets_span, rate_span, ago_span]); let mut lines = vec![line1, line2]; if let Some(ref frame) = state.last_frame { let speed_span = Span::styled( format!(" {:>6.0} km/h", frame.speed_kmh), Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ); let gear_span = Span::styled( format!(" Gear:{}", frame.gear_display()), Style::default().fg(Color::Yellow), ); let rpm_span = Span::raw(format!(" {:.0} RPM", frame.engine_rpm)); let lap_span = Span::raw(format!(" Lap:{}", TelemetryFrame::format_lap_time(frame.lap_time_ms))); let line3 = Line::from(vec![speed_span, gear_span, rpm_span, lap_span]); lines.push(line3); let throttle_pct = (frame.throttle.clamp(0.0, 1.0) * 100.0) as u8; let brake_pct = (frame.brake.clamp(0.0, 1.0) * 100.0) as u8; let inputs_span = Span::raw(format!(" THR:{:>3}% BRK:{:>3}%", throttle_pct, brake_pct)); let pos_span = Span::raw(format!(" Pos:{:.3}", frame.spline_position)); let line4 = Line::from(vec![inputs_span, pos_span]); lines.push(line4); } let paragraph = Paragraph::new(lines); frame.render_widget(paragraph, inner); } pub fn render( frame: &mut Frame, feeds: &[(FeedDisplayInfo, Arc>)], session_dir: &str, version: &str, ) { let total_feeds = feeds.len() as u16; let outer = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(total_feeds * 4 + 2), Constraint::Length(3), ]) .split(frame.area()); let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); let title_inner = title_block.inner(outer[0]); frame.render_widget(title_block, outer[0]); let title_text = Line::from(Span::styled( format!( " rusty-telemetry v{} -- Telemetry Listener ", version ), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), )); let title_para = Paragraph::new(title_text); frame.render_widget(title_para, title_inner); let feed_constraints: Vec = feeds .iter() .map(|_| Constraint::Min(6)) .collect(); let feed_layout = Layout::default() .direction(Direction::Vertical) .constraints(feed_constraints) .split(outer[1]); for (i, (info, state)) in feeds.iter().enumerate() { // Clone inside the lock, then drop the guard before rendering // to avoid blocking the UDP receive task during the entire draw. let snapshot = state.lock().ok().map(|mut s| { s.tick(); s.clone() }); if let Some(snap) = snapshot { render_feed_panel(frame, feed_layout[i], info, &snap); } } let footer_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)); let footer_inner = footer_block.inner(outer[2]); frame.render_widget(footer_block, outer[2]); let footer_text = Line::from(vec![ Span::styled( format!(" Session: {}/ ", session_dir), Style::default().fg(Color::White), ), Span::raw(" "), Span::styled( "Ctrl+C Quit".to_string(), Style::default().fg(Color::DarkGray), ), ]); let footer_para = Paragraph::new(footer_text).wrap(Wrap { trim: true }); frame.render_widget(footer_para, footer_inner); }