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); + } +}