Version Check / check-version (pull_request) Successful in 10s
The headline flow — fill a config from its template — did not work. Three bugs, each individually enough to make the tool feel unfinished: - `add_missing` was unreachable. `find_binding` returned the first match in a linear array and `edit_append` was also bound to `a`, 17 entries earlier. Bindings now resolve through one table built from the config, and `KeybindsConfig::conflicts()` reports any key bound twice instead of letting one silently shadow the other. `add_missing` moves to `<Space>`, with `A` to take every missing default at once. - Item status was ignored on write. Every writer fell back to `template_value`, so "(missing)" keys landed in the output regardless and saving was close to a copy. Unaccepted entries are now omitted. - Parse errors were swallowed, then the good file was overwritten. A malformed target opened as an empty editor and `:w` truncated it. Parse failures are now fatal, reported with context, before the TUI starts. Also: - Split "differs from the template" (ItemStatus::Overridden) from "you edited this" (ConfigItem::dirty). A real config differs from its example on nearly every line, so the old Modified flag painted the whole list orange and carried no signal. - Splice template-only entries in at their natural position instead of appending them, so a missing nested key no longer strands itself at the bottom of the tree at depth 3. - Preserve .env comments and blank lines across a save. - Header naming the output file and the template it is compared against, plus missing/changed counts. Previously nothing on screen said which files were in play or where `:w` would write. - `?` opens a two-column keybinding reference built from the user's own config, scrollable on short terminals. The status bar drops to a few contextual hints; the old one joined 11+ hints into an unwrapped line that truncated at 80 columns. - Foldable groups, which the `+ ` prefix had always implied. - Search covers values as well as keys, reports `match n/m`, and `Esc` restores the pre-search cursor. - Quitting with unsaved changes is refused; added `:q!`, `:x`, `:w <path>`. - Status messages expire after 4s instead of permanently replacing hints. - Cancelling a rename on a just-created item removes it again. - Editing a Number field with text demotes the type instead of failing obscurely at write time. - Undo-tree branches are reachable via `g-`/`g+` and reported on undo. - Poll instead of blocking, so resizes repaint; drop the unused mouse capture that was costing terminal text selection for nothing. - `--format` accepts xml, ini and properties, which were already supported. 36 tests, up from 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5mrbEFmwv2A4cdzbcSiS1
460 lines
16 KiB
Rust
460 lines
16 KiB
Rust
//! 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 (`"<Tab>,za"`).
|
|
/// Non-character keys are written in angle brackets: `<Space>`, `<Tab>`.
|
|
#[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: "<Space>".to_string(),
|
|
accept_all: "A".to_string(),
|
|
toggle_fold: "<Tab>,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<String> {
|
|
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<String>) {
|
|
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::<Config>(&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 == "<Tab>" && *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), "<Tab>");
|
|
}
|
|
}
|