From b6da92625824fd278aa59902946c8a1eac56b19c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 28 Feb 2026 14:29:50 -0600 Subject: [PATCH] Implement Phase 5: Roster, Matchup, Lineup, and Settings screens Replace all four stub screens with full implementations. Wire up nav bar with [r/m/l/S] keys and Box ActiveScreen variants. Roster shows tabbed batter/pitcher tables with card ratings. Matchup adds standalone analysis with sort modes. Lineup provides two-panel builder with save/load/delete. Settings offers form-based TOML config editing with live team validation. Co-Authored-By: Claude Opus 4.6 --- rust/src/app.rs | 167 ++++--- rust/src/screens/lineup.rs | 815 ++++++++++++++++++++++++++++++++++- rust/src/screens/matchup.rs | 531 ++++++++++++++++++++++- rust/src/screens/roster.rs | 514 +++++++++++++++++++++- rust/src/screens/settings.rs | 528 ++++++++++++++++++++++- 5 files changed, 2490 insertions(+), 65 deletions(-) diff --git a/rust/src/app.rs b/rust/src/app.rs index 3bc8693..6dc452e 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -1,9 +1,9 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, Frame, }; use sqlx::sqlite::SqlitePool; @@ -15,6 +15,10 @@ use crate::config::Settings; use crate::db::models::{BatterCard, Lineup, Player, Roster, SyncStatus, Team}; use crate::screens::dashboard::DashboardState; use crate::screens::gameday::GamedayState; +use crate::screens::lineup::LineupState; +use crate::screens::matchup::MatchupState; +use crate::screens::roster::{RosterRow, RosterState}; +use crate::screens::settings::SettingsState; // ============================================================================= // Messages @@ -33,23 +37,40 @@ pub enum AppMessage { SyncStarted, SyncComplete(Result), - // Gameday — init + // Gameday + Matchup — init CacheReady, CacheError(String), TeamsLoaded(Vec), MyBattersLoaded(Vec<(Player, Option)>), LineupsLoaded(Vec), - // Gameday — on team select + // Gameday + Matchup — on team select PitchersLoaded(Vec), - // Gameday — on pitcher select + // Gameday + Matchup — on pitcher select MatchupsCalculated(Vec), - // Gameday — lineup + // Gameday + Lineup — lineup ops LineupSaved(String), LineupSaveError(String), + // Roster + RosterPlayersLoaded { + majors: Vec, + minors: Vec, + il: Vec, + swar_total: f64, + }, + + // Lineup (standalone) + LineupBattersLoaded(Vec<(Player, Option)>), + LineupListLoaded(Vec), + LineupDeleted(String), + LineupDeleteError(String), + + // Settings + SettingsTeamValidated(Option<(String, Option)>), + // General Notify(String, NotifyLevel), } @@ -91,30 +112,13 @@ impl Notification { // Screen enum // ============================================================================= -/// Stub screen identifier for screens not yet implemented. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StubScreen { - Roster, - Matchup, - Lineup, - 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), + Dashboard(Box), + Gameday(Box), + Roster(Box), + Matchup(Box), + Lineup(Box), + Settings(Box), } impl ActiveScreen { @@ -122,7 +126,10 @@ impl ActiveScreen { match self { ActiveScreen::Dashboard(_) => "Dashboard", ActiveScreen::Gameday(_) => "Gameday", - ActiveScreen::Stub(s) => s.label(), + ActiveScreen::Roster(_) => "Roster", + ActiveScreen::Matchup(_) => "Matchup", + ActiveScreen::Lineup(_) => "Lineup", + ActiveScreen::Settings(_) => "Settings", } } } @@ -146,11 +153,11 @@ impl App { pool: SqlitePool, tx: mpsc::UnboundedSender, ) -> Self { - let screen = ActiveScreen::Dashboard(DashboardState::new( + let screen = ActiveScreen::Dashboard(Box::new(DashboardState::new( settings.team.abbrev.clone(), settings.team.season as i64, &settings, - )); + ))); Self { screen, pool, @@ -179,15 +186,15 @@ impl App { /// 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, - } + !self.is_input_captured() } fn is_input_captured(&self) -> bool { match &self.screen { ActiveScreen::Gameday(s) => s.is_input_captured(), + ActiveScreen::Matchup(s) => s.is_input_captured(), + ActiveScreen::Lineup(s) => s.is_input_captured(), + ActiveScreen::Settings(s) => s.is_input_captured(), _ => false, } } @@ -208,6 +215,30 @@ impl App { return; } } + KeyCode::Char('r') => { + if !matches!(&self.screen, ActiveScreen::Roster(_)) { + self.switch_to_roster(); + return; + } + } + KeyCode::Char('m') => { + if !matches!(&self.screen, ActiveScreen::Matchup(_)) { + self.switch_to_matchup(); + return; + } + } + KeyCode::Char('l') => { + if !matches!(&self.screen, ActiveScreen::Lineup(_)) { + self.switch_to_lineup(); + return; + } + } + KeyCode::Char('S') => { + if !matches!(&self.screen, ActiveScreen::Settings(_)) { + self.switch_to_settings(); + return; + } + } _ => {} } } @@ -216,7 +247,10 @@ impl App { 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(_) => {} + ActiveScreen::Roster(s) => s.handle_key(key, &self.pool, &self.tx), + ActiveScreen::Matchup(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), + ActiveScreen::Lineup(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), + ActiveScreen::Settings(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), } } @@ -228,7 +262,10 @@ impl App { _ => match &mut self.screen { ActiveScreen::Dashboard(s) => s.handle_message(msg, &self.pool, &self.tx), ActiveScreen::Gameday(s) => s.handle_message(msg), - ActiveScreen::Stub(_) => {} + ActiveScreen::Roster(s) => s.handle_message(msg), + ActiveScreen::Matchup(s) => s.handle_message(msg, &self.pool, &self.settings, &self.tx), + ActiveScreen::Lineup(s) => s.handle_message(msg, &self.pool, &self.tx), + ActiveScreen::Settings(s) => s.handle_message(msg), }, } } @@ -240,7 +277,7 @@ impl App { &self.settings, ); state.mount(&self.pool, &self.tx); - self.screen = ActiveScreen::Dashboard(state); + self.screen = ActiveScreen::Dashboard(Box::new(state)); } fn switch_to_gameday(&mut self) { @@ -249,7 +286,40 @@ impl App { self.settings.team.season as i64, ); state.mount(&self.pool, &self.tx); - self.screen = ActiveScreen::Gameday(state); + self.screen = ActiveScreen::Gameday(Box::new(state)); + } + + fn switch_to_roster(&mut self) { + let mut state = RosterState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + &self.settings, + ); + state.mount(&self.pool, &self.tx); + self.screen = ActiveScreen::Roster(Box::new(state)); + } + + fn switch_to_matchup(&mut self) { + let mut state = MatchupState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + ); + state.mount(&self.pool, &self.tx); + self.screen = ActiveScreen::Matchup(Box::new(state)); + } + + fn switch_to_lineup(&mut self) { + let mut state = LineupState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + ); + state.mount(&self.pool, &self.tx); + self.screen = ActiveScreen::Lineup(Box::new(state)); + } + + fn switch_to_settings(&mut self) { + let state = SettingsState::new(&self.settings); + self.screen = ActiveScreen::Settings(Box::new(state)); } pub fn render(&self, frame: &mut Frame) { @@ -266,7 +336,10 @@ impl App { 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), + ActiveScreen::Roster(s) => s.render(frame, chunks[1]), + ActiveScreen::Matchup(s) => s.render(frame, chunks[1], self.tick_count), + ActiveScreen::Lineup(s) => s.render(frame, chunks[1]), + ActiveScreen::Settings(s) => s.render(frame, chunks[1]), } self.render_status_bar(frame, chunks[2]); @@ -277,6 +350,10 @@ impl App { let items = [ ("d", "Dashboard"), ("g", "Gameday"), + ("r", "Roster"), + ("m", "Matchup"), + ("l", "Lineup"), + ("S", "Settings"), ]; let spans: Vec = items @@ -317,11 +394,3 @@ impl App { 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/screens/lineup.rs b/rust/src/screens/lineup.rs index 5169b51..b88dfd6 100644 --- a/rust/src/screens/lineup.rs +++ b/rust/src/screens/lineup.rs @@ -1,6 +1,813 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use std::collections::HashMap; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Lineup"); - 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::{Cell, Paragraph, Row, Table, TableState}, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; + +use crate::app::{AppMessage, NotifyLevel}; +use crate::config::Settings; +use crate::db::models::{BatterCard, Lineup, Player}; +use crate::widgets::selector::{SelectorEvent, SelectorWidget}; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineupFocus { + AvailableTable, + LineupTable, + LineupName, + LoadSelector, +} + +#[derive(Debug, Clone)] +pub struct LineupSlot { + pub order: usize, + pub player_id: Option, + pub player_name: Option, + pub position: Option, +} + +impl LineupSlot { + pub fn empty(order: usize) -> Self { + Self { + order, + player_id: None, + player_name: None, + position: None, + } + } + + pub fn is_empty(&self) -> bool { + self.player_id.is_none() + } +} + +// ============================================================================= +// State +// ============================================================================= + +pub struct LineupState { + pub team_abbrev: String, + pub season: i64, + + // Data + pub available_batters: Vec<(Player, Option)>, + pub lineup_slots: [LineupSlot; 9], + pub saved_lineups: Vec, + pub is_loading: bool, + + // UI + pub focus: LineupFocus, + pub available_table_state: TableState, + pub lineup_table_state: TableState, + pub lineup_name: String, + pub lineup_name_cursor: usize, + pub load_selector: SelectorWidget, +} + +impl LineupState { + 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, + available_batters: Vec::new(), + lineup_slots, + saved_lineups: Vec::new(), + is_loading: true, + focus: LineupFocus::AvailableTable, + available_table_state: TableState::default(), + lineup_table_state: TableState::default().with_selected(0), + lineup_name: String::new(), + lineup_name_cursor: 0, + load_selector: SelectorWidget::new("Load Lineup"), + } + } + + pub fn is_input_captured(&self) -> bool { + matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open + } + + pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + self.is_loading = true; + + // Load my batters with cards + 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::LineupBattersLoaded(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::LineupListLoaded(lineups)); + } + }); + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + _settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + // Load selector + 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 == LineupFocus::LineupName { + match key.code { + KeyCode::Esc | KeyCode::Tab => { + self.focus = LineupFocus::AvailableTable; + } + 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 { + LineupFocus::AvailableTable => self.handle_available_key(key), + LineupFocus::LineupTable => self.handle_lineup_table_key(key, pool, tx), + LineupFocus::LoadSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.refresh_load_selector(); + self.load_selector.is_open = true; + self.load_selector.is_focused = true; + } + _ => {} + }, + LineupFocus::LineupName => {} // handled above + } + } + + fn cycle_focus(&mut self) { + self.load_selector.is_focused = false; + self.focus = match self.focus { + LineupFocus::AvailableTable => LineupFocus::LineupTable, + LineupFocus::LineupTable => LineupFocus::LineupName, + LineupFocus::LineupName => LineupFocus::AvailableTable, + LineupFocus::LoadSelector => LineupFocus::AvailableTable, + }; + } + + fn handle_available_key(&mut self, key: KeyEvent) { + let available = self.filtered_available(); + let len = available.len(); + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if len > 0 { + let i = self.available_table_state.selected().unwrap_or(0); + self.available_table_state.select(Some((i + 1) % len)); + } + } + KeyCode::Char('k') | KeyCode::Up => { + if len > 0 { + let i = self.available_table_state.selected().unwrap_or(0); + self.available_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') => { + self.lineup_slots[sel] = LineupSlot::empty(sel + 1); + } + KeyCode::Char('p') => { + self.cycle_position(sel); + } + KeyCode::Char('J') => { + if sel < 8 { + self.lineup_slots.swap(sel, sel + 1); + 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') => { + 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') => { + self.refresh_load_selector(); + self.focus = LineupFocus::LoadSelector; + self.load_selector.is_focused = true; + self.load_selector.is_open = true; + } + KeyCode::Char('D') => { + self.delete_lineup(pool, tx); + } + KeyCode::Char('c') => { + for i in 0..9 { + self.lineup_slots[i] = LineupSlot::empty(i + 1); + } + } + _ => {} + } + } + + // ========================================================================= + // Lineup operations + // ========================================================================= + + fn filtered_available(&self) -> Vec<&(Player, Option)> { + let lineup_ids: Vec = self + .lineup_slots + .iter() + .filter_map(|s| s.player_id) + .collect(); + self.available_batters + .iter() + .filter(|(p, _)| !lineup_ids.contains(&p.id)) + .collect() + } + + fn add_selected_to_lineup(&mut self) { + let available = self.filtered_available(); + let Some(idx) = self.available_table_state.selected() else { + return; + }; + let Some((player, _)) = available.get(idx) else { + return; + }; + + // Find first empty slot + let Some(slot_idx) = self.lineup_slots.iter().position(|s| s.is_empty()) else { + return; + }; + + // Auto-suggest position: first eligible not already assigned + let assigned_positions: Vec = self + .lineup_slots + .iter() + .filter_map(|s| s.position.clone()) + .collect(); + let eligible = player.positions(); + let pos = eligible + .iter() + .find(|p| !assigned_positions.contains(&p.to_string())) + .map(|p| p.to_string()) + .or_else(|| Some("DH".to_string())); + + self.lineup_slots[slot_idx] = LineupSlot { + order: slot_idx + 1, + player_id: Some(player.id), + player_name: Some(player.name.clone()), + position: pos, + }; + + // Adjust available table selection + let new_available_len = self.filtered_available().len(); + if new_available_len > 0 { + let new_idx = idx.min(new_available_len - 1); + self.available_table_state.select(Some(new_idx)); + } else { + self.available_table_state.select(None); + } + } + + fn cycle_position(&mut self, slot_idx: usize) { + let slot = &mut self.lineup_slots[slot_idx]; + let Some(pid) = slot.player_id else { return }; + + let eligible: Vec = self + .available_batters + .iter() + .find(|(p, _)| p.id == pid) + .map(|(p, _)| { + let mut positions: Vec = + p.positions().iter().map(|s| s.to_string()).collect(); + if !positions.contains(&"DH".to_string()) { + positions.push("DH".to_string()); + } + positions + }) + .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.available_batters.iter().find(|(p, _)| p.id == *pid); + if let Some((p, _)) = player { + let pos = positions_map + .iter() + .find(|(_, v)| **v == *pid) + .map(|(k, _)| k.clone()) + .or_else(|| p.pos_1.clone()); + + self.lineup_slots[i] = LineupSlot { + order: i + 1, + player_id: Some(*pid), + player_name: Some(p.name.clone()), + position: pos, + }; + } + } + self.focus = LineupFocus::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 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, + "lineup", + None, + None, + ) + .await + { + Ok(_) => { + let _ = tx.send(AppMessage::LineupSaved(name)); + } + Err(e) => { + let _ = tx.send(AppMessage::LineupSaveError(format!("{e}"))); + } + } + }); + } + + fn delete_lineup(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + if self.lineup_name.is_empty() { + let _ = tx.send(AppMessage::Notify( + "No lineup loaded to delete".to_string(), + NotifyLevel::Error, + )); + return; + } + + let name = self.lineup_name.clone(); + let pool = pool.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + match crate::db::queries::delete_lineup(&pool, &name).await { + Ok(true) => { + let _ = tx.send(AppMessage::LineupDeleted(name)); + } + Ok(false) => { + let _ = tx.send(AppMessage::LineupDeleteError( + "Lineup not found".to_string(), + )); + } + Err(e) => { + let _ = tx.send(AppMessage::LineupDeleteError(format!("{e}"))); + } + } + }); + } + + pub fn handle_message( + &mut self, + msg: AppMessage, + pool: &SqlitePool, + tx: &mpsc::UnboundedSender, + ) { + match msg { + AppMessage::LineupBattersLoaded(batters) => { + self.available_batters = batters; + self.is_loading = false; + if !self.available_batters.is_empty() { + self.available_table_state.select(Some(0)); + } + } + AppMessage::LineupListLoaded(lineups) => { + self.saved_lineups = lineups; + } + AppMessage::LineupSaved(name) => { + let _ = tx.send(AppMessage::Notify( + format!("Lineup '{}' saved", name), + NotifyLevel::Success, + )); + // Refresh lineups list + let pool = pool.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await { + let _ = tx.send(AppMessage::LineupListLoaded(lineups)); + } + }); + } + AppMessage::LineupSaveError(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Save failed: {}", e), + NotifyLevel::Error, + )); + } + AppMessage::LineupDeleted(name) => { + let _ = tx.send(AppMessage::Notify( + format!("Lineup '{}' deleted", name), + NotifyLevel::Success, + )); + self.lineup_name.clear(); + self.lineup_name_cursor = 0; + for i in 0..9 { + self.lineup_slots[i] = LineupSlot::empty(i + 1); + } + // Refresh lineups list + let pool = pool.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await { + let _ = tx.send(AppMessage::LineupListLoaded(lineups)); + } + }); + } + AppMessage::LineupDeleteError(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Delete failed: {}", e), + NotifyLevel::Error, + )); + } + _ => {} + } + } + + // ========================================================================= + // Rendering + // ========================================================================= + + pub fn render(&self, frame: &mut Frame, area: Rect) { + // 50/50 horizontal split + let panels = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + self.render_available_panel(frame, panels[0]); + self.render_lineup_panel(frame, panels[1]); + + // Load selector overlay + if self.load_selector.is_open { + self.load_selector.render_popup(frame, panels[1]); + } + } + + fn render_available_panel(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(1), // title + Constraint::Min(0), // table + Constraint::Length(1), // hints + ]) + .split(area); + + let title_style = if self.focus == LineupFocus::AvailableTable { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget( + Paragraph::new(" Available Batters").style(title_style), + chunks[0], + ); + + if self.is_loading { + frame.render_widget( + Paragraph::new(" Loading...").style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + return; + } + + let available = self.filtered_available(); + if available.is_empty() { + frame.render_widget( + Paragraph::new(" No available batters").style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + } else { + let header = Row::new(vec![ + Cell::from("Name"), + Cell::from("Pos"), + Cell::from("H"), + Cell::from("vL"), + Cell::from("vR"), + Cell::from("sWAR"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::White)); + + let rows: Vec = available + .iter() + .map(|(p, card)| { + let (vl, vr) = card + .as_ref() + .map(|c| (format_rating(c.rating_vl), format_rating(c.rating_vr))) + .unwrap_or(("—".to_string(), "—".to_string())); + Row::new(vec![ + Cell::from(p.name.clone()), + Cell::from(p.positions().join("/")), + Cell::from(p.hand.clone().unwrap_or_default()), + Cell::from(vl), + Cell::from(vr), + Cell::from(format_swar(p.swar)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(18), // Name + Constraint::Length(12), // Pos + Constraint::Length(3), // Hand + Constraint::Length(5), // vL + Constraint::Length(5), // vR + Constraint::Min(0), // sWAR + ]; + + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + let mut table_state = self.available_table_state; + frame.render_stateful_widget(table, chunks[1], &mut table_state); + } + + frame.render_widget( + Paragraph::new(" [Enter] Add [j/k] Scroll").style(Style::default().fg(Color::DarkGray)), + chunks[2], + ); + } + + fn render_lineup_panel(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(1), // title + name input + Constraint::Min(0), // lineup table + Constraint::Length(2), // hints + ]) + .split(area); + + // Title with inline name + let name_style = if self.focus == LineupFocus::LineupName { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let title_style = if matches!(self.focus, LineupFocus::LineupTable | LineupFocus::LineupName) { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let display_name = if self.lineup_name.is_empty() { + "" + } else { + &self.lineup_name + }; + let title_line = Line::from(vec![ + Span::styled(" Lineup: ", title_style), + Span::styled(display_name, name_style), + ]); + frame.render_widget(Paragraph::new(title_line), chunks[0]); + + // Lineup table (always 9 rows) + let header = Row::new(vec![ + Cell::from("#"), + Cell::from("Name"), + Cell::from("Pos"), + Cell::from("Elig"), + Cell::from("H"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::White)); + + let rows: Vec = self + .lineup_slots + .iter() + .map(|slot| { + if slot.is_empty() { + Row::new(vec![ + Cell::from(format!("{}", slot.order)), + Cell::from("(empty)").style(Style::default().fg(Color::DarkGray)), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]) + } else { + let name = slot.player_name.as_deref().unwrap_or("?"); + let pos = slot.position.as_deref().unwrap_or("—"); + let eligible = self + .available_batters + .iter() + .find(|(p, _)| Some(p.id) == slot.player_id) + .map(|(p, _)| p.positions().join("/")) + .unwrap_or_default(); + let hand = self + .available_batters + .iter() + .find(|(p, _)| Some(p.id) == slot.player_id) + .and_then(|(p, _)| p.hand.clone()) + .unwrap_or_default(); + Row::new(vec![ + Cell::from(format!("{}", slot.order)), + Cell::from(name.to_string()), + Cell::from(pos.to_string()), + Cell::from(eligible), + Cell::from(hand), + ]) + } + }) + .collect(); + + let widths = [ + Constraint::Length(3), // # + Constraint::Length(18), // Name + Constraint::Length(5), // Pos + Constraint::Length(12), // Elig + Constraint::Min(0), // Hand + ]; + + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + let mut table_state = self.lineup_table_state; + frame.render_stateful_widget(table, chunks[1], &mut table_state); + + // Hints + let hints = Paragraph::new( + " [x] Remove [p] Pos [J/K] Move [l] Load\n [Ctrl+S] Save [D] Delete [c] Clear [Tab] Focus", + ) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(hints, chunks[2]); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +fn format_rating(val: Option) -> String { + match val { + Some(v) => { + let rounded = v.round() as i64; + if rounded > 0 { + format!("+{}", rounded) + } else { + format!("{}", rounded) + } + } + None => "—".to_string(), + } +} + +fn format_swar(val: Option) -> String { + match val { + Some(v) => format!("{:.1}", v), + None => "—".to_string(), + } } diff --git a/rust/src/screens/matchup.rs b/rust/src/screens/matchup.rs index ce86e9b..3786ab1 100644 --- a/rust/src/screens/matchup.rs +++ b/rust/src/screens/matchup.rs @@ -1,6 +1,529 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Cell, Paragraph, Row, Table, TableState}, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Matchup"); - frame.render_widget(widget, area); +use crate::app::{AppMessage, NotifyLevel}; +use crate::calc::matchup::MatchupResult; +use crate::config::Settings; +use crate::db::models::{BatterCard, Player}; +use crate::widgets::selector::{SelectorEvent, SelectorWidget}; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchupFocus { + TeamSelector, + PitcherSelector, + Table, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortMode { + Rating, + Name, + Position, +} + +impl SortMode { + pub fn label(&self) -> &'static str { + match self { + SortMode::Rating => "Rating", + SortMode::Name => "Name", + SortMode::Position => "Position", + } + } +} + +// ============================================================================= +// State +// ============================================================================= + +pub struct MatchupState { + pub team_abbrev: String, + pub season: i64, + + // Selectors + pub team_selector: SelectorWidget, + pub pitcher_selector: SelectorWidget, + + // Data + pub my_batters: Vec<(Player, Option)>, + pub matchup_results: Vec, + pub selected_pitcher: Option, + pub is_loading_pitchers: bool, + pub is_calculating: bool, + pub cache_ready: bool, + + // UI + pub focus: MatchupFocus, + pub table_state: TableState, + pub sort_mode: SortMode, +} + +impl MatchupState { + pub fn new(team_abbrev: String, season: i64) -> Self { + Self { + team_abbrev, + season, + team_selector: SelectorWidget::new("Opponent"), + pitcher_selector: SelectorWidget::new("Pitcher"), + my_batters: Vec::new(), + matchup_results: Vec::new(), + selected_pitcher: None, + is_loading_pitchers: false, + is_calculating: false, + cache_ready: false, + focus: MatchupFocus::TeamSelector, + table_state: TableState::default(), + sort_mode: SortMode::Rating, + } + } + + pub fn is_input_captured(&self) -> bool { + self.team_selector.is_open || self.pitcher_selector.is_open + } + + 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) => { + tracing::error!("Failed to load teams: {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)); + } + } + }); + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + _settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + // Selectors first + 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; + } + + // Tab: cycle focus + if key.code == KeyCode::Tab { + self.cycle_focus(); + return; + } + + // Focus-specific keys + match self.focus { + MatchupFocus::TeamSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.team_selector.is_open = true; + self.team_selector.is_focused = true; + } + _ => {} + }, + MatchupFocus::PitcherSelector => match key.code { + KeyCode::Enter | KeyCode::Char(' ') => { + self.pitcher_selector.is_open = true; + self.pitcher_selector.is_focused = true; + } + _ => {} + }, + MatchupFocus::Table => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let len = self.matchup_results.len(); + if len > 0 { + let i = self.table_state.selected().unwrap_or(0); + self.table_state.select(Some((i + 1) % len)); + } + } + KeyCode::Char('k') | KeyCode::Up => { + let len = self.matchup_results.len(); + if len > 0 { + let i = self.table_state.selected().unwrap_or(0); + self.table_state.select(Some(i.checked_sub(1).unwrap_or(len - 1))); + } + } + KeyCode::Char('s') => { + self.sort_mode = SortMode::Rating; + self.sort_results(); + } + KeyCode::Char('n') => { + self.sort_mode = SortMode::Name; + self.sort_results(); + } + KeyCode::Char('p') => { + self.sort_mode = SortMode::Position; + self.sort_results(); + } + KeyCode::Char('f') => { + self.mount(pool, tx); + } + _ => {} + }, + } + } + + fn cycle_focus(&mut self) { + self.team_selector.is_focused = false; + self.pitcher_selector.is_focused = false; + self.focus = match self.focus { + MatchupFocus::TeamSelector => MatchupFocus::PitcherSelector, + MatchupFocus::PitcherSelector => MatchupFocus::Table, + MatchupFocus::Table => MatchupFocus::TeamSelector, + }; + self.team_selector.is_focused = self.focus == MatchupFocus::TeamSelector; + self.pitcher_selector.is_focused = self.focus == MatchupFocus::PitcherSelector; + } + + fn sort_results(&mut self) { + match self.sort_mode { + SortMode::Rating => { + self.matchup_results + .sort_by(|a, b| b.rating.partial_cmp(&a.rating).unwrap_or(std::cmp::Ordering::Equal)); + } + SortMode::Name => { + self.matchup_results.sort_by(|a, b| a.player.name.cmp(&b.player.name)); + } + SortMode::Position => { + self.matchup_results.sort_by(|a, b| { + let a_pos = a.player.pos_1.as_deref().unwrap_or("ZZ"); + let b_pos = b.player.pos_1.as_deref().unwrap_or("ZZ"); + a_pos.cmp(b_pos).then(a.player.name.cmp(&b.player.name)) + }); + } + } + if !self.matchup_results.is_empty() { + self.table_state.select(Some(0)); + } + } + + fn on_team_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let Some(&team_id) = self.team_selector.selected_value() else { + return; + }; + self.pitcher_selector.set_items(Vec::new()); + self.matchup_results.clear(); + self.selected_pitcher = None; + 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) => { + tracing::error!("Failed to load pitchers: {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 { + 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) => { + tracing::error!("Matchup calculation failed: {e}"); + let _ = tx.send(AppMessage::Notify( + format!("Matchup calculation failed: {e}"), + NotifyLevel::Error, + )); + } + } + }); + } + + pub fn handle_message( + &mut self, + msg: AppMessage, + _pool: &SqlitePool, + _settings: &Settings, + _tx: &mpsc::UnboundedSender, + ) { + match msg { + AppMessage::CacheReady => { + self.cache_ready = true; + } + AppMessage::CacheError(e) => { + self.cache_ready = false; + tracing::warn!("Cache error: {e}"); + } + AppMessage::TeamsLoaded(teams) => { + 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::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(mut results) => { + self.is_calculating = false; + // Apply current sort + results.sort_by(|a, b| { + b.rating.partial_cmp(&a.rating).unwrap_or(std::cmp::Ordering::Equal) + }); + self.matchup_results = results; + if !self.matchup_results.is_empty() { + self.table_state.select(Some(0)); + } + self.sort_results(); + } + _ => {} + } + } + + // ========================================================================= + // Rendering + // ========================================================================= + + pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let chunks = Layout::vertical([ + Constraint::Length(3), // team selector + Constraint::Length(3), // pitcher selector + Constraint::Length(1), // pitcher info + Constraint::Min(0), // table + Constraint::Length(1), // hints + ]) + .split(area); + + self.team_selector.render_closed(frame, chunks[0]); + self.pitcher_selector.render_closed(frame, chunks[1]); + self.render_pitcher_info(frame, chunks[2]); + self.render_table(frame, chunks[3], tick_count); + self.render_hints(frame, chunks[4]); + + // Selector overlays + if self.team_selector.is_open { + self.team_selector.render_popup(frame, chunks[0]); + } + if self.pitcher_selector.is_open { + self.pitcher_selector.render_popup(frame, chunks[1]); + } + } + + fn render_pitcher_info(&self, frame: &mut Frame, area: Rect) { + let text = if let Some(pitcher) = &self.selected_pitcher { + let hand = pitcher.hand.as_deref().unwrap_or("?"); + let pos = pitcher.positions().join("/"); + format!(" vs {} ({}HP) — {}", pitcher.name, hand, pos) + } else { + String::new() + }; + let widget = Paragraph::new(text).style(Style::default().fg(Color::White)); + frame.render_widget(widget, area); + } + + fn render_table(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + if self.is_calculating { + let dots = ".".repeat((tick_count as usize / 2) % 4); + let loading = Paragraph::new(format!(" Calculating matchups{}", dots)) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(loading, area); + return; + } + + if self.matchup_results.is_empty() { + let msg = if self.team_selector.selected_value().is_none() { + " Select an opponent team to begin" + } else if self.pitcher_selector.selected_value().is_none() { + " Select a pitcher to calculate matchups" + } else { + " No matchup results" + }; + let widget = Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(widget, area); + return; + } + + let header = Row::new(vec![ + Cell::from("#"), + Cell::from("Name"), + Cell::from("H"), + Cell::from("Pos"), + Cell::from("Rating"), + Cell::from("Tier"), + Cell::from("sWAR"), + Cell::from("Split"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::White)); + + let rows: Vec = self + .matchup_results + .iter() + .enumerate() + .map(|(i, r)| { + let tier_style = tier_style(&r.tier); + Row::new(vec![ + Cell::from(format!("{}", i + 1)), + Cell::from(r.player.name.clone()), + Cell::from(r.batter_hand.clone()), + Cell::from(r.player.positions().join("/")), + Cell::from(r.rating_display()).style(tier_style), + Cell::from(r.tier.clone()).style(tier_style), + Cell::from(format_swar(r.player.swar)), + Cell::from(r.batter_split.clone()), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(3), // # + Constraint::Length(20), // Name + Constraint::Length(3), // Hand + Constraint::Length(14), // Pos + Constraint::Length(7), // Rating + Constraint::Length(5), // Tier + Constraint::Length(6), // sWAR + Constraint::Min(0), // Split + ]; + + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + let mut table_state = self.table_state; + frame.render_stateful_widget(table, area, &mut table_state); + } + + fn render_hints(&self, frame: &mut Frame, area: Rect) { + let hints = Paragraph::new(format!( + " [Tab] Focus [s/n/p] Sort ({}) [f] Refresh", + self.sort_mode.label() + )) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(hints, area); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +fn tier_style(tier: &str) -> Style { + match tier { + "A" => Style::default().fg(Color::Green), + "B" => Style::default().fg(Color::LightGreen), + "C" => Style::default().fg(Color::Yellow), + "D" => Style::default().fg(Color::LightRed), + "F" => Style::default().fg(Color::Red), + _ => Style::default().fg(Color::DarkGray), + } +} + +fn format_swar(val: Option) -> String { + match val { + Some(v) => format!("{:.1}", v), + None => "—".to_string(), + } } diff --git a/rust/src/screens/roster.rs b/rust/src/screens/roster.rs index 06c769e..dcd126b 100644 --- a/rust/src/screens/roster.rs +++ b/rust/src/screens/roster.rs @@ -1,6 +1,512 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Cell, Paragraph, Row, Table, TableState}, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Roster"); - frame.render_widget(widget, area); +use crate::app::{AppMessage, NotifyLevel}; +use crate::config::Settings; +use crate::db::models::Player; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RosterTab { + Majors, + Minors, + IL, +} + +#[derive(Debug, Clone)] +pub struct RosterRow { + pub player: Player, + pub rating_vl: Option, + pub rating_vr: Option, + pub rating_overall: Option, + pub endurance_start: Option, + pub endurance_relief: Option, + pub endurance_close: Option, +} + +// ============================================================================= +// State +// ============================================================================= + +pub struct RosterState { + pub team_abbrev: String, + pub season: i64, + pub major_slots: usize, + pub minor_slots: usize, + + // Data + pub majors: Vec, + pub minors: Vec, + pub il: Vec, + pub swar_total: f64, + pub swar_cap: Option, + pub team_name: String, + pub is_loading: bool, + + // UI + pub active_tab: RosterTab, + pub table_state: TableState, +} + +impl RosterState { + pub fn new(team_abbrev: String, season: i64, settings: &Settings) -> Self { + Self { + team_abbrev, + season, + major_slots: settings.team.major_league_slots, + minor_slots: settings.team.minor_league_slots, + majors: Vec::new(), + minors: Vec::new(), + il: Vec::new(), + swar_total: 0.0, + swar_cap: None, + team_name: String::new(), + is_loading: true, + active_tab: RosterTab::Majors, + table_state: TableState::default(), + } + } + + pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + self.is_loading = true; + + // Load team info + { + 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_team_by_abbrev(&pool, &abbrev, season).await { + Ok(Some(team)) => { + let name = if team.long_name.is_empty() { + team.short_name.clone() + } else { + team.long_name.clone() + }; + let _ = tx.send(AppMessage::TeamInfoLoaded(name, team.salary_cap)); + } + Ok(None) => { + let _ = tx.send(AppMessage::TeamInfoLoaded(abbrev, None)); + } + Err(e) => { + tracing::error!("Failed to load team info: {e}"); + } + } + }); + } + + // Load roster with card data + { + 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 mut swar_total = 0.0; + + let majors = build_roster_rows(&pool, &roster.majors, &mut swar_total).await; + let minors = build_roster_rows(&pool, &roster.minors, &mut swar_total).await; + let il = build_roster_rows(&pool, &roster.il, &mut swar_total).await; + + let _ = tx.send(AppMessage::RosterPlayersLoaded { + majors, + minors, + il, + swar_total, + }); + } + Err(e) => { + tracing::error!("Failed to load roster: {e}"); + let _ = tx.send(AppMessage::Notify( + format!("Failed to load roster: {e}"), + NotifyLevel::Error, + )); + } + } + }); + } + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + tx: &mpsc::UnboundedSender, + ) { + match key.code { + KeyCode::Char('1') => { + self.active_tab = RosterTab::Majors; + self.table_state = TableState::default(); + } + KeyCode::Char('2') => { + self.active_tab = RosterTab::Minors; + self.table_state = TableState::default(); + } + KeyCode::Char('3') => { + self.active_tab = RosterTab::IL; + self.table_state = TableState::default(); + } + KeyCode::Char('j') | KeyCode::Down => { + let len = self.active_rows().len(); + if len > 0 { + let i = self.table_state.selected().map_or(0, |i| (i + 1).min(len - 1)); + self.table_state.select(Some(i)); + } + } + KeyCode::Char('k') | KeyCode::Up => { + let len = self.active_rows().len(); + if len > 0 { + let i = self.table_state.selected().map_or(0, |i| i.saturating_sub(1)); + self.table_state.select(Some(i)); + } + } + KeyCode::Char('f') => { + self.mount(pool, tx); + } + _ => {} + } + } + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::RosterPlayersLoaded { + majors, + minors, + il, + swar_total, + } => { + self.majors = majors; + self.minors = minors; + self.il = il; + self.swar_total = swar_total; + self.is_loading = false; + self.table_state = TableState::default(); + } + AppMessage::TeamInfoLoaded(name, cap) => { + self.team_name = name; + self.swar_cap = cap; + } + _ => {} + } + } + + fn active_rows(&self) -> &[RosterRow] { + match self.active_tab { + RosterTab::Majors => &self.majors, + RosterTab::Minors => &self.minors, + RosterTab::IL => &self.il, + } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(1), // title + Constraint::Length(1), // tabs + Constraint::Min(0), // table + Constraint::Length(1), // hints + ]) + .split(area); + + self.render_title(frame, chunks[0]); + self.render_tabs(frame, chunks[1]); + self.render_table(frame, chunks[2]); + self.render_hints(frame, chunks[3]); + } + + fn render_title(&self, frame: &mut Frame, area: Rect) { + let cap_str = match self.swar_cap { + Some(cap) => format!(" / {:.1}", cap), + None => String::new(), + }; + let title = format!( + " Roster: {} — sWAR: {:.1}{}", + if self.team_name.is_empty() { + &self.team_abbrev + } else { + &self.team_name + }, + self.swar_total, + cap_str, + ); + let widget = Paragraph::new(title) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + frame.render_widget(widget, area); + } + + fn render_tabs(&self, frame: &mut Frame, area: Rect) { + let tabs = [ + (RosterTab::Majors, "1", "Majors", self.majors.len(), self.major_slots), + (RosterTab::Minors, "2", "Minors", self.minors.len(), self.minor_slots), + ]; + + let mut spans: Vec = Vec::new(); + for (tab, key, label, count, slots) in &tabs { + let is_active = self.active_tab == *tab; + let style = if is_active { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + spans.push(Span::styled( + format!(" [{}] {} ({}/{})", key, label, count, slots), + style, + )); + } + // IL tab (no slot limit) + let il_active = self.active_tab == RosterTab::IL; + let il_style = if il_active { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + spans.push(Span::styled( + format!(" [3] IL ({})", self.il.len()), + il_style, + )); + + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + fn render_table(&self, frame: &mut Frame, area: Rect) { + let rows_data = self.active_rows(); + + if self.is_loading { + let loading = Paragraph::new(" Loading roster...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(loading, area); + return; + } + + if rows_data.is_empty() { + let empty = Paragraph::new(" No players") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(empty, area); + return; + } + + // Split into batters and pitchers + let batters: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_batter()).collect(); + let pitchers: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_pitcher()).collect(); + + // Calculate split area + let batter_height = batters.len() as u16 + 2; // +2 for header + section label + let available = area.height; + + let table_chunks = Layout::vertical([ + Constraint::Length(1), // "Batters" label + Constraint::Length(batter_height.min(available / 2)), // batter table + Constraint::Length(1), // "Pitchers" label + Constraint::Min(0), // pitcher table + ]) + .split(area); + + // Batter section label + frame.render_widget( + Paragraph::new(" Batters") + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + table_chunks[0], + ); + + // Batter table + let batter_header = Row::new(vec![ + Cell::from("Name"), + Cell::from("H"), + Cell::from("Pos"), + Cell::from("vL"), + Cell::from("vR"), + Cell::from("Ovr"), + Cell::from("sWAR"), + Cell::from("Fld"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::White)); + + let batter_rows: Vec = batters + .iter() + .map(|r| { + Row::new(vec![ + Cell::from(r.player.name.clone()), + Cell::from(r.player.hand.clone().unwrap_or_default()), + Cell::from(r.player.positions().join("/")), + Cell::from(format_rating(r.rating_vl)), + Cell::from(format_rating(r.rating_vr)), + Cell::from(format_rating(r.rating_overall)), + Cell::from(format_swar(r.player.swar)), + Cell::from(String::new()), + ]) + }) + .collect(); + + let batter_table = Table::new( + batter_rows, + [ + Constraint::Length(20), // Name + Constraint::Length(3), // Hand + Constraint::Length(14), // Pos + Constraint::Length(6), // vL + Constraint::Length(6), // vR + Constraint::Length(6), // Ovr + Constraint::Length(6), // sWAR + Constraint::Min(0), // Fld + ], + ) + .header(batter_header) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_widget(batter_table, table_chunks[1]); + + // Pitcher section label + frame.render_widget( + Paragraph::new(" Pitchers") + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + table_chunks[2], + ); + + // Pitcher table + let pitcher_header = Row::new(vec![ + Cell::from("Name"), + Cell::from("H"), + Cell::from("Pos"), + Cell::from("vL"), + Cell::from("vR"), + Cell::from("Ovr"), + Cell::from("sWAR"), + Cell::from("S"), + Cell::from("R"), + Cell::from("C"), + ]) + .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::White)); + + let pitcher_rows: Vec = pitchers + .iter() + .map(|r| { + Row::new(vec![ + Cell::from(r.player.name.clone()), + Cell::from(r.player.hand.clone().unwrap_or_default()), + Cell::from(r.player.positions().join("/")), + Cell::from(format_rating(r.rating_vl)), + Cell::from(format_rating(r.rating_vr)), + Cell::from(format_rating(r.rating_overall)), + Cell::from(format_swar(r.player.swar)), + Cell::from(format_endurance(r.endurance_start)), + Cell::from(format_endurance(r.endurance_relief)), + Cell::from(format_endurance(r.endurance_close)), + ]) + }) + .collect(); + + let pitcher_table = Table::new( + pitcher_rows, + [ + Constraint::Length(20), // Name + Constraint::Length(3), // Hand + Constraint::Length(14), // Pos + Constraint::Length(6), // vL + Constraint::Length(6), // vR + Constraint::Length(6), // Ovr + Constraint::Length(6), // sWAR + Constraint::Length(3), // S + Constraint::Length(3), // R + Constraint::Length(3), // C + ], + ) + .header(pitcher_header) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_widget(pitcher_table, table_chunks[3]); + } + + fn render_hints(&self, frame: &mut Frame, area: Rect) { + let hints = Paragraph::new(" [1-3] Tab [j/k] Scroll [f] Refresh") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(hints, area); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +async fn build_roster_rows( + pool: &SqlitePool, + players: &[Player], + swar_total: &mut f64, +) -> Vec { + let mut rows = Vec::with_capacity(players.len()); + for player in players { + if let Some(swar) = player.swar { + *swar_total += swar; + } + + let (rating_vl, rating_vr, rating_overall, end_s, end_r, end_c) = if player.is_pitcher() { + match crate::db::queries::get_pitcher_card(pool, player.id).await { + Ok(Some(card)) => ( + card.rating_vlhb, + card.rating_vrhb, + card.rating_overall, + card.endurance_start, + card.endurance_relief, + card.endurance_close, + ), + _ => (None, None, None, None, None, None), + } + } else { + match crate::db::queries::get_batter_card(pool, player.id).await { + Ok(Some(card)) => (card.rating_vl, card.rating_vr, card.rating_overall, None, None, None), + _ => (None, None, None, None, None, None), + } + }; + + rows.push(RosterRow { + player: player.clone(), + rating_vl, + rating_vr, + rating_overall, + endurance_start: end_s, + endurance_relief: end_r, + endurance_close: end_c, + }); + } + rows +} + +fn format_rating(val: Option) -> String { + match val { + Some(v) => { + let rounded = v.round() as i64; + if rounded > 0 { + format!("+{}", rounded) + } else { + format!("{}", rounded) + } + } + None => "—".to_string(), + } +} + +fn format_swar(val: Option) -> String { + match val { + Some(v) => format!("{:.1}", v), + None => "—".to_string(), + } +} + +fn format_endurance(val: Option) -> String { + match val { + Some(v) => format!("{}", v), + None => "—".to_string(), + } } diff --git a/rust/src/screens/settings.rs b/rust/src/screens/settings.rs index d82f3e6..4cc5e92 100644 --- a/rust/src/screens/settings.rs +++ b/rust/src/screens/settings.rs @@ -1,6 +1,526 @@ -use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; +use sqlx::sqlite::SqlitePool; +use tokio::sync::mpsc; -pub fn render(frame: &mut Frame, area: Rect) { - let widget = Paragraph::new("Settings"); - frame.render_widget(widget, area); +use crate::app::{AppMessage, NotifyLevel}; +use crate::config::Settings; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsFocus { + TeamAbbrev, + TeamSeason, + MajorSlots, + MinorSlots, + ApiUrl, + ApiKey, + SaveButton, + ResetButton, +} + +impl SettingsFocus { + fn next(self) -> Self { + match self { + Self::TeamAbbrev => Self::TeamSeason, + Self::TeamSeason => Self::MajorSlots, + Self::MajorSlots => Self::MinorSlots, + Self::MinorSlots => Self::ApiUrl, + Self::ApiUrl => Self::ApiKey, + Self::ApiKey => Self::SaveButton, + Self::SaveButton => Self::ResetButton, + Self::ResetButton => Self::TeamAbbrev, + } + } + + fn prev(self) -> Self { + match self { + Self::TeamAbbrev => Self::ResetButton, + Self::TeamSeason => Self::TeamAbbrev, + Self::MajorSlots => Self::TeamSeason, + Self::MinorSlots => Self::MajorSlots, + Self::ApiUrl => Self::MinorSlots, + Self::ApiKey => Self::ApiUrl, + Self::SaveButton => Self::ApiKey, + Self::ResetButton => Self::SaveButton, + } + } + + fn is_text_input(self) -> bool { + matches!( + self, + Self::TeamAbbrev + | Self::TeamSeason + | Self::MajorSlots + | Self::MinorSlots + | Self::ApiUrl + | Self::ApiKey + ) + } +} + +// ============================================================================= +// State +// ============================================================================= + +pub struct SettingsState { + // Text inputs + pub abbrev: String, + pub season_str: String, + pub major_slots_str: String, + pub minor_slots_str: String, + pub base_url: String, + pub api_key: String, + pub api_key_visible: bool, + + // Validation + pub team_info: Option, + + // UI + pub focus: SettingsFocus, + pub has_unsaved_changes: bool, + + // Original settings for change detection + original: SettingsSnapshot, +} + +#[derive(Clone)] +struct SettingsSnapshot { + abbrev: String, + season_str: String, + major_slots_str: String, + minor_slots_str: String, + base_url: String, + api_key: String, +} + +impl SettingsState { + pub fn new(settings: &Settings) -> Self { + let snapshot = SettingsSnapshot { + abbrev: settings.team.abbrev.clone(), + season_str: settings.team.season.to_string(), + major_slots_str: settings.team.major_league_slots.to_string(), + minor_slots_str: settings.team.minor_league_slots.to_string(), + base_url: settings.api.base_url.clone(), + api_key: settings.api.api_key.clone(), + }; + + Self { + abbrev: snapshot.abbrev.clone(), + season_str: snapshot.season_str.clone(), + major_slots_str: snapshot.major_slots_str.clone(), + minor_slots_str: snapshot.minor_slots_str.clone(), + base_url: snapshot.base_url.clone(), + api_key: snapshot.api_key.clone(), + api_key_visible: false, + team_info: None, + focus: SettingsFocus::TeamAbbrev, + has_unsaved_changes: false, + original: snapshot, + } + } + + pub fn is_input_captured(&self) -> bool { + self.focus.is_text_input() + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + _settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + // Ctrl+S: save from anywhere + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { + self.save_settings(tx); + return; + } + + // Tab / Shift+Tab navigation + if key.code == KeyCode::Tab { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.focus = self.focus.prev(); + } else { + self.focus = self.focus.next(); + } + return; + } + + // Esc exits text input focus to save button + if key.code == KeyCode::Esc && self.focus.is_text_input() { + self.focus = SettingsFocus::SaveButton; + return; + } + + // Text input handling + if self.focus.is_text_input() { + match key.code { + KeyCode::Char('t') if self.focus == SettingsFocus::ApiKey => { + self.api_key_visible = !self.api_key_visible; + return; + } + KeyCode::Char(c) => { + self.insert_char(c); + self.check_unsaved(); + // Validate team on abbrev/season change + if matches!(self.focus, SettingsFocus::TeamAbbrev | SettingsFocus::TeamSeason) { + self.validate_team(pool, tx); + } + } + KeyCode::Backspace => { + self.delete_char(); + self.check_unsaved(); + if matches!(self.focus, SettingsFocus::TeamAbbrev | SettingsFocus::TeamSeason) { + self.validate_team(pool, tx); + } + } + _ => {} + } + return; + } + + // Button handling + match self.focus { + SettingsFocus::SaveButton => { + if key.code == KeyCode::Enter { + self.save_settings(tx); + } + } + SettingsFocus::ResetButton => { + if key.code == KeyCode::Enter { + self.reset_to_defaults(); + let _ = tx.send(AppMessage::Notify( + "Reset to defaults".to_string(), + NotifyLevel::Info, + )); + } + } + _ => {} + } + } + + fn insert_char(&mut self, c: char) { + match self.focus { + SettingsFocus::TeamAbbrev => self.abbrev.push(c), + SettingsFocus::TeamSeason => { + if c.is_ascii_digit() { + self.season_str.push(c); + } + } + SettingsFocus::MajorSlots => { + if c.is_ascii_digit() { + self.major_slots_str.push(c); + } + } + SettingsFocus::MinorSlots => { + if c.is_ascii_digit() { + self.minor_slots_str.push(c); + } + } + SettingsFocus::ApiUrl => self.base_url.push(c), + SettingsFocus::ApiKey => self.api_key.push(c), + _ => {} + } + } + + fn delete_char(&mut self) { + match self.focus { + SettingsFocus::TeamAbbrev => { self.abbrev.pop(); } + SettingsFocus::TeamSeason => { self.season_str.pop(); } + SettingsFocus::MajorSlots => { self.major_slots_str.pop(); } + SettingsFocus::MinorSlots => { self.minor_slots_str.pop(); } + SettingsFocus::ApiUrl => { self.base_url.pop(); } + SettingsFocus::ApiKey => { self.api_key.pop(); } + _ => {} + } + } + + fn check_unsaved(&mut self) { + self.has_unsaved_changes = self.abbrev != self.original.abbrev + || self.season_str != self.original.season_str + || self.major_slots_str != self.original.major_slots_str + || self.minor_slots_str != self.original.minor_slots_str + || self.base_url != self.original.base_url + || self.api_key != self.original.api_key; + } + + fn validate_team(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let abbrev = self.abbrev.clone(); + let season: i64 = self.season_str.parse().unwrap_or(0); + if abbrev.is_empty() || season == 0 { + return; + } + + let pool = pool.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + match crate::db::queries::get_team_by_abbrev(&pool, &abbrev, season).await { + Ok(Some(team)) => { + let name = if team.long_name.is_empty() { + team.short_name + } else { + team.long_name + }; + let _ = tx.send(AppMessage::SettingsTeamValidated(Some(( + name, + team.salary_cap, + )))); + } + Ok(None) => { + let _ = tx.send(AppMessage::SettingsTeamValidated(None)); + } + Err(_) => { + let _ = tx.send(AppMessage::SettingsTeamValidated(None)); + } + } + }); + } + + fn save_settings(&mut self, tx: &mpsc::UnboundedSender) { + let season: i32 = match self.season_str.parse() { + Ok(v) => v, + Err(_) => { + let _ = tx.send(AppMessage::Notify( + "Invalid season number".to_string(), + NotifyLevel::Error, + )); + return; + } + }; + let major_slots: usize = self.major_slots_str.parse().unwrap_or(26); + let minor_slots: usize = self.minor_slots_str.parse().unwrap_or(6); + + let settings = Settings { + db_path: std::path::PathBuf::from("data/sba_scout.db"), + api: crate::config::ApiSettings { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + timeout: 30, + }, + team: crate::config::TeamSettings { + abbrev: self.abbrev.clone(), + season, + major_league_slots: major_slots, + minor_league_slots: minor_slots, + }, + ui: crate::config::UiSettings::default(), + rating_weights: crate::config::RatingWeights::default(), + }; + + match toml::to_string_pretty(&settings) { + Ok(toml_str) => match std::fs::write("settings.toml", &toml_str) { + Ok(_) => { + self.has_unsaved_changes = false; + self.original = SettingsSnapshot { + abbrev: self.abbrev.clone(), + season_str: self.season_str.clone(), + major_slots_str: self.major_slots_str.clone(), + minor_slots_str: self.minor_slots_str.clone(), + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + }; + let _ = tx.send(AppMessage::Notify( + "Settings saved to settings.toml (restart to apply)".to_string(), + NotifyLevel::Success, + )); + } + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Failed to write settings.toml: {e}"), + NotifyLevel::Error, + )); + } + }, + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("Failed to serialize settings: {e}"), + NotifyLevel::Error, + )); + } + } + } + + fn reset_to_defaults(&mut self) { + let defaults = Settings::default(); + self.abbrev = defaults.team.abbrev; + self.season_str = defaults.team.season.to_string(); + self.major_slots_str = defaults.team.major_league_slots.to_string(); + self.minor_slots_str = defaults.team.minor_league_slots.to_string(); + self.base_url = defaults.api.base_url; + self.api_key = defaults.api.api_key; + self.has_unsaved_changes = true; + self.team_info = None; + } + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::SettingsTeamValidated(result) => { + self.team_info = match result { + Some((name, cap)) => { + let cap_str = cap.map(|c| format!(" (Cap: {:.1})", c)).unwrap_or_default(); + Some(format!("{}{}", name, cap_str)) + } + None => Some("Team not found".to_string()), + }; + } + _ => {} + } + } + + // ========================================================================= + // Rendering + // ========================================================================= + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Length(1), // title + Constraint::Length(1), // spacer + Constraint::Length(1), // "Team" section header + Constraint::Length(1), // abbrev + season + Constraint::Length(1), // team validation + Constraint::Length(1), // major/minor slots + Constraint::Length(1), // spacer + Constraint::Length(1), // "API" section header + Constraint::Length(1), // base URL + Constraint::Length(1), // api key + Constraint::Length(1), // spacer + Constraint::Length(1), // buttons + Constraint::Min(0), // spacer + Constraint::Length(1), // hints + ]) + .split(area); + + // Title + let title = if self.has_unsaved_changes { + " Settings *" + } else { + " Settings" + }; + frame.render_widget( + Paragraph::new(title) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + chunks[0], + ); + + // Team section + frame.render_widget( + Paragraph::new(" Team").style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + chunks[2], + ); + + // Abbrev + Season on same row + let team_row = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[3]); + + self.render_input(frame, team_row[0], "Abbreviation", &self.abbrev, SettingsFocus::TeamAbbrev, false); + self.render_input(frame, team_row[1], "Season", &self.season_str, SettingsFocus::TeamSeason, false); + + // Team validation + let team_info_style = if self.team_info.as_deref() == Some("Team not found") { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }; + let team_info_text = self + .team_info + .as_ref() + .map(|s| format!(" → {}", s)) + .unwrap_or_default(); + frame.render_widget(Paragraph::new(team_info_text).style(team_info_style), chunks[4]); + + // Major/Minor slots + let slots_row = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[5]); + self.render_input(frame, slots_row[0], "Major Slots", &self.major_slots_str, SettingsFocus::MajorSlots, false); + self.render_input(frame, slots_row[1], "Minor Slots", &self.minor_slots_str, SettingsFocus::MinorSlots, false); + + // API section + frame.render_widget( + Paragraph::new(" API").style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + chunks[7], + ); + + self.render_input(frame, chunks[8], "Base URL", &self.base_url, SettingsFocus::ApiUrl, false); + let api_key_display = if self.api_key_visible { + self.api_key.clone() + } else { + "•".repeat(self.api_key.len().min(20)) + }; + self.render_input(frame, chunks[9], "API Key", &api_key_display, SettingsFocus::ApiKey, true); + + // Buttons + let button_row = Layout::horizontal([ + Constraint::Length(12), + Constraint::Length(2), + Constraint::Length(22), + Constraint::Min(0), + ]) + .split(chunks[11]); + + let save_style = if self.focus == SettingsFocus::SaveButton { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let reset_style = if self.focus == SettingsFocus::ResetButton { + Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Yellow) + }; + + frame.render_widget(Paragraph::new(" [ Save ] ").style(save_style), button_row[0]); + frame.render_widget(Paragraph::new(" [ Reset to Defaults ]").style(reset_style), button_row[2]); + + // Hints + let hint_text = if self.focus == SettingsFocus::ApiKey { + " [Tab] Next [Shift+Tab] Prev [t] Toggle key [Ctrl+S] Save [Esc] Buttons" + } else if self.focus.is_text_input() { + " [Tab] Next [Shift+Tab] Prev [Ctrl+S] Save [Esc] Buttons" + } else { + " [Tab] Next [Enter] Activate [Ctrl+S] Save" + }; + frame.render_widget( + Paragraph::new(hint_text).style(Style::default().fg(Color::DarkGray)), + chunks[13], + ); + } + + fn render_input( + &self, + frame: &mut Frame, + area: Rect, + label: &str, + value: &str, + target_focus: SettingsFocus, + is_password: bool, + ) { + let is_focused = self.focus == target_focus; + let label_style = Style::default().fg(Color::DarkGray); + let value_style = if is_focused { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let cursor = if is_focused { "▏" } else { "" }; + let toggle_hint = if is_password && is_focused { " [t]" } else { "" }; + + let line = Line::from(vec![ + Span::styled(format!(" {}: ", label), label_style), + Span::styled(format!("[{}{}]", value, cursor), value_style), + Span::styled(toggle_hint, Style::default().fg(Color::DarkGray)), + ]); + + frame.render_widget(Paragraph::new(line), area); + } }