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
390 lines
14 KiB
Rust
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
|
|
));
|
|
}
|
|
}
|