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, pub player_name: Option, pub position: Option, pub matchup_rating: Option, pub matchup_tier: Option, } 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, pub pitcher_selector: SelectorWidget, pub load_selector: SelectorWidget, // Data pub my_batters: Vec<(Player, Option)>, pub matchup_results: Vec, // 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, 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) { // 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, ) { // 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, ) { 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 = 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) { if self.lineup_name.is_empty() { let _ = tx.send(AppMessage::Notify( "Enter a lineup name first".to_string(), NotifyLevel::Error, )); return; } let batting_order: Vec = self .lineup_slots .iter() .filter_map(|s| s.player_id) .collect(); let mut positions: HashMap = HashMap::new(); for slot in &self.lineup_slots { if let (Some(pid), Some(pos)) = (slot.player_id, &slot.position) { positions.insert(pos.clone(), pid); } } let 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) { 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) { 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 = 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 = 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 = 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); } }