diff options
Diffstat (limited to 'src/app/input/settings.rs')
| -rw-r--r-- | src/app/input/settings.rs | 352 |
1 files changed, 352 insertions, 0 deletions
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs new file mode 100644 index 00000000..9433b7d8 --- /dev/null +++ b/src/app/input/settings.rs @@ -0,0 +1,352 @@ +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::app::{ + state::{AppState, SettingsSection, THEME_NAMES}, + App, Mode, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SettingsAction { + SaveTheme(String), + SaveSound(bool), + SaveToast(bool), +} + +impl App { + pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { + if let Some(action) = update_settings_state(&mut self.state, key) { + match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + } + } + } +} + +fn normalize_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn current_theme_index(theme_name: &str) -> usize { + let normalized = normalize_theme_name(theme_name); + THEME_NAMES + .iter() + .position(|name| normalize_theme_name(name) == normalized) + .unwrap_or(0) +} + +fn preview_selected_theme(state: &mut AppState) { + use crate::app::state::Palette; + + let name = THEME_NAMES[state.settings.list.selected]; + if let Some(palette) = Palette::from_name(name) { + state.palette = palette; + state.theme_name = name.to_string(); + } +} + +fn cancel_settings(state: &mut AppState) { + if let Some(palette) = state.settings.original_palette.take() { + state.palette = palette; + } + if let Some(theme_name) = state.settings.original_theme.take() { + state.theme_name = theme_name; + } + super::modal::leave_modal(state); +} + +fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { + match state.settings.section { + SettingsSection::Theme => { + let theme_name = state.theme_name.clone(); + state.settings.original_palette = None; + state.settings.original_theme = None; + super::modal::leave_modal(state); + Some(SettingsAction::SaveTheme(theme_name)) + } + _ => { + super::modal::leave_modal(state); + None + } + } +} + +pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> { + match state.settings.section { + SettingsSection::Theme => match key.code { + KeyCode::Up | KeyCode::Char('k') => { + let previous = state.settings.list.selected; + state.settings.list.move_prev(); + if state.settings.list.selected != previous { + preview_selected_theme(state); + } + } + KeyCode::Down | KeyCode::Char('j') => { + let previous = state.settings.list.selected; + state.settings.list.move_next(THEME_NAMES.len()); + if state.settings.list.selected != previous { + preview_selected_theme(state); + } + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Sound; + state.settings.list.selected = usize::from(!state.sound_enabled()); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Apply) => return apply_settings(state), + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + SettingsSection::Sound => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let enabled = state.settings.list.selected == 0; + state.sound.enabled = enabled; + return Some(SettingsAction::SaveSound(enabled)); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Toast; + state.settings.list.selected = usize::from(!state.toast_config.enabled); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + SettingsSection::Toast => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let enabled = state.settings.list.selected == 0; + state.toast_config.enabled = enabled; + return Some(SettingsAction::SaveToast(enabled)); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Sound; + state.settings.list.selected = usize::from(!state.sound_enabled()); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, + } + + None +} + +pub(super) fn open_settings(state: &mut AppState) { + state.settings.original_palette = Some(state.palette.clone()); + state.settings.original_theme = Some(state.theme_name.clone()); + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + state.mode = Mode::Settings; +} + +impl AppState { + fn settings_popup_rect(&self) -> Rect { + crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default() + } + + fn settings_inner_rect(&self) -> Rect { + let popup = self.settings_popup_rect(); + Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ) + } + + fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> { + let inner = self.settings_inner_rect(); + let tab_y = inner.y + 1; + if row != tab_y { + return None; + } + let mut x = inner.x; + for section in SettingsSection::ALL { + let width = section.label().len() as u16 + 2; + if col >= x && col < x + width { + return Some(*section); + } + x += width + 1; + } + None + } + + pub(crate) fn settings_content_rect(&self) -> Rect { + let inner = self.settings_inner_rect(); + crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content + } + + fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> { + let area = self.settings_content_rect(); + if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width + { + return None; + } + + match self.settings.section { + SettingsSection::Theme => { + let max_visible = area.height as usize; + let scroll = if self.settings.list.selected >= max_visible { + self.settings.list.selected - max_visible + 1 + } else { + 0 + }; + let idx = scroll + (row - area.y) as usize; + (idx < THEME_NAMES.len()).then_some(idx) + } + SettingsSection::Sound | SettingsSection::Toast => { + let list_y = area.y + 2; + if row >= list_y && row < list_y + 2 { + Some((row - list_y) as usize) + } else { + None + } + } + } + } + + pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { + self.settings.section = section; + self.settings.list.select(match section { + SettingsSection::Theme => current_theme_index(&self.theme_name), + SettingsSection::Sound => usize::from(!self.sound_enabled()), + SettingsSection::Toast => usize::from(!self.toast_config.enabled), + }); + return None; + } + if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { + self.settings.list.select(idx); + return match self.settings.section { + SettingsSection::Theme => { + preview_selected_theme(self); + None + } + SettingsSection::Sound => { + let enabled = idx == 0; + self.sound.enabled = enabled; + Some(SettingsAction::SaveSound(enabled)) + } + SettingsSection::Toast => { + let enabled = idx == 0; + self.toast_config.enabled = enabled; + Some(SettingsAction::SaveToast(enabled)) + } + }; + } + + let inner = self.settings_inner_rect(); + let (apply, close) = crate::ui::settings_button_rects(inner); + match super::modal::modal_action_from_buttons( + mouse.column, + mouse.row, + &[ + (apply, super::modal::ModalAction::Apply), + (close, super::modal::ModalAction::Close), + ], + ) { + Some(super::modal::ModalAction::Apply) => apply_settings(self), + Some(super::modal::ModalAction::Close) => { + cancel_settings(self); + None + } + _ => { + cancel_settings(self); + None + } + } + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; + + use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; + use super::*; + + #[test] + fn settings_cancel_restores_previewed_theme_from_other_sections() { + let mut state = state_with_workspaces(&["test"]); + let original_palette = state.palette.clone(); + let original_theme = state.theme_name.clone(); + + open_settings(&mut state); + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + assert_ne!(state.theme_name, original_theme); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), + ); + assert_eq!( + state.settings.section, + crate::app::state::SettingsSection::Sound + ); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + assert_eq!(state.theme_name, original_theme); + assert_eq!(state.palette.accent, original_palette.accent); + assert_eq!(state.palette.panel_bg, original_palette.panel_bg); + } + + #[test] + fn settings_sound_toggle_returns_save_action() { + let mut state = state_with_workspaces(&["test"]); + open_settings(&mut state); + state.settings.section = crate::app::state::SettingsSection::Sound; + state.settings.list.selected = 0; + + let action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!(action, Some(SettingsAction::SaveSound(true))); + assert!(state.sound.enabled); + assert_eq!(state.mode, Mode::Settings); + } + + #[test] + fn settings_hover_does_not_change_selection() { + let mut app = app_for_mouse_test(); + open_settings(&mut app.state); + app.state.settings.list.select(0); + + let area = app.state.settings_content_rect(); + app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); + + assert_eq!(app.state.settings.list.selected, 0); + } +} |
