//! User configuration: theming and keybindings. //! //! Everything the TUI reacts to is declared here. Actions are resolved through //! one table built from the user's config, so a rebound key takes effect //! everywhere — including the on-screen hints and the help overlay. use ratatui::style::Color; use serde::{Deserialize, Serialize}; use std::fs; /// Configuration for the application's appearance. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(default)] pub struct ThemeConfig { /// If true, skip rendering the background block to let the terminal's transparency show. pub transparent: bool, /// Default background. pub bg_normal: String, /// Background for selected items and standard UI elements. pub bg_highlight: String, /// Active element background (e.g., insert mode). pub bg_active: String, /// Active element background (e.g., search mode). pub bg_search: String, /// Standard text. pub fg_normal: String, /// Secondary/inactive text. pub fg_dimmed: String, /// Text on selected items. pub fg_highlight: String, /// Red/Alert color for missing items. pub fg_warning: String, /// Accent color for items edited this session. pub fg_modified: String, /// High-contrast accent for titles and active UI elements. pub fg_accent: String, /// Borders. pub border_normal: String, /// Active borders (e.g., input mode). pub border_active: String, /// Color for tree indentation depth 1. pub tree_depth_1: String, /// Color for tree indentation depth 2. pub tree_depth_2: String, /// Color for tree indentation depth 3. pub tree_depth_3: String, /// Color for tree indentation depth 4. pub tree_depth_4: String, } impl ThemeConfig { /// Internal helper to parse a hex color string ("#RRGGBB") into a TUI Color. fn parse_hex(hex: &str) -> Color { let hex = hex.trim_start_matches('#'); if hex.len() == 6 { let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); Color::Rgb(r, g, b) } else { Color::Reset } } pub fn bg_normal(&self) -> Color { Self::parse_hex(&self.bg_normal) } pub fn bg_highlight(&self) -> Color { Self::parse_hex(&self.bg_highlight) } pub fn bg_active(&self) -> Color { Self::parse_hex(&self.bg_active) } pub fn bg_search(&self) -> Color { Self::parse_hex(&self.bg_search) } pub fn fg_normal(&self) -> Color { Self::parse_hex(&self.fg_normal) } pub fn fg_dimmed(&self) -> Color { Self::parse_hex(&self.fg_dimmed) } pub fn fg_highlight(&self) -> Color { Self::parse_hex(&self.fg_highlight) } pub fn fg_warning(&self) -> Color { Self::parse_hex(&self.fg_warning) } pub fn fg_modified(&self) -> Color { Self::parse_hex(&self.fg_modified) } pub fn fg_accent(&self) -> Color { Self::parse_hex(&self.fg_accent) } pub fn border_normal(&self) -> Color { Self::parse_hex(&self.border_normal) } pub fn border_active(&self) -> Color { Self::parse_hex(&self.border_active) } pub fn tree_depth_1(&self) -> Color { Self::parse_hex(&self.tree_depth_1) } pub fn tree_depth_2(&self) -> Color { Self::parse_hex(&self.tree_depth_2) } pub fn tree_depth_3(&self) -> Color { Self::parse_hex(&self.tree_depth_3) } pub fn tree_depth_4(&self) -> Color { Self::parse_hex(&self.tree_depth_4) } } impl Default for ThemeConfig { /// Default theme: Semantic Catppuccin Mocha. fn default() -> Self { Self { transparent: false, bg_normal: "#1e1e2e".to_string(), // base bg_highlight: "#89b4fa".to_string(), // blue bg_active: "#a6e3a1".to_string(), // green bg_search: "#cba6f7".to_string(), // mauve fg_normal: "#cdd6f4".to_string(), // text fg_dimmed: "#6c7086".to_string(), // overlay0 fg_highlight: "#1e1e2e".to_string(), // base (dark for contrast against highlights) fg_warning: "#f38ba8".to_string(), // red fg_modified: "#fab387".to_string(), // peach fg_accent: "#b4befe".to_string(), // lavender border_normal: "#45475a".to_string(), // surface1 border_active: "#a6e3a1".to_string(), // green tree_depth_1: "#b4befe".to_string(), // lavender tree_depth_2: "#cba6f7".to_string(), // mauve tree_depth_3: "#89b4fa".to_string(), // blue tree_depth_4: "#fab387".to_string(), // peach } } } /// Every action the user can trigger from Normal mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Down, Up, JumpTop, JumpBottom, Search, NextMatch, PreviousMatch, Edit, EditAppend, EditSubstitute, Rename, AppendItem, PrependItem, AppendGroup, PrependGroup, ToggleGroup, DeleteItem, AddMissing, AcceptAll, ToggleFold, FoldAll, UnfoldAll, Undo, Redo, UndoBranchPrev, UndoBranchNext, Help, Command, Quit, } impl Action { /// Short human-readable label, used by the status bar and help overlay. pub fn label(self) -> &'static str { match self { Action::Down => "move down", Action::Up => "move up", Action::JumpTop => "jump to top", Action::JumpBottom => "jump to bottom", Action::Search => "search keys and values", Action::NextMatch => "next match", Action::PreviousMatch => "previous match", Action::Edit => "edit value (cursor at start)", Action::EditAppend => "edit value (cursor at end)", Action::EditSubstitute => "replace value", Action::Rename => "rename key", Action::AppendItem => "add item below", Action::PrependItem => "add item above", Action::AppendGroup => "add child group below", Action::PrependGroup => "add child group above", Action::ToggleGroup => "toggle group / value", Action::DeleteItem => "delete item", Action::AddMissing => "take template default", Action::AcceptAll => "take all missing defaults", Action::ToggleFold => "fold / unfold group", Action::FoldAll => "fold everything", Action::UnfoldAll => "unfold everything", Action::Undo => "undo", Action::Redo => "redo", Action::UndoBranchPrev => "previous undo branch", Action::UndoBranchNext => "next undo branch", Action::Help => "show this help", Action::Command => "command prompt", Action::Quit => "quit", } } /// Help-overlay grouping. pub fn section(self) -> &'static str { match self { Action::Down | Action::Up | Action::JumpTop | Action::JumpBottom | Action::Search | Action::NextMatch | Action::PreviousMatch | Action::ToggleFold | Action::FoldAll | Action::UnfoldAll => "Navigate", Action::Edit | Action::EditAppend | Action::EditSubstitute | Action::Rename => "Edit", Action::AppendItem | Action::PrependItem | Action::AppendGroup | Action::PrependGroup | Action::ToggleGroup | Action::DeleteItem => "Structure", Action::AddMissing | Action::AcceptAll => "Template", Action::Undo | Action::Redo | Action::UndoBranchPrev | Action::UndoBranchNext => { "History" } Action::Help | Action::Command | Action::Quit => "Session", } } } /// Custom keybindings for navigation and application control. /// /// A binding may list several alternatives separated by commas (`",za"`). /// Non-character keys are written in angle brackets: ``, ``. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(default)] pub struct KeybindsConfig { pub down: String, pub up: String, pub edit: String, pub edit_append: String, pub edit_substitute: String, /// Command string that saves, entered at the `:` prompt. pub save: String, /// Command string that quits, entered at the `:` prompt. pub quit: String, /// Normal-mode key that quits directly. pub quit_key: String, /// Normal-mode key that opens the `:` prompt. pub command: String, pub search: String, pub next_match: String, pub previous_match: String, pub jump_top: String, pub jump_bottom: String, pub append_item: String, pub prepend_item: String, pub delete_item: String, pub undo: String, pub redo: String, pub undo_branch_prev: String, pub undo_branch_next: String, pub rename: String, pub append_group: String, pub prepend_group: String, pub toggle_group: String, pub add_missing: String, pub accept_all: String, pub toggle_fold: String, pub fold_all: String, pub unfold_all: String, pub help: String, } impl Default for KeybindsConfig { fn default() -> Self { Self { down: "j".to_string(), up: "k".to_string(), edit: "i".to_string(), edit_append: "a".to_string(), edit_substitute: "s,S".to_string(), save: ":w".to_string(), quit: ":q".to_string(), quit_key: "q".to_string(), command: ":".to_string(), search: "/".to_string(), next_match: "n".to_string(), previous_match: "N".to_string(), jump_top: "gg".to_string(), jump_bottom: "G".to_string(), append_item: "o".to_string(), prepend_item: "O".to_string(), delete_item: "dd".to_string(), undo: "u".to_string(), redo: "U".to_string(), undo_branch_prev: "g-".to_string(), undo_branch_next: "g+".to_string(), rename: "r".to_string(), append_group: "alt+o".to_string(), prepend_group: "alt+O".to_string(), toggle_group: "t".to_string(), add_missing: "".to_string(), accept_all: "A".to_string(), toggle_fold: ",za".to_string(), fold_all: "zM".to_string(), unfold_all: "zR".to_string(), help: "?".to_string(), } } } impl KeybindsConfig { /// The full Normal-mode action table, in help-overlay order. /// /// Returned as `(binding spec, action)`; a spec may hold comma-separated /// alternatives, which [`Self::resolved`] expands. pub fn table(&self) -> Vec<(&str, Action)> { vec![ (&self.down, Action::Down), (&self.up, Action::Up), (&self.jump_top, Action::JumpTop), (&self.jump_bottom, Action::JumpBottom), (&self.search, Action::Search), (&self.next_match, Action::NextMatch), (&self.previous_match, Action::PreviousMatch), (&self.toggle_fold, Action::ToggleFold), (&self.fold_all, Action::FoldAll), (&self.unfold_all, Action::UnfoldAll), (&self.edit, Action::Edit), (&self.edit_append, Action::EditAppend), (&self.edit_substitute, Action::EditSubstitute), (&self.rename, Action::Rename), (&self.append_item, Action::AppendItem), (&self.prepend_item, Action::PrependItem), (&self.append_group, Action::AppendGroup), (&self.prepend_group, Action::PrependGroup), (&self.toggle_group, Action::ToggleGroup), (&self.delete_item, Action::DeleteItem), (&self.add_missing, Action::AddMissing), (&self.accept_all, Action::AcceptAll), (&self.undo, Action::Undo), (&self.redo, Action::Redo), (&self.undo_branch_prev, Action::UndoBranchPrev), (&self.undo_branch_next, Action::UndoBranchNext), (&self.help, Action::Help), (&self.command, Action::Command), (&self.quit_key, Action::Quit), ] } /// Every individual key sequence with the action it triggers. pub fn resolved(&self) -> Vec<(String, Action)> { let mut out = Vec::new(); for (spec, action) in self.table() { for key in spec.split(',').map(str::trim).filter(|k| !k.is_empty()) { out.push((key.to_string(), action)); } } out } /// The first key sequence bound to an action, for display in hints. pub fn key_for(&self, action: Action) -> String { self.table() .iter() .find(|(_, a)| *a == action) .and_then(|(spec, _)| spec.split(',').next()) .unwrap_or("") .trim() .to_string() } /// Reports any key sequence bound to more than one action. /// /// Without this an accidental collision silently shadows an action, which is /// exactly how `add_missing` ended up unreachable behind `edit_append`. pub fn conflicts(&self) -> Vec { let resolved = self.resolved(); let mut conflicts = Vec::new(); for (i, (key, action)) in resolved.iter().enumerate() { if let Some((_, other)) = resolved[..i].iter().find(|(k, _)| k == key) { conflicts.push(format!( "'{}' is bound to both {} and {}", key, other.label(), action.label() )); } } conflicts } } /// Root configuration structure for mould. #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct Config { #[serde(default)] pub theme: ThemeConfig, #[serde(default)] pub keybinds: KeybindsConfig, } /// Loads the configuration from the user's config directory (`~/.config/mould/config.toml`). /// /// Returns the config alongside an optional problem description. A broken config /// still falls back to defaults, but the caller surfaces the reason instead of /// leaving the user wondering why their settings did nothing. pub fn load_config() -> (Config, Option) { let Some(mut path) = dirs::config_dir() else { return (Config::default(), None); }; path.push("mould"); path.push("config.toml"); if !path.exists() { return (Config::default(), None); } let config = match fs::read_to_string(&path) { Ok(content) => match toml::from_str::(&content) { Ok(config) => config, Err(e) => return (Config::default(), Some(e.message().to_string())), }, Err(e) => return (Config::default(), Some(e.to_string())), }; let conflicts = config.keybinds.conflicts(); let warning = conflicts.first().cloned(); (config, warning) } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_have_no_conflicting_binds() { assert!(KeybindsConfig::default().conflicts().is_empty()); } #[test] fn conflicts_are_reported() { let kb = KeybindsConfig { add_missing: "a".to_string(), ..Default::default() }; let conflicts = kb.conflicts(); assert_eq!(conflicts.len(), 1); assert!(conflicts[0].contains("'a'")); } /// The shipped example must stay loadable, or it teaches people a broken config. #[test] fn example_config_parses_and_matches_defaults() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.example.toml"); let content = std::fs::read_to_string(path).unwrap(); let config: Config = toml::from_str(&content).unwrap(); assert!(config.keybinds.conflicts().is_empty()); let defaults = KeybindsConfig::default(); for ((spec, action), (default_spec, _)) in config.keybinds.table().iter().zip(defaults.table()) { assert_eq!( *spec, default_spec, "example config drifted from the default for {:?}", action ); } assert_eq!(config.theme.bg_normal, ThemeConfig::default().bg_normal); } #[test] fn alternatives_expand() { let kb = KeybindsConfig::default(); let resolved = kb.resolved(); assert!(resolved.iter().any(|(k, a)| k == "" && *a == Action::ToggleFold)); assert!(resolved.iter().any(|(k, a)| k == "za" && *a == Action::ToggleFold)); // Hints show the first alternative. assert_eq!(kb.key_for(Action::ToggleFold), ""); } }