diff options
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/io.rs | 226 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 1089 | ||||
| -rw-r--r-- | src/config/model.rs | 216 | ||||
| -rw-r--r-- | src/config/sound.rs | 249 | ||||
| -rw-r--r-- | src/config/theme.rs | 163 |
5 files changed, 1943 insertions, 0 deletions
diff --git a/src/config/io.rs b/src/config/io.rs new file mode 100644 index 00000000..3bc18c4b --- /dev/null +++ b/src/config/io.rs @@ -0,0 +1,226 @@ +use std::path::{Path, PathBuf}; + +use tracing::warn; + +use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR}; + +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())) + } +} + +impl Config { + 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(err) => { + warn!(err = %err, "config parse error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config parse error: {err}; using defaults")], + }; + } + }, + Err(err) => { + warn!(err = %err, "config read error, using defaults"); + return LoadedConfig { + config: Self::default(), + diagnostics: vec![format!("config read error: {err}; using defaults")], + }; + } + } + } + LoadedConfig { + config: Self::default(), + diagnostics: Vec::new(), + } + } +} + +pub(super) 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) +} + +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() +} + +pub(super) 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 !inserted { + result.push(assignment); + } + + result.join("\n") + "\n" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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")); + } +} diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs new file mode 100644 index 00000000..07c682ba --- /dev/null +++ b/src/config/keybinds.rs @@ -0,0 +1,1089 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::Deserialize; +use tracing::warn; + +use super::Config; + +#[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, 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 detach: Option<(KeyCode, KeyModifiers)>, + pub detach_label: Option<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>, +} + +impl Config { + pub(super) 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> { + self.seen + .insert((scope, binding.0, binding.1), field.to_string()) + } + + fn conflict( + &self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + ) -> Option<&str> { + 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, + ) { + 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, + "shift+d", + (KeyCode::Char('d'), KeyModifiers::SHIFT), + &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.detach", + &self.keys.detach, + &mut diagnostics, + ), + 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(), + detach: optional_bindings[0].value, + detach_label: optional_bindings[0].label.clone(), + previous_workspace: optional_bindings[1].value, + previous_workspace_label: optional_bindings[1].label.clone(), + next_workspace: optional_bindings[2].value, + next_workspace_label: optional_bindings[2].label.clone(), + new_tab: bindings[3].value, + new_tab_label: bindings[3].label.clone(), + rename_tab: optional_bindings[3].value, + rename_tab_label: optional_bindings[3].label.clone(), + previous_tab: optional_bindings[4].value, + previous_tab_label: optional_bindings[4].label.clone(), + next_tab: optional_bindings[5].value, + next_tab_label: optional_bindings[5].label.clone(), + close_tab: optional_bindings[6].value, + close_tab_label: optional_bindings[6].label.clone(), + focus_pane_left: optional_bindings[7].value, + focus_pane_left_label: optional_bindings[7].label.clone(), + focus_pane_down: optional_bindings[8].value, + focus_pane_down_label: optional_bindings[8].label.clone(), + focus_pane_up: optional_bindings[9].value, + focus_pane_up_label: optional_bindings[9].label.clone(), + focus_pane_right: optional_bindings[10].value, + focus_pane_right_label: optional_bindings[10].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) + } +} + +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("+") +} + +pub(super) 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)) +} + +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 crate::config::Config; + + #[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, + (KeyCode::Char('d'), KeyModifiers::SHIFT) + ); + assert_eq!(kb.detach, None); + 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] +close_pane = "X" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + assert_eq!(kb.close_pane, (KeyCode::Char('x'), 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 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 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()) + ); + } +} diff --git a/src/config/model.rs b/src/config/model.rs new file mode 100644 index 00000000..70eede5a --- /dev/null +++ b/src/config/model.rs @@ -0,0 +1,216 @@ +use serde::Deserialize; + +use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; + +#[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, +} + +#[derive(Debug)] +pub struct LoadedConfig { + pub config: Config, + pub diagnostics: Vec<String>, +} + +#[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: "shift+d" + pub close_workspace: String, + /// Optional explicit detach shortcut in server/client mode. Unset by default. + pub detach: 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, +} + +impl Default for KeysConfig { + fn default() -> Self { + Self { + prefix: "ctrl+b".into(), + new_workspace: "n".into(), + rename_workspace: "shift+n".into(), + close_workspace: "shift+d".into(), + detach: "".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 AdvancedConfig { + fn default() -> Self { + Self { + allow_nested: false, + scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 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); + } +} diff --git a/src/config/sound.rs b/src/config/sound.rs new file mode 100644 index 00000000..063a521b --- /dev/null +++ b/src/config/sound.rs @@ -0,0 +1,249 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +use crate::detect::Agent; + +use super::io::resolve_config_relative_path; + +#[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: &str| !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 SoundConfig { + fn default() -> Self { + Self { + enabled: true, + path: None, + done_path: None, + request_path: None, + agents: AgentSoundOverrides::default(), + } + } +} + +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)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::config::{config_path, Config}; + + #[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") + })); + } +} diff --git a/src/config/theme.rs b/src/config/theme.rs new file mode 100644 index 00000000..9d641862 --- /dev/null +++ b/src/config/theme.rs @@ -0,0 +1,163 @@ +use serde::Deserialize; +use tracing::warn; + +/// 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>, +} + +/// 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, + _ => {} + } + + 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); + } + } + } + + 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); + } + } + } + + 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 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[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()); + } +} |
