fix: make the template workflow real, and the UI legible
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:
nvrlandClaude Opus 5 committed 2026-09-05 05:01:39 +02:00
1 parent 2e974e0503
commit e8634c17a2
16 files changed
+2218 -915

No files matched your search

+230 -220
View File
@@ -1,18 +1,22 @@
//! Orchestrates the main application execution, terminal events, and TUI rendering.
//!
//! The `AppRunner` is responsible for the event loop, intercepting raw
//! keyboard input, and translating it into high-level application actions.
//! The `AppRunner` owns the event loop, turns raw key events into the
//! [`Action`]s declared in the user's config, and runs the `:` command line.
use crate::app::{App, InsertVariant, Mode};
use crate::config::Config;
use crate::config::{Action, Config};
use crate::format::FormatHandler;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::Backend;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tui_input::backend::crossterm::EventHandler;
/// How often the loop wakes up with no input, so timed UI state can settle.
const TICK: Duration = Duration::from_millis(250);
/// Manages the main application execution loop, event handling, and terminal interaction.
pub struct AppRunner<'a, B: Backend> {
/// Reference to the terminal instance.
@@ -21,10 +25,10 @@ pub struct AppRunner<'a, B: Backend> {
app: &'a mut App,
/// Loaded user configuration (keybinds, theme).
config: &'a Config,
/// Path where the final configuration will be saved.
output_path: &'a Path,
/// Handler for the specific file format (env, json, yaml, toml).
handler: &'a dyn FormatHandler,
/// Every key sequence and the action it triggers, resolved from the config once.
bindings: Vec<(String, Action)>,
/// Buffer for storing active command entry (e.g., ":w").
command_buffer: String,
/// Buffer for storing multi-key sequence of presses (e.g., "gg").
@@ -40,15 +44,14 @@ where
terminal: &'a mut Terminal<B>,
app: &'a mut App,
config: &'a Config,
output_path: &'a Path,
handler: &'a dyn FormatHandler,
) -> Self {
Self {
terminal,
app,
config,
output_path,
handler,
bindings: config.keybinds.resolved(),
command_buffer: String::new(),
key_sequence: String::new(),
}
@@ -56,15 +59,25 @@ where
/// Starts the main application loop.
///
/// This loop continues until `self.app.running` is set to false.
/// Each iteration draws the UI and waits for a keyboard event.
/// This loop continues until `self.app.running` is set to false. It polls
/// rather than blocking so that a resize or an expiring status message
/// repaints without waiting for the next keystroke.
pub fn run(&mut self) -> io::Result<()> {
while self.app.running {
self.terminal
.draw(|f| crate::ui::draw(f, self.app, self.config))?;
if let Event::Key(key) = event::read()? {
self.handle_key_event(key)?;
if !event::poll(TICK)? {
continue;
}
match event::read()? {
// Key *releases* are delivered on some terminals; acting on both
// edges would fire every binding twice.
Event::Key(key) if key.kind != KeyEventKind::Release => {
self.handle_key_event(key)?;
}
_ => {}
}
}
Ok(())
@@ -77,12 +90,27 @@ where
fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> {
match self.app.mode {
Mode::Normal => self.handle_normal_mode(key),
Mode::Insert => self.handle_insert_mode(key),
Mode::InsertKey => self.handle_insert_key_mode(key),
Mode::Insert | Mode::InsertKey => self.handle_insert_mode(key),
Mode::Search => self.handle_search_mode(key),
Mode::Help => self.handle_help_mode(key),
}
}
/// The help overlay scrolls with the usual movement keys; anything else closes it.
fn handle_help_mode(&mut self, key: KeyEvent) -> io::Result<()> {
let token = Self::key_token(key);
let is = |action| token.as_deref() == Some(self.config.keybinds.key_for(action)).as_deref();
if is(Action::Down) || key.code == KeyCode::Down {
self.app.scroll_help(true);
} else if is(Action::Up) || key.code == KeyCode::Up {
self.app.scroll_help(false);
} else {
self.app.toggle_help();
}
Ok(())
}
/// Handles keys in Normal mode, separating navigation from command entry.
fn handle_normal_mode(&mut self, key: KeyEvent) -> io::Result<()> {
if !self.command_buffer.is_empty() {
@@ -98,204 +126,156 @@ where
KeyCode::Enter => {
let cmd = self.command_buffer.clone();
self.command_buffer.clear();
self.execute_command(&cmd)
self.app.prompt = None;
self.execute_command(cmd.trim())
}
KeyCode::Esc => {
self.command_buffer.clear();
self.app.status_message = None;
self.app.prompt = None;
self.app.clear_status();
Ok(())
}
KeyCode::Backspace => {
self.command_buffer.pop();
self.sync_command_status();
self.sync_command_prompt();
Ok(())
}
KeyCode::Char(c) => {
self.command_buffer.push(c);
self.sync_command_status();
self.sync_command_prompt();
Ok(())
}
_ => Ok(()),
}
}
/// Renders a key event as the string used in the keybinding config.
///
/// Named keys are spelled in angle brackets (`<Space>`) so that they can
/// never be confused with a sequence of ordinary characters.
fn key_token(key: KeyEvent) -> Option<String> {
let base = match key.code {
KeyCode::Char(' ') => "<Space>".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Tab => "<Tab>".to_string(),
KeyCode::BackTab => "<S-Tab>".to_string(),
_ => return None,
};
if key.modifiers.contains(KeyModifiers::ALT) {
Some(format!("alt+{}", base))
} else {
Some(base)
}
}
/// Handles primary navigation and transitions to insert or command modes.
///
/// This method manages multi-key sequences (like `gg`) and immediate
/// actions (like `i`). It correctly re-evaluates sequences to prevent
/// "one-key-behind" responsiveness bugs.
fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> {
let key_str = if let KeyCode::Char(c) = key.code {
let mut s = String::new();
if key.modifiers.contains(event::KeyModifiers::ALT) {
s.push_str("alt+");
}
s.push(c);
s
} else {
String::new()
};
if !key_str.is_empty() {
self.key_sequence.push_str(&key_str);
let mut match_result = self.find_binding();
if match_result.is_none() {
if self.is_prefix_binding() {
// It's a prefix for a multi-key bind (like first 'g' in 'gg'), wait for more.
return Ok(());
} else {
// Not a match and not a prefix, restart the buffer with the current key.
self.key_sequence.clear();
self.key_sequence.push_str(&key_str);
match_result = self.find_binding();
}
}
if let Some(action) = match_result {
self.key_sequence.clear();
match action {
"down" => self.app.next(),
"up" => self.app.previous(),
"edit" => self.app.enter_insert(InsertVariant::Start),
"edit_append" => self.app.enter_insert(InsertVariant::End),
"edit_substitute" => self.app.enter_insert(InsertVariant::Substitute),
"search" => {
self.app.mode = Mode::Search;
self.app.search_query.clear();
self.app.status_message = Some(format!("{} ", self.config.keybinds.search));
}
"next_match" => self.app.jump_next_match(),
"previous_match" => self.app.jump_previous_match(),
"jump_top" => self.app.jump_top(),
"jump_bottom" => self.app.jump_bottom(),
"append_item" => self.app.add_item(true, false, false),
"prepend_item" => self.app.add_item(false, false, false),
"delete_item" => self.app.delete_selected(),
"undo" => self.app.undo(),
"redo" => self.app.redo(),
"rename" => self.app.enter_insert_key(),
"append_group" => self.app.add_item(true, true, true),
"prepend_group" => self.app.add_item(false, true, true),
"toggle_group" => {
self.app.toggle_group_selected();
self.app.save_undo_state();
}
"add_missing" => {
self.add_missing_item();
}
"command" => {
self.command_buffer.push(':');
self.sync_command_status();
}
"quit" => self.app.running = false,
_ => {}
}
}
} else {
// Reset the sequence buffer if a non-character key (like Arrow Keys) is pressed.
let Some(token) = Self::key_token(key) else {
// Reset the sequence buffer if a non-character key is pressed.
self.key_sequence.clear();
match key.code {
KeyCode::Down => self.app.next(),
KeyCode::Up => self.app.previous(),
KeyCode::Enter => self.save_file()?,
KeyCode::Esc => self.app.status_message = None,
KeyCode::Enter => {
self.save_file(None);
}
KeyCode::Esc => self.app.clear_status(),
_ => {}
}
return Ok(());
};
self.key_sequence.push_str(&token);
let mut action = self.find_binding();
if action.is_none() {
if self.is_prefix_binding() {
// A partial multi-key bind (like the first 'g' of 'gg'): wait.
return Ok(());
}
// Not a match and not a prefix: restart from the current key.
self.key_sequence = token;
action = self.find_binding();
if action.is_none() && !self.is_prefix_binding() {
self.key_sequence.clear();
}
}
let Some(action) = action else { return Ok(()) };
self.key_sequence.clear();
self.dispatch(action);
Ok(())
}
/// Looks up the current `key_sequence` in the configured keybindings.
fn find_binding(&self) -> Option<&'static str> {
let binds = [
(&self.config.keybinds.down, "down"),
(&self.config.keybinds.up, "up"),
(&self.config.keybinds.edit, "edit"),
(&self.config.keybinds.edit_append, "edit_append"),
(&self.config.keybinds.edit_substitute, "edit_substitute"),
(&"S".to_string(), "edit_substitute"), // Semantic alias for Vim compatibility.
(&self.config.keybinds.search, "search"),
(&self.config.keybinds.next_match, "next_match"),
(&self.config.keybinds.previous_match, "previous_match"),
(&self.config.keybinds.jump_top, "jump_top"),
(&self.config.keybinds.jump_bottom, "jump_bottom"),
(&self.config.keybinds.append_item, "append_item"),
(&self.config.keybinds.prepend_item, "prepend_item"),
(&self.config.keybinds.delete_item, "delete_item"),
(&self.config.keybinds.undo, "undo"),
(&self.config.keybinds.redo, "redo"),
(&self.config.keybinds.rename, "rename"),
(&self.config.keybinds.append_group, "append_group"),
(&self.config.keybinds.prepend_group, "prepend_group"),
(&self.config.keybinds.toggle_group, "toggle_group"),
(&"a".to_string(), "add_missing"),
(&":".to_string(), "command"),
(&"q".to_string(), "quit"),
];
for (bind, action) in binds.iter() {
if bind == &&self.key_sequence {
return Some(*action);
/// Applies a resolved action to the application state.
fn dispatch(&mut self, action: Action) {
match action {
Action::Down => self.app.next(),
Action::Up => self.app.previous(),
Action::JumpTop => self.app.jump_top(),
Action::JumpBottom => self.app.jump_bottom(),
Action::Edit => self.app.enter_insert(InsertVariant::Start),
Action::EditAppend => self.app.enter_insert(InsertVariant::End),
Action::EditSubstitute => self.app.enter_insert(InsertVariant::Substitute),
Action::Rename => self.app.enter_insert_key(),
Action::Search => {
self.app.begin_search();
self.sync_search_prompt();
}
Action::NextMatch => self.app.jump_next_match(),
Action::PreviousMatch => self.app.jump_previous_match(),
Action::AppendItem => self.app.add_item(true, false, false),
Action::PrependItem => self.app.add_item(false, false, false),
Action::AppendGroup => self.app.add_item(true, true, true),
Action::PrependGroup => self.app.add_item(false, true, true),
Action::DeleteItem => self.app.delete_selected(),
Action::ToggleGroup => {
self.app.toggle_group_selected();
self.app.save_undo_state();
}
Action::AddMissing => self.app.accept_selected(),
Action::AcceptAll => self.app.accept_all_missing(),
Action::ToggleFold => self.app.toggle_fold(),
Action::FoldAll => self.app.set_all_folds(true),
Action::UnfoldAll => self.app.set_all_folds(false),
Action::Undo => self.app.undo(),
Action::Redo => self.app.redo(),
Action::UndoBranchPrev => self.app.cycle_undo_branch(false),
Action::UndoBranchNext => self.app.cycle_undo_branch(true),
Action::Help => self.app.toggle_help(),
Action::Command => {
self.command_buffer.push(':');
self.sync_command_prompt();
}
Action::Quit => self.request_quit(false),
}
None
}
/// Looks up the current `key_sequence` in the configured keybindings.
fn find_binding(&self) -> Option<Action> {
self.bindings
.iter()
.find(|(bind, _)| *bind == self.key_sequence)
.map(|(_, action)| *action)
}
/// Returns true if the current `key_sequence` is a partial prefix of any configured bind.
fn is_prefix_binding(&self) -> bool {
let binds = [
&self.config.keybinds.down,
&self.config.keybinds.up,
&self.config.keybinds.edit,
&self.config.keybinds.edit_append,
&self.config.keybinds.edit_substitute,
&self.config.keybinds.search,
&self.config.keybinds.next_match,
&self.config.keybinds.previous_match,
&self.config.keybinds.jump_top,
&self.config.keybinds.jump_bottom,
&self.config.keybinds.append_item,
&self.config.keybinds.prepend_item,
&self.config.keybinds.delete_item,
&self.config.keybinds.undo,
&self.config.keybinds.redo,
&self.config.keybinds.rename,
&self.config.keybinds.append_group,
&self.config.keybinds.prepend_group,
&self.config.keybinds.toggle_group,
];
for bind in binds.iter() {
if bind.starts_with(&self.key_sequence) && bind.as_str() != self.key_sequence {
return true;
}
}
false
self.bindings
.iter()
.any(|(bind, _)| bind.starts_with(&self.key_sequence) && bind != &self.key_sequence)
}
/// Adds a missing item from the template to the active configuration.
fn add_missing_item(&mut self) {
if let Some(var) = self.app.vars.get_mut(self.app.selected)
&& var.status == crate::format::ItemStatus::MissingFromActive {
var.status = crate::format::ItemStatus::Present;
if !var.is_group {
var.value = var.template_value.clone();
}
self.app.sync_input_with_selected();
self.app.save_undo_state();
}
}
/// Delegates key events to the `tui_input` handler during active value editing.
/// Delegates key events to the `tui_input` handler during active editing.
fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Esc => {
self.app.cancel_insert();
}
KeyCode::Enter => {
self.app.enter_normal();
}
KeyCode::Esc => self.app.cancel_insert(),
KeyCode::Enter => self.app.enter_normal(),
_ => {
self.app.input.handle_event(&Event::Key(key));
}
@@ -303,37 +283,21 @@ where
Ok(())
}
/// Delegates key events to the `tui_input` handler during active key renaming.
fn handle_insert_key_mode(&mut self, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Esc => {
self.app.cancel_insert();
}
KeyCode::Enter => {
self.app.enter_normal();
}
_ => {
self.app.input.handle_event(&Event::Key(key));
}
}
Ok(())
}
/// Handles search mode key events and live-updates search filtering.
/// Handles search mode key events and live-updates the match position.
fn handle_search_mode(&mut self, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Enter | KeyCode::Esc => {
self.app.mode = Mode::Normal;
self.app.status_message = None;
}
// Enter keeps the query so `n`/`N` continue to work; Esc abandons it
// and puts the cursor back where the search started.
KeyCode::Enter => self.app.end_search(true),
KeyCode::Esc => self.app.end_search(false),
KeyCode::Backspace => {
self.app.search_query.pop();
self.app.status_message = Some(format!("{}{}", self.config.keybinds.search, self.app.search_query));
self.sync_search_prompt();
self.app.jump_next_match();
}
KeyCode::Char(c) => {
self.app.search_query.push(c);
self.app.status_message = Some(format!("{}{}", self.config.keybinds.search, self.app.search_query));
self.sync_search_prompt();
self.app.jump_next_match();
}
_ => {}
@@ -341,39 +305,85 @@ where
Ok(())
}
/// Logic to map command strings (like ":w") to internal application actions.
/// Maps command strings (like `:w`) to internal application actions.
fn execute_command(&mut self, cmd: &str) -> io::Result<()> {
if cmd == self.config.keybinds.save {
self.save_file()
} else if cmd == self.config.keybinds.quit {
self.app.running = false;
Ok(())
} else if cmd == ":wq" {
self.save_file()?;
self.app.running = false;
Ok(())
} else {
self.app.status_message = Some(format!("Unknown command: {}", cmd));
Ok(())
}
}
let kb = &self.config.keybinds;
let save = kb.save.clone();
let quit = kb.quit.clone();
/// Attempts to write the current app state to the specified output file.
fn save_file(&mut self) -> io::Result<()> {
if self.handler.write(self.output_path, &self.app.vars).is_ok() {
self.app.status_message = Some(format!("Saved to {}", self.output_path.display()));
} else {
self.app.status_message = Some("Error saving file".to_string());
// `:w some/other/path` retargets the write without changing the session.
if let Some(rest) = cmd.strip_prefix(&format!("{} ", save)) {
let target = PathBuf::from(rest.trim());
self.save_file(Some(target));
return Ok(());
}
match cmd {
c if c == save => {
self.save_file(None);
}
c if c == quit => self.request_quit(false),
":q!" => self.request_quit(true),
":wq" | ":x" => {
if self.save_file(None) {
self.app.running = false;
}
}
":help" | ":h" => self.app.toggle_help(),
other => self.app.set_status(format!("Unknown command: {}", other)),
}
Ok(())
}
/// Synchronizes the status bar display with the active command buffer.
fn sync_command_status(&mut self) {
if self.command_buffer.is_empty() {
self.app.status_message = None;
} else {
self.app.status_message = Some(self.command_buffer.clone());
/// Quits, refusing to discard unsaved work unless `force` is set.
fn request_quit(&mut self, force: bool) {
if self.app.is_dirty && !force {
self.app.set_status(format!(
"Unsaved changes — {} to save, :q! to discard",
self.config.keybinds.save
));
return;
}
self.app.running = false;
}
/// Writes the current state to disk. Returns true on success.
fn save_file(&mut self, target: Option<PathBuf>) -> bool {
let path = target.unwrap_or_else(|| self.app.output_path.clone());
match self.handler.write(&path, &self.app.vars) {
Ok(()) => {
self.app.output_path = path.clone();
self.app.is_dirty = false;
for var in self.app.vars.iter_mut() {
var.dirty = false;
}
self.app.set_status(format!("Saved to {}", path.display()));
true
}
Err(e) => {
// The reason matters: a read-only path and an unrepresentable
// document fail very differently.
self.app.set_status(format!("Could not save {}: {}", path.display(), e));
false
}
}
}
/// Synchronizes the prompt line with the active command buffer.
fn sync_command_prompt(&mut self) {
self.app.prompt = if self.command_buffer.is_empty() {
None
} else {
Some(self.command_buffer.clone())
};
}
/// Synchronizes the prompt line with the active search query.
fn sync_search_prompt(&mut self) {
self.app.prompt = Some(format!(
"{}{}",
self.config.keybinds.key_for(Action::Search),
self.app.search_query
));
}
}