Roster: sWAR only sums majors, stateful table rendering for j/k scroll, rows sorted batters-first for correct selection mapping. Matchup: initial focus highlight, descriptive sort key hints, state cached on nav-away to preserve opponent/pitcher selections. Lineup: load/delete/clear promoted to work from any focus, load selector popup anchored correctly, delete confirmation prompt. Settings: API key toggle changed to Ctrl+T so t can be typed, per-field yellow change markers, sWAR/cap formatting to two decimal places. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
533 lines
19 KiB
Rust
533 lines
19 KiB
Rust
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;
|
|
|
|
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
|
|
&& key.modifiers.contains(KeyModifiers::CONTROL) =>
|
|
{
|
|
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: {:.2})", 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 = " 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.abbrev != self.original.abbrev);
|
|
self.render_input(frame, team_row[1], "Season", &self.season_str, SettingsFocus::TeamSeason, false, self.season_str != self.original.season_str);
|
|
|
|
// 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.major_slots_str != self.original.major_slots_str);
|
|
self.render_input(frame, slots_row[1], "Minor Slots", &self.minor_slots_str, SettingsFocus::MinorSlots, false, self.minor_slots_str != self.original.minor_slots_str);
|
|
|
|
// 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, self.base_url != self.original.base_url);
|
|
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, self.api_key != self.original.api_key);
|
|
|
|
// 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_base = if self.focus == SettingsFocus::ApiKey {
|
|
" [Tab] Next [Shift+Tab] Prev [Ctrl+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"
|
|
};
|
|
let mut spans = vec![Span::styled(hint_base, Style::default().fg(Color::DarkGray))];
|
|
if self.has_unsaved_changes {
|
|
spans.push(Span::styled(
|
|
" * unsaved changes",
|
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
|
));
|
|
}
|
|
frame.render_widget(Paragraph::new(Line::from(spans)), chunks[13]);
|
|
}
|
|
|
|
fn render_input(
|
|
&self,
|
|
frame: &mut Frame,
|
|
area: Rect,
|
|
label: &str,
|
|
value: &str,
|
|
target_focus: SettingsFocus,
|
|
is_password: bool,
|
|
changed: 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 change_marker = if changed { "* " } else { " " };
|
|
let toggle_hint = if is_password && is_focused { " [Ctrl+T]" } else { "" };
|
|
|
|
let line = Line::from(vec![
|
|
Span::styled(change_marker, Style::default().fg(Color::Yellow)),
|
|
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);
|
|
}
|
|
}
|