fix: make the template workflow real, and the UI legible
Version Check / check-version (pull_request) Successful in 10s
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
This commit is contained in:
1 parent
2e974e0503
commit
e8634c17a2
16 files changed
+2218
-915
No files matched your search
+295
-18
@@ -1,3 +1,9 @@
|
||||
//! 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;
|
||||
@@ -24,7 +30,7 @@ pub struct ThemeConfig {
|
||||
pub fg_highlight: String,
|
||||
/// Red/Alert color for missing items.
|
||||
pub fg_warning: String,
|
||||
/// Accent color for modified items.
|
||||
/// Accent color for items edited this session.
|
||||
pub fg_modified: String,
|
||||
/// High-contrast accent for titles and active UI elements.
|
||||
pub fg_accent: String,
|
||||
@@ -99,7 +105,109 @@ impl Default for ThemeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -108,9 +216,14 @@ pub struct KeybindsConfig {
|
||||
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,
|
||||
pub normal_mode: 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,
|
||||
@@ -121,23 +234,32 @@ pub struct KeybindsConfig {
|
||||
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 {
|
||||
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".to_string(),
|
||||
edit_substitute: "s,S".to_string(),
|
||||
save: ":w".to_string(),
|
||||
quit: ":q".to_string(),
|
||||
normal_mode: "Esc".to_string(),
|
||||
quit_key: "q".to_string(),
|
||||
command: ":".to_string(),
|
||||
search: "/".to_string(),
|
||||
next_match: "n".to_string(),
|
||||
previous_match: "N".to_string(),
|
||||
@@ -148,14 +270,104 @@ pub struct KeybindsConfig {
|
||||
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 {
|
||||
@@ -165,18 +377,83 @@ pub struct Config {
|
||||
pub keybinds: KeybindsConfig,
|
||||
}
|
||||
|
||||
/// Loads the configuration from the user's home config directory (~/.config/mould/config.toml).
|
||||
/// Falls back to default settings if no configuration is found.
|
||||
pub fn load_config() -> Config {
|
||||
if let Some(mut config_dir) = dirs::config_dir() {
|
||||
config_dir.push("mould");
|
||||
config_dir.push("config.toml");
|
||||
/// 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 config_dir.exists()
|
||||
&& let Ok(content) = fs::read_to_string(config_dir)
|
||||
&& let Ok(config) = toml::from_str(&content) {
|
||||
return config;
|
||||
}
|
||||
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>");
|
||||
}
|
||||
Config::default()
|
||||
}
|
||||
Reference in new issue
Block a user