sba-scouting/rust/src/screens/settings.rs
Cal Corum ad8cf33139 Fix Phase 5 live testing bugs across all four new screens
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>
2026-02-28 15:59:09 -06:00

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