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<State> 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 <noreply@anthropic.com>
This commit is contained in:
parent
5968901cb2
commit
b6da926258
167
rust/src/app.rs
167
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<crate::api::sync::SyncResult, anyhow::Error>),
|
||||
|
||||
// Gameday — init
|
||||
// Gameday + Matchup — init
|
||||
CacheReady,
|
||||
CacheError(String),
|
||||
TeamsLoaded(Vec<Team>),
|
||||
MyBattersLoaded(Vec<(Player, Option<BatterCard>)>),
|
||||
LineupsLoaded(Vec<Lineup>),
|
||||
|
||||
// Gameday — on team select
|
||||
// Gameday + Matchup — on team select
|
||||
PitchersLoaded(Vec<Player>),
|
||||
|
||||
// Gameday — on pitcher select
|
||||
// Gameday + Matchup — on pitcher select
|
||||
MatchupsCalculated(Vec<MatchupResult>),
|
||||
|
||||
// Gameday — lineup
|
||||
// Gameday + Lineup — lineup ops
|
||||
LineupSaved(String),
|
||||
LineupSaveError(String),
|
||||
|
||||
// Roster
|
||||
RosterPlayersLoaded {
|
||||
majors: Vec<RosterRow>,
|
||||
minors: Vec<RosterRow>,
|
||||
il: Vec<RosterRow>,
|
||||
swar_total: f64,
|
||||
},
|
||||
|
||||
// Lineup (standalone)
|
||||
LineupBattersLoaded(Vec<(Player, Option<BatterCard>)>),
|
||||
LineupListLoaded(Vec<Lineup>),
|
||||
LineupDeleted(String),
|
||||
LineupDeleteError(String),
|
||||
|
||||
// Settings
|
||||
SettingsTeamValidated(Option<(String, Option<f64>)>),
|
||||
|
||||
// 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<DashboardState>),
|
||||
Gameday(Box<GamedayState>),
|
||||
Roster(Box<RosterState>),
|
||||
Matchup(Box<MatchupState>),
|
||||
Lineup(Box<LineupState>),
|
||||
Settings(Box<SettingsState>),
|
||||
}
|
||||
|
||||
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<AppMessage>,
|
||||
) -> 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<Span> = 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);
|
||||
}
|
||||
|
||||
@ -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<i64>,
|
||||
pub player_name: Option<String>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
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<BatterCard>)>,
|
||||
pub lineup_slots: [LineupSlot; 9],
|
||||
pub saved_lineups: Vec<Lineup>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<AppMessage>) {
|
||||
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<AppMessage>,
|
||||
) {
|
||||
// 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<AppMessage>,
|
||||
) {
|
||||
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<BatterCard>)> {
|
||||
let lineup_ids: Vec<i64> = 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<String> = 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<String> = self
|
||||
.available_batters
|
||||
.iter()
|
||||
.find(|(p, _)| p.id == pid)
|
||||
.map(|(p, _)| {
|
||||
let mut positions: Vec<String> =
|
||||
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<AppMessage>) {
|
||||
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<i64> = self
|
||||
.lineup_slots
|
||||
.iter()
|
||||
.filter_map(|s| s.player_id)
|
||||
.collect();
|
||||
|
||||
let mut positions: HashMap<String, i64> = 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<AppMessage>) {
|
||||
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<AppMessage>,
|
||||
) {
|
||||
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<Row> = 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() {
|
||||
"<unnamed>"
|
||||
} 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<Row> = 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<f64>) -> 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<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<i64>,
|
||||
pub pitcher_selector: SelectorWidget<i64>,
|
||||
|
||||
// Data
|
||||
pub my_batters: Vec<(Player, Option<BatterCard>)>,
|
||||
pub matchup_results: Vec<MatchupResult>,
|
||||
pub selected_pitcher: Option<Player>,
|
||||
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<AppMessage>) {
|
||||
// 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<AppMessage>,
|
||||
) {
|
||||
// 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<AppMessage>) {
|
||||
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<AppMessage>) {
|
||||
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<AppMessage>,
|
||||
) {
|
||||
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<Row> = 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<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<f64>,
|
||||
pub rating_vr: Option<f64>,
|
||||
pub rating_overall: Option<f64>,
|
||||
pub endurance_start: Option<i64>,
|
||||
pub endurance_relief: Option<i64>,
|
||||
pub endurance_close: Option<i64>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State
|
||||
// =============================================================================
|
||||
|
||||
pub struct RosterState {
|
||||
pub team_abbrev: String,
|
||||
pub season: i64,
|
||||
pub major_slots: usize,
|
||||
pub minor_slots: usize,
|
||||
|
||||
// Data
|
||||
pub majors: Vec<RosterRow>,
|
||||
pub minors: Vec<RosterRow>,
|
||||
pub il: Vec<RosterRow>,
|
||||
pub swar_total: f64,
|
||||
pub swar_cap: Option<f64>,
|
||||
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<AppMessage>) {
|
||||
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<AppMessage>,
|
||||
) {
|
||||
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<Span> = 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<Row> = 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<Row> = 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<RosterRow> {
|
||||
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<f64>) -> 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<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_endurance(val: Option<i64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>,
|
||||
|
||||
// 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<AppMessage>,
|
||||
) {
|
||||
// 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<AppMessage>) {
|
||||
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<AppMessage>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user