diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 21:13:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 21:13:41 +0300 |
| commit | fa1adf1a26033fc50a2920d96045e76aba93bddb (patch) | |
| tree | 383bd48d0c6bcad7ccf1d5c907f78fd7eee1797f /src/config.rs | |
| parent | d05ba2ea9f1ab2379f5be5ef7fc3898a03f5dce1 (diff) | |
feat: add onboarding and toast notification preferences
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 159 |
1 files changed, 158 insertions, 1 deletions
diff --git a/src/config.rs b/src/config.rs index 8df9d6b5..c1195a24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,9 +6,16 @@ use tracing::warn; 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 keys: KeysConfig, pub ui: UiConfig, } @@ -53,6 +60,8 @@ pub struct UiConfig { /// 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, } @@ -141,11 +150,18 @@ impl Default for UiConfig { 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 { @@ -174,6 +190,10 @@ impl Default for AgentSoundOverrides { } impl Config { + pub fn should_show_onboarding(&self) -> bool { + self.onboarding.unwrap_or(true) + } + pub fn load() -> LoadedConfig { let path = config_path(); if path.exists() { @@ -470,7 +490,20 @@ pub fn parse_color(s: &str) -> ratatui::style::Color { } } -fn config_path() -> PathBuf { +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(dir) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(dir).join("herdr/config.toml") } else if let Ok(home) = std::env::var("HOME") { @@ -480,6 +513,93 @@ fn config_path() -> PathBuf { } } +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()) + } +} + +fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> 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(); @@ -724,6 +844,43 @@ rename_workspace = "wat" } #[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] |
