sba-scouting/rust/src/screens/gameday.rs
Cal Corum c5e1fb44a6 Simplify and deduplicate codebase (-261 lines)
Consolidate shared helpers (format_rating, format_swar, tier_style,
format_relative_time) into widgets/mod.rs and screens/mod.rs. Replace
heap allocations with stack arrays and HashSets, parallelize DB queries
with tokio::try_join, wrap schema init in transactions, use OnceLock for
invariant hashes, and fix clippy warnings. Auto-sync on dashboard mount
when last sync >24h ago. All 105 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:07:23 -06:00

983 lines
34 KiB
Rust

use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
Frame,
};
use sqlx::sqlite::SqlitePool;
use tokio::sync::mpsc;
use crate::app::{AppMessage, NotifyLevel};
use crate::calc::matchup::MatchupResult;
use crate::config::Settings;
use crate::db::models::{BatterCard, Lineup, Player};
use crate::widgets::selector::{SelectorEvent, SelectorWidget};
// =============================================================================
// Focus & slot types
// =============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GamedayFocus {
TeamSelector,
PitcherSelector,
MatchupTable,
LineupName,
LineupTable,
LoadSelector,
}
#[derive(Debug, Clone)]
pub struct LineupSlot {
pub order: usize,
pub player_id: Option<i64>,
pub player_name: Option<String>,
pub position: Option<String>,
pub matchup_rating: Option<f64>,
pub matchup_tier: Option<String>,
}
impl LineupSlot {
pub fn empty(order: usize) -> Self {
Self {
order,
player_id: None,
player_name: None,
position: None,
matchup_rating: None,
matchup_tier: None,
}
}
pub fn is_empty(&self) -> bool {
self.player_id.is_none()
}
}
// =============================================================================
// Gameday state
// =============================================================================
pub struct GamedayState {
pub team_abbrev: String,
pub season: i64,
// Selectors
pub team_selector: SelectorWidget<i64>,
pub pitcher_selector: SelectorWidget<i64>,
pub load_selector: SelectorWidget<String>,
// Data
pub my_batters: Vec<(Player, Option<BatterCard>)>,
pub matchup_results: Vec<MatchupResult>,
// Matchup table
pub matchup_table_state: TableState,
// Lineup
pub lineup_slots: [LineupSlot; 9],
pub lineup_name: String,
pub lineup_name_cursor: usize,
pub saved_lineups: Vec<Lineup>,
pub lineup_table_state: TableState,
// Focus
pub focus: GamedayFocus,
// Loading flags
pub is_loading_teams: bool,
pub is_loading_pitchers: bool,
pub is_calculating: bool,
pub cache_ready: bool,
}
impl GamedayState {
pub fn new(team_abbrev: String, season: i64) -> Self {
let lineup_slots = std::array::from_fn(|i| LineupSlot::empty(i + 1));
Self {
team_abbrev,
season,
team_selector: SelectorWidget::new("Opponent"),
pitcher_selector: SelectorWidget::new("Pitcher"),
load_selector: SelectorWidget::new("Load Lineup"),
my_batters: Vec::new(),
matchup_results: Vec::new(),
matchup_table_state: TableState::default(),
lineup_slots,
lineup_name: String::new(),
lineup_name_cursor: 0,
saved_lineups: Vec::new(),
lineup_table_state: TableState::default().with_selected(0),
focus: GamedayFocus::MatchupTable,
is_loading_teams: true,
is_loading_pitchers: false,
is_calculating: false,
cache_ready: false,
}
}
/// Returns true if a text input or selector popup is capturing keys.
pub fn is_input_captured(&self) -> bool {
matches!(self.focus, GamedayFocus::LineupName)
|| self.team_selector.is_open
|| self.pitcher_selector.is_open
|| self.load_selector.is_open
}
// =========================================================================
// Mount — fire background loads
// =========================================================================
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<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 {
if let Ok(Some(team)) =
crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
&& let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{
let mut with_cards = Vec::with_capacity(batters.len());
for p in batters {
let card = crate::db::queries::get_batter_card(&pool_c, p.id)
.await
.ok()
.flatten();
with_cards.push((p, card));
}
let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards));
}
});
// Load saved lineups
let pool_c = pool.clone();
let tx_c = tx.clone();
tokio::spawn(async move {
if let Ok(lineups) = crate::db::queries::get_lineups(&pool_c).await {
let _ = tx_c.send(AppMessage::LineupsLoaded(lineups));
}
});
}
// =========================================================================
// Key handling
// =========================================================================
pub fn handle_key(
&mut self,
key: KeyEvent,
pool: &SqlitePool,
_settings: &Settings,
tx: &mpsc::UnboundedSender<AppMessage>,
) {
// Try selectors first (they consume keys when open)
if self.team_selector.is_open {
if let SelectorEvent::Selected(_) = self.team_selector.handle_key(key) {
self.on_team_selected(pool, tx);
}
return;
}
if self.pitcher_selector.is_open {
if let SelectorEvent::Selected(_) = self.pitcher_selector.handle_key(key) {
self.on_pitcher_selected(pool, tx);
}
return;
}
if self.load_selector.is_open {
if let SelectorEvent::Selected(_) = self.load_selector.handle_key(key) {
self.on_lineup_load_selected();
}
return;
}
// Lineup name text input
if self.focus == GamedayFocus::LineupName {
match key.code {
KeyCode::Esc | KeyCode::Tab => {
self.focus = GamedayFocus::LineupTable;
}
KeyCode::Char(c) => {
self.lineup_name.insert(self.lineup_name_cursor, c);
self.lineup_name_cursor += 1;
}
KeyCode::Backspace => {
if self.lineup_name_cursor > 0 {
self.lineup_name_cursor -= 1;
self.lineup_name.remove(self.lineup_name_cursor);
}
}
KeyCode::Left => {
self.lineup_name_cursor = self.lineup_name_cursor.saturating_sub(1);
}
KeyCode::Right => {
if self.lineup_name_cursor < self.lineup_name.len() {
self.lineup_name_cursor += 1;
}
}
_ => {}
}
return;
}
// Ctrl-S: save lineup
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
self.save_lineup(pool, tx);
return;
}
// Tab: cycle focus
if key.code == KeyCode::Tab {
self.cycle_focus();
return;
}
// Focus-specific keys
match self.focus {
GamedayFocus::TeamSelector => match key.code {
KeyCode::Enter | KeyCode::Char(' ') => {
self.team_selector.is_open = true;
self.team_selector.is_focused = true;
}
_ => {}
},
GamedayFocus::PitcherSelector => match key.code {
KeyCode::Enter | KeyCode::Char(' ') => {
self.pitcher_selector.is_open = true;
self.pitcher_selector.is_focused = true;
}
_ => {}
},
GamedayFocus::MatchupTable => self.handle_matchup_table_key(key),
GamedayFocus::LineupTable => self.handle_lineup_table_key(key, pool, tx),
GamedayFocus::LoadSelector => match key.code {
KeyCode::Enter | KeyCode::Char(' ') => {
self.load_selector.is_open = true;
self.load_selector.is_focused = true;
}
_ => {}
},
GamedayFocus::LineupName => {} // handled above
}
}
fn cycle_focus(&mut self) {
self.team_selector.is_focused = false;
self.pitcher_selector.is_focused = false;
self.load_selector.is_focused = false;
self.focus = match self.focus {
GamedayFocus::MatchupTable => GamedayFocus::LineupTable,
GamedayFocus::LineupTable => GamedayFocus::LineupName,
GamedayFocus::LineupName => GamedayFocus::TeamSelector,
GamedayFocus::TeamSelector => GamedayFocus::PitcherSelector,
GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector,
GamedayFocus::LoadSelector => GamedayFocus::MatchupTable,
};
self.team_selector.is_focused = self.focus == GamedayFocus::TeamSelector;
self.pitcher_selector.is_focused = self.focus == GamedayFocus::PitcherSelector;
self.load_selector.is_focused = self.focus == GamedayFocus::LoadSelector;
}
fn handle_matchup_table_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
let len = self.matchup_results.len();
if len > 0 {
let i = self.matchup_table_state.selected().unwrap_or(0);
self.matchup_table_state.select(Some((i + 1) % len));
}
}
KeyCode::Char('k') | KeyCode::Up => {
let len = self.matchup_results.len();
if len > 0 {
let i = self.matchup_table_state.selected().unwrap_or(0);
self.matchup_table_state
.select(Some(i.checked_sub(1).unwrap_or(len - 1)));
}
}
KeyCode::Enter => {
self.add_selected_to_lineup();
}
_ => {}
}
}
fn handle_lineup_table_key(
&mut self,
key: KeyEvent,
pool: &SqlitePool,
tx: &mpsc::UnboundedSender<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') => {
// Remove player from slot
self.lineup_slots[sel] = LineupSlot::empty(sel + 1);
}
KeyCode::Char('p') => {
// Cycle position
self.cycle_position(sel);
}
KeyCode::Char('J') => {
// Move slot down
if sel < 8 {
self.lineup_slots.swap(sel, sel + 1);
// Fix order numbers
self.lineup_slots[sel].order = sel + 1;
self.lineup_slots[sel + 1].order = sel + 2;
self.lineup_table_state.select(Some(sel + 1));
}
}
KeyCode::Char('K') => {
// Move slot up
if sel > 0 {
self.lineup_slots.swap(sel - 1, sel);
self.lineup_slots[sel - 1].order = sel;
self.lineup_slots[sel].order = sel + 1;
self.lineup_table_state.select(Some(sel - 1));
}
}
KeyCode::Char('l') => {
// Open load selector
self.refresh_load_selector();
self.focus = GamedayFocus::LoadSelector;
self.load_selector.is_focused = true;
self.load_selector.is_open = true;
}
KeyCode::Enter => {
// Add from matchup table to this slot
self.add_to_specific_slot(sel);
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.save_lineup(pool, tx);
}
_ => {}
}
}
// =========================================================================
// Lineup operations
// =========================================================================
fn add_selected_to_lineup(&mut self) {
let Some(slot_idx) = self.lineup_slots.iter().position(|s| s.is_empty()) else {
return; // lineup full
};
self.add_to_specific_slot(slot_idx);
}
fn add_to_specific_slot(&mut self, slot_idx: usize) {
let Some(matchup_idx) = self.matchup_table_state.selected() else {
return;
};
let Some(result) = self.matchup_results.get(matchup_idx) else {
return;
};
let pid = result.player.id;
if self.lineup_slots.iter().any(|s| s.player_id == Some(pid)) {
return;
}
self.lineup_slots[slot_idx] = LineupSlot {
order: slot_idx + 1,
player_id: Some(pid),
player_name: Some(result.player.name.clone()),
position: result.player.pos_1.clone(),
matchup_rating: result.rating,
matchup_tier: Some(result.tier.clone()),
};
}
fn cycle_position(&mut self, slot_idx: usize) {
let slot = &mut self.lineup_slots[slot_idx];
let Some(pid) = slot.player_id else { return };
// Get player's eligible positions
let eligible: Vec<String> = self
.my_batters
.iter()
.find(|(p, _)| p.id == pid)
.map(|(p, _)| p.positions().iter().map(|s| s.to_string()).collect())
.unwrap_or_default();
if eligible.is_empty() {
return;
}
let current = slot.position.as_deref().unwrap_or("");
let current_idx = eligible.iter().position(|p| p == current).unwrap_or(0);
let next_idx = (current_idx + 1) % eligible.len();
slot.position = Some(eligible[next_idx].clone());
}
fn refresh_load_selector(&mut self) {
let items: Vec<(String, String)> = self
.saved_lineups
.iter()
.map(|l| (l.name.clone(), l.name.clone()))
.collect();
self.load_selector.set_items(items);
}
fn on_lineup_load_selected(&mut self) {
let Some(name) = self.load_selector.selected_value().cloned() else {
return;
};
let Some(lineup) = self.saved_lineups.iter().find(|l| l.name == name) else {
return;
};
self.lineup_name = lineup.name.clone();
self.lineup_name_cursor = self.lineup_name.len();
let order = lineup.batting_order_vec();
let positions_map = lineup.positions_map();
// Clear all slots
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
// Populate from saved lineup
for (i, pid) in order.iter().enumerate() {
if i >= 9 {
break;
}
let player = self.my_batters.iter().find(|(p, _)| p.id == *pid);
if let Some((p, _)) = player {
// Find position from map or default to pos_1
let pos = positions_map
.iter()
.find(|(_, v)| **v == *pid)
.map(|(k, _)| k.clone())
.or_else(|| p.pos_1.clone());
// Find matchup data if available
let matchup = self.matchup_results.iter().find(|r| r.player.id == *pid);
self.lineup_slots[i] = LineupSlot {
order: i + 1,
player_id: Some(*pid),
player_name: Some(p.name.clone()),
position: pos,
matchup_rating: matchup.and_then(|m| m.rating),
matchup_tier: matchup.map(|m| m.tier.clone()),
};
}
}
self.focus = GamedayFocus::LineupTable;
}
fn save_lineup(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<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 pitcher_id = self.pitcher_selector.selected_value().copied();
let name = self.lineup_name.clone();
let pool = pool.clone();
let tx = tx.clone();
tokio::spawn(async move {
match crate::db::queries::save_lineup(
&pool,
&name,
&batting_order,
&positions,
"gameday",
None,
pitcher_id,
)
.await
{
Ok(_) => {
let _ = tx.send(AppMessage::LineupSaved(name));
}
Err(e) => {
let _ = tx.send(AppMessage::LineupSaveError(format!("{e}")));
}
}
});
}
// =========================================================================
// Cascading selectors
// =========================================================================
fn on_team_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
let Some(&team_id) = self.team_selector.selected_value() else {
return;
};
// Clear pitcher selector and matchups
self.pitcher_selector.set_items(Vec::new());
self.matchup_results.clear();
self.is_loading_pitchers = true;
let pool = pool.clone();
let tx = tx.clone();
let season = self.season;
tokio::spawn(async move {
match crate::db::queries::get_pitchers(&pool, Some(team_id), Some(season)).await {
Ok(pitchers) => {
let _ = tx.send(AppMessage::PitchersLoaded(pitchers));
}
Err(e) => {
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 {
// Get pitcher and card
let pitcher = crate::db::queries::get_player_by_id(&pool, pitcher_id).await;
let Ok(Some(pitcher)) = pitcher else { return };
let pitcher_card = crate::db::queries::get_pitcher_card(&pool, pitcher_id)
.await
.ok()
.flatten();
match crate::calc::matchup::calculate_team_matchups_cached(
&pool,
&batters,
&pitcher,
pitcher_card.as_ref(),
)
.await
{
Ok(results) => {
let _ = tx.send(AppMessage::MatchupsCalculated(results));
}
Err(e) => {
tracing::error!("Matchup calculation failed: {e}");
let _ = tx.send(AppMessage::Notify(
format!("Matchup calculation failed: {e}"),
NotifyLevel::Error,
));
}
}
});
}
// =========================================================================
// Message handling
// =========================================================================
pub fn handle_message(&mut self, msg: AppMessage) {
match msg {
AppMessage::CacheReady => {
self.cache_ready = true;
}
AppMessage::CacheError(e) => {
self.cache_ready = false;
// Cache error is non-fatal; real-time calc will still work
tracing::warn!("Cache error: {e}");
}
AppMessage::TeamsLoaded(teams) => {
self.is_loading_teams = false;
let items: Vec<(String, i64)> = teams
.into_iter()
.filter(|t| t.abbrev != self.team_abbrev)
.map(|t| (format!("{} ({})", t.abbrev, t.short_name), t.id))
.collect();
self.team_selector.set_items(items);
}
AppMessage::MyBattersLoaded(batters) => {
self.my_batters = batters;
}
AppMessage::LineupsLoaded(lineups) => {
self.saved_lineups = lineups;
}
AppMessage::PitchersLoaded(pitchers) => {
self.is_loading_pitchers = false;
let items: Vec<(String, i64)> = pitchers
.into_iter()
.map(|p| {
let hand = p.hand.as_deref().unwrap_or("?");
(format!("{} ({})", p.name, hand), p.id)
})
.collect();
self.pitcher_selector.set_items(items);
}
AppMessage::MatchupsCalculated(results) => {
self.is_calculating = false;
self.matchup_results = results;
if !self.matchup_results.is_empty() {
self.matchup_table_state.select(Some(0));
}
}
AppMessage::LineupSaved(_) => {}
AppMessage::LineupSaveError(_) => {}
_ => {}
}
}
// =========================================================================
// Rendering
// =========================================================================
pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
// 60/40 horizontal split
let panels = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
self.render_left_panel(frame, panels[0], tick_count);
self.render_right_panel(frame, panels[1], tick_count);
// Render popup overlays last (on top)
if self.team_selector.is_open || self.pitcher_selector.is_open {
let left_chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(panels[0]);
if self.team_selector.is_open {
self.team_selector.render_popup(frame, left_chunks[0]);
}
if self.pitcher_selector.is_open {
self.pitcher_selector.render_popup(frame, left_chunks[1]);
}
}
if self.load_selector.is_open {
let right_chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(panels[1]);
self.load_selector.render_popup(frame, right_chunks[0]);
}
}
fn render_left_panel(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
let chunks = Layout::vertical([
Constraint::Length(3), // team selector
Constraint::Length(3), // pitcher selector
Constraint::Min(0), // matchup table
])
.split(area);
self.team_selector.render_closed(frame, chunks[0]);
self.pitcher_selector.render_closed(frame, chunks[1]);
self.render_matchup_table(frame, chunks[2], tick_count);
}
fn render_matchup_table(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
let is_focused = self.focus == GamedayFocus::MatchupTable;
let border_style = if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
if self.is_calculating {
let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4];
let widget = Paragraph::new(format!("{spinner} Calculating matchups..."))
.block(
Block::default()
.borders(Borders::ALL)
.title("Matchups")
.border_style(border_style),
);
frame.render_widget(widget, area);
return;
}
if self.matchup_results.is_empty() {
let msg = if self.pitcher_selector.selected_idx.is_some() {
"No matchup data"
} else {
"Select a team and pitcher"
};
let widget = Paragraph::new(msg).block(
Block::default()
.borders(Borders::ALL)
.title("Matchups")
.border_style(border_style),
);
frame.render_widget(widget, area);
return;
}
let lineup_pids: Vec<i64> = self
.lineup_slots
.iter()
.filter_map(|s| s.player_id)
.collect();
let header = Row::new(vec!["", "Name", "Hand", "Pos", "Tier", "Rating"])
.style(Style::default().add_modifier(Modifier::BOLD));
let rows: Vec<Row> = self
.matchup_results
.iter()
.map(|r| {
let in_lineup = if lineup_pids.contains(&r.player.id) {
"*"
} else {
" "
};
let hand = r.batter_hand.as_str();
let pos = r.player.pos_1.as_deref().unwrap_or("-");
Row::new(vec![
Cell::from(in_lineup),
Cell::from(r.player.name.as_str()),
Cell::from(hand),
Cell::from(pos),
Cell::from(r.tier.as_str()),
Cell::from(r.rating_display()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(1),
Constraint::Min(15),
Constraint::Length(4),
Constraint::Length(3),
Constraint::Length(4),
Constraint::Length(7),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title("Matchups")
.border_style(border_style),
)
.row_highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(table, area, &mut self.matchup_table_state.clone());
}
fn render_right_panel(&self, frame: &mut Frame, area: Rect, _tick_count: u64) {
let chunks = Layout::vertical([
Constraint::Length(3), // lineup name / load selector
Constraint::Min(0), // lineup table
Constraint::Length(1), // key hints
])
.split(area);
self.render_lineup_header(frame, chunks[0]);
self.render_lineup_table(frame, chunks[1]);
self.render_lineup_hints(frame, chunks[2]);
}
fn render_lineup_header(&self, frame: &mut Frame, area: Rect) {
let cols = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// Lineup name input
let name_focused = self.focus == GamedayFocus::LineupName;
let name_style = if name_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let display = if self.lineup_name.is_empty() {
"untitled".to_string()
} else {
self.lineup_name.clone()
};
let cursor_line = if name_focused {
let mut s = display.clone();
if self.lineup_name_cursor <= s.len() {
s.insert(self.lineup_name_cursor, '|');
}
s
} else {
display
};
let name_widget = Paragraph::new(cursor_line)
.block(
Block::default()
.borders(Borders::ALL)
.title("Lineup Name")
.border_style(name_style),
);
frame.render_widget(name_widget, cols[0]);
// Load selector
self.load_selector.render_closed(frame, cols[1]);
}
fn render_lineup_table(&self, frame: &mut Frame, area: Rect) {
let is_focused = self.focus == GamedayFocus::LineupTable;
let border_style = if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let header = Row::new(vec!["#", "Name", "Pos", "Tier", "Rating"])
.style(Style::default().add_modifier(Modifier::BOLD));
let rows: Vec<Row> = self
.lineup_slots
.iter()
.map(|slot| {
let order = slot.order.to_string();
if slot.is_empty() {
Row::new(vec![
Cell::from(order),
Cell::from("---"),
Cell::from("-"),
Cell::from("-"),
Cell::from("-"),
])
.style(Style::default().fg(Color::DarkGray))
} else {
let name = slot.player_name.as_deref().unwrap_or("?");
let pos = slot.position.as_deref().unwrap_or("-");
let tier = slot.matchup_tier.as_deref().unwrap_or("-");
let rating = slot
.matchup_rating
.map(|r| {
if r >= 0.0 {
format!("+{:.0}", r)
} else {
format!("{:.0}", r)
}
})
.unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(order),
Cell::from(name),
Cell::from(pos),
Cell::from(tier),
Cell::from(rating),
])
}
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(2),
Constraint::Min(12),
Constraint::Length(3),
Constraint::Length(4),
Constraint::Length(7),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title("Lineup")
.border_style(border_style),
)
.row_highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(table, area, &mut self.lineup_table_state.clone());
}
fn render_lineup_hints(&self, frame: &mut Frame, area: Rect) {
let hints = match self.focus {
GamedayFocus::MatchupTable => {
" Enter:add j/k:nav Tab:lineup"
}
GamedayFocus::LineupTable => {
" x:remove p:pos J/K:move l:load Ctrl-S:save"
}
GamedayFocus::LineupName => {
" Type name Tab/Esc:done"
}
_ => " Tab:cycle focus",
};
let line = Line::from(vec![Span::styled(
hints,
Style::default().fg(Color::DarkGray),
)]);
let widget = Paragraph::new(line);
frame.render_widget(widget, area);
}
}