Files
mould-rs/src/runner.rs
T
nvrlandClaude Opus 5 e8634c17a2
Version Check / check-version (pull_request) Successful in 10s
fix: make the template workflow real, and the UI legible
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
2026-09-05 05:01:39 +02:00

390 lines
14 KiB
Rust

//! Orchestrates the main application execution, terminal events, and TUI rendering.
//!
//! 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::{Action, Config};
use crate::format::FormatHandler;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::Backend;
use std::io;
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.
terminal: &'a mut Terminal<B>,
/// Mutable reference to the application state.
app: &'a mut App,
/// Loaded user configuration (keybinds, theme).
config: &'a Config,
/// 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").
key_sequence: String,
}
impl<'a, B: Backend> AppRunner<'a, B>
where
io::Error: From<B::Error>,
{
/// Creates a new runner instance with all required dependencies.
pub fn new(
terminal: &'a mut Terminal<B>,
app: &'a mut App,
config: &'a Config,
handler: &'a dyn FormatHandler,
) -> Self {
Self {
terminal,
app,
config,
handler,
bindings: config.keybinds.resolved(),
command_buffer: String::new(),
key_sequence: String::new(),
}
}
/// Starts the main application loop.
///
/// 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 !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(())
}
/// Primary dispatcher for all keyboard events.
///
/// It delegates handling to specialized methods based on the
/// current application mode.
fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> {
match self.app.mode {
Mode::Normal => self.handle_normal_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() {
self.handle_command_mode(key)
} else {
self.handle_navigation_mode(key)
}
}
/// Logic for entering and executing ":" style commands.
fn handle_command_mode(&mut self, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Enter => {
let cmd = self.command_buffer.clone();
self.command_buffer.clear();
self.app.prompt = None;
self.execute_command(cmd.trim())
}
KeyCode::Esc => {
self.command_buffer.clear();
self.app.prompt = None;
self.app.clear_status();
Ok(())
}
KeyCode::Backspace => {
self.command_buffer.pop();
self.sync_command_prompt();
Ok(())
}
KeyCode::Char(c) => {
self.command_buffer.push(c);
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 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(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(())
}
/// 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),
}
}
/// 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 {
self.bindings
.iter()
.any(|(bind, _)| bind.starts_with(&self.key_sequence) && bind != &self.key_sequence)
}
/// 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(),
_ => {
self.app.input.handle_event(&Event::Key(key));
}
}
Ok(())
}
/// Handles search mode key events and live-updates the match position.
fn handle_search_mode(&mut self, key: KeyEvent) -> io::Result<()> {
match key.code {
// 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.sync_search_prompt();
self.app.jump_next_match();
}
KeyCode::Char(c) => {
self.app.search_query.push(c);
self.sync_search_prompt();
self.app.jump_next_match();
}
_ => {}
}
Ok(())
}
/// Maps command strings (like `:w`) to internal application actions.
fn execute_command(&mut self, cmd: &str) -> io::Result<()> {
let kb = &self.config.keybinds;
let save = kb.save.clone();
let quit = kb.quit.clone();
// `: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(())
}
/// 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
));
}
}