diff options
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 1960 |
1 files changed, 23 insertions, 1937 deletions
diff --git a/src/config.rs b/src/config.rs index 7a317204..4fa772d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,410 +1,31 @@ -use std::path::{Path, PathBuf}; - use crossterm::event::{KeyCode, KeyModifiers}; -use serde::Deserialize; + +mod io; +mod keybinds; +mod model; +mod sound; +mod theme; + +pub use self::{ + io::{ + config_dir, config_path, load_live_keybinds, save_onboarding_choices, upsert_section_bool, + upsert_section_value, + }, + keybinds::{ + format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, + Keybinds, LiveKeybindConfig, + }, + model::{Config, ToastConfig}, + sound::{AgentSoundSetting, SoundConfig}, + theme::{parse_color, CustomThemeColors, ThemeConfig}, +}; pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000; -use tracing::warn; - -pub fn app_dir_name() -> &'static str { - if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - } -} - -pub fn config_dir() -> PathBuf { - if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(dir).join(app_dir_name()) - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(format!(".config/{}", app_dir_name())) - } else { - PathBuf::from(format!("/tmp/{}", app_dir_name())) - } -} - -use crate::detect::Agent; - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct ToastConfig { - pub enabled: bool, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct Config { - pub onboarding: Option<bool>, - pub theme: ThemeConfig, - pub keys: KeysConfig, - pub ui: UiConfig, - pub advanced: AdvancedConfig, -} - -/// Theme configuration: pick a built-in or override individual tokens. -/// -/// ```toml -/// [theme] -/// name = "tokyo-night" # built-in: catppuccin, tokyo-night, dracula, nord, etc. -/// -/// [theme.custom] # override individual tokens on top of the base -/// accent = "#f5c2e7" -/// red = "#ff6188" -/// ``` -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct ThemeConfig { - /// Built-in theme name. Default: "catppuccin". - pub name: Option<String>, - /// Custom overrides — applied on top of the selected base theme. - pub custom: Option<CustomThemeColors>, -} - -/// Per-token color overrides. All fields optional — only set what you want to change. -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct CustomThemeColors { - pub accent: Option<String>, - pub panel_bg: Option<String>, - pub surface0: Option<String>, - pub surface1: Option<String>, - pub surface_dim: Option<String>, - pub overlay0: Option<String>, - pub overlay1: Option<String>, - pub text: Option<String>, - pub subtext0: Option<String>, - pub mauve: Option<String>, - pub green: Option<String>, - pub yellow: Option<String>, - pub red: Option<String>, - pub blue: Option<String>, - pub teal: Option<String>, - pub peach: Option<String>, -} - -#[derive(Debug)] -pub struct LoadedConfig { - pub config: Config, - pub diagnostics: Vec<String>, -} - -#[derive(Debug)] -pub struct LiveKeybindConfig { - pub prefix: (KeyCode, KeyModifiers), - pub keybinds: Keybinds, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum CommandKeybindType { - #[default] - Shell, - Pane, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct CommandKeybindConfig { - /// Navigate-mode key that runs a command after pressing the prefix key. - pub key: String, - /// Command executed either in the background shell or inside a pane. - pub command: String, - /// Command execution mode. Default: "shell". - #[serde(rename = "type")] - pub action_type: CommandKeybindType, -} - -impl Default for CommandKeybindConfig { - fn default() -> Self { - Self { - key: String::new(), - command: String::new(), - action_type: CommandKeybindType::Shell, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct KeysConfig { - /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). - pub prefix: String, - /// Create a new workspace. Default: "n" - pub new_workspace: String, - /// Rename the selected workspace. Default: "shift+n" - pub rename_workspace: String, - /// Close the selected workspace. Default: "d" - pub close_workspace: String, - /// Select the previous workspace. Unset by default. - pub previous_workspace: String, - /// Select the next workspace. Unset by default. - pub next_workspace: String, - /// Create a new tab in the active workspace. Default: "c" - pub new_tab: String, - /// Rename the active tab. Unset by default. - pub rename_tab: String, - /// Select the previous tab. Unset by default. - pub previous_tab: String, - /// Select the next tab. Unset by default. - pub next_tab: String, - /// Close the active tab. Unset by default. - pub close_tab: String, - /// Focus the pane to the left in terminal mode. Unset by default. - pub focus_pane_left: String, - /// Focus the pane below in terminal mode. Unset by default. - pub focus_pane_down: String, - /// Focus the pane above in terminal mode. Unset by default. - pub focus_pane_up: String, - /// Focus the pane to the right in terminal mode. Unset by default. - pub focus_pane_right: String, - /// Split pane vertically (side by side). Default: "v" - pub split_vertical: String, - /// Split pane horizontally (stacked). Default: "-" - pub split_horizontal: String, - /// Close the focused pane. Default: "x" - pub close_pane: String, - /// Toggle fullscreen for the focused pane. Default: "f" - pub fullscreen: String, - /// Enter resize mode. Default: "r" - pub resize_mode: String, - /// Toggle sidebar collapse. Default: "b" - pub toggle_sidebar: String, - /// Prefix-mode custom command bindings. - pub command: Vec<CommandKeybindConfig>, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct UiConfig { - pub sidebar_width: u16, - /// Ask for confirmation before closing a workspace. Default: true. - pub confirm_close: bool, - /// Accent color for highlights, borders, and navigation UI. - /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). - pub accent: String, - /// Optional visual toast notifications for background workspace events. - pub toast: ToastConfig, - /// Play sounds when agents change state in background workspaces. - pub sound: SoundConfig, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct AdvancedConfig { - /// Allow launching herdr inside an existing herdr pane. Default: false. - pub allow_nested: bool, - /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000. - #[serde(alias = "scrollback_lines")] - pub scrollback_limit_bytes: usize, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct SoundConfig { - pub enabled: bool, - /// Optional mp3 file path used for all notification sounds. - /// Relative paths are resolved from the config file's directory. - pub path: Option<PathBuf>, - /// Optional mp3 file path for "done" notifications. - /// Relative paths are resolved from the config file's directory. - pub done_path: Option<PathBuf>, - /// Optional mp3 file path for "request" notifications. - /// Relative paths are resolved from the config file's directory. - pub request_path: Option<PathBuf>, - pub agents: AgentSoundOverrides, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct AgentSoundOverrides { - pub pi: AgentSoundSetting, - pub claude: AgentSoundSetting, - pub codex: AgentSoundSetting, - pub gemini: AgentSoundSetting, - pub cursor: AgentSoundSetting, - pub cline: AgentSoundSetting, - pub open_code: AgentSoundSetting, - pub github_copilot: AgentSoundSetting, - pub kimi: AgentSoundSetting, - pub droid: AgentSoundSetting, - pub amp: AgentSoundSetting, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentSoundSetting { - #[default] - Default, - On, - Off, -} - -impl SoundConfig { - pub fn allows(&self, agent: Option<Agent>) -> bool { - if !self.enabled { - return false; - } - - !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) - } - - pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> { - let path = match sound { - crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()), - crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()), - }?; - - Some(resolve_config_relative_path(path)) - } - - pub fn diagnostics(&self) -> Vec<String> { - let mut diagnostics = Vec::new(); - for (field, path) in [ - ("ui.sound.path", self.path.as_ref()), - ("ui.sound.done_path", self.done_path.as_ref()), - ("ui.sound.request_path", self.request_path.as_ref()), - ] { - let Some(path) = path else { - continue; - }; - - let resolved = resolve_config_relative_path(path); - if resolved - .extension() - .and_then(|ext| ext.to_str()) - .is_none_or(|ext| !ext.eq_ignore_ascii_case("mp3")) - { - diagnostics.push(format!( - "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound", - path.display(), - resolved.display() - )); - continue; - } - - if !resolved.exists() { - diagnostics.push(format!( - "missing sound file: {field} = {} resolves to {}; using default sound", - path.display(), - resolved.display() - )); - } else if !resolved.is_file() { - diagnostics.push(format!( - "invalid sound file: {field} = {} resolves to {}; using default sound", - path.display(), - resolved.display() - )); - } - } - diagnostics - } -} - -impl AgentSoundOverrides { - pub fn for_agent(&self, agent: Option<Agent>) -> AgentSoundSetting { - match agent { - Some(Agent::Pi) => self.pi, - Some(Agent::Claude) => self.claude, - Some(Agent::Codex) => self.codex, - Some(Agent::Gemini) => self.gemini, - Some(Agent::Cursor) => self.cursor, - Some(Agent::Cline) => self.cline, - Some(Agent::OpenCode) => self.open_code, - Some(Agent::GithubCopilot) => self.github_copilot, - Some(Agent::Kimi) => self.kimi, - Some(Agent::Droid) => self.droid, - Some(Agent::Amp) => self.amp, - None => AgentSoundSetting::Default, - } - } -} - -impl Default for KeysConfig { - fn default() -> Self { - Self { - prefix: "ctrl+b".into(), - new_workspace: "n".into(), - rename_workspace: "shift+n".into(), - close_workspace: "d".into(), - previous_workspace: "".into(), - next_workspace: "".into(), - new_tab: "c".into(), - rename_tab: "".into(), - previous_tab: "".into(), - next_tab: "".into(), - close_tab: "".into(), - focus_pane_left: "".into(), - focus_pane_down: "".into(), - focus_pane_up: "".into(), - focus_pane_right: "".into(), - split_vertical: "v".into(), - split_horizontal: "-".into(), - close_pane: "x".into(), - fullscreen: "f".into(), - resize_mode: "r".into(), - toggle_sidebar: "b".into(), - command: Vec::new(), - } - } -} - -impl Default for UiConfig { - fn default() -> Self { - Self { - sidebar_width: 26, - confirm_close: true, - accent: "cyan".into(), - toast: ToastConfig::default(), - sound: SoundConfig::default(), - } - } -} - -impl Default for ToastConfig { - fn default() -> Self { - Self { enabled: false } - } -} - -impl Default for SoundConfig { - fn default() -> Self { - Self { - enabled: true, - path: None, - done_path: None, - request_path: None, - agents: AgentSoundOverrides::default(), - } - } -} - -impl Default for AdvancedConfig { - fn default() -> Self { - Self { - allow_nested: false, - scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES, - } - } -} -impl Default for AgentSoundOverrides { - fn default() -> Self { - Self { - pi: AgentSoundSetting::Default, - claude: AgentSoundSetting::Default, - codex: AgentSoundSetting::Default, - gemini: AgentSoundSetting::Default, - cursor: AgentSoundSetting::Default, - cline: AgentSoundSetting::Default, - open_code: AgentSoundSetting::Default, - github_copilot: AgentSoundSetting::Default, - kimi: AgentSoundSetting::Default, - droid: AgentSoundSetting::Off, - amp: AgentSoundSetting::Default, - } - } +#[cfg(test)] +pub(crate) fn app_dir_name() -> &'static str { + io::app_dir_name() } impl Config { @@ -412,41 +33,6 @@ impl Config { self.onboarding.unwrap_or(true) } - pub fn load() -> LoadedConfig { - let path = config_path(); - if path.exists() { - match std::fs::read_to_string(&path) { - Ok(content) => match toml::from_str::<Config>(&content) { - Ok(config) => { - let diagnostics = config.collect_diagnostics(); - return LoadedConfig { - config, - diagnostics, - }; - } - Err(e) => { - warn!(err = %e, "config parse error, using defaults"); - return LoadedConfig { - config: Self::default(), - diagnostics: vec![format!("config parse error: {e}; using defaults")], - }; - } - }, - Err(e) => { - warn!(err = %e, "config read error, using defaults"); - return LoadedConfig { - config: Self::default(), - diagnostics: vec![format!("config read error: {e}; using defaults")], - }; - } - } - } - LoadedConfig { - config: Self::default(), - diagnostics: Vec::new(), - } - } - pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { self.validated_keybinds().1 } @@ -475,1503 +61,3 @@ impl Config { } } } - -fn resolve_config_relative_path(path: &Path) -> PathBuf { - if path.is_absolute() { - return path.to_path_buf(); - } - - config_path() - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(path) -} - -impl Config { - fn validated_keybinds( - &self, - ) -> ( - Option<String>, - (KeyCode, KeyModifiers), - Vec<String>, - Keybinds, - ) { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - enum BindingScope { - Navigate, - TerminalDirect, - } - - #[derive(Clone)] - struct RequiredBinding<'a> { - scope: BindingScope, - field: &'a str, - label: String, - default_label: &'a str, - value: (KeyCode, KeyModifiers), - default: (KeyCode, KeyModifiers), - } - - struct OptionalBinding { - scope: BindingScope, - field: &'static str, - value: Option<(KeyCode, KeyModifiers)>, - label: Option<String>, - } - - #[derive(Default)] - struct BindingRegistry { - seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, - } - - impl BindingRegistry { - fn register( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) -> Option<String> { - let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None); - self.seen - .insert((scope, binding.0, binding.1), field.to_string()) - } - - fn conflict( - &self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - ) -> Option<&str> { - let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None); - self.seen - .get(&(scope, binding.0, binding.1)) - .map(String::as_str) - } - - fn reserve_if_unbound( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) { - let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None); - self.seen - .entry((scope, binding.0, binding.1)) - .or_insert_with(|| field.to_string()); - } - } - - fn required_binding<'a>( - scope: BindingScope, - field: &'a str, - configured_label: &'a str, - default_label: &'a str, - default: (KeyCode, KeyModifiers), - diagnostics: &mut Vec<String>, - ) -> RequiredBinding<'a> { - let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); - let label = if let Some(diag) = diag { - diagnostics.push(diag); - default_label.to_string() - } else { - configured_label.to_string() - }; - RequiredBinding { - scope, - field, - label, - default_label, - value, - default, - } - } - - fn optional_binding( - scope: BindingScope, - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec<String>, - ) -> OptionalBinding { - if configured_label.trim().is_empty() { - return OptionalBinding { - scope, - field, - value: None, - label: None, - }; - } - match parse_key_combo(configured_label) { - Some(value) => OptionalBinding { - scope, - field, - value: Some(value), - label: Some(configured_label.to_string()), - }, - None => { - let diag = format!( - "invalid keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - OptionalBinding { - scope, - field, - value: None, - label: None, - } - } - } - } - - let mut diagnostics = Vec::new(); - let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ); - if let Some(diag) = &prefix_diag { - warn!(message = %diag, "config diagnostic"); - } - - let mut bindings = vec![ - required_binding( - BindingScope::Navigate, - "keys.new_workspace", - &self.keys.new_workspace, - "n", - (KeyCode::Char('n'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.rename_workspace", - &self.keys.rename_workspace, - "shift+n", - (KeyCode::Char('n'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_workspace", - &self.keys.close_workspace, - "d", - (KeyCode::Char('d'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.new_tab", - &self.keys.new_tab, - "c", - (KeyCode::Char('c'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_vertical", - &self.keys.split_vertical, - "v", - (KeyCode::Char('v'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_horizontal", - &self.keys.split_horizontal, - "-", - (KeyCode::Char('-'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_pane", - &self.keys.close_pane, - "x", - (KeyCode::Char('x'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.fullscreen", - &self.keys.fullscreen, - "f", - (KeyCode::Char('f'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.resize_mode", - &self.keys.resize_mode, - "r", - (KeyCode::Char('r'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.toggle_sidebar", - &self.keys.toggle_sidebar, - "b", - (KeyCode::Char('b'), KeyModifiers::empty()), - &mut diagnostics, - ), - ]; - - let mut optional_bindings = vec![ - optional_binding( - BindingScope::Navigate, - "keys.previous_workspace", - &self.keys.previous_workspace, - &mut diagnostics, - ), - optional_binding( - BindingScope::Navigate, - "keys.next_workspace", - &self.keys.next_workspace, - &mut diagnostics, - ), - optional_binding( - BindingScope::Navigate, - "keys.rename_tab", - &self.keys.rename_tab, - &mut diagnostics, - ), - optional_binding( - BindingScope::Navigate, - "keys.previous_tab", - &self.keys.previous_tab, - &mut diagnostics, - ), - optional_binding( - BindingScope::Navigate, - "keys.next_tab", - &self.keys.next_tab, - &mut diagnostics, - ), - optional_binding( - BindingScope::Navigate, - "keys.close_tab", - &self.keys.close_tab, - &mut diagnostics, - ), - optional_binding( - BindingScope::TerminalDirect, - "keys.focus_pane_left", - &self.keys.focus_pane_left, - &mut diagnostics, - ), - optional_binding( - BindingScope::TerminalDirect, - "keys.focus_pane_down", - &self.keys.focus_pane_down, - &mut diagnostics, - ), - optional_binding( - BindingScope::TerminalDirect, - "keys.focus_pane_up", - &self.keys.focus_pane_up, - &mut diagnostics, - ), - optional_binding( - BindingScope::TerminalDirect, - "keys.focus_pane_right", - &self.keys.focus_pane_right, - &mut diagnostics, - ), - ]; - - let mut registry = BindingRegistry::default(); - for binding in &mut bindings { - if let Some(first_field) = registry.conflict(binding.scope, binding.value) { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; using default {}", - binding.field, first_field, binding.default_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = binding.default; - binding.label = binding.default_label.to_string(); - } - registry.register(binding.scope, binding.value, binding.field); - } - - for binding in &mut optional_bindings { - let Some(value) = binding.value else { - continue; - }; - if let Some(first_field) = registry.conflict(binding.scope, value) { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } - registry.register(binding.scope, value, binding.field); - } - - registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); - for (field, binding) in [ - ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), - ( - "navigate.open_workspace", - (KeyCode::Enter, KeyModifiers::empty()), - ), - ( - "navigate.settings", - (KeyCode::Char('s'), KeyModifiers::empty()), - ), - ( - "navigate.keybind_help", - (KeyCode::Char('?'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_up", - (KeyCode::Up, KeyModifiers::empty()), - ), - ( - "navigate.workspace_down", - (KeyCode::Down, KeyModifiers::empty()), - ), - ( - "navigate.focus_left", - (KeyCode::Char('h'), KeyModifiers::empty()), - ), - ( - "navigate.focus_down", - (KeyCode::Char('j'), KeyModifiers::empty()), - ), - ( - "navigate.focus_up", - (KeyCode::Char('k'), KeyModifiers::empty()), - ), - ( - "navigate.focus_right", - (KeyCode::Char('l'), KeyModifiers::empty()), - ), - ( - "navigate.arrow_left", - (KeyCode::Left, KeyModifiers::empty()), - ), - ( - "navigate.arrow_right", - (KeyCode::Right, KeyModifiers::empty()), - ), - ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), - ( - "navigate.tab_prev", - (KeyCode::BackTab, KeyModifiers::empty()), - ), - ( - "navigate.workspace_1", - (KeyCode::Char('1'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_2", - (KeyCode::Char('2'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_3", - (KeyCode::Char('3'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_4", - (KeyCode::Char('4'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_5", - (KeyCode::Char('5'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_6", - (KeyCode::Char('6'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_7", - (KeyCode::Char('7'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_8", - (KeyCode::Char('8'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_9", - (KeyCode::Char('9'), KeyModifiers::empty()), - ), - ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), - ] { - registry.reserve_if_unbound(BindingScope::Navigate, binding, field); - } - - let mut custom_commands = Vec::new(); - for (index, command) in self.keys.command.iter().enumerate() { - let key_field = format!("keys.command[{index}].key"); - let command_field = format!("keys.command[{index}].command"); - - if command.command.trim().is_empty() { - let diag = - format!("empty custom command: {command_field}; disabling custom command"); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - continue; - } - - let Some(binding) = parse_key_combo(&command.key) else { - let diag = format!( - "invalid keybinding: {} = {:?}; disabling custom command", - key_field, command.key - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - continue; - }; - - if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { - let diag = format!( - "duplicate custom keybinding: {} conflicts with {}; disabling custom command", - key_field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - continue; - } - - registry.register(BindingScope::Navigate, binding, &key_field); - let action = match command.action_type { - CommandKeybindType::Shell => CustomCommandAction::Shell, - CommandKeybindType::Pane => CustomCommandAction::Pane, - }; - custom_commands.push(CustomCommandKeybind { - key: binding, - label: format_key_combo(binding), - command: command.command.clone(), - action, - }); - } - - let keybinds = Keybinds { - new_workspace: bindings[0].value, - new_workspace_label: bindings[0].label.clone(), - rename_workspace: bindings[1].value, - rename_workspace_label: bindings[1].label.clone(), - close_workspace: bindings[2].value, - close_workspace_label: bindings[2].label.clone(), - previous_workspace: optional_bindings[0].value, - previous_workspace_label: optional_bindings[0].label.clone(), - next_workspace: optional_bindings[1].value, - next_workspace_label: optional_bindings[1].label.clone(), - new_tab: bindings[3].value, - new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[2].value, - rename_tab_label: optional_bindings[2].label.clone(), - previous_tab: optional_bindings[3].value, - previous_tab_label: optional_bindings[3].label.clone(), - next_tab: optional_bindings[4].value, - next_tab_label: optional_bindings[4].label.clone(), - close_tab: optional_bindings[5].value, - close_tab_label: optional_bindings[5].label.clone(), - focus_pane_left: optional_bindings[6].value, - focus_pane_left_label: optional_bindings[6].label.clone(), - focus_pane_down: optional_bindings[7].value, - focus_pane_down_label: optional_bindings[7].label.clone(), - focus_pane_up: optional_bindings[8].value, - focus_pane_up_label: optional_bindings[8].label.clone(), - focus_pane_right: optional_bindings[9].value, - focus_pane_right_label: optional_bindings[9].label.clone(), - split_vertical: bindings[4].value, - split_vertical_label: bindings[4].label.clone(), - split_horizontal: bindings[5].value, - split_horizontal_label: bindings[5].label.clone(), - close_pane: bindings[6].value, - close_pane_label: bindings[6].label.clone(), - fullscreen: bindings[7].value, - fullscreen_label: bindings[7].label.clone(), - resize_mode: bindings[8].value, - resize_mode_label: bindings[8].label.clone(), - toggle_sidebar: bindings[9].value, - toggle_sidebar_label: bindings[9].label.clone(), - custom_commands, - }; - - (prefix_diag, prefix, diagnostics, keybinds) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CustomCommandAction { - Shell, - Pane, -} - -#[derive(Debug, Clone)] -pub struct CustomCommandKeybind { - pub key: (KeyCode, KeyModifiers), - pub label: String, - pub command: String, - pub action: CustomCommandAction, -} - -/// Parsed keybinds for navigate mode actions. -#[derive(Debug, Clone)] -pub struct Keybinds { - pub new_workspace: (KeyCode, KeyModifiers), - pub new_workspace_label: String, - pub rename_workspace: (KeyCode, KeyModifiers), - pub rename_workspace_label: String, - pub close_workspace: (KeyCode, KeyModifiers), - pub close_workspace_label: String, - pub previous_workspace: Option<(KeyCode, KeyModifiers)>, - pub previous_workspace_label: Option<String>, - pub next_workspace: Option<(KeyCode, KeyModifiers)>, - pub next_workspace_label: Option<String>, - pub new_tab: (KeyCode, KeyModifiers), - pub new_tab_label: String, - pub rename_tab: Option<(KeyCode, KeyModifiers)>, - pub rename_tab_label: Option<String>, - pub previous_tab: Option<(KeyCode, KeyModifiers)>, - pub previous_tab_label: Option<String>, - pub next_tab: Option<(KeyCode, KeyModifiers)>, - pub next_tab_label: Option<String>, - pub close_tab: Option<(KeyCode, KeyModifiers)>, - pub close_tab_label: Option<String>, - pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_left_label: Option<String>, - pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_down_label: Option<String>, - pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_up_label: Option<String>, - pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_right_label: Option<String>, - pub split_vertical: (KeyCode, KeyModifiers), - pub split_vertical_label: String, - pub split_horizontal: (KeyCode, KeyModifiers), - pub split_horizontal_label: String, - pub close_pane: (KeyCode, KeyModifiers), - pub close_pane_label: String, - pub fullscreen: (KeyCode, KeyModifiers), - pub fullscreen_label: String, - pub resize_mode: (KeyCode, KeyModifiers), - pub resize_mode_label: String, - pub toggle_sidebar: (KeyCode, KeyModifiers), - pub toggle_sidebar_label: String, - pub custom_commands: Vec<CustomCommandKeybind>, -} - -/// Parse a color string into a ratatui Color. -/// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b), and reset aliases. -pub fn parse_color(s: &str) -> ratatui::style::Color { - use ratatui::style::Color; - let s = s.trim().to_lowercase(); - - match s.as_str() { - "reset" | "default" | "none" | "transparent" => return Color::Reset, - _ => {} - } - - // Hex: #rrggbb or #rgb - if let Some(hex) = s.strip_prefix('#') { - if hex.len() == 6 { - if let (Ok(r), Ok(g), Ok(b)) = ( - u8::from_str_radix(&hex[0..2], 16), - u8::from_str_radix(&hex[2..4], 16), - u8::from_str_radix(&hex[4..6], 16), - ) { - return Color::Rgb(r, g, b); - } - } else if hex.len() == 3 { - let chars: Vec<u8> = hex - .chars() - .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok()) - .collect(); - if chars.len() == 3 { - return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17); - } - } - } - - // rgb(r, g, b) - if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) { - let parts: Vec<&str> = inner.split(',').collect(); - if parts.len() == 3 { - if let (Ok(r), Ok(g), Ok(b)) = ( - parts[0].trim().parse::<u8>(), - parts[1].trim().parse::<u8>(), - parts[2].trim().parse::<u8>(), - ) { - return Color::Rgb(r, g, b); - } - } - } - - // Named colors - match s.as_str() { - "black" => Color::Black, - "red" => Color::Red, - "green" => Color::Green, - "yellow" => Color::Yellow, - "blue" => Color::Blue, - "magenta" | "purple" => Color::Magenta, - "cyan" => Color::Cyan, - "white" => Color::White, - "gray" | "grey" => Color::Gray, - "darkgray" | "darkgrey" => Color::DarkGray, - "lightred" => Color::LightRed, - "lightgreen" => Color::LightGreen, - "lightyellow" => Color::LightYellow, - "lightblue" => Color::LightBlue, - "lightmagenta" => Color::LightMagenta, - "lightcyan" => Color::LightCyan, - _ => { - warn!(color = s, "unknown color, defaulting to cyan"); - Color::Cyan - } - } -} - -pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> { - let path = config_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let content = upsert_top_level_bool(&content, "onboarding", false); - let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled); - let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled); - std::fs::write(path, content) -} - -pub fn config_path() -> PathBuf { - if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) { - return PathBuf::from(path); - } - config_dir().join("config.toml") -} - -pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> { - let path = config_path(); - if !path.exists() { - return Config::default().live_keybinds(); - } - - let content = std::fs::read_to_string(&path).map_err(|err| { - vec![format!( - "config read error: {err}; keeping current keybinds" - )] - })?; - let config = toml::from_str::<Config>(&content).map_err(|err| { - vec![format!( - "config parse error: {err}; keeping current keybinds" - )] - })?; - config.live_keybinds() -} - -fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { - let replacement = format!("{key} = {value}"); - let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect(); - let mut in_section = false; - - for line in &mut lines { - let trimmed = line.trim(); - if trimmed.starts_with('[') && trimmed.ends_with(']') { - in_section = true; - continue; - } - if in_section { - continue; - } - if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) { - *line = replacement.clone(); - return lines.join("\n") + "\n"; - } - } - - if lines.is_empty() { - format!("{replacement}\n") - } else { - format!("{replacement}\n{}\n", lines.join("\n").trim_end()) - } -} - -/// Write a key = value pair in a TOML section (creates section if missing). -pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String { - upsert_section_raw(content, section, key, value) -} - -pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { - upsert_section_raw(content, section, key, &value.to_string()) -} - -fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { - let header = format!("[{section}]"); - let assignment = format!("{key} = {value}"); - let lines: Vec<&str> = content.lines().collect(); - let mut result = Vec::new(); - let mut i = 0; - let mut found_section = false; - let mut inserted = false; - - while i < lines.len() { - let line = lines[i]; - let trimmed = line.trim(); - - if trimmed == header { - found_section = true; - result.push(line.to_string()); - i += 1; - - while i < lines.len() { - let current = lines[i]; - let current_trimmed = current.trim(); - if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') { - if !inserted { - result.push(assignment.clone()); - inserted = true; - } - break; - } - - if current_trimmed.starts_with(&format!("{key} ")) - || current_trimmed.starts_with(&format!("{key}=")) - { - result.push(assignment.clone()); - inserted = true; - } else { - result.push(current.to_string()); - } - i += 1; - } - - continue; - } - - result.push(line.to_string()); - i += 1; - } - - if !found_section { - if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) { - result.push(String::new()); - } - result.push(header); - result.push(assignment); - } else if found_section && !inserted { - result.push(assignment); - } - - result.join("\n") + "\n" -} - -fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { - let parts: Vec<&str> = s.split('+').collect(); - let mut modifiers = KeyModifiers::empty(); - let mut key_str: Option<&str> = None; - - for part in &parts { - let trimmed = part.trim(); - match trimmed.to_lowercase().as_str() { - "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, - "shift" => modifiers |= KeyModifiers::SHIFT, - "alt" | "meta" => modifiers |= KeyModifiers::ALT, - _ if trimmed.is_empty() => return None, - _ => { - if key_str.is_some() { - return None; - } - key_str = Some(trimmed); - } - } - } - - let key_str = key_str?; - - let lower = key_str.to_lowercase(); - let code = match lower.as_str() { - "space" | " " => KeyCode::Char(' '), - "enter" | "return" => KeyCode::Enter, - "esc" | "escape" => KeyCode::Esc, - "tab" => KeyCode::Tab, - "backspace" | "bs" => KeyCode::Backspace, - "left" => KeyCode::Left, - "right" => KeyCode::Right, - "up" => KeyCode::Up, - "down" => KeyCode::Down, - s if s.len() == 1 => { - let ch = key_str.chars().next().unwrap(); - if ch.is_ascii_uppercase() { - modifiers |= KeyModifiers::SHIFT; - KeyCode::Char(ch.to_ascii_lowercase()) - } else { - KeyCode::Char(ch) - } - } - s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?, - _ => return None, - }; - - Some((code, modifiers)) -} - -pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { - let (code, modifiers) = binding; - let mut parts = Vec::new(); - if modifiers.contains(KeyModifiers::CONTROL) { - parts.push("ctrl".to_string()); - } - if modifiers.contains(KeyModifiers::ALT) { - parts.push("alt".to_string()); - } - if modifiers.contains(KeyModifiers::SHIFT) { - parts.push("shift".to_string()); - } - - let key = match code { - KeyCode::Char(' ') => "space".to_string(), - KeyCode::Char(c) => c.to_string(), - KeyCode::Enter => "enter".to_string(), - KeyCode::Esc => "esc".to_string(), - KeyCode::Tab => "tab".to_string(), - KeyCode::BackTab => "shift+tab".to_string(), - KeyCode::Backspace => "backspace".to_string(), - KeyCode::Left => "left".to_string(), - KeyCode::Right => "right".to_string(), - KeyCode::Up => "up".to_string(), - KeyCode::Down => "down".to_string(), - KeyCode::F(n) => format!("f{n}"), - _ => format!("{:?}", code).to_lowercase(), - }; - - if matches!(code, KeyCode::BackTab) { - return if parts.is_empty() { - key - } else { - format!("{}+tab", parts.join("+")) - }; - } - - parts.push(key); - parts.join("+") -} - -fn parse_key_combo_with_diagnostic( - s: &str, - field: &str, - fallback: (KeyCode, KeyModifiers), -) -> ((KeyCode, KeyModifiers), Option<String>) { - match parse_key_combo(s) { - Some(binding) => (binding, None), - None => { - let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); - warn!(message = %diag, "config diagnostic"); - (fallback, Some(diag)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyCode, KeyModifiers}; - - #[test] - fn parse_simple_char() { - assert_eq!( - parse_key_combo("v"), - Some((KeyCode::Char('v'), KeyModifiers::empty())) - ); - } - - #[test] - fn parse_ctrl_combo() { - assert_eq!( - parse_key_combo("ctrl+b"), - Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) - ); - } - - #[test] - fn parse_special_key() { - assert_eq!( - parse_key_combo("enter"), - Some((KeyCode::Enter, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("tab"), - Some((KeyCode::Tab, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("esc"), - Some((KeyCode::Esc, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("left"), - Some((KeyCode::Left, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("alt+right"), - Some((KeyCode::Right, KeyModifiers::ALT)) - ); - } - - #[test] - fn parse_ctrl_shift() { - assert_eq!( - parse_key_combo("ctrl+shift+a"), - Some(( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - )) - ); - } - - #[test] - fn parse_f_key() { - assert_eq!( - parse_key_combo("f5"), - Some((KeyCode::F(5), KeyModifiers::empty())) - ); - } - - #[test] - fn parse_punctuation_key() { - assert_eq!( - parse_key_combo("ctrl+`"), - Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) - ); - } - - #[test] - fn uppercase_char_implies_shift() { - assert_eq!( - parse_key_combo("D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) - ); - } - - #[test] - fn explicit_shift_and_uppercase_do_not_double_apply_shift() { - assert_eq!( - parse_key_combo("shift+D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) - ); - } - - #[test] - fn invalid_keybinding_is_rejected() { - assert_eq!(parse_key_combo("ctrl+foo+bar"), None); - assert_eq!(parse_key_combo("ctrl+"), None); - } - - #[test] - fn default_keybinds_parse() { - let config = Config::default(); - let kb = config.keybinds(); - assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.close_workspace.0, KeyCode::Char('d')); - assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); - assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); - assert_eq!(kb.close_pane.0, KeyCode::Char('x')); - assert_eq!(kb.fullscreen.0, KeyCode::Char('f')); - assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); - assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); - assert!(kb.custom_commands.is_empty()); - } - - #[test] - fn custom_keybinds_from_toml() { - let toml = r#" -[keys] -prefix = "ctrl+a" -new_workspace = "c" -rename_workspace = "shift+r" -close_workspace = "ctrl+d" -split_vertical = "s" -split_horizontal = "shift+s" -close_pane = "ctrl+w" -fullscreen = "z" -resize_mode = "ctrl+r" -toggle_sidebar = "tab" -focus_pane_left = "alt+h" -focus_pane_right = "alt+right" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let (code, mods) = config.prefix_key(); - assert_eq!(code, KeyCode::Char('a')); - assert_eq!(mods, KeyModifiers::CONTROL); - - let kb = config.keybinds(); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('c'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('r'), KeyModifiers::SHIFT) - ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::CONTROL) - ); - assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); - assert_eq!( - kb.split_horizontal, - (KeyCode::Char('s'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(kb.fullscreen.0, KeyCode::Char('z')); - assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); - assert_eq!( - kb.focus_pane_left, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) - ); - assert_eq!( - kb.focus_pane_right, - Some((KeyCode::Right, KeyModifiers::ALT)) - ); - assert_eq!(kb.focus_pane_down, None); - assert_eq!(kb.focus_pane_up, None); - } - - #[test] - fn uppercase_keybind_from_toml_flows_into_shift_combo() { - let toml = r#" -[keys] -split_horizontal = "D" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!( - kb.split_horizontal, - (KeyCode::Char('d'), KeyModifiers::SHIFT) - ); - } - - #[test] - fn invalid_keybinding_produces_diagnostic_and_falls_back() { - let toml = r#" -[keys] -rename_workspace = "wat" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.rename_workspace_label, "shift+n"); - } - - #[test] - fn toast_config_parses() { - let toml = r#" -[ui.toast] -enabled = true -"#; - let config: Config = toml::from_str(toml).unwrap(); - assert!(config.ui.toast.enabled); - } - - #[test] - fn missing_onboarding_shows_setup() { - let config = Config::default(); - assert!(config.should_show_onboarding()); - } - - #[test] - fn onboarding_false_skips_setup() { - let config: Config = toml::from_str("onboarding = false").unwrap(); - assert!(!config.should_show_onboarding()); - } - - #[test] - fn upsert_top_level_bool_replaces_existing_value() { - let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n"; - let updated = upsert_top_level_bool(content, "onboarding", false); - assert!(updated.contains("onboarding = false")); - assert!(!updated.contains("onboarding = true")); - } - - #[test] - fn upsert_section_bool_adds_missing_section() { - let updated = upsert_section_bool("", "ui.toast", "enabled", true); - assert!(updated.contains("[ui.toast]")); - assert!(updated.contains("enabled = true")); - } - - #[test] - fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { - let toml = r#" -[keys] -new_workspace = "g" -rename_workspace = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("duplicate keybinding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.rename_workspace_label, "shift+n"); - } - - #[test] - fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" -rename_tab = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.rename_tab, None); - } - - #[test] - fn duplicate_shifted_symbol_binding_is_rejected_after_normalization() { - let toml = r#" -[keys] -new_workspace = "?" -rename_workspace = "shift+/" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate keybinding") - && d.contains("keys.rename_workspace") - && d.contains("keys.new_workspace") - })); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('?'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.rename_workspace_label, "shift+n"); - } - - #[test] - fn custom_command_conflicting_with_shifted_symbol_reserved_key_is_disabled() { - let toml = r#" -[[keys.command]] -key = "shift+/" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("navigate.keybind_help") - })); - assert!(kb.custom_commands.is_empty()); - } - - #[test] - fn custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!( - kb.custom_commands[0].key, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.custom_commands[0].label, "g"); - assert_eq!(kb.custom_commands[0].command, "echo hi"); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); - } - - #[test] - fn pane_custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -type = "pane" -command = "lazygit" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); - } - - #[test] - fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" - -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("keys.new_workspace") - })); - assert!(kb.custom_commands.is_empty()); - } - - #[test] - fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { - let toml = r#" -[[keys.command]] -key = "q" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("navigate.quit") - })); - assert!(kb.custom_commands.is_empty()); - } - - #[test] - fn live_keybinds_reject_invalid_keybinding() { - let config: Config = toml::from_str( - r#" -[keys] -rename_workspace = "wat" -"#, - ) - .unwrap(); - - let diagnostics = config.live_keybinds().unwrap_err(); - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); - } - - #[test] - fn live_keybinds_ignore_non_key_diagnostics() { - let config: Config = toml::from_str( - r#" -[keys] -new_workspace = "g" - -[ui.sound] -done_path = "sounds/missing.mp3" -"#, - ) - .unwrap(); - - let live = config.live_keybinds().unwrap(); - assert_eq!( - live.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - } - - #[test] - fn sound_table_config_parses() { - let toml = r#" -[ui.sound] -enabled = true -path = "sounds/all.mp3" -done_path = "sounds/done.mp3" -request_path = "/tmp/request.mp3" - -[ui.sound.agents] -droid = "off" -claude = "on" -"#; - let config: Config = toml::from_str(toml).unwrap(); - assert!(config.ui.sound.enabled); - assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3"))); - assert_eq!( - config.ui.sound.done_path, - Some(PathBuf::from("sounds/done.mp3")) - ); - assert_eq!( - config.ui.sound.request_path, - Some(PathBuf::from("/tmp/request.mp3")) - ); - assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); - assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); - assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); - } - - #[test] - fn sound_path_resolution_prefers_specific_over_global() { - let config: Config = toml::from_str( - r#" -[ui.sound] -path = "sounds/all.mp3" -done_path = "sounds/done.mp3" -"#, - ) - .unwrap(); - - let config_root = config_path().parent().unwrap().to_path_buf(); - assert_eq!( - config.ui.sound.path_for(crate::sound::Sound::Done), - Some(config_root.join("sounds/done.mp3")) - ); - assert_eq!( - config.ui.sound.path_for(crate::sound::Sound::Request), - Some(config_root.join("sounds/all.mp3")) - ); - } - - #[test] - fn missing_sound_file_produces_diagnostic() { - let config: Config = toml::from_str( - r#" -[ui.sound] -done_path = "sounds/missing.mp3" -"#, - ) - .unwrap(); - - let diagnostics = config.collect_diagnostics(); - assert!(diagnostics.iter().any( - |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound") - )); - } - - #[test] - fn non_mp3_sound_file_produces_diagnostic() { - let config: Config = toml::from_str( - r#" -[ui.sound] -path = "sounds/notification.wav" -"#, - ) - .unwrap(); - - let diagnostics = config.collect_diagnostics(); - assert!(diagnostics.iter().any(|diag| { - diag.contains("ui.sound.path") && diag.contains("expected an mp3 file") - })); - } - - #[test] - fn advanced_defaults_include_scrollback_limit_bytes() { - let config = Config::default(); - assert_eq!( - config.advanced.scrollback_limit_bytes, - DEFAULT_SCROLLBACK_LIMIT_BYTES - ); - } - - #[test] - fn advanced_config_parses() { - let toml = r#" -[advanced] -allow_nested = true -scrollback_limit_bytes = 12345 -"#; - let config: Config = toml::from_str(toml).unwrap(); - assert!(config.advanced.allow_nested); - assert_eq!(config.advanced.scrollback_limit_bytes, 12345); - } - - #[test] - fn advanced_legacy_scrollback_lines_alias_parses() { - let toml = r#" -[advanced] -scrollback_lines = 12345 -"#; - let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.advanced.scrollback_limit_bytes, 12345); - } - - #[test] - fn theme_name_parses() { - let toml = r#" -[theme] -name = "dracula" -"#; - let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.theme.name.as_deref(), Some("dracula")); - } - - #[test] - fn parse_color_accepts_reset_aliases() { - use ratatui::style::Color; - - for value in ["reset", "default", "none", "transparent"] { - assert_eq!(parse_color(value), Color::Reset, "value: {value}"); - } - } - - #[test] - fn theme_custom_overrides_parse() { - let toml = r##" -[theme] -name = "nord" - -[theme.custom] -panel_bg = "#1e1e2e" -accent = "#ff79c6" -red = "rgb(255, 85, 85)" -"##; - let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.theme.name.as_deref(), Some("nord")); - let custom = config.theme.custom.as_ref().unwrap(); - assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e")); - assert_eq!(custom.accent.as_deref(), Some("#ff79c6")); - assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)")); - assert!(custom.green.is_none()); - } - - #[test] - fn theme_defaults_when_missing() { - let config: Config = toml::from_str("").unwrap(); - assert!(config.theme.name.is_none()); - assert!(config.theme.custom.is_none()); - } -} |
