diff --git a/rust/src/app.rs b/rust/src/app.rs index 6dc452e..9ea3463 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -145,6 +145,8 @@ pub struct App { pub tx: mpsc::UnboundedSender, pub notification: Option, pub tick_count: u64, + /// Cached matchup state so navigating away and back preserves selections. + cached_matchup: Option>, } impl App { @@ -165,6 +167,7 @@ impl App { tx, notification: None, tick_count: 0, + cached_matchup: None, } } @@ -270,7 +273,24 @@ impl App { } } + /// If leaving the Matchup screen, save its state for later restoration. + fn cache_matchup_if_active(&mut self) { + if matches!(&self.screen, ActiveScreen::Matchup(_)) { + // Swap screen with a temporary placeholder, extract the matchup state + let placeholder = ActiveScreen::Dashboard(Box::new(DashboardState::new( + String::new(), + 0, + &self.settings, + ))); + let old = std::mem::replace(&mut self.screen, placeholder); + if let ActiveScreen::Matchup(state) = old { + self.cached_matchup = Some(state); + } + } + } + fn switch_to_dashboard(&mut self) { + self.cache_matchup_if_active(); let mut state = DashboardState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, @@ -281,6 +301,7 @@ impl App { } fn switch_to_gameday(&mut self) { + self.cache_matchup_if_active(); let mut state = GamedayState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, @@ -290,6 +311,7 @@ impl App { } fn switch_to_roster(&mut self) { + self.cache_matchup_if_active(); let mut state = RosterState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, @@ -300,6 +322,11 @@ impl App { } fn switch_to_matchup(&mut self) { + // Restore cached state if available (preserves opponent/pitcher selection) + if let Some(state) = self.cached_matchup.take() { + self.screen = ActiveScreen::Matchup(state); + return; + } let mut state = MatchupState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, @@ -309,6 +336,7 @@ impl App { } fn switch_to_lineup(&mut self) { + self.cache_matchup_if_active(); let mut state = LineupState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, @@ -318,6 +346,7 @@ impl App { } fn switch_to_settings(&mut self) { + self.cache_matchup_if_active(); let state = SettingsState::new(&self.settings); self.screen = ActiveScreen::Settings(Box::new(state)); } diff --git a/rust/src/screens/lineup.rs b/rust/src/screens/lineup.rs index b88dfd6..4f55e44 100644 --- a/rust/src/screens/lineup.rs +++ b/rust/src/screens/lineup.rs @@ -72,6 +72,7 @@ pub struct LineupState { pub lineup_name: String, pub lineup_name_cursor: usize, pub load_selector: SelectorWidget, + pub confirm_delete: bool, } impl LineupState { @@ -90,11 +91,12 @@ impl LineupState { lineup_name: String::new(), lineup_name_cursor: 0, load_selector: SelectorWidget::new("Load Lineup"), + confirm_delete: false, } } pub fn is_input_captured(&self) -> bool { - matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open + matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open || self.confirm_delete } pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { @@ -141,6 +143,20 @@ impl LineupState { _settings: &Settings, tx: &mpsc::UnboundedSender, ) { + // Delete confirmation + if self.confirm_delete { + self.confirm_delete = false; + if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') { + self.delete_lineup(pool, tx); + } else { + let _ = tx.send(AppMessage::Notify( + "Delete cancelled".to_string(), + NotifyLevel::Info, + )); + } + return; + } + // Load selector if self.load_selector.is_open { if let SelectorEvent::Selected(_) = self.load_selector.handle_key(key) { @@ -190,10 +206,43 @@ impl LineupState { return; } + // Lineup-wide keys (work from any focus except text input) + match key.code { + KeyCode::Char('l') => { + self.refresh_load_selector(); + self.focus = LineupFocus::LoadSelector; + self.load_selector.is_focused = true; + self.load_selector.is_open = true; + return; + } + KeyCode::Char('D') => { + if self.lineup_name.is_empty() { + let _ = tx.send(AppMessage::Notify( + "No lineup loaded to delete".to_string(), + NotifyLevel::Error, + )); + } else { + self.confirm_delete = true; + let _ = tx.send(AppMessage::Notify( + format!("Delete '{}'? Press y to confirm", self.lineup_name), + NotifyLevel::Info, + )); + } + return; + } + KeyCode::Char('c') => { + for i in 0..9 { + self.lineup_slots[i] = LineupSlot::empty(i + 1); + } + 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::LineupTable => self.handle_lineup_table_key(key), LineupFocus::LoadSelector => match key.code { KeyCode::Enter | KeyCode::Char(' ') => { self.refresh_load_selector(); @@ -241,12 +290,7 @@ impl LineupState { } } - fn handle_lineup_table_key( - &mut self, - key: KeyEvent, - pool: &SqlitePool, - tx: &mpsc::UnboundedSender, - ) { + fn handle_lineup_table_key(&mut self, key: KeyEvent) { let sel = self.lineup_table_state.selected().unwrap_or(0); match key.code { KeyCode::Char('j') | KeyCode::Down => { @@ -280,20 +324,6 @@ impl LineupState { 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); - } - } _ => {} } } @@ -594,9 +624,15 @@ impl LineupState { self.render_available_panel(frame, panels[0]); self.render_lineup_panel(frame, panels[1]); - // Load selector overlay + // Load selector overlay (anchor at top of right panel so popup renders over it) if self.load_selector.is_open { - self.load_selector.render_popup(frame, panels[1]); + let anchor = Rect { + x: panels[1].x, + y: panels[1].y, + width: panels[1].width, + height: 0, + }; + self.load_selector.render_popup(frame, anchor); } } @@ -807,7 +843,7 @@ fn format_rating(val: Option) -> String { fn format_swar(val: Option) -> String { match val { - Some(v) => format!("{:.1}", v), + Some(v) => format!("{:.2}", v), None => "—".to_string(), } } diff --git a/rust/src/screens/matchup.rs b/rust/src/screens/matchup.rs index 3786ab1..a2aba50 100644 --- a/rust/src/screens/matchup.rs +++ b/rust/src/screens/matchup.rs @@ -70,7 +70,7 @@ pub struct MatchupState { impl MatchupState { pub fn new(team_abbrev: String, season: i64) -> Self { - Self { + let mut state = Self { team_abbrev, season, team_selector: SelectorWidget::new("Opponent"), @@ -84,7 +84,9 @@ impl MatchupState { focus: MatchupFocus::TeamSelector, table_state: TableState::default(), sort_mode: SortMode::Rating, - } + }; + state.team_selector.is_focused = true; + state } pub fn is_input_captured(&self) -> bool { @@ -498,7 +500,7 @@ impl MatchupState { fn render_hints(&self, frame: &mut Frame, area: Rect) { let hints = Paragraph::new(format!( - " [Tab] Focus [s/n/p] Sort ({}) [f] Refresh", + " [Tab] Focus [j/k] Scroll Sort: [s]core [n]ame [p]os ({}) [f] Refresh", self.sort_mode.label() )) .style(Style::default().fg(Color::DarkGray)); @@ -523,7 +525,7 @@ fn tier_style(tier: &str) -> Style { fn format_swar(val: Option) -> String { match val { - Some(v) => format!("{:.1}", v), + Some(v) => format!("{:.2}", v), None => "—".to_string(), } } diff --git a/rust/src/screens/roster.rs b/rust/src/screens/roster.rs index dcd126b..0da3de2 100644 --- a/rust/src/screens/roster.rs +++ b/rust/src/screens/roster.rs @@ -119,8 +119,9 @@ impl RosterState { 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 mut _unused = 0.0; + let minors = build_roster_rows(&pool, &roster.minors, &mut _unused).await; + let il = build_roster_rows(&pool, &roster.il, &mut _unused).await; let _ = tx.send(AppMessage::RosterPlayersLoaded { majors, @@ -192,6 +193,10 @@ impl RosterState { self.majors = majors; self.minors = minors; self.il = il; + // Sort each list: batters first, then pitchers (matches render order) + for list in [&mut self.majors, &mut self.minors, &mut self.il] { + list.sort_by_key(|r| if r.player.is_batter() { 0 } else { 1 }); + } self.swar_total = swar_total; self.is_loading = false; self.table_state = TableState::default(); @@ -229,11 +234,11 @@ impl RosterState { fn render_title(&self, frame: &mut Frame, area: Rect) { let cap_str = match self.swar_cap { - Some(cap) => format!(" / {:.1}", cap), + Some(cap) => format!(" / {:.2}", cap), None => String::new(), }; let title = format!( - " Roster: {} — sWAR: {:.1}{}", + " Roster: {} — sWAR: {:.2}{}", if self.team_name.is_empty() { &self.team_abbrev } else { @@ -298,10 +303,19 @@ impl RosterState { return; } - // Split into batters and pitchers + // Split into batters and pitchers (rows are pre-sorted: batters first) 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(); + // Map combined selection index to the correct sub-table + let selected = self.table_state.selected(); + let batter_count = batters.len(); + let (batter_selected, pitcher_selected) = match selected { + Some(i) if i < batter_count => (Some(i), None), + Some(i) => (None, Some(i - batter_count)), + None => (None, None), + }; + // Calculate split area let batter_height = batters.len() as u16 + 2; // +2 for header + section label let available = area.height; @@ -366,7 +380,9 @@ impl RosterState { .header(batter_header) .row_highlight_style(Style::default().bg(Color::DarkGray)); - frame.render_widget(batter_table, table_chunks[1]); + let mut batter_state = TableState::default(); + batter_state.select(batter_selected); + frame.render_stateful_widget(batter_table, table_chunks[1], &mut batter_state); // Pitcher section label frame.render_widget( @@ -426,7 +442,9 @@ impl RosterState { .header(pitcher_header) .row_highlight_style(Style::default().bg(Color::DarkGray)); - frame.render_widget(pitcher_table, table_chunks[3]); + let mut pitcher_state = TableState::default(); + pitcher_state.select(pitcher_selected); + frame.render_stateful_widget(pitcher_table, table_chunks[3], &mut pitcher_state); } fn render_hints(&self, frame: &mut Frame, area: Rect) { @@ -499,7 +517,7 @@ fn format_rating(val: Option) -> String { fn format_swar(val: Option) -> String { match val { - Some(v) => format!("{:.1}", v), + Some(v) => format!("{:.2}", v), None => "—".to_string(), } } diff --git a/rust/src/screens/settings.rs b/rust/src/screens/settings.rs index 4cc5e92..c02faf4 100644 --- a/rust/src/screens/settings.rs +++ b/rust/src/screens/settings.rs @@ -165,7 +165,10 @@ impl SettingsState { // Text input handling if self.focus.is_text_input() { match key.code { - KeyCode::Char('t') if self.focus == SettingsFocus::ApiKey => { + KeyCode::Char('t') + if self.focus == SettingsFocus::ApiKey + && key.modifiers.contains(KeyModifiers::CONTROL) => + { self.api_key_visible = !self.api_key_visible; return; } @@ -367,7 +370,7 @@ impl SettingsState { AppMessage::SettingsTeamValidated(result) => { self.team_info = match result { Some((name, cap)) => { - let cap_str = cap.map(|c| format!(" (Cap: {:.1})", c)).unwrap_or_default(); + let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default(); Some(format!("{}{}", name, cap_str)) } None => Some("Team not found".to_string()), @@ -401,11 +404,7 @@ impl SettingsState { .split(area); // Title - let title = if self.has_unsaved_changes { - " Settings *" - } else { - " Settings" - }; + let title = " Settings"; frame.render_widget( Paragraph::new(title) .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), @@ -422,8 +421,8 @@ impl SettingsState { 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); + self.render_input(frame, team_row[0], "Abbreviation", &self.abbrev, SettingsFocus::TeamAbbrev, false, self.abbrev != self.original.abbrev); + self.render_input(frame, team_row[1], "Season", &self.season_str, SettingsFocus::TeamSeason, false, self.season_str != self.original.season_str); // Team validation let team_info_style = if self.team_info.as_deref() == Some("Team not found") { @@ -441,8 +440,8 @@ impl SettingsState { // 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); + self.render_input(frame, slots_row[0], "Major Slots", &self.major_slots_str, SettingsFocus::MajorSlots, false, self.major_slots_str != self.original.major_slots_str); + self.render_input(frame, slots_row[1], "Minor Slots", &self.minor_slots_str, SettingsFocus::MinorSlots, false, self.minor_slots_str != self.original.minor_slots_str); // API section frame.render_widget( @@ -450,13 +449,13 @@ impl SettingsState { chunks[7], ); - self.render_input(frame, chunks[8], "Base URL", &self.base_url, SettingsFocus::ApiUrl, false); + self.render_input(frame, chunks[8], "Base URL", &self.base_url, SettingsFocus::ApiUrl, false, self.base_url != self.original.base_url); 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); + self.render_input(frame, chunks[9], "API Key", &api_key_display, SettingsFocus::ApiKey, true, self.api_key != self.original.api_key); // Buttons let button_row = Layout::horizontal([ @@ -482,17 +481,21 @@ impl SettingsState { 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" + let hint_base = if self.focus == SettingsFocus::ApiKey { + " [Tab] Next [Shift+Tab] Prev [Ctrl+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], - ); + let mut spans = vec![Span::styled(hint_base, Style::default().fg(Color::DarkGray))]; + if self.has_unsaved_changes { + spans.push(Span::styled( + " * unsaved changes", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), chunks[13]); } fn render_input( @@ -503,6 +506,7 @@ impl SettingsState { value: &str, target_focus: SettingsFocus, is_password: bool, + changed: bool, ) { let is_focused = self.focus == target_focus; let label_style = Style::default().fg(Color::DarkGray); @@ -513,10 +517,12 @@ impl SettingsState { }; let cursor = if is_focused { "▏" } else { "" }; - let toggle_hint = if is_password && is_focused { " [t]" } else { "" }; + let change_marker = if changed { "* " } else { " " }; + let toggle_hint = if is_password && is_focused { " [Ctrl+T]" } else { "" }; let line = Line::from(vec![ - Span::styled(format!(" {}: ", label), label_style), + Span::styled(change_marker, Style::default().fg(Color::Yellow)), + Span::styled(format!(" {}: ", label), label_style), Span::styled(format!("[{}{}]", value, cursor), value_style), Span::styled(toggle_hint, Style::default().fg(Color::DarkGray)), ]);