aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/settings.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input/settings.rs')
-rw-r--r--src/app/input/settings.rs352
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);
+ }
+}