From 6d2b11a797be981ca820b45e73fa2e4762007ec8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 28 Feb 2026 07:09:25 -0600 Subject: [PATCH] Implement Phase 4: async TUI with Dashboard and Gameday screens Replace blocking event loop with tokio::select! over EventStream, mpsc channel, and tick interval. Add message bus architecture with AppMessage enum for background task results. Implement Dashboard with roster summary cards and API sync, and Gameday with cascading team/pitcher selectors, matchup table, and 9-slot lineup management with save/load. Co-Authored-By: Claude Opus 4.6 --- rust/Cargo.lock | 29 + rust/Cargo.toml | 3 + rust/src/app.rs | 317 ++++++++-- rust/src/config.rs | 10 +- rust/src/lib.rs | 1 + rust/src/main.rs | 54 +- rust/src/screens/dashboard.rs | 196 ++++++- rust/src/screens/gameday.rs | 1021 ++++++++++++++++++++++++++++++++- rust/src/widgets/mod.rs | 1 + rust/src/widgets/selector.rs | 166 ++++++ 10 files changed, 1737 insertions(+), 61 deletions(-) create mode 100644 rust/src/widgets/mod.rs create mode 100644 rust/src/widgets/selector.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3f163d4..a55c961 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -667,6 +667,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -711,6 +726,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -729,8 +755,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -2181,6 +2209,7 @@ dependencies = [ "crossterm 0.28.1", "csv", "figment", + "futures", "ratatui", "regex", "reqwest", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4ab71a2..31be65c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -42,5 +42,8 @@ csv = "1" # Hashing sha2 = "0.10" +# Async streams +futures = "0.3" + # Regex regex = "1" diff --git a/rust/src/app.rs b/rust/src/app.rs index b455626..e34fa5c 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -1,76 +1,319 @@ -use crossterm::event::KeyEvent; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - layout::{Constraint, Layout}, - text::Text, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; use sqlx::sqlite::SqlitePool; +use std::time::Instant; +use tokio::sync::mpsc; +use crate::calc::matchup::MatchupResult; use crate::config::Settings; +use crate::db::models::{BatterCard, Lineup, Player, Roster, Team}; +use crate::screens::dashboard::DashboardState; +use crate::screens::gameday::GamedayState; + +// ============================================================================= +// Messages +// ============================================================================= + +#[derive(Debug)] +pub enum AppMessage { + // Dashboard + RosterLoaded(Roster), + SyncStarted, + SyncComplete(Result), + + // Gameday — init + CacheReady, + CacheError(String), + TeamsLoaded(Vec), + MyBattersLoaded(Vec<(Player, Option)>), + LineupsLoaded(Vec), + + // Gameday — on team select + PitchersLoaded(Vec), + + // Gameday — on pitcher select + MatchupsCalculated(Vec), + + // Gameday — lineup + LineupSaved(String), + LineupSaveError(String), + + // General + Notify(String, NotifyLevel), +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Screen { - Dashboard, +pub enum NotifyLevel { + Info, + Success, + Error, +} + +// ============================================================================= +// Notification +// ============================================================================= + +pub struct Notification { + pub message: String, + pub level: NotifyLevel, + pub created_at: Instant, + pub duration_ms: u64, +} + +impl Notification { + pub fn new(message: String, level: NotifyLevel) -> Self { + Self { + message, + level, + created_at: Instant::now(), + duration_ms: 3000, + } + } + + pub fn is_expired(&self) -> bool { + self.created_at.elapsed().as_millis() > self.duration_ms as u128 + } +} + +// ============================================================================= +// Screen enum +// ============================================================================= + +/// Stub screen identifier for screens not yet implemented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StubScreen { Roster, Matchup, Lineup, - Gameday, Settings, } +impl StubScreen { + pub fn label(&self) -> &'static str { + match self { + StubScreen::Roster => "Roster", + StubScreen::Matchup => "Matchup", + StubScreen::Lineup => "Lineup", + StubScreen::Settings => "Settings", + } + } +} + +pub enum ActiveScreen { + Dashboard(DashboardState), + Gameday(GamedayState), + Stub(StubScreen), +} + +impl ActiveScreen { + pub fn label(&self) -> &'static str { + match self { + ActiveScreen::Dashboard(_) => "Dashboard", + ActiveScreen::Gameday(_) => "Gameday", + ActiveScreen::Stub(s) => s.label(), + } + } +} + +// ============================================================================= +// App +// ============================================================================= + pub struct App { - pub current_screen: Screen, + pub screen: ActiveScreen, pub pool: SqlitePool, pub settings: Settings, + pub tx: mpsc::UnboundedSender, + pub notification: Option, + pub tick_count: u64, } impl App { - pub fn new(settings: Settings, pool: SqlitePool) -> Self { + pub fn new( + settings: Settings, + pool: SqlitePool, + tx: mpsc::UnboundedSender, + ) -> Self { + let screen = ActiveScreen::Dashboard(DashboardState::new( + settings.team.abbrev.clone(), + settings.team.season as i64, + )); Self { - current_screen: Screen::Dashboard, + screen, pool, settings, + tx, + notification: None, + tick_count: 0, + } + } + + /// Fire initial data load for the starting screen. + pub fn on_mount(&mut self) { + if let ActiveScreen::Dashboard(s) = &mut self.screen { + s.mount(&self.pool, &self.tx); + } + } + + pub fn on_tick(&mut self) { + self.tick_count = self.tick_count.wrapping_add(1); + if let Some(n) = &self.notification { + if n.is_expired() { + self.notification = None; + } + } + } + + /// Returns false when a text input or selector popup is active. + pub fn should_quit_on_q(&self) -> bool { + match &self.screen { + ActiveScreen::Gameday(s) => !s.is_input_captured(), + _ => true, + } + } + + fn is_input_captured(&self) -> bool { + match &self.screen { + ActiveScreen::Gameday(s) => s.is_input_captured(), + _ => false, } } pub fn handle_key(&mut self, key: KeyEvent) { - use crossterm::event::KeyCode; - match key.code { - KeyCode::Char('1') => self.current_screen = Screen::Dashboard, - KeyCode::Char('2') => self.current_screen = Screen::Roster, - KeyCode::Char('3') => self.current_screen = Screen::Matchup, - KeyCode::Char('4') => self.current_screen = Screen::Lineup, - KeyCode::Char('5') => self.current_screen = Screen::Gameday, - KeyCode::Char('6') => self.current_screen = Screen::Settings, - _ => {} + // Global navigation (only when not capturing input) + if !self.is_input_captured() { + match key.code { + KeyCode::Char('d') => { + if !matches!(&self.screen, ActiveScreen::Dashboard(_)) { + self.switch_to_dashboard(); + return; + } + } + KeyCode::Char('g') => { + if !matches!(&self.screen, ActiveScreen::Gameday(_)) { + self.switch_to_gameday(); + return; + } + } + _ => {} + } } + + // Delegate to current screen + match &mut self.screen { + ActiveScreen::Dashboard(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), + ActiveScreen::Gameday(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), + ActiveScreen::Stub(_) => {} + } + } + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::Notify(text, level) => { + self.notification = Some(Notification::new(text, level)); + } + _ => match &mut self.screen { + ActiveScreen::Dashboard(s) => s.handle_message(msg), + ActiveScreen::Gameday(s) => s.handle_message(msg), + ActiveScreen::Stub(_) => {} + }, + } + } + + fn switch_to_dashboard(&mut self) { + let mut state = DashboardState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + ); + state.mount(&self.pool, &self.tx); + self.screen = ActiveScreen::Dashboard(state); + } + + fn switch_to_gameday(&mut self) { + let mut state = GamedayState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + ); + state.mount(&self.pool, &self.tx); + self.screen = ActiveScreen::Gameday(state); } pub fn render(&self, frame: &mut Frame) { let area = frame.area(); + let chunks = Layout::vertical([ + Constraint::Length(1), // nav bar + Constraint::Min(0), // screen content + Constraint::Length(1), // status bar + ]) + .split(area); - let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + self.render_nav_bar(frame, chunks[0]); - // Navigation bar - let nav = Paragraph::new(Text::raw( - " [1] Dashboard [2] Roster [3] Matchup [4] Lineup [5] Gameday [6] Settings [q] Quit", - )) - .block(Block::default().borders(Borders::ALL).title("SBA Scout")); - frame.render_widget(nav, chunks[0]); + match &self.screen { + ActiveScreen::Dashboard(s) => s.render(frame, chunks[1], self.tick_count), + ActiveScreen::Gameday(s) => s.render(frame, chunks[1], self.tick_count), + ActiveScreen::Stub(scr) => render_stub(frame, chunks[1], scr), + } - // Screen content - let content = match self.current_screen { - Screen::Dashboard => "Dashboard - Press a number key to navigate", - Screen::Roster => "Roster Screen (not yet implemented)", - Screen::Matchup => "Matchup Screen (not yet implemented)", - Screen::Lineup => "Lineup Screen (not yet implemented)", - Screen::Gameday => "Gameday Screen (not yet implemented)", - Screen::Settings => "Settings Screen (not yet implemented)", + self.render_status_bar(frame, chunks[2]); + } + + fn render_nav_bar(&self, frame: &mut Frame, area: Rect) { + let active = self.screen.label(); + let items = [ + ("d", "Dashboard"), + ("g", "Gameday"), + ]; + + let spans: Vec = items + .iter() + .flat_map(|(key, label)| { + let is_active = *label == active; + let style = if is_active { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + vec![ + Span::styled(format!(" [{}] ", key), Style::default().fg(Color::DarkGray)), + Span::styled(*label, style), + ] + }) + .collect(); + + let nav = Paragraph::new(Line::from(spans)); + frame.render_widget(nav, area); + } + + fn render_status_bar(&self, frame: &mut Frame, area: Rect) { + let (text, style) = match &self.notification { + Some(n) => { + let color = match n.level { + NotifyLevel::Info => Color::White, + NotifyLevel::Success => Color::Green, + NotifyLevel::Error => Color::Red, + }; + (n.message.as_str(), Style::default().fg(color)) + } + None => ("", Style::default()), }; - - let body = Paragraph::new(Text::raw(content)) - .block(Block::default().borders(Borders::ALL).title(format!("{:?}", self.current_screen))); - frame.render_widget(body, chunks[1]); + let bar = Paragraph::new(text).style(style); + frame.render_widget(bar, area); } } + +fn render_stub(frame: &mut Frame, area: Rect, screen: &StubScreen) { + let text = format!("{} (not yet implemented)", screen.label()); + let widget = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(screen.label())) + .alignment(Alignment::Center); + frame.render_widget(widget, area); +} diff --git a/rust/src/config.rs b/rust/src/config.rs index 3c0be0e..53e2055 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -6,7 +6,7 @@ use figment::{ }; use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Settings { pub db_path: PathBuf, pub api: ApiSettings, @@ -15,26 +15,26 @@ pub struct Settings { pub rating_weights: RatingWeights, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApiSettings { pub base_url: String, pub api_key: String, pub timeout: u64, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct TeamSettings { pub abbrev: String, pub season: i32, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct UiSettings { pub theme: String, pub refresh_interval: u64, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct RatingWeights { pub hit: f64, pub on_base: f64, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d3bdefc..63fd40b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,3 +4,4 @@ pub mod calc; pub mod config; pub mod db; pub mod screens; +pub mod widgets; diff --git a/rust/src/main.rs b/rust/src/main.rs index 147dea9..9f9d189 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,16 +1,17 @@ use anyhow::Result; -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers}; +use futures::StreamExt; use ratatui::DefaultTerminal; use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; +use tokio::time::{interval, Duration}; -use sba_scout::app::App; -use sba_scout::config::{self, Settings}; +use sba_scout::app::{App, AppMessage}; +use sba_scout::config; use sba_scout::db; #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt::init(); - let settings = match config::load_settings() { Ok(s) => s, Err(e) => { @@ -32,17 +33,48 @@ async fn main() -> Result<()> { result } -async fn run(terminal: &mut DefaultTerminal, settings: Settings, pool: SqlitePool) -> Result<()> { - let mut app = App::new(settings, pool); +async fn run(terminal: &mut DefaultTerminal, settings: config::Settings, pool: SqlitePool) -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut app = App::new(settings, pool, tx); + + app.on_mount(); + + let mut event_stream = EventStream::new(); + let mut tick = interval(Duration::from_millis(250)); loop { terminal.draw(|frame| app.render(frame))?; - if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Char('q') { - break; + tokio::select! { + maybe_event = event_stream.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) => { + if key.code == KeyCode::Char('q') + && key.modifiers == KeyModifiers::NONE + && app.should_quit_on_q() + { + break; + } + app.handle_key(key); + } + Some(Ok(Event::Resize(_, _))) => { + // ratatui handles resize on next draw + } + Some(Err(_)) => {} + None => break, + _ => {} + } + } + + maybe_msg = rx.recv() => { + if let Some(msg) = maybe_msg { + app.handle_message(msg); + } + } + + _ = tick.tick() => { + app.on_tick(); } - app.handle_key(key); } } diff --git a/rust/src/screens/dashboard.rs b/rust/src/screens/dashboard.rs index 50411b3..1271ece 100644 --- a/rust/src/screens/dashboard.rs +++ b/rust/src/screens/dashboard.rs @@ -1,6 +1,194 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Dashboard"); - frame.render_widget(widget, area); +use crate::app::{AppMessage, NotifyLevel}; +use crate::config::Settings; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + Never, + Syncing, + Success, + Error, +} + +pub struct DashboardState { + pub team_abbrev: String, + pub season: i64, + pub majors_count: usize, + pub minors_count: usize, + pub il_count: usize, + pub swar_total: f64, + pub sync_state: SyncState, + pub sync_message: String, +} + +impl DashboardState { + pub fn new(team_abbrev: String, season: i64) -> Self { + Self { + team_abbrev, + season, + majors_count: 0, + minors_count: 0, + il_count: 0, + swar_total: 0.0, + sync_state: SyncState::Never, + sync_message: String::new(), + } + } + + /// Fire async roster load on mount. + pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let pool = pool.clone(); + let tx = tx.clone(); + let abbrev = self.team_abbrev.clone(); + let season = self.season; + tokio::spawn(async move { + match crate::db::queries::get_my_roster(&pool, &abbrev, season).await { + Ok(roster) => { + let _ = tx.send(AppMessage::RosterLoaded(roster)); + } + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Failed to load roster: {e}"), + NotifyLevel::Error, + )); + } + } + }); + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + if let KeyCode::Char('s') = key.code { + self.trigger_sync(pool, settings, tx); + } + } + + fn trigger_sync( + &mut self, + pool: &SqlitePool, + settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + if self.sync_state == SyncState::Syncing { + return; + } + self.sync_state = SyncState::Syncing; + self.sync_message = "Syncing...".to_string(); + let pool = pool.clone(); + let settings = settings.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let _ = tx.send(AppMessage::SyncStarted); + let result = crate::api::sync::sync_all(&pool, &settings).await; + let _ = tx.send(AppMessage::SyncComplete(result)); + }); + } + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::RosterLoaded(roster) => { + self.majors_count = roster.majors.len(); + self.minors_count = roster.minors.len(); + self.il_count = roster.il.len(); + self.swar_total = roster.majors.iter().filter_map(|p| p.swar).sum(); + } + AppMessage::SyncStarted => { + self.sync_state = SyncState::Syncing; + self.sync_message = "Syncing...".to_string(); + } + AppMessage::SyncComplete(result) => match result { + Ok(r) => { + self.sync_state = SyncState::Success; + self.sync_message = format!( + "Synced {} teams, {} players, {} transactions", + r.teams, r.players, r.transactions + ); + } + Err(e) => { + self.sync_state = SyncState::Error; + self.sync_message = format!("Sync failed: {e}"); + } + }, + _ => {} + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let chunks = Layout::vertical([ + Constraint::Length(3), // title + Constraint::Length(5), // summary cards + Constraint::Length(3), // sync status + Constraint::Min(0), // key hints + ]) + .split(area); + + // Title + let title = Paragraph::new(format!( + "SBA Scout -- {} -- Season {}", + self.team_abbrev, self.season + )) + .block(Block::default().borders(Borders::ALL)) + .alignment(Alignment::Center); + frame.render_widget(title, chunks[0]); + + // Summary cards + let card_chunks = Layout::horizontal([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ]) + .split(chunks[1]); + + let cards = [ + ("Majors", format!("{}/26", self.majors_count)), + ("Minors", format!("{}/6", self.minors_count)), + ("IL", self.il_count.to_string()), + ("sWAR", format!("{:.1}", self.swar_total)), + ]; + for (i, (label, value)) in cards.iter().enumerate() { + let card = Paragraph::new(value.as_str()) + .block(Block::default().borders(Borders::ALL).title(*label)) + .alignment(Alignment::Center); + frame.render_widget(card, card_chunks[i]); + } + + // Sync status + let spinner_chars = ['|', '/', '-', '\\']; + let spinner = spinner_chars[(tick_count as usize / 2) % 4]; + let sync_text = match self.sync_state { + SyncState::Never => "[s] Sync data from API".to_string(), + SyncState::Syncing => format!("{spinner} {}", self.sync_message), + SyncState::Success => format!("OK {} [s] Sync again", self.sync_message), + SyncState::Error => format!("ERR {} [s] Retry", self.sync_message), + }; + let sync_style = match self.sync_state { + SyncState::Error => Style::default().fg(Color::Red), + SyncState::Success => Style::default().fg(Color::Green), + _ => Style::default(), + }; + let sync_widget = Paragraph::new(sync_text) + .style(sync_style) + .block(Block::default().borders(Borders::ALL).title("Sync")); + frame.render_widget(sync_widget, chunks[2]); + + // Key hints + let hints = Paragraph::new(" [s] Sync [g] Gameday [q] Quit") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(hints, chunks[3]); + } } diff --git a/rust/src/screens/gameday.rs b/rust/src/screens/gameday.rs index e27c380..7bb02ba 100644 --- a/rust/src/screens/gameday.rs +++ b/rust/src/screens/gameday.rs @@ -1,6 +1,1019 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use std::collections::HashMap; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Gameday"); - frame.render_widget(widget, area); +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; + +use crate::app::{AppMessage, NotifyLevel}; +use crate::calc::matchup::MatchupResult; +use crate::config::Settings; +use crate::db::models::{BatterCard, Lineup, Player}; +use crate::widgets::selector::{SelectorEvent, SelectorWidget}; + +// ============================================================================= +// Focus & slot types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GamedayFocus { + TeamSelector, + PitcherSelector, + MatchupTable, + LineupName, + LineupTable, + LoadSelector, +} + +#[derive(Debug, Clone)] +pub struct LineupSlot { + pub order: usize, + pub player_id: Option, + pub player_name: Option, + pub position: Option, + pub matchup_rating: Option, + pub matchup_tier: Option, +} + +impl LineupSlot { + pub fn empty(order: usize) -> Self { + Self { + order, + player_id: None, + player_name: None, + position: None, + matchup_rating: None, + matchup_tier: None, + } + } + + pub fn is_empty(&self) -> bool { + self.player_id.is_none() + } +} + +// ============================================================================= +// Gameday state +// ============================================================================= + +pub struct GamedayState { + pub team_abbrev: String, + pub season: i64, + + // Selectors + pub team_selector: SelectorWidget, + pub pitcher_selector: SelectorWidget, + pub load_selector: SelectorWidget, + + // Data + pub my_batters: Vec<(Player, Option)>, + pub matchup_results: Vec, + + // Matchup table + pub matchup_table_state: TableState, + + // Lineup + pub lineup_slots: [LineupSlot; 9], + pub lineup_name: String, + pub lineup_name_cursor: usize, + pub saved_lineups: Vec, + pub lineup_table_state: TableState, + + // Focus + pub focus: GamedayFocus, + + // Loading flags + pub is_loading_teams: bool, + pub is_loading_pitchers: bool, + pub is_calculating: bool, + pub cache_ready: bool, +} + +impl GamedayState { + pub fn new(team_abbrev: String, season: i64) -> Self { + let lineup_slots = std::array::from_fn(|i| LineupSlot::empty(i + 1)); + Self { + team_abbrev, + season, + team_selector: SelectorWidget::new("Opponent"), + pitcher_selector: SelectorWidget::new("Pitcher"), + load_selector: SelectorWidget::new("Load Lineup"), + my_batters: Vec::new(), + matchup_results: Vec::new(), + matchup_table_state: TableState::default(), + lineup_slots, + lineup_name: String::new(), + lineup_name_cursor: 0, + saved_lineups: Vec::new(), + lineup_table_state: TableState::default().with_selected(0), + focus: GamedayFocus::MatchupTable, + is_loading_teams: true, + is_loading_pitchers: false, + is_calculating: false, + cache_ready: false, + } + } + + /// Returns true if a text input or selector popup is capturing keys. + pub fn is_input_captured(&self) -> bool { + matches!(self.focus, GamedayFocus::LineupName) + || self.team_selector.is_open + || self.pitcher_selector.is_open + || self.load_selector.is_open + } + + // ========================================================================= + // Mount — fire background loads + // ========================================================================= + + pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + // Ensure score cache + let pool_c = pool.clone(); + let tx_c = tx.clone(); + tokio::spawn(async move { + match crate::calc::score_cache::ensure_cache_exists(&pool_c).await { + Ok(_) => { + let _ = tx_c.send(AppMessage::CacheReady); + } + Err(e) => { + let _ = tx_c.send(AppMessage::CacheError(format!("{e}"))); + } + } + }); + + // Load teams + let pool_c = pool.clone(); + let tx_c = tx.clone(); + let season = self.season; + tokio::spawn(async move { + match crate::db::queries::get_all_teams(&pool_c, season, true).await { + Ok(teams) => { + let _ = tx_c.send(AppMessage::TeamsLoaded(teams)); + } + Err(e) => { + let _ = tx_c.send(AppMessage::Notify( + format!("Failed to load teams: {e}"), + NotifyLevel::Error, + )); + } + } + }); + + // Load my batters + let pool_c = pool.clone(); + let tx_c = tx.clone(); + let abbrev = self.team_abbrev.clone(); + let season = self.season; + tokio::spawn(async move { + let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; + if let Ok(Some(team)) = team { + if let Ok(batters) = + crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await + { + let mut with_cards = Vec::with_capacity(batters.len()); + for p in batters { + let card = crate::db::queries::get_batter_card(&pool_c, p.id) + .await + .ok() + .flatten(); + with_cards.push((p, card)); + } + let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards)); + } + } + }); + + // Load saved lineups + let pool_c = pool.clone(); + let tx_c = tx.clone(); + tokio::spawn(async move { + if let Ok(lineups) = crate::db::queries::get_lineups(&pool_c).await { + let _ = tx_c.send(AppMessage::LineupsLoaded(lineups)); + } + }); + } + + // ========================================================================= + // Key handling + // ========================================================================= + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + _settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + // Try selectors first (they consume keys when open) + if self.team_selector.is_open { + if let SelectorEvent::Selected(_) = self.team_selector.handle_key(key) { + self.on_team_selected(pool, tx); + } + return; + } + if self.pitcher_selector.is_open { + if let SelectorEvent::Selected(_) = self.pitcher_selector.handle_key(key) { + self.on_pitcher_selected(pool, tx); + } + return; + } + if self.load_selector.is_open { + if let SelectorEvent::Selected(_) = self.load_selector.handle_key(key) { + self.on_lineup_load_selected(); + } + return; + } + + // Lineup name text input + if self.focus == GamedayFocus::LineupName { + match key.code { + KeyCode::Esc | KeyCode::Tab => { + self.focus = GamedayFocus::LineupTable; + } + KeyCode::Char(c) => { + self.lineup_name.insert(self.lineup_name_cursor, c); + self.lineup_name_cursor += 1; + } + KeyCode::Backspace => { + if self.lineup_name_cursor > 0 { + self.lineup_name_cursor -= 1; + self.lineup_name.remove(self.lineup_name_cursor); + } + } + KeyCode::Left => { + self.lineup_name_cursor = self.lineup_name_cursor.saturating_sub(1); + } + KeyCode::Right => { + if self.lineup_name_cursor < self.lineup_name.len() { + self.lineup_name_cursor += 1; + } + } + _ => {} + } + return; + } + + // Ctrl-S: save lineup + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { + self.save_lineup(pool, tx); + return; + } + + // Tab: cycle focus + if key.code == KeyCode::Tab { + self.cycle_focus(); + return; + } + + // Focus-specific keys + match self.focus { + GamedayFocus::TeamSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.team_selector.is_open = true; + self.team_selector.is_focused = true; + } + _ => {} + }, + GamedayFocus::PitcherSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.pitcher_selector.is_open = true; + self.pitcher_selector.is_focused = true; + } + _ => {} + }, + GamedayFocus::MatchupTable => self.handle_matchup_table_key(key), + GamedayFocus::LineupTable => self.handle_lineup_table_key(key, pool, tx), + GamedayFocus::LoadSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.load_selector.is_open = true; + self.load_selector.is_focused = true; + } + _ => {} + }, + GamedayFocus::LineupName => {} // handled above + } + } + + fn cycle_focus(&mut self) { + self.unfocus_all_selectors(); + self.focus = match self.focus { + GamedayFocus::MatchupTable => GamedayFocus::LineupTable, + GamedayFocus::LineupTable => GamedayFocus::LineupName, + GamedayFocus::LineupName => GamedayFocus::TeamSelector, + GamedayFocus::TeamSelector => GamedayFocus::PitcherSelector, + GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector, + GamedayFocus::LoadSelector => GamedayFocus::MatchupTable, + }; + self.update_selector_focus(); + } + + fn unfocus_all_selectors(&mut self) { + self.team_selector.is_focused = false; + self.pitcher_selector.is_focused = false; + self.load_selector.is_focused = false; + } + + fn update_selector_focus(&mut self) { + self.team_selector.is_focused = self.focus == GamedayFocus::TeamSelector; + self.pitcher_selector.is_focused = self.focus == GamedayFocus::PitcherSelector; + self.load_selector.is_focused = self.focus == GamedayFocus::LoadSelector; + } + + fn handle_matchup_table_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let len = self.matchup_results.len(); + if len > 0 { + let i = self.matchup_table_state.selected().unwrap_or(0); + self.matchup_table_state.select(Some((i + 1) % len)); + } + } + KeyCode::Char('k') | KeyCode::Up => { + let len = self.matchup_results.len(); + if len > 0 { + let i = self.matchup_table_state.selected().unwrap_or(0); + self.matchup_table_state + .select(Some(i.checked_sub(1).unwrap_or(len - 1))); + } + } + KeyCode::Enter => { + self.add_selected_to_lineup(); + } + _ => {} + } + } + + fn handle_lineup_table_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + tx: &mpsc::UnboundedSender, + ) { + let sel = self.lineup_table_state.selected().unwrap_or(0); + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if sel < 8 { + self.lineup_table_state.select(Some(sel + 1)); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.lineup_table_state + .select(Some(sel.saturating_sub(1))); + } + KeyCode::Char('x') => { + // Remove player from slot + self.lineup_slots[sel] = LineupSlot::empty(sel + 1); + } + KeyCode::Char('p') => { + // Cycle position + self.cycle_position(sel); + } + KeyCode::Char('J') => { + // Move slot down + if sel < 8 { + self.lineup_slots.swap(sel, sel + 1); + // Fix order numbers + self.lineup_slots[sel].order = sel + 1; + self.lineup_slots[sel + 1].order = sel + 2; + self.lineup_table_state.select(Some(sel + 1)); + } + } + KeyCode::Char('K') => { + // Move slot up + if sel > 0 { + self.lineup_slots.swap(sel - 1, sel); + self.lineup_slots[sel - 1].order = sel; + self.lineup_slots[sel].order = sel + 1; + self.lineup_table_state.select(Some(sel - 1)); + } + } + KeyCode::Char('l') => { + // Open load selector + self.refresh_load_selector(); + self.focus = GamedayFocus::LoadSelector; + self.load_selector.is_focused = true; + self.load_selector.is_open = true; + } + KeyCode::Enter => { + // Add from matchup table to this slot + self.add_to_specific_slot(sel); + } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.save_lineup(pool, tx); + } + _ => {} + } + } + + // ========================================================================= + // Lineup operations + // ========================================================================= + + fn add_selected_to_lineup(&mut self) { + let Some(idx) = self.matchup_table_state.selected() else { + return; + }; + let Some(result) = self.matchup_results.get(idx) else { + return; + }; + + // Find first empty slot + let slot_idx = self.lineup_slots.iter().position(|s| s.is_empty()); + let Some(slot_idx) = slot_idx else { + return; // lineup full + }; + + // Don't add duplicates + let pid = result.player.id; + if self.lineup_slots.iter().any(|s| s.player_id == Some(pid)) { + return; + } + + self.lineup_slots[slot_idx] = LineupSlot { + order: slot_idx + 1, + player_id: Some(pid), + player_name: Some(result.player.name.clone()), + position: result.player.pos_1.clone(), + matchup_rating: result.rating, + matchup_tier: Some(result.tier.clone()), + }; + } + + fn add_to_specific_slot(&mut self, slot_idx: usize) { + let Some(matchup_idx) = self.matchup_table_state.selected() else { + return; + }; + let Some(result) = self.matchup_results.get(matchup_idx) else { + return; + }; + + let pid = result.player.id; + if self.lineup_slots.iter().any(|s| s.player_id == Some(pid)) { + return; + } + + self.lineup_slots[slot_idx] = LineupSlot { + order: slot_idx + 1, + player_id: Some(pid), + player_name: Some(result.player.name.clone()), + position: result.player.pos_1.clone(), + matchup_rating: result.rating, + matchup_tier: Some(result.tier.clone()), + }; + } + + fn cycle_position(&mut self, slot_idx: usize) { + let slot = &mut self.lineup_slots[slot_idx]; + let Some(pid) = slot.player_id else { return }; + + // Get player's eligible positions + let eligible: Vec = self + .my_batters + .iter() + .find(|(p, _)| p.id == pid) + .map(|(p, _)| p.positions().iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + if eligible.is_empty() { + return; + } + + let current = slot.position.as_deref().unwrap_or(""); + let current_idx = eligible.iter().position(|p| p == current).unwrap_or(0); + let next_idx = (current_idx + 1) % eligible.len(); + slot.position = Some(eligible[next_idx].clone()); + } + + fn refresh_load_selector(&mut self) { + let items: Vec<(String, String)> = self + .saved_lineups + .iter() + .map(|l| (l.name.clone(), l.name.clone())) + .collect(); + self.load_selector.set_items(items); + } + + fn on_lineup_load_selected(&mut self) { + let Some(name) = self.load_selector.selected_value().cloned() else { + return; + }; + let Some(lineup) = self.saved_lineups.iter().find(|l| l.name == name) else { + return; + }; + + self.lineup_name = lineup.name.clone(); + self.lineup_name_cursor = self.lineup_name.len(); + + let order = lineup.batting_order_vec(); + let positions_map = lineup.positions_map(); + + // Clear all slots + for i in 0..9 { + self.lineup_slots[i] = LineupSlot::empty(i + 1); + } + + // Populate from saved lineup + for (i, pid) in order.iter().enumerate() { + if i >= 9 { + break; + } + let player = self.my_batters.iter().find(|(p, _)| p.id == *pid); + if let Some((p, _)) = player { + // Find position from map or default to pos_1 + let pos = positions_map + .iter() + .find(|(_, v)| **v == *pid) + .map(|(k, _)| k.clone()) + .or_else(|| p.pos_1.clone()); + + // Find matchup data if available + let matchup = self.matchup_results.iter().find(|r| r.player.id == *pid); + + self.lineup_slots[i] = LineupSlot { + order: i + 1, + player_id: Some(*pid), + player_name: Some(p.name.clone()), + position: pos, + matchup_rating: matchup.and_then(|m| m.rating), + matchup_tier: matchup.map(|m| m.tier.clone()), + }; + } + } + self.focus = GamedayFocus::LineupTable; + } + + fn save_lineup(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + if self.lineup_name.is_empty() { + let _ = tx.send(AppMessage::Notify( + "Enter a lineup name first".to_string(), + NotifyLevel::Error, + )); + return; + } + + let batting_order: Vec = self + .lineup_slots + .iter() + .filter_map(|s| s.player_id) + .collect(); + + let mut positions: HashMap = HashMap::new(); + for slot in &self.lineup_slots { + if let (Some(pid), Some(pos)) = (slot.player_id, &slot.position) { + positions.insert(pos.clone(), pid); + } + } + + let pitcher_id = self.pitcher_selector.selected_value().copied(); + let name = self.lineup_name.clone(); + let pool = pool.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + match crate::db::queries::save_lineup( + &pool, + &name, + &batting_order, + &positions, + "gameday", + None, + pitcher_id, + ) + .await + { + Ok(_) => { + let _ = tx.send(AppMessage::LineupSaved(name)); + } + Err(e) => { + let _ = tx.send(AppMessage::LineupSaveError(format!("{e}"))); + } + } + }); + } + + // ========================================================================= + // Cascading selectors + // ========================================================================= + + fn on_team_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let Some(&team_id) = self.team_selector.selected_value() else { + return; + }; + + // Clear pitcher selector and matchups + self.pitcher_selector.set_items(Vec::new()); + self.matchup_results.clear(); + self.is_loading_pitchers = true; + + let pool = pool.clone(); + let tx = tx.clone(); + let season = self.season; + tokio::spawn(async move { + match crate::db::queries::get_pitchers(&pool, Some(team_id), Some(season)).await { + Ok(pitchers) => { + let _ = tx.send(AppMessage::PitchersLoaded(pitchers)); + } + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Failed to load pitchers: {e}"), + NotifyLevel::Error, + )); + } + } + }); + } + + fn on_pitcher_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let Some(&pitcher_id) = self.pitcher_selector.selected_value() else { + return; + }; + + self.is_calculating = true; + self.matchup_results.clear(); + + let pool = pool.clone(); + let tx = tx.clone(); + let batters = self.my_batters.clone(); + tokio::spawn(async move { + // Get pitcher and card + let pitcher = crate::db::queries::get_player_by_id(&pool, pitcher_id).await; + let Ok(Some(pitcher)) = pitcher else { return }; + let pitcher_card = crate::db::queries::get_pitcher_card(&pool, pitcher_id) + .await + .ok() + .flatten(); + + match crate::calc::matchup::calculate_team_matchups_cached( + &pool, + &batters, + &pitcher, + pitcher_card.as_ref(), + ) + .await + { + Ok(results) => { + let _ = tx.send(AppMessage::MatchupsCalculated(results)); + } + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Matchup calculation failed: {e}"), + NotifyLevel::Error, + )); + } + } + }); + } + + // ========================================================================= + // Message handling + // ========================================================================= + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::CacheReady => { + self.cache_ready = true; + } + AppMessage::CacheError(e) => { + self.cache_ready = false; + // Cache error is non-fatal; real-time calc will still work + tracing::warn!("Cache error: {e}"); + } + AppMessage::TeamsLoaded(teams) => { + self.is_loading_teams = false; + let items: Vec<(String, i64)> = teams + .into_iter() + .filter(|t| t.abbrev != self.team_abbrev) + .map(|t| (format!("{} ({})", t.abbrev, t.short_name), t.id)) + .collect(); + self.team_selector.set_items(items); + } + AppMessage::MyBattersLoaded(batters) => { + self.my_batters = batters; + } + AppMessage::LineupsLoaded(lineups) => { + self.saved_lineups = lineups; + } + AppMessage::PitchersLoaded(pitchers) => { + self.is_loading_pitchers = false; + let items: Vec<(String, i64)> = pitchers + .into_iter() + .map(|p| { + let hand = p.hand.as_deref().unwrap_or("?"); + (format!("{} ({})", p.name, hand), p.id) + }) + .collect(); + self.pitcher_selector.set_items(items); + } + AppMessage::MatchupsCalculated(results) => { + self.is_calculating = false; + self.matchup_results = results; + if !self.matchup_results.is_empty() { + self.matchup_table_state.select(Some(0)); + } + } + AppMessage::LineupSaved(name) => { + // Refresh saved lineups list + let _ = name; // name used for notification in Notify + } + AppMessage::LineupSaveError(_) => {} + _ => {} + } + } + + // ========================================================================= + // Rendering + // ========================================================================= + + pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + // 60/40 horizontal split + let panels = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.render_left_panel(frame, panels[0], tick_count); + self.render_right_panel(frame, panels[1], tick_count); + + // Render popup overlays last (on top) + // Team selector popup anchored to its closed position + if self.team_selector.is_open { + let left_chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(panels[0]); + self.team_selector.render_popup(frame, left_chunks[0]); + } + if self.pitcher_selector.is_open { + let left_chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(panels[0]); + self.pitcher_selector.render_popup(frame, left_chunks[1]); + } + if self.load_selector.is_open { + let right_chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(panels[1]); + self.load_selector.render_popup(frame, right_chunks[0]); + } + } + + fn render_left_panel(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let chunks = Layout::vertical([ + Constraint::Length(3), // team selector + Constraint::Length(3), // pitcher selector + Constraint::Min(0), // matchup table + ]) + .split(area); + + self.team_selector.render_closed(frame, chunks[0]); + self.pitcher_selector.render_closed(frame, chunks[1]); + self.render_matchup_table(frame, chunks[2], tick_count); + } + + fn render_matchup_table(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let is_focused = self.focus == GamedayFocus::MatchupTable; + let border_style = if is_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + if self.is_calculating { + let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4]; + let widget = Paragraph::new(format!("{spinner} Calculating matchups...")) + .block( + Block::default() + .borders(Borders::ALL) + .title("Matchups") + .border_style(border_style), + ); + frame.render_widget(widget, area); + return; + } + + if self.matchup_results.is_empty() { + let msg = if self.pitcher_selector.selected_idx.is_some() { + "No matchup data" + } else { + "Select a team and pitcher" + }; + let widget = Paragraph::new(msg).block( + Block::default() + .borders(Borders::ALL) + .title("Matchups") + .border_style(border_style), + ); + frame.render_widget(widget, area); + return; + } + + let lineup_pids: Vec = self + .lineup_slots + .iter() + .filter_map(|s| s.player_id) + .collect(); + + let header = Row::new(vec!["", "Name", "Hand", "Pos", "Tier", "Rating"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let rows: Vec = self + .matchup_results + .iter() + .map(|r| { + let in_lineup = if lineup_pids.contains(&r.player.id) { + "*" + } else { + " " + }; + let hand = r.batter_hand.as_str(); + let pos = r.player.pos_1.as_deref().unwrap_or("-"); + Row::new(vec![ + Cell::from(in_lineup), + Cell::from(r.player.name.as_str()), + Cell::from(hand), + Cell::from(pos), + Cell::from(r.tier.as_str()), + Cell::from(r.rating_display()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(1), + Constraint::Min(15), + Constraint::Length(4), + Constraint::Length(3), + Constraint::Length(4), + Constraint::Length(7), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title("Matchups") + .border_style(border_style), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_stateful_widget(table, area, &mut self.matchup_table_state.clone()); + } + + fn render_right_panel(&self, frame: &mut Frame, area: Rect, _tick_count: u64) { + let chunks = Layout::vertical([ + Constraint::Length(3), // lineup name / load selector + Constraint::Min(0), // lineup table + Constraint::Length(1), // key hints + ]) + .split(area); + + self.render_lineup_header(frame, chunks[0]); + self.render_lineup_table(frame, chunks[1]); + self.render_lineup_hints(frame, chunks[2]); + } + + fn render_lineup_header(&self, frame: &mut Frame, area: Rect) { + let cols = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + // Lineup name input + let name_focused = self.focus == GamedayFocus::LineupName; + let name_style = if name_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let display = if self.lineup_name.is_empty() { + "untitled".to_string() + } else { + self.lineup_name.clone() + }; + let cursor_line = if name_focused { + let mut s = display.clone(); + if self.lineup_name_cursor <= s.len() { + s.insert(self.lineup_name_cursor, '|'); + } + s + } else { + display + }; + let name_widget = Paragraph::new(cursor_line) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lineup Name") + .border_style(name_style), + ); + frame.render_widget(name_widget, cols[0]); + + // Load selector + self.load_selector.render_closed(frame, cols[1]); + } + + fn render_lineup_table(&self, frame: &mut Frame, area: Rect) { + let is_focused = self.focus == GamedayFocus::LineupTable; + let border_style = if is_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let header = Row::new(vec!["#", "Name", "Pos", "Tier", "Rating"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let rows: Vec = self + .lineup_slots + .iter() + .map(|slot| { + let order = format!("{}", slot.order); + if slot.is_empty() { + Row::new(vec![ + Cell::from(order), + Cell::from("---"), + Cell::from("-"), + Cell::from("-"), + Cell::from("-"), + ]) + .style(Style::default().fg(Color::DarkGray)) + } else { + let name = slot.player_name.as_deref().unwrap_or("?"); + let pos = slot.position.as_deref().unwrap_or("-"); + let tier = slot.matchup_tier.as_deref().unwrap_or("-"); + let rating = slot + .matchup_rating + .map(|r| { + if r >= 0.0 { + format!("+{:.0}", r) + } else { + format!("{:.0}", r) + } + }) + .unwrap_or_else(|| "-".to_string()); + Row::new(vec![ + Cell::from(order), + Cell::from(name), + Cell::from(pos), + Cell::from(tier), + Cell::from(rating), + ]) + } + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(2), + Constraint::Min(12), + Constraint::Length(3), + Constraint::Length(4), + Constraint::Length(7), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lineup") + .border_style(border_style), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_stateful_widget(table, area, &mut self.lineup_table_state.clone()); + } + + fn render_lineup_hints(&self, frame: &mut Frame, area: Rect) { + let hints = match self.focus { + GamedayFocus::MatchupTable => { + " Enter:add j/k:nav Tab:lineup" + } + GamedayFocus::LineupTable => { + " x:remove p:pos J/K:move l:load Ctrl-S:save" + } + GamedayFocus::LineupName => { + " Type name Tab/Esc:done" + } + _ => " Tab:cycle focus", + }; + let line = Line::from(vec![Span::styled( + hints, + Style::default().fg(Color::DarkGray), + )]); + let widget = Paragraph::new(line); + frame.render_widget(widget, area); + } } diff --git a/rust/src/widgets/mod.rs b/rust/src/widgets/mod.rs new file mode 100644 index 0000000..199a414 --- /dev/null +++ b/rust/src/widgets/mod.rs @@ -0,0 +1 @@ +pub mod selector; diff --git a/rust/src/widgets/selector.rs b/rust/src/widgets/selector.rs new file mode 100644 index 0000000..6e149af --- /dev/null +++ b/rust/src/widgets/selector.rs @@ -0,0 +1,166 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + Frame, +}; + +/// Event returned from selector key handling. +#[derive(Debug)] +pub enum SelectorEvent { + /// Key was not consumed (not focused or irrelevant). + None, + /// Key was consumed but no selection change. + Consumed, + /// An item was selected — contains the index. + Selected(usize), +} + +/// A focusable popup-list selector widget. +/// +/// Closed: renders as a single-line display showing the selected item label. +/// Open: renders as a bordered List overlay showing all items. +pub struct SelectorWidget { + pub title: String, + pub items: Vec<(String, T)>, + pub selected_idx: Option, + pub highlighted_idx: usize, + pub is_open: bool, + pub is_focused: bool, +} + +impl SelectorWidget { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + items: Vec::new(), + selected_idx: None, + highlighted_idx: 0, + is_open: false, + is_focused: false, + } + } + + pub fn set_items(&mut self, items: Vec<(String, T)>) { + self.items = items; + self.selected_idx = None; + self.highlighted_idx = 0; + } + + pub fn selected_value(&self) -> Option<&T> { + self.selected_idx + .and_then(|i| self.items.get(i).map(|(_, v)| v)) + } + + pub fn selected_label(&self) -> &str { + self.selected_idx + .and_then(|i| self.items.get(i)) + .map(|(label, _)| label.as_str()) + .unwrap_or("-- None --") + } + + /// Handle a key event. Returns a SelectorEvent indicating what happened. + pub fn handle_key(&mut self, key: KeyEvent) -> SelectorEvent { + if !self.is_focused { + return SelectorEvent::None; + } + + if !self.is_open { + match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.is_open = true; + self.highlighted_idx = self.selected_idx.unwrap_or(0); + return SelectorEvent::Consumed; + } + _ => return SelectorEvent::None, + } + } + + // Popup is open + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !self.items.is_empty() { + self.highlighted_idx = (self.highlighted_idx + 1) % self.items.len(); + } + SelectorEvent::Consumed + } + KeyCode::Char('k') | KeyCode::Up => { + if !self.items.is_empty() { + self.highlighted_idx = self + .highlighted_idx + .checked_sub(1) + .unwrap_or(self.items.len().saturating_sub(1)); + } + SelectorEvent::Consumed + } + KeyCode::Enter => { + if !self.items.is_empty() { + self.selected_idx = Some(self.highlighted_idx); + self.is_open = false; + return SelectorEvent::Selected(self.highlighted_idx); + } + SelectorEvent::Consumed + } + KeyCode::Esc => { + self.is_open = false; + SelectorEvent::Consumed + } + _ => SelectorEvent::Consumed, // consume all keys when open + } + } + + /// Render the closed (single-line) view into `area`. + pub fn render_closed(&self, frame: &mut Frame, area: Rect) { + let style = if self.is_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let label = format!("{}: {}", self.title, self.selected_label()); + let widget = Paragraph::new(label) + .style(style) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(widget, area); + } + + /// Render the popup overlay. Call AFTER rendering main content. + /// `anchor` is the area the popup appears below. + pub fn render_popup(&self, frame: &mut Frame, anchor: Rect) { + if !self.is_open { + return; + } + + let popup_height = (self.items.len() as u16 + 2).min(20); + let popup_area = Rect { + x: anchor.x, + y: anchor.y + anchor.height, + width: anchor.width, + height: popup_height.min(frame.area().height.saturating_sub(anchor.y + anchor.height)), + }; + + if popup_area.height < 3 { + return; + } + + frame.render_widget(Clear, popup_area); + + let items: Vec = self + .items + .iter() + .enumerate() + .map(|(i, (label, _))| { + let style = if i == self.highlighted_idx { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default() + }; + ListItem::new(label.as_str()).style(style) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(self.title.as_str())); + frame.render_widget(list, popup_area); + } +}