diff options
Diffstat (limited to 'src/app/input/settings.rs')
| -rw-r--r-- | src/app/input/settings.rs | 693 |
1 files changed, 0 insertions, 693 deletions
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs deleted file mode 100644 index fb1a7d75..00000000 --- a/src/app/input/settings.rs +++ /dev/null @@ -1,693 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{AppState, SettingsSection, THEME_NAMES}, - App, Mode, - }, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -// The shared `Save` verb is semantic: these actions persist settings. -#[allow(clippy::enum_variant_names)] -pub(super) enum SettingsAction { - SaveTheme(String), - SaveStatusIndicators(StatusIndicatorStyle), - SaveSound(bool), - SaveToastDelivery(ToastDelivery), - SaveAgentBorderLabels(bool), - InstallRecommendedIntegrations, -} - -impl App { - pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { - let previous_section = self.state.settings.section; - if let Some(action) = update_settings_state(&mut self.state, key) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() - } - } - } - if previous_section != SettingsSection::Integrations - && self.state.settings.section == SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - } -} - -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 status_indicator_index(style: StatusIndicatorStyle) -> usize { - match style { - StatusIndicatorStyle::Dots => 0, - StatusIndicatorStyle::Symbols => 1, - } -} - -fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle { - if idx == 0 { - StatusIndicatorStyle::Dots - } else { - StatusIndicatorStyle::Symbols - } -} - -fn toast_delivery_index(delivery: ToastDelivery) -> usize { - match delivery { - ToastDelivery::Off => 0, - ToastDelivery::Herdr => 1, - ToastDelivery::Terminal => 2, - ToastDelivery::System => 3, - } -} - -fn toast_delivery_for_index(idx: usize) -> ToastDelivery { - match idx { - 0 => ToastDelivery::Off, - 1 => ToastDelivery::Herdr, - 2 => ToastDelivery::Terminal, - _ => ToastDelivery::System, - } -} - -fn preview_selected_theme(state: &mut AppState) { - use crate::app::state::Palette; - - let name = THEME_NAMES[state.settings.list.selected]; - if let Some(mut palette) = Palette::from_name(name) { - if let Some(custom) = &state.theme_runtime.custom { - palette = palette.with_overrides(custom); - } - if let Some(accent) = &state.theme_runtime.legacy_accent { - palette.accent = crate::config::parse_color(accent); - } - 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 integrations_need_install(state: &AppState) -> bool { - state - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) -} - -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)) - } - SettingsSection::Integrations if integrations_need_install(state) => { - Some(SettingsAction::InstallRecommendedIntegrations) - } - SettingsSection::Integrations => None, - _ => { - 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::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => 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::Indicators => 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 style = status_indicator_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveStatusIndicators(style)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - 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; - return Some(SettingsAction::SaveSound(enabled)); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Toast => match key.code { - KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4), - KeyCode::Enter | KeyCode::Char(' ') => { - let delivery = toast_delivery_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveToastDelivery(delivery)); - } - 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::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::PaneLabels => 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; - return Some(SettingsAction::SaveAgentBorderLabels(enabled)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Integrations => match key.code { - KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { - return Some(SettingsAction::InstallRecommendedIntegrations); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_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::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - } - - None -} - -pub(crate) fn open_settings(state: &mut AppState) { - open_settings_at(state, SettingsSection::Theme); -} - -pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { - state.integration_install_messages.clear(); - state.settings.original_palette = Some(state.palette.clone()); - state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = section; - state.settings.list.selected = match section { - SettingsSection::Theme => current_theme_index(&state.theme_name), - SettingsSection::Indicators => status_indicator_index(state.status_indicators), - SettingsSection::Sound => usize::from(!state.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), - SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), - SettingsSection::Integrations => 0, - }; - state.mode = Mode::Settings; -} - -impl AppState { - fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect( - self.screen_rect(), - crate::ui::SETTINGS_POPUP_WIDTH, - crate::ui::settings_popup_height(self), - ) - .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 badge_width = if self.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - let width = section.label().len() as u16 + 2 + badge_width; - 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::Indicators | SettingsSection::Sound => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Toast => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 8 { - Some(((row - list_y) / 2) as usize) - } else { - None - } - } - SettingsSection::PaneLabels => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Integrations => 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::Indicators => { - status_indicator_index(self.status_indicators) - } - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), - SettingsSection::PaneLabels => { - usize::from(!self.agent_border_labels_enabled()) - } - SettingsSection::Integrations => 0, - }); - 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::Indicators => Some(SettingsAction::SaveStatusIndicators( - status_indicator_for_index(idx), - )), - SettingsSection::Sound => { - let enabled = idx == 0; - Some(SettingsAction::SaveSound(enabled)) - } - SettingsSection::Toast => { - let delivery = toast_delivery_for_index(idx); - Some(SettingsAction::SaveToastDelivery(delivery)) - } - SettingsSection::PaneLabels => { - let enabled = idx == 0; - Some(SettingsAction::SaveAgentBorderLabels(enabled)) - } - SettingsSection::Integrations => None, - }; - } - - let inner = self.settings_inner_rect(); - let show_primary = crate::ui::settings_show_primary_action(self); - let (apply, close) = - crate::ui::settings_button_rects(inner, self.settings.section, show_primary); - let mut buttons = vec![(close, super::modal::ModalAction::Close)]; - if let Some(apply) = apply { - buttons.insert(0, (apply, super::modal::ModalAction::Apply)); - } - match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) { - 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::Indicators - ); - - 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_indicator_choice_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Indicators); - state.settings.list.selected = 1; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!( - action, - Some(SettingsAction::SaveStatusIndicators( - StatusIndicatorStyle::Symbols - )) - ); - assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots); - assert_eq!(state.mode, Mode::Settings); - } - - #[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_tab_cycle_wraps_after_integrations() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::PaneLabels); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::PaneLabels); - } - - #[test] - fn integrations_enter_does_nothing_when_nothing_needs_install() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Integrations); - - let enter_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(enter_action, None); - - let space_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), - ); - assert_eq!(space_action, None); - } - - #[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); - } - - #[test] - fn integration_update_badge_only_tracks_outdated_recommendations() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - false, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Current, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - assert!(state.integration_updates_available()); - } - - #[test] - fn settings_tab_hit_area_includes_integration_update_badge() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - open_settings(&mut state); - - let inner = state.settings_inner_rect(); - let tab_y = inner.y + 1; - let integrations_idx = SettingsSection::ALL - .iter() - .position(|section| *section == SettingsSection::Integrations) - .expect("integrations section should be present"); - let integrations_x = inner.x - + SettingsSection::ALL[..integrations_idx] - .iter() - .map(|section| { - let badge_width = if state.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - section.label().len() as u16 + 3 + badge_width - }) - .sum::<u16>(); - let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4; - - assert_eq!( - state.settings_tab_at(integrations_x + dotted_width - 1, tab_y), - Some(SettingsSection::Integrations) - ); - } - - fn integration_recommendation( - state: crate::integration::IntegrationStatusKind, - available: bool, - ) -> crate::integration::IntegrationRecommendation { - crate::integration::IntegrationRecommendation { - target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", - available, - path: std::path::PathBuf::from("/tmp/herdr-test-integration"), - state, - } - } -} |
