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, // 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, ) { // 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) { 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) { 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); } }