Implement Phase 4: async TUI with Dashboard and Gameday screens
Replace blocking event loop with tokio::select! over EventStream, mpsc channel, and tick interval. Add message bus architecture with AppMessage enum for background task results. Implement Dashboard with roster summary cards and API sync, and Gameday with cascading team/pitcher selectors, matchup table, and 9-slot lineup management with save/load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ebe4196bfc
commit
6d2b11a797
29
rust/Cargo.lock
generated
29
rust/Cargo.lock
generated
@ -667,6 +667,21 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@ -711,6 +726,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@ -729,8 +755,10 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@ -2181,6 +2209,7 @@ dependencies = [
|
||||
"crossterm 0.28.1",
|
||||
"csv",
|
||||
"figment",
|
||||
"futures",
|
||||
"ratatui",
|
||||
"regex",
|
||||
"reqwest",
|
||||
|
||||
@ -42,5 +42,8 @@ csv = "1"
|
||||
# Hashing
|
||||
sha2 = "0.10"
|
||||
|
||||
# Async streams
|
||||
futures = "0.3"
|
||||
|
||||
# Regex
|
||||
regex = "1"
|
||||
|
||||
317
rust/src/app.rs
317
rust/src/app.rs
@ -1,76 +1,319 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout},
|
||||
text::Text,
|
||||
layout::{Alignment, Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::calc::matchup::MatchupResult;
|
||||
use crate::config::Settings;
|
||||
use crate::db::models::{BatterCard, Lineup, Player, Roster, Team};
|
||||
use crate::screens::dashboard::DashboardState;
|
||||
use crate::screens::gameday::GamedayState;
|
||||
|
||||
// =============================================================================
|
||||
// Messages
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppMessage {
|
||||
// Dashboard
|
||||
RosterLoaded(Roster),
|
||||
SyncStarted,
|
||||
SyncComplete(Result<crate::api::sync::SyncResult, anyhow::Error>),
|
||||
|
||||
// Gameday — init
|
||||
CacheReady,
|
||||
CacheError(String),
|
||||
TeamsLoaded(Vec<Team>),
|
||||
MyBattersLoaded(Vec<(Player, Option<BatterCard>)>),
|
||||
LineupsLoaded(Vec<Lineup>),
|
||||
|
||||
// Gameday — on team select
|
||||
PitchersLoaded(Vec<Player>),
|
||||
|
||||
// Gameday — on pitcher select
|
||||
MatchupsCalculated(Vec<MatchupResult>),
|
||||
|
||||
// Gameday — lineup
|
||||
LineupSaved(String),
|
||||
LineupSaveError(String),
|
||||
|
||||
// General
|
||||
Notify(String, NotifyLevel),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
Dashboard,
|
||||
pub enum NotifyLevel {
|
||||
Info,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notification
|
||||
// =============================================================================
|
||||
|
||||
pub struct Notification {
|
||||
pub message: String,
|
||||
pub level: NotifyLevel,
|
||||
pub created_at: Instant,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
pub fn new(message: String, level: NotifyLevel) -> Self {
|
||||
Self {
|
||||
message,
|
||||
level,
|
||||
created_at: Instant::now(),
|
||||
duration_ms: 3000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed().as_millis() > self.duration_ms as u128
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Screen enum
|
||||
// =============================================================================
|
||||
|
||||
/// Stub screen identifier for screens not yet implemented.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StubScreen {
|
||||
Roster,
|
||||
Matchup,
|
||||
Lineup,
|
||||
Gameday,
|
||||
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),
|
||||
}
|
||||
|
||||
impl ActiveScreen {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ActiveScreen::Dashboard(_) => "Dashboard",
|
||||
ActiveScreen::Gameday(_) => "Gameday",
|
||||
ActiveScreen::Stub(s) => s.label(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App
|
||||
// =============================================================================
|
||||
|
||||
pub struct App {
|
||||
pub current_screen: Screen,
|
||||
pub screen: ActiveScreen,
|
||||
pub pool: SqlitePool,
|
||||
pub settings: Settings,
|
||||
pub tx: mpsc::UnboundedSender<AppMessage>,
|
||||
pub notification: Option<Notification>,
|
||||
pub tick_count: u64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(settings: Settings, pool: SqlitePool) -> Self {
|
||||
pub fn new(
|
||||
settings: Settings,
|
||||
pool: SqlitePool,
|
||||
tx: mpsc::UnboundedSender<AppMessage>,
|
||||
) -> Self {
|
||||
let screen = ActiveScreen::Dashboard(DashboardState::new(
|
||||
settings.team.abbrev.clone(),
|
||||
settings.team.season as i64,
|
||||
));
|
||||
Self {
|
||||
current_screen: Screen::Dashboard,
|
||||
screen,
|
||||
pool,
|
||||
settings,
|
||||
tx,
|
||||
notification: None,
|
||||
tick_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire initial data load for the starting screen.
|
||||
pub fn on_mount(&mut self) {
|
||||
if let ActiveScreen::Dashboard(s) = &mut self.screen {
|
||||
s.mount(&self.pool, &self.tx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_tick(&mut self) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
if let Some(n) = &self.notification {
|
||||
if n.is_expired() {
|
||||
self.notification = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_input_captured(&self) -> bool {
|
||||
match &self.screen {
|
||||
ActiveScreen::Gameday(s) => s.is_input_captured(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
||||
use crossterm::event::KeyCode;
|
||||
match key.code {
|
||||
KeyCode::Char('1') => self.current_screen = Screen::Dashboard,
|
||||
KeyCode::Char('2') => self.current_screen = Screen::Roster,
|
||||
KeyCode::Char('3') => self.current_screen = Screen::Matchup,
|
||||
KeyCode::Char('4') => self.current_screen = Screen::Lineup,
|
||||
KeyCode::Char('5') => self.current_screen = Screen::Gameday,
|
||||
KeyCode::Char('6') => self.current_screen = Screen::Settings,
|
||||
_ => {}
|
||||
// Global navigation (only when not capturing input)
|
||||
if !self.is_input_captured() {
|
||||
match key.code {
|
||||
KeyCode::Char('d') => {
|
||||
if !matches!(&self.screen, ActiveScreen::Dashboard(_)) {
|
||||
self.switch_to_dashboard();
|
||||
return;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
if !matches!(&self.screen, ActiveScreen::Gameday(_)) {
|
||||
self.switch_to_gameday();
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to current screen
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_message(&mut self, msg: AppMessage) {
|
||||
match msg {
|
||||
AppMessage::Notify(text, level) => {
|
||||
self.notification = Some(Notification::new(text, level));
|
||||
}
|
||||
_ => match &mut self.screen {
|
||||
ActiveScreen::Dashboard(s) => s.handle_message(msg),
|
||||
ActiveScreen::Gameday(s) => s.handle_message(msg),
|
||||
ActiveScreen::Stub(_) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_to_dashboard(&mut self) {
|
||||
let mut state = DashboardState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
);
|
||||
state.mount(&self.pool, &self.tx);
|
||||
self.screen = ActiveScreen::Dashboard(state);
|
||||
}
|
||||
|
||||
fn switch_to_gameday(&mut self) {
|
||||
let mut state = GamedayState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
);
|
||||
state.mount(&self.pool, &self.tx);
|
||||
self.screen = ActiveScreen::Gameday(state);
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // nav bar
|
||||
Constraint::Min(0), // screen content
|
||||
Constraint::Length(1), // status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
|
||||
self.render_nav_bar(frame, chunks[0]);
|
||||
|
||||
// Navigation bar
|
||||
let nav = Paragraph::new(Text::raw(
|
||||
" [1] Dashboard [2] Roster [3] Matchup [4] Lineup [5] Gameday [6] Settings [q] Quit",
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL).title("SBA Scout"));
|
||||
frame.render_widget(nav, chunks[0]);
|
||||
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),
|
||||
}
|
||||
|
||||
// Screen content
|
||||
let content = match self.current_screen {
|
||||
Screen::Dashboard => "Dashboard - Press a number key to navigate",
|
||||
Screen::Roster => "Roster Screen (not yet implemented)",
|
||||
Screen::Matchup => "Matchup Screen (not yet implemented)",
|
||||
Screen::Lineup => "Lineup Screen (not yet implemented)",
|
||||
Screen::Gameday => "Gameday Screen (not yet implemented)",
|
||||
Screen::Settings => "Settings Screen (not yet implemented)",
|
||||
self.render_status_bar(frame, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_nav_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let active = self.screen.label();
|
||||
let items = [
|
||||
("d", "Dashboard"),
|
||||
("g", "Gameday"),
|
||||
];
|
||||
|
||||
let spans: Vec<Span> = items
|
||||
.iter()
|
||||
.flat_map(|(key, label)| {
|
||||
let is_active = *label == active;
|
||||
let style = if is_active {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
vec![
|
||||
Span::styled(format!(" [{}] ", key), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(*label, style),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let nav = Paragraph::new(Line::from(spans));
|
||||
frame.render_widget(nav, area);
|
||||
}
|
||||
|
||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let (text, style) = match &self.notification {
|
||||
Some(n) => {
|
||||
let color = match n.level {
|
||||
NotifyLevel::Info => Color::White,
|
||||
NotifyLevel::Success => Color::Green,
|
||||
NotifyLevel::Error => Color::Red,
|
||||
};
|
||||
(n.message.as_str(), Style::default().fg(color))
|
||||
}
|
||||
None => ("", Style::default()),
|
||||
};
|
||||
|
||||
let body = Paragraph::new(Text::raw(content))
|
||||
.block(Block::default().borders(Borders::ALL).title(format!("{:?}", self.current_screen)));
|
||||
frame.render_widget(body, chunks[1]);
|
||||
let bar = Paragraph::new(text).style(style);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use figment::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Settings {
|
||||
pub db_path: PathBuf,
|
||||
pub api: ApiSettings,
|
||||
@ -15,26 +15,26 @@ pub struct Settings {
|
||||
pub rating_weights: RatingWeights,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApiSettings {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TeamSettings {
|
||||
pub abbrev: String,
|
||||
pub season: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UiSettings {
|
||||
pub theme: String,
|
||||
pub refresh_interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RatingWeights {
|
||||
pub hit: f64,
|
||||
pub on_base: f64,
|
||||
|
||||
@ -4,3 +4,4 @@ pub mod calc;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod screens;
|
||||
pub mod widgets;
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
|
||||
use futures::StreamExt;
|
||||
use ratatui::DefaultTerminal;
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
use sba_scout::app::App;
|
||||
use sba_scout::config::{self, Settings};
|
||||
use sba_scout::app::{App, AppMessage};
|
||||
use sba_scout::config;
|
||||
use sba_scout::db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let settings = match config::load_settings() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@ -32,17 +33,48 @@ async fn main() -> Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(terminal: &mut DefaultTerminal, settings: Settings, pool: SqlitePool) -> Result<()> {
|
||||
let mut app = App::new(settings, pool);
|
||||
async fn run(terminal: &mut DefaultTerminal, settings: config::Settings, pool: SqlitePool) -> Result<()> {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<AppMessage>();
|
||||
let mut app = App::new(settings, pool, tx);
|
||||
|
||||
app.on_mount();
|
||||
|
||||
let mut event_stream = EventStream::new();
|
||||
let mut tick = interval(Duration::from_millis(250));
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| app.render(frame))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.code == KeyCode::Char('q') {
|
||||
break;
|
||||
tokio::select! {
|
||||
maybe_event = event_stream.next() => {
|
||||
match maybe_event {
|
||||
Some(Ok(Event::Key(key))) => {
|
||||
if key.code == KeyCode::Char('q')
|
||||
&& key.modifiers == KeyModifiers::NONE
|
||||
&& app.should_quit_on_q()
|
||||
{
|
||||
break;
|
||||
}
|
||||
app.handle_key(key);
|
||||
}
|
||||
Some(Ok(Event::Resize(_, _))) => {
|
||||
// ratatui handles resize on next draw
|
||||
}
|
||||
Some(Err(_)) => {}
|
||||
None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
maybe_msg = rx.recv() => {
|
||||
if let Some(msg) = maybe_msg {
|
||||
app.handle_message(msg);
|
||||
}
|
||||
}
|
||||
|
||||
_ = tick.tick() => {
|
||||
app.on_tick();
|
||||
}
|
||||
app.handle_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,194 @@
|
||||
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub fn render(frame: &mut Frame, area: Rect) {
|
||||
let widget = Paragraph::new("Dashboard");
|
||||
frame.render_widget(widget, area);
|
||||
use crate::app::{AppMessage, NotifyLevel};
|
||||
use crate::config::Settings;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncState {
|
||||
Never,
|
||||
Syncing,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub struct DashboardState {
|
||||
pub team_abbrev: String,
|
||||
pub season: i64,
|
||||
pub majors_count: usize,
|
||||
pub minors_count: usize,
|
||||
pub il_count: usize,
|
||||
pub swar_total: f64,
|
||||
pub sync_state: SyncState,
|
||||
pub sync_message: String,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
pub fn new(team_abbrev: String, season: i64) -> Self {
|
||||
Self {
|
||||
team_abbrev,
|
||||
season,
|
||||
majors_count: 0,
|
||||
minors_count: 0,
|
||||
il_count: 0,
|
||||
swar_total: 0.0,
|
||||
sync_state: SyncState::Never,
|
||||
sync_message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire async roster load on mount.
|
||||
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
||||
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 _ = tx.send(AppMessage::RosterLoaded(roster));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppMessage::Notify(
|
||||
format!("Failed to load roster: {e}"),
|
||||
NotifyLevel::Error,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn handle_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
pool: &SqlitePool,
|
||||
settings: &Settings,
|
||||
tx: &mpsc::UnboundedSender<AppMessage>,
|
||||
) {
|
||||
if let KeyCode::Char('s') = key.code {
|
||||
self.trigger_sync(pool, settings, tx);
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_sync(
|
||||
&mut self,
|
||||
pool: &SqlitePool,
|
||||
settings: &Settings,
|
||||
tx: &mpsc::UnboundedSender<AppMessage>,
|
||||
) {
|
||||
if self.sync_state == SyncState::Syncing {
|
||||
return;
|
||||
}
|
||||
self.sync_state = SyncState::Syncing;
|
||||
self.sync_message = "Syncing...".to_string();
|
||||
let pool = pool.clone();
|
||||
let settings = settings.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(AppMessage::SyncStarted);
|
||||
let result = crate::api::sync::sync_all(&pool, &settings).await;
|
||||
let _ = tx.send(AppMessage::SyncComplete(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn handle_message(&mut self, msg: AppMessage) {
|
||||
match msg {
|
||||
AppMessage::RosterLoaded(roster) => {
|
||||
self.majors_count = roster.majors.len();
|
||||
self.minors_count = roster.minors.len();
|
||||
self.il_count = roster.il.len();
|
||||
self.swar_total = roster.majors.iter().filter_map(|p| p.swar).sum();
|
||||
}
|
||||
AppMessage::SyncStarted => {
|
||||
self.sync_state = SyncState::Syncing;
|
||||
self.sync_message = "Syncing...".to_string();
|
||||
}
|
||||
AppMessage::SyncComplete(result) => match result {
|
||||
Ok(r) => {
|
||||
self.sync_state = SyncState::Success;
|
||||
self.sync_message = format!(
|
||||
"Synced {} teams, {} players, {} transactions",
|
||||
r.teams, r.players, r.transactions
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.sync_state = SyncState::Error;
|
||||
self.sync_message = format!("Sync failed: {e}");
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(3), // title
|
||||
Constraint::Length(5), // summary cards
|
||||
Constraint::Length(3), // sync status
|
||||
Constraint::Min(0), // key hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Title
|
||||
let title = Paragraph::new(format!(
|
||||
"SBA Scout -- {} -- Season {}",
|
||||
self.team_abbrev, self.season
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
// Summary cards
|
||||
let card_chunks = Layout::horizontal([
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
])
|
||||
.split(chunks[1]);
|
||||
|
||||
let cards = [
|
||||
("Majors", format!("{}/26", self.majors_count)),
|
||||
("Minors", format!("{}/6", self.minors_count)),
|
||||
("IL", self.il_count.to_string()),
|
||||
("sWAR", format!("{:.1}", self.swar_total)),
|
||||
];
|
||||
for (i, (label, value)) in cards.iter().enumerate() {
|
||||
let card = Paragraph::new(value.as_str())
|
||||
.block(Block::default().borders(Borders::ALL).title(*label))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(card, card_chunks[i]);
|
||||
}
|
||||
|
||||
// Sync status
|
||||
let spinner_chars = ['|', '/', '-', '\\'];
|
||||
let spinner = spinner_chars[(tick_count as usize / 2) % 4];
|
||||
let sync_text = match self.sync_state {
|
||||
SyncState::Never => "[s] Sync data from API".to_string(),
|
||||
SyncState::Syncing => format!("{spinner} {}", self.sync_message),
|
||||
SyncState::Success => format!("OK {} [s] Sync again", self.sync_message),
|
||||
SyncState::Error => format!("ERR {} [s] Retry", self.sync_message),
|
||||
};
|
||||
let sync_style = match self.sync_state {
|
||||
SyncState::Error => Style::default().fg(Color::Red),
|
||||
SyncState::Success => Style::default().fg(Color::Green),
|
||||
_ => Style::default(),
|
||||
};
|
||||
let sync_widget = Paragraph::new(sync_text)
|
||||
.style(sync_style)
|
||||
.block(Block::default().borders(Borders::ALL).title("Sync"));
|
||||
frame.render_widget(sync_widget, chunks[2]);
|
||||
|
||||
// Key hints
|
||||
let hints = Paragraph::new(" [s] Sync [g] Gameday [q] Quit")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(hints, chunks[3]);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
rust/src/widgets/mod.rs
Normal file
1
rust/src/widgets/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod selector;
|
||||
166
rust/src/widgets/selector.rs
Normal file
166
rust/src/widgets/selector.rs
Normal file
@ -0,0 +1,166 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Event returned from selector key handling.
|
||||
#[derive(Debug)]
|
||||
pub enum SelectorEvent {
|
||||
/// Key was not consumed (not focused or irrelevant).
|
||||
None,
|
||||
/// Key was consumed but no selection change.
|
||||
Consumed,
|
||||
/// An item was selected — contains the index.
|
||||
Selected(usize),
|
||||
}
|
||||
|
||||
/// A focusable popup-list selector widget.
|
||||
///
|
||||
/// Closed: renders as a single-line display showing the selected item label.
|
||||
/// Open: renders as a bordered List overlay showing all items.
|
||||
pub struct SelectorWidget<T: Clone> {
|
||||
pub title: String,
|
||||
pub items: Vec<(String, T)>,
|
||||
pub selected_idx: Option<usize>,
|
||||
pub highlighted_idx: usize,
|
||||
pub is_open: bool,
|
||||
pub is_focused: bool,
|
||||
}
|
||||
|
||||
impl<T: Clone> SelectorWidget<T> {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
items: Vec::new(),
|
||||
selected_idx: None,
|
||||
highlighted_idx: 0,
|
||||
is_open: false,
|
||||
is_focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_items(&mut self, items: Vec<(String, T)>) {
|
||||
self.items = items;
|
||||
self.selected_idx = None;
|
||||
self.highlighted_idx = 0;
|
||||
}
|
||||
|
||||
pub fn selected_value(&self) -> Option<&T> {
|
||||
self.selected_idx
|
||||
.and_then(|i| self.items.get(i).map(|(_, v)| v))
|
||||
}
|
||||
|
||||
pub fn selected_label(&self) -> &str {
|
||||
self.selected_idx
|
||||
.and_then(|i| self.items.get(i))
|
||||
.map(|(label, _)| label.as_str())
|
||||
.unwrap_or("-- None --")
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns a SelectorEvent indicating what happened.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SelectorEvent {
|
||||
if !self.is_focused {
|
||||
return SelectorEvent::None;
|
||||
}
|
||||
|
||||
if !self.is_open {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
self.is_open = true;
|
||||
self.highlighted_idx = self.selected_idx.unwrap_or(0);
|
||||
return SelectorEvent::Consumed;
|
||||
}
|
||||
_ => return SelectorEvent::None,
|
||||
}
|
||||
}
|
||||
|
||||
// Popup is open
|
||||
match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
if !self.items.is_empty() {
|
||||
self.highlighted_idx = (self.highlighted_idx + 1) % self.items.len();
|
||||
}
|
||||
SelectorEvent::Consumed
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
if !self.items.is_empty() {
|
||||
self.highlighted_idx = self
|
||||
.highlighted_idx
|
||||
.checked_sub(1)
|
||||
.unwrap_or(self.items.len().saturating_sub(1));
|
||||
}
|
||||
SelectorEvent::Consumed
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.items.is_empty() {
|
||||
self.selected_idx = Some(self.highlighted_idx);
|
||||
self.is_open = false;
|
||||
return SelectorEvent::Selected(self.highlighted_idx);
|
||||
}
|
||||
SelectorEvent::Consumed
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.is_open = false;
|
||||
SelectorEvent::Consumed
|
||||
}
|
||||
_ => SelectorEvent::Consumed, // consume all keys when open
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the closed (single-line) view into `area`.
|
||||
pub fn render_closed(&self, frame: &mut Frame, area: Rect) {
|
||||
let style = if self.is_focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let label = format!("{}: {}", self.title, self.selected_label());
|
||||
let widget = Paragraph::new(label)
|
||||
.style(style)
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
frame.render_widget(widget, area);
|
||||
}
|
||||
|
||||
/// Render the popup overlay. Call AFTER rendering main content.
|
||||
/// `anchor` is the area the popup appears below.
|
||||
pub fn render_popup(&self, frame: &mut Frame, anchor: Rect) {
|
||||
if !self.is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let popup_height = (self.items.len() as u16 + 2).min(20);
|
||||
let popup_area = Rect {
|
||||
x: anchor.x,
|
||||
y: anchor.y + anchor.height,
|
||||
width: anchor.width,
|
||||
height: popup_height.min(frame.area().height.saturating_sub(anchor.y + anchor.height)),
|
||||
};
|
||||
|
||||
if popup_area.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
let items: Vec<ListItem> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, _))| {
|
||||
let style = if i == self.highlighted_idx {
|
||||
Style::default().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(label.as_str()).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(self.title.as_str()));
|
||||
frame.render_widget(list, popup_area);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user