diff options
Diffstat (limited to 'src/client/shell/settings.rs')
| -rw-r--r-- | src/client/shell/settings.rs | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs new file mode 100644 index 00000000..95a437fe --- /dev/null +++ b/src/client/shell/settings.rs @@ -0,0 +1,385 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +pub(super) fn normalized_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn theme_index(name: &str) -> usize { + let normalized = normalized_theme_name(name); + crate::config::THEME_NAMES + .iter() + .position(|candidate| normalized_theme_name(candidate) == normalized) + .unwrap_or(0) +} + +fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize { + usize::from(style == crate::config::StatusIndicatorStyle::Symbols) +} + +fn toast_index(delivery: crate::config::ToastDelivery) -> usize { + match delivery { + crate::config::ToastDelivery::Off => 0, + crate::config::ToastDelivery::Herdr => 1, + crate::config::ToastDelivery::Terminal => 2, + crate::config::ToastDelivery::System => 3, + } +} + +pub(super) fn integration_needs_install(info: &crate::api::schema::IntegrationInfo) -> bool { + info.state == crate::api::schema::IntegrationState::Outdated + || info.available && info.state == crate::api::schema::IntegrationState::NotInstalled +} + +impl ClientShellState { + pub(super) fn open_settings_overlay(&mut self) { + self.overlay = Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: theme_index(&self.config.theme_name), + original_theme_name: self.config.theme_name.clone(), + original_palette: self.config.palette.clone(), + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + })); + } + + fn selected_index_for_settings_section(&self, section: ClientSettingsSection) -> usize { + match section { + ClientSettingsSection::Theme => theme_index(&self.config.theme_name), + ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators), + ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled), + ClientSettingsSection::Toast => toast_index(self.config.toast_delivery), + ClientSettingsSection::Integrations => 0, + } + } + + pub(super) fn select_settings_section( + &mut self, + section: ClientSettingsSection, + outcome: &mut ClientShellInput, + ) { + let selected = self.selected_index_for_settings_section(section); + let request_integrations = matches!(section, ClientSettingsSection::Integrations) + && matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: false, + installing_integrations: false, + .. + })) + ); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.section = section; + settings.selected = selected; + } + if request_integrations { + self.queue_integration_list(outcome, true); + } + outcome.repaint = true; + } + + fn move_settings_section(&mut self, delta: isize, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let current = ClientSettingsSection::ALL + .iter() + .position(|section| *section == settings.section) + .unwrap_or(0); + let next = (current as isize + delta).rem_euclid(ClientSettingsSection::ALL.len() as isize) + as usize; + self.select_settings_section(ClientSettingsSection::ALL[next], outcome); + } + + fn settings_choice_count(&self) -> usize { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => match settings.section { + ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(), + ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2, + ClientSettingsSection::Toast => 4, + ClientSettingsSection::Integrations => settings.integrations.len(), + }, + _ => 0, + } + } + + pub(super) fn move_settings_selection(&mut self, delta: isize) { + let count = self.settings_choice_count(); + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() else { + return; + }; + if count == 0 { + settings.selected = 0; + return; + } + settings.selected = (settings.selected as isize + delta) + .clamp(0, count.saturating_sub(1) as isize) as usize; + if settings.section == ClientSettingsSection::Theme { + self.preview_selected_theme(); + } + } + + pub(super) fn select_settings_choice(&mut self, index: usize) { + let count = self.settings_choice_count(); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + if count > 0 { + settings.selected = index.min(count - 1); + } + } + if matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + ) { + self.preview_selected_theme(); + } + } + + fn preview_selected_theme(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let Some(name) = crate::config::THEME_NAMES.get(settings.selected) else { + return; + }; + self.config.theme_name = (*name).to_owned(); + self.config.palette = + crate::app::client_palette_for_theme(&self.config.theme_runtime, name); + } + + pub(super) fn cancel_settings_overlay(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.take() else { + return; + }; + self.config.theme_name = settings.original_theme_name; + self.config.palette = settings.original_palette; + } + + fn save_settings_edit( + &mut self, + edit: crate::config::ConfigEdit<'_>, + outcome: &mut ClientShellInput, + ) -> bool { + if let Err(error) = crate::config::write_edit(edit) { + self.endpoint_error = Some(error); + outcome.repaint = true; + return false; + } + self.reload_client_config(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + outcome.repaint = true; + true + } + + pub(super) fn apply_settings_choice(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let section = settings.section; + let selected = settings.selected; + match section { + ClientSettingsSection::Theme => { + let Some(name) = crate::config::THEME_NAMES.get(selected).copied() else { + return; + }; + if self.save_settings_edit(crate::config::ConfigEdit::Theme(name), outcome) { + self.overlay = None; + } + } + ClientSettingsSection::Indicators => { + let style = if selected == 0 { + crate::config::StatusIndicatorStyle::Dots + } else { + crate::config::StatusIndicatorStyle::Symbols + }; + self.save_settings_edit( + crate::config::ConfigEdit::StatusIndicators(style), + outcome, + ); + } + ClientSettingsSection::Sound => { + self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome); + } + ClientSettingsSection::Toast => { + let delivery = match selected { + 0 => crate::config::ToastDelivery::Off, + 1 => crate::config::ToastDelivery::Herdr, + 2 => crate::config::ToastDelivery::Terminal, + _ => crate::config::ToastDelivery::System, + }; + self.save_settings_edit( + crate::config::ConfigEdit::ToastDelivery(delivery), + outcome, + ); + } + ClientSettingsSection::Integrations => self.install_recommended_integrations(outcome), + } + } + + fn queue_integration_list(&mut self, outcome: &mut ClientShellInput, clear_messages: bool) { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = true; + if clear_messages { + settings.integration_messages.clear(); + } + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()), + PendingEndpointKind::IntegrationList, + outcome, + ); + } + + fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) { + if self.pending_integration_installs > 0 { + return; + } + let targets = match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => settings + .integrations + .iter() + .filter(|integration| integration_needs_install(integration)) + .map(|integration| integration.target) + .collect::<Vec<_>>(), + _ => return, + }; + if targets.is_empty() { + return; + } + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.installing_integrations = true; + settings.integration_messages.clear(); + } + self.pending_integration_installs = targets.len(); + for target in targets { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { target }, + ), + PendingEndpointKind::IntegrationInstall, + outcome, + ); + } + outcome.repaint = true; + } + + pub(super) fn handle_settings_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + match kind { + PendingEndpointKind::IntegrationList => { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = false; + match result { + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations, + }) => { + settings.integrations = integrations; + settings.selected = settings + .selected + .min(settings.integrations.len().saturating_sub(1)); + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected integration list result".into(), + ); + } + Err(error) => self.endpoint_error = Some(error.message), + } + } + (true, Vec::new()) + } + PendingEndpointKind::IntegrationInstall => { + self.pending_integration_installs = + self.pending_integration_installs.saturating_sub(1); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + match result { + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + details, + .. + }) => settings.integration_messages.extend(details.messages), + Ok(_) => settings + .integration_messages + .push("endpoint returned an unexpected integration result".into()), + Err(error) => settings.integration_messages.push(error.message), + } + settings.installing_integrations = self.pending_integration_installs > 0; + } + let actions = if self.pending_integration_installs == 0 + && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + let mut deferred = ClientShellInput::default(); + self.queue_integration_list(&mut deferred, false); + deferred.actions + } else { + Vec::new() + }; + (true, actions) + } + _ => (false, Vec::new()), + } + } + + pub(super) fn route_settings_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + return false; + } + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if code == KeyCode::Esc { + if !matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ) { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + return true; + } + if matches!(code, KeyCode::Tab | KeyCode::Right | KeyCode::Char('l')) + && modifiers.is_empty() + { + self.move_settings_section(1, outcome); + return true; + } + if matches!(code, KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h')) + && modifiers.difference(KeyModifiers::SHIFT).is_empty() + { + self.move_settings_section(-1, outcome); + return true; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_settings_selection(-1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_settings_selection(1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Enter | KeyCode::Char(' ')) && modifiers.is_empty() { + self.apply_settings_choice(outcome); + return true; + } + true + } +} |
