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:
Cal Corum 2026-02-28 14:29:50 -06:00
parent 5968901cb2
commit b6da926258
5 changed files with 2490 additions and 65 deletions

View File

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

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

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