//! 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, /// 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, { /// Creates a new runner instance with all required dependencies. pub fn new( terminal: &'a mut Terminal, 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 (``) so that they can /// never be confused with a sequence of ordinary characters. fn key_token(key: KeyEvent) -> Option { let base = match key.code { KeyCode::Char(' ') => "".to_string(), KeyCode::Char(c) => c.to_string(), KeyCode::Tab => "".to_string(), KeyCode::BackTab => "".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 { 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) -> 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 )); } }