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.rs693
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,
- }
- }
-}