diff --git a/Cargo.lock b/Cargo.lock index 3ffebd9..9eca4e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -914,7 +914,7 @@ dependencies = [ [[package]] name = "mould" -version = "0.5.1" +version = "0.6.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 90443f5..30b1129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ authors = ["Nils Pukropp "] edition = "2024" name = "mould" -version = "0.5.1" +version = "0.6.0" [[bin]] name = "mould" diff --git a/README.md b/README.md index 99cca45..8944a1f 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ ## Features - **Universal Format Support**: Read, edit, and write `.env`, `JSON`, `YAML`, `TOML`, `XML`, `INI`, and `Properties` files seamlessly. -- **Tree View Navigation**: Edit nested data structures in a beautiful, depth-colored tree view. +- **Tree View Navigation**: Edit nested data structures in a depth-colored tree view, with foldable groups so a large `docker-compose.yml` stays readable. - **Smart Template Discovery**: Rule-based resolver automatically discovers relationships (e.g., `.env.example` vs `.env`, `config.template.properties` vs `config.properties`) and highlights missing keys. - **Strict Type Preservation**: Intelligently preserves data types (integers, booleans, strings) during edit-save cycles, ensuring your configuration stays valid. -- **Add Missing Keys**: Instantly pull missing keys and their default values from your template into your active configuration with a single keystroke. +- **Nothing Written Behind Your Back**: Keys that exist only in the template are marked `(missing)` and stay out of the output until you pull them in with `` (or `A` for all of them). +- **Comment Preservation**: `.env` comments and blank lines survive a save, so the documentation in your `.env.example` is not the price of using the tool. +- **Discoverable**: A header names the file being written and the template it is compared against; `?` opens the full keybinding reference, built from *your* config. - **Advanced Undo/Redo Engine**: Features a tree-based undo/redo history that ensures you never lose changes during complex branching edits. It properly tracks all modifications, including nested renames and item additions/deletions. - **Vim-inspired Workflow**: Navigate with `j`/`k`, `gg`/`G`, edit with `i`, search with `/`, and save with `:w`. - **Modern UI**: A polished, rounded interface featuring a semantic Catppuccin Mocha palette with support for terminal transparency. @@ -69,36 +71,59 @@ Open any configuration file in Neovim and run `:Mould`. It will launch a floatin ### Keybindings (Default) -- **Normal Mode** - - `j` / `Down`: Move selection down - - `k` / `Up`: Move selection up - - `gg`: Jump to the top - - `G`: Jump to the bottom - - `i`: Edit value (cursor at start). If selected is a group, enters rename mode. - - `a`: Edit value (cursor at end) - - `s` / `S`: Substitute value (clear and edit) - - `r`: Rename the current key or group. (Cannot rename array indices). - - `o`: Append a new item (as a sibling or array element). Enters rename mode for non-array items. - - `O`: Prepend a new item (as a sibling or array element). Enters rename mode for non-array items. - - `alt+o`: Append a new group/object as a child. Enters rename mode for the new group. - - `alt+O`: Prepend a new group/object as a child. Enters rename mode for the new group. - - `t`: Toggle the selected item between a group/object and a standard variable. - - `dd`: Delete the currently selected variable or group (removes all nested children). - - `u`: Undo the last change - - `U`: Redo the reverted change - - `a`: Add missing value from template to active config - - `/`: Search for configuration keys - - `n` / `N`: Jump to next / previous search match - - `:w` or `Enter`: Save the current configuration - - `:q` or `q`: Quit the application - - `:wq`: Save and quit - - `Esc`: Clear current command prompt +Press `?` inside `mould` for the same list, rendered from your own configuration. -- **Insert / Rename Modes** - - Type your value/key string. - - Arrow keys: Navigate within the input field - - `Enter`: Commit the value/key and return to Normal Mode. If renaming, checks for key collisions. - - `Esc`: Cancel edits and return to Normal Mode. Reverts changes to the current field. +**Navigate** + +- `j` / `k` (or arrow keys): Move the selection +- `gg` / `G`: Jump to the top / bottom +- `Tab` or `za`: Fold or unfold the selected group +- `zM` / `zR`: Fold / unfold everything +- `/`: Search over keys *and* values. `Enter` keeps the query for `n` / `N`; `Esc` cancels and restores the cursor +- `n` / `N`: Jump to the next / previous match + +**Edit** + +- `i`: Edit the value with the cursor at the start. On a group, renames it instead +- `a`: Edit the value with the cursor at the end +- `s` / `S`: Replace the value (clears it first) +- `r`: Rename the current key or group (array indices cannot be renamed) + +**Structure** + +- `o` / `O`: Add an item below / above (as a sibling, or as an array element) +- `alt+o` / `alt+O`: Add a child group below / above +- `t`: Toggle the selected item between a group and a plain value +- `dd`: Delete the selected item and everything nested under it + +**Template** + +- `Space`: Take the template default for the selected `(missing)` entry +- `A`: Take every missing template default at once + +**History** + +- `u` / `U`: Undo / redo +- `g-` / `g+`: Switch between alternative branches of the undo tree + +**Session** + +- `?`: Toggle the keybinding reference +- `:`: Open the command prompt +- `q`: Quit (refused while there are unsaved changes) + +**Commands** + +- `:w`: Write the output file +- `:w `: Write somewhere else, and keep writing there +- `:q`: Quit; `:q!` quits and discards changes +- `:wq` or `:x`: Write and quit + +**Insert / Rename Modes** + +- Type your value or key. Arrow keys navigate within the field +- `Enter`: Commit and return to Normal mode. Renames are checked for collisions +- `Esc`: Cancel. An item you just created and never named is removed again --- @@ -131,14 +156,17 @@ tree_depth_3 = "#89b4fa" tree_depth_4 = "#fab387" [keybinds] +# A binding may list alternatives separated by commas. +# Named keys are written in angle brackets: , . down = "j" up = "k" edit = "i" edit_append = "a" -edit_substitute = "s" +edit_substitute = "s,S" save = ":w" quit = ":q" -normal_mode = "Esc" +quit_key = "q" +command = ":" search = "/" next_match = "n" previous_match = "N" @@ -149,10 +177,18 @@ prepend_item = "O" delete_item = "dd" undo = "u" redo = "U" +undo_branch_prev = "g-" +undo_branch_next = "g+" rename = "r" append_group = "alt+o" prepend_group = "alt+O" toggle_group = "t" +add_missing = "" +accept_all = "A" +toggle_fold = ",za" +fold_all = "zM" +unfold_all = "zR" +help = "?" ``` --- @@ -165,7 +201,8 @@ toggle_group = "t" - **Separation of Concerns**: Clear boundaries between UI rendering, application state, input handling, and file format logic. - **Unified Data Model**: All parsed configuration data is normalized into a single `Vec` structure, simplifying application logic across different file types. -- **Vim-inspired Modality**: The application operates in distinct modes (`Normal`, `Insert`, `InsertKey`, `Search`), each with specific keybinding behaviors, enabling efficient interaction. +- **Vim-inspired Modality**: The application operates in distinct modes (`Normal`, `Insert`, `InsertKey`, `Search`, `Help`), each with specific keybinding behaviors, enabling efficient interaction. +- **One Keybinding Table**: Every Normal-mode key resolves through `KeybindsConfig::resolved()`, so a rebind takes effect in the dispatcher, the status-bar hints and the help overlay at once. `KeybindsConfig::conflicts()` reports any key bound to two actions rather than letting one silently shadow the other. - **Non-linear Undo/Redo**: A robust undo tree allows users to revert and re-apply changes across complex branching edit histories. ### Key Components @@ -181,8 +218,9 @@ toggle_group = "t" * `path`: A `Vec` (composed of `PathSegment::Key(String)` for object keys and `PathSegment::Index(usize)` for array indices) that defines its full hierarchical location. * `value`: `Option` holding the actual value. * `is_group`: A boolean indicating if this item is a structural node (object or array). - * `status`: (`ItemStatus::Present`, `MissingFromActive`, `Modified`) reflecting comparison with a template. + * `status`: (`ItemStatus::Present`, `MissingFromActive`, `Overridden`) reflecting comparison with a template. Whether *you* edited an entry is tracked separately by `dirty`, so a config that legitimately differs from its example file does not render as if every line had just been touched. * `value_type`: (`ValueType::String`, `Number`, `Bool`, `Null`) to ensure type preservation during writes. + * `collapsed` / `leading_trivia`: fold state, and the comment lines that preceded the entry in the source file. 3. **Format Handlers (`src/format/*`)** * Each file format has a dedicated handler (`EnvHandler`, `IniHandler`, `HierarchicalHandler`, `PropertiesHandler`) implementing the `FormatHandler` trait. @@ -195,7 +233,7 @@ toggle_group = "t" 5. **Terminal UI & Event Loop (`src/ui.rs` & `src/runner.rs`)** * **UI Rendering (`src/ui.rs`)**: Powered by the `ratatui` library. Renders a flexible layout consisting of a styled hierarchical list, an active input field for editing, and a dynamic status bar. - * **Event Runner (`src/runner.rs`)**: Powered by `crossterm`. It intercepts raw keyboard events, resolves multi-key sequences (like `dd` or `gg`), delegates character input to the `tui-input` backend during active editing, and dispatches actions to mutate the `App` state. It includes logic to prevent "one-key-behind" issues and manage complex keybindings like `alt+o`. + * **Event Runner (`src/runner.rs`)**: Powered by `crossterm`. It polls for events rather than blocking, so terminal resizes and expiring status messages repaint without waiting for a keystroke. It resolves multi-key sequences (like `dd` or `gg`), delegates character input to the `tui-input` backend during active editing, and dispatches actions to mutate the `App` state. 6. **Neovim Plugin (`lua/mould/init.lua`)** * Implements a Lua wrapper that calculates terminal geometries and launches the CLI `mould` binary inside an ephemeral, floating terminal buffer, ensuring automatic Neovim `checktime` synchronization upon `mould`'s exit. diff --git a/config.example.toml b/config.example.toml index d02c015..7de8aab 100644 --- a/config.example.toml +++ b/config.example.toml @@ -28,14 +28,20 @@ tree_depth_4 = "#fab387" [keybinds] # Keybindings for navigation and application control. +# +# A binding may list alternatives separated by commas (",za"). +# Named keys are written in angle brackets: , . +# A key bound to two actions is reported on startup instead of silently +# shadowing one of them. down = "j" up = "k" edit = "i" edit_append = "a" -edit_substitute = "s" +edit_substitute = "s,S" save = ":w" quit = ":q" -normal_mode = "Esc" +quit_key = "q" +command = ":" search = "/" next_match = "n" previous_match = "N" @@ -46,7 +52,15 @@ prepend_item = "O" delete_item = "dd" undo = "u" redo = "U" +undo_branch_prev = "g-" +undo_branch_next = "g+" rename = "r" append_group = "alt+o" prepend_group = "alt+O" toggle_group = "t" +add_missing = "" +accept_all = "A" +toggle_fold = ",za" +fold_all = "zM" +unfold_all = "zR" +help = "?" diff --git a/src/app.rs b/src/app.rs index bc97e07..fb104fe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,19 @@ -use crate::format::{ConfigItem, PathSegment}; -use tui_input::Input; +//! Central application state: the item tree, selection, modes and history. + +use crate::format::{ConfigItem, ItemStatus, PathSegment, ValueType}; use crate::undo::UndoTree; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tui_input::Input; + +/// How long a transient status message stays on screen. +const STATUS_TTL: Duration = Duration::from_secs(4); /// Represents the current operating mode of the application. /// /// Modality allows the application to reuse the same keyboard events /// for different contextual actions (navigation vs. text entry). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { /// Standard navigation and command mode. Normal, @@ -15,6 +23,8 @@ pub enum Mode { InsertKey, /// Active search mode for filtering keys. Search, + /// Full-screen keybinding reference. + Help, } /// Defines where the cursor starts when entering Insert mode. @@ -34,27 +44,43 @@ pub enum InsertVariant { pub struct App { /// The flattened list of configuration variables being edited. pub vars: Vec, - /// Index of the currently selected variable in the list. + /// Index into `vars` of the currently selected variable. pub selected: usize, /// The current interaction mode (Normal, Insert, etc.). pub mode: Mode, /// Whether the main application loop should continue running. pub running: bool, - /// An optional message to display in the status bar. - pub status_message: Option, + /// A transient message shown in the status bar, with the time it was set. + status: Option<(String, Instant)>, + /// A live prompt echo (`:w`, `/query`) which stays until the prompt closes. + pub prompt: Option, /// The active text input buffer for the selected variable. pub input: Input, - /// The current search query for filtering keys. + /// The current search query. pub search_query: String, + /// Selection to restore if the search is cancelled. + search_origin: usize, /// Undo history structured as a tree. pub undo_tree: UndoTree, + /// Where `:w` writes. + pub output_path: PathBuf, + /// The template being compared against, if one was found. + pub template_path: Option, + /// True if there are changes that have not been written to disk. + pub is_dirty: bool, + /// Index of an item created but not yet named, so cancelling removes it. + pending_new: Option, + /// Mode to return to when the help overlay closes. + previous_mode: Mode, + /// First visible row of the help overlay. + pub help_scroll: usize, } impl App { /// Initializes a new application instance with the provided variables. /// /// It automatically initializes the undo tree with the starting state. - pub fn new(vars: Vec) -> Self { + pub fn new(vars: Vec, output_path: PathBuf, template_path: Option) -> Self { let initial_input = vars.first().and_then(|v| v.value.clone()).unwrap_or_default(); let undo_tree = UndoTree::new(vars.clone(), 0); Self { @@ -62,59 +88,168 @@ impl App { selected: 0, mode: Mode::Normal, running: true, - status_message: None, + status: None, + prompt: None, input: Input::new(initial_input), search_query: String::new(), + search_origin: 0, undo_tree, + output_path, + template_path, + is_dirty: false, + pending_new: None, + previous_mode: Mode::Normal, + help_scroll: 0, } } - /// Returns the indices of variables that match the search query (case-insensitive). + // ---------------------------------------------------------------- status + + /// Shows a transient message in the status bar. + pub fn set_status(&mut self, msg: impl Into) { + self.status = Some((msg.into(), Instant::now())); + } + + /// Clears any transient message. + pub fn clear_status(&mut self) { + self.status = None; + } + + /// The message to display, or `None` once it has aged out. + /// + /// Without expiry a single "Saved to .env" would permanently replace the + /// keybinding hints, which is most of what a new user has to go on. + pub fn status_text(&self) -> Option<&str> { + self.status + .as_ref() + .filter(|(_, at)| at.elapsed() < STATUS_TTL) + .map(|(msg, _)| msg.as_str()) + } + + // --------------------------------------------------------------- folding + + /// Indices of items currently visible, honouring collapsed groups. + pub fn visible_indices(&self) -> Vec { + let mut visible = Vec::with_capacity(self.vars.len()); + let mut hidden_under: Option> = None; + + for (i, var) in self.vars.iter().enumerate() { + if let Some(prefix) = &hidden_under { + if var.is_descendant_of(prefix) { + continue; + } + hidden_under = None; + } + visible.push(i); + if var.is_group && var.collapsed { + hidden_under = Some(var.path.clone()); + } + } + visible + } + + /// Position of the selection within the visible list, for the list widget. + pub fn visible_position(&self) -> usize { + self.visible_indices() + .iter() + .position(|&i| i == self.selected) + .unwrap_or(0) + } + + /// Folds or unfolds the selected group. + pub fn toggle_fold(&mut self) { + let Some(var) = self.vars.get_mut(self.selected) else { return }; + if !var.is_group { + self.set_status("Not a group"); + return; + } + var.collapsed = !var.collapsed; + } + + /// Folds or unfolds every group at once. + pub fn set_all_folds(&mut self, collapsed: bool) { + for var in self.vars.iter_mut() { + if var.is_group { + var.collapsed = collapsed; + } + } + // The selection may now sit inside a folded group; walk out to its + // nearest visible ancestor rather than jumping the cursor to the top. + if collapsed { + self.reveal_selected(); + } + } + + /// Moves the selection to the closest visible ancestor if it is hidden. + fn reveal_selected(&mut self) { + let visible = self.visible_indices(); + if visible.contains(&self.selected) { + return; + } + self.selected = visible + .iter() + .rev() + .find(|&&i| i < self.selected) + .copied() + .unwrap_or(0); + self.sync_input_with_selected(); + } + + // ------------------------------------------------------------ navigation + + /// Returns the indices of variables matching the search query, over keys and values. pub fn matching_indices(&self) -> Vec { if self.search_query.is_empty() { return Vec::new(); } let query = self.search_query.to_lowercase(); - self.vars - .iter() - .enumerate() - .filter(|(_, v)| v.key.to_lowercase().contains(&query)) - .map(|(i, _)| i) + self.visible_indices() + .into_iter() + .filter(|&i| { + let v = &self.vars[i]; + v.key.to_lowercase().contains(&query) + || v + .value + .as_deref() + .is_some_and(|val| val.to_lowercase().contains(&query)) + }) .collect() } - /// Moves the selection to the next variable in the list, wrapping around if necessary. + /// Moves the selection to the next visible variable, wrapping around. pub fn next(&mut self) { - if !self.vars.is_empty() { - self.selected = (self.selected + 1) % self.vars.len(); - self.sync_input_with_selected(); + let visible = self.visible_indices(); + if visible.is_empty() { + return; } + let pos = visible.iter().position(|&i| i == self.selected).unwrap_or(0); + self.selected = visible[(pos + 1) % visible.len()]; + self.sync_input_with_selected(); } - /// Moves the selection to the previous variable in the list, wrapping around if necessary. + /// Moves the selection to the previous visible variable, wrapping around. pub fn previous(&mut self) { - if !self.vars.is_empty() { - if self.selected == 0 { - self.selected = self.vars.len() - 1; - } else { - self.selected -= 1; - } - self.sync_input_with_selected(); + let visible = self.visible_indices(); + if visible.is_empty() { + return; } + let pos = visible.iter().position(|&i| i == self.selected).unwrap_or(0); + self.selected = visible[(pos + visible.len() - 1) % visible.len()]; + self.sync_input_with_selected(); } /// Jumps the selection to the top of the list. pub fn jump_top(&mut self) { - if !self.vars.is_empty() { - self.selected = 0; + if let Some(&first) = self.visible_indices().first() { + self.selected = first; self.sync_input_with_selected(); } } /// Jumps the selection to the bottom of the list. pub fn jump_bottom(&mut self) { - if !self.vars.is_empty() { - self.selected = self.vars.len() - 1; + if let Some(&last) = self.visible_indices().last() { + self.selected = last; self.sync_input_with_selected(); } } @@ -156,6 +291,39 @@ impl App { } } + /// Enters search mode, remembering where to return to on cancel. + pub fn begin_search(&mut self) { + self.mode = Mode::Search; + self.search_query.clear(); + self.search_origin = self.selected; + } + + /// Leaves search mode. `keep` retains the query so `n`/`N` still work. + pub fn end_search(&mut self, keep: bool) { + self.mode = Mode::Normal; + self.prompt = None; + if !keep { + self.search_query.clear(); + self.selected = self.search_origin.min(self.vars.len().saturating_sub(1)); + self.sync_input_with_selected(); + } + } + + /// `n of m` progress through the current matches, for the status bar. + pub fn match_progress(&self) -> Option<(usize, usize)> { + if self.search_query.is_empty() { + return None; + } + let indices = self.matching_indices(); + if indices.is_empty() { + return Some((0, 0)); + } + let pos = indices.iter().position(|&i| i == self.selected); + Some((pos.map(|p| p + 1).unwrap_or(0), indices.len())) + } + + // ----------------------------------------------------------------- edits + /// Updates the input buffer to reflect the current state of the selected item. /// /// If in `InsertKey` mode, the buffer is synced with the item's `key`. @@ -177,22 +345,51 @@ impl App { pub fn commit_input(&mut self) -> bool { match self.mode { Mode::Insert => { - if let Some(var) = self.vars.get_mut(self.selected) - && !var.is_group { - var.value = Some(self.input.value().to_string()); - var.status = crate::format::ItemStatus::Modified; + let raw = self.input.value().to_string(); + let Some(var) = self.vars.get_mut(self.selected) else { return true }; + if var.is_group { + return true; + } + + // Keep the declared type honest: if the text no longer parses as + // the original type, demote it to a string here rather than + // failing obscurely at write time. + let mismatch = match var.value_type { + ValueType::Number if !raw.is_empty() && raw.parse::().is_err() => { + Some("not a number") } + ValueType::Bool + if !raw.is_empty() && raw.parse::().is_err() => + { + Some("not true/false") + } + _ => None, + }; + if mismatch.is_some() { + var.value_type = ValueType::String; + } + + var.value = Some(raw); + var.dirty = true; + if var.status == ItemStatus::MissingFromActive { + var.status = ItemStatus::Present; + } + if let Some(reason) = mismatch { + self.set_status(format!("{} — stored as text", reason)); + } + self.is_dirty = true; true } Mode::InsertKey => { let new_key = self.input.value().trim().to_string(); if new_key.is_empty() { - self.status_message = Some("Key cannot be empty".to_string()); + self.set_status("Key cannot be empty"); return false; } let selected_var = self.vars[self.selected].clone(); if selected_var.key == new_key { + self.pending_new = None; return true; } @@ -211,7 +408,7 @@ impl App { }); if exists { - self.status_message = Some(format!("Key already exists: {}", new_key)); + self.set_status(format!("Key already exists: {}", new_key)); return false; } @@ -224,20 +421,22 @@ impl App { let var = self.vars.get_mut(self.selected).unwrap(); var.key = new_key; var.path = new_path.clone(); - var.status = crate::format::ItemStatus::Modified; + var.dirty = true; } // Recursively update paths of all children if the renamed item is a group. if selected_var.is_group { for var in self.vars.iter_mut() { - if var.path.starts_with(&old_path) && var.path.len() > old_path.len() { + if var.is_descendant_of(&old_path) { let mut p = new_path.clone(); p.extend(var.path[old_path.len()..].iter().cloned()); var.path = p; - var.status = crate::format::ItemStatus::Modified; + var.dirty = true; } } } + self.is_dirty = true; + self.pending_new = None; true } _ => true, @@ -252,7 +451,7 @@ impl App { if !self.vars.is_empty() { if let Some(var) = self.vars.get(self.selected) && matches!(var.path.last(), Some(PathSegment::Index(_))) { - self.status_message = Some("Cannot rename array indices".to_string()); + self.set_status("Cannot rename array indices"); return; } self.mode = Mode::InsertKey; @@ -299,12 +498,101 @@ impl App { } /// Aborts the current input and reverts to `Normal` mode without saving changes. + /// + /// If the item was created moments ago and never named, it is removed again + /// so that a cancelled `o` does not litter the tree with `NEW_VAR` entries. pub fn cancel_insert(&mut self) { self.mode = Mode::Normal; + self.clear_status(); + + if self.pending_new == Some(self.selected) { + self.pending_new = None; + self.vars.remove(self.selected); + self.undo_tree.pop(); + if self.selected >= self.vars.len() { + self.selected = self.vars.len().saturating_sub(1); + } + } self.sync_input_with_selected(); - self.status_message = None; } + /// Toggles the help overlay. + pub fn toggle_help(&mut self) { + if self.mode == Mode::Help { + self.mode = self.previous_mode; + } else { + self.previous_mode = self.mode; + self.mode = Mode::Help; + self.help_scroll = 0; + } + } + + /// Scrolls the help overlay. Clamped by the renderer against the real height. + pub fn scroll_help(&mut self, down: bool) { + self.help_scroll = if down { + self.help_scroll.saturating_add(1) + } else { + self.help_scroll.saturating_sub(1) + }; + } + + // ------------------------------------------------------------- templates + + /// Pulls the template default into the active config for the selected item. + pub fn accept_selected(&mut self) { + let Some(var) = self.vars.get_mut(self.selected) else { return }; + if var.status != ItemStatus::MissingFromActive { + self.set_status("Nothing missing here"); + return; + } + var.status = ItemStatus::Present; + if !var.is_group { + var.value = var.template_value.clone().or(Some(String::new())); + } + var.dirty = true; + self.is_dirty = true; + self.sync_input_with_selected(); + self.save_undo_state(); + } + + /// Pulls in every missing template entry at once. + pub fn accept_all_missing(&mut self) { + let mut count = 0; + for var in self.vars.iter_mut() { + if var.status == ItemStatus::MissingFromActive { + var.status = ItemStatus::Present; + if !var.is_group { + var.value = var.template_value.clone().or(Some(String::new())); + } + var.dirty = true; + count += 1; + } + } + if count == 0 { + self.set_status("Nothing missing"); + return; + } + self.is_dirty = true; + self.sync_input_with_selected(); + self.save_undo_state(); + self.set_status(format!("Took {} template default(s)", count)); + } + + /// Number of entries present in the template but not yet accepted. + pub fn missing_count(&self) -> usize { + self.vars + .iter() + .filter(|v| !v.is_group && v.status == ItemStatus::MissingFromActive) + .count() + } + + /// Number of entries edited during this session. + pub fn changed_count(&self) -> usize { + self.vars.iter().filter(|v| v.dirty).count() + } + + // ------------------------------------------------------------- structure + /// Deletes the currently selected item and all its nested children. /// /// If the deleted item is part of an array, subsequent indices are @@ -318,15 +606,11 @@ impl App { let is_group = self.vars[self.selected].is_group; // 1. Identify all items to remove (the item itself + all children) - let mut to_remove = Vec::new(); - to_remove.push(self.selected); + let mut to_remove = vec![self.selected]; if is_group { for (i, var) in self.vars.iter().enumerate() { - if i == self.selected { - continue; - } - if var.path.starts_with(&selected_path) { + if i != self.selected && var.path.starts_with(&selected_path) { to_remove.push(i); } } @@ -343,16 +627,17 @@ impl App { let base_path = &selected_path[..selected_path.len() - 1]; for var in self.vars.iter_mut() { - if var.path.starts_with(base_path) && var.path.len() >= selected_path.len() { - if let PathSegment::Index(i) = var.path[selected_path.len() - 1] - && i > *removed_idx { - let new_idx = i - 1; - var.path[selected_path.len() - 1] = PathSegment::Index(new_idx); - - if var.path.len() == selected_path.len() { - var.key = format!("[{}]", new_idx); - } - } + if var.path.starts_with(base_path) + && var.path.len() >= selected_path.len() + && let PathSegment::Index(i) = var.path[selected_path.len() - 1] + && i > *removed_idx + { + let new_idx = i - 1; + var.path[selected_path.len() - 1] = PathSegment::Index(new_idx); + + if var.path.len() == selected_path.len() { + var.key = format!("[{}]", new_idx); + } } } } @@ -361,6 +646,7 @@ impl App { if self.selected >= self.vars.len() && !self.vars.is_empty() { self.selected = self.vars.len() - 1; } + self.is_dirty = true; self.sync_input_with_selected(); self.save_undo_state(); } @@ -375,21 +661,20 @@ impl App { /// formats the new key accordingly (e.g., `[1]`). pub fn add_item(&mut self, after: bool, is_group: bool, as_child: bool) { if self.vars.is_empty() { - let new_key = if is_group { "NEW_GROUP".to_string() } else { "NEW_VAR".to_string() }; - self.vars.push(ConfigItem { - key: new_key.clone(), - path: vec![PathSegment::Key(new_key)], - value: if is_group { None } else { Some("".to_string()) }, - template_value: None, - default_value: None, - depth: 0, - is_group, - status: crate::format::ItemStatus::Modified, - value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String }, - }); + let new_key = if is_group { "NEW_GROUP" } else { "NEW_VAR" }; + let path = vec![PathSegment::Key(new_key.to_string())]; + let mut item = if is_group { + ConfigItem::group(new_key, path, 0) + } else { + ConfigItem::leaf(new_key, path, "", 0, ValueType::String) + }; + item.dirty = true; + self.vars.push(item); self.selected = 0; + self.is_dirty = true; self.sync_input_with_selected(); self.save_undo_state(); + self.pending_new = Some(0); if is_group { self.enter_insert_key(); } else { @@ -415,7 +700,7 @@ impl App { // Shift subsequent indices for var in self.vars.iter_mut() { - if var.path.starts_with(&base_path) && var.path.len() > base_path.len() + if var.is_descendant_of(&base_path) && let PathSegment::Index(i) = var.path[base_path.len()] && i >= new_idx { var.path[base_path.len()] = PathSegment::Index(i + 1); @@ -441,7 +726,7 @@ impl App { // Shift existing children for var in self.vars.iter_mut() { - if var.path.starts_with(&selected_item.path) && var.path.len() > selected_item.path.len() + if var.is_descendant_of(&selected_item.path) && let PathSegment::Index(i) = var.path[selected_item.path.len()] { var.path[selected_item.path.len()] = PathSegment::Index(i + 1); if var.path.len() == selected_item.path.len() + 1 { @@ -507,21 +792,18 @@ impl App { }; // 3. Insert new item - let new_item = ConfigItem { - key: final_key, - path: new_path, - value: if is_group { None } else { Some("".to_string()) }, - template_value: None, - default_value: None, - depth: new_depth, - is_group, - status: crate::format::ItemStatus::Modified, - value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String }, + let mut new_item = if is_group { + ConfigItem::group(final_key, new_path, new_depth) + } else { + ConfigItem::leaf(final_key, new_path, "", new_depth, ValueType::String) }; + new_item.dirty = true; self.vars.insert(insert_pos, new_item); self.selected = insert_pos; + self.is_dirty = true; self.save_undo_state(); + self.pending_new = Some(insert_pos); if is_array_item { self.sync_input_with_selected(); @@ -529,7 +811,7 @@ impl App { } else { self.enter_insert_key(); } - self.status_message = None; + self.clear_status(); } /// Toggles the group status of the currently selected item. @@ -537,23 +819,23 @@ impl App { /// Changing a group to a variable clears its children (visually) /// and resets its value. Changing a variable to a group removes its value. pub fn toggle_group_selected(&mut self) { - if let Some(var) = self.vars.get_mut(self.selected) { - if matches!(var.path.last(), Some(PathSegment::Index(_))) { - self.status_message = Some("Cannot toggle array items".to_string()); - return; - } - - var.is_group = !var.is_group; - if var.is_group { - var.value = None; - var.value_type = crate::format::ValueType::Null; - } else { - var.value = Some("".to_string()); - var.value_type = crate::format::ValueType::String; - } - var.status = crate::format::ItemStatus::Modified; - self.sync_input_with_selected(); + let Some(var) = self.vars.get_mut(self.selected) else { return }; + if matches!(var.path.last(), Some(PathSegment::Index(_))) { + self.set_status("Cannot toggle array items"); + return; } + + var.is_group = !var.is_group; + if var.is_group { + var.value = None; + var.value_type = ValueType::Null; + } else { + var.value = Some(String::new()); + var.value_type = ValueType::String; + } + var.dirty = true; + self.is_dirty = true; + self.sync_input_with_selected(); } /// Returns true if the selected item is a structural node (group/object). @@ -564,26 +846,21 @@ impl App { /// Returns true if the provided path identifies a node that contains array elements. pub fn is_array_group(&self, group_path: &[PathSegment]) -> bool { self.vars.iter().any(|v| - v.path.starts_with(group_path) - && v.path.len() == group_path.len() + 1 + v.path.len() == group_path.len() + 1 + && v.path.starts_with(group_path) && matches!(v.path.last(), Some(PathSegment::Index(_))) ) } - /// Returns true if the selected item is an indexed array element. - pub fn selected_is_array(&self) -> bool { - self.vars.get(self.selected) - .map(|v| !v.is_group && matches!(v.path.last(), Some(PathSegment::Index(_)))) - .unwrap_or(false) - } - /// Returns true if the selected item exists in the template but not the active config. pub fn selected_is_missing(&self) -> bool { self.vars.get(self.selected) - .map(|v| v.status == crate::format::ItemStatus::MissingFromActive) + .map(|v| v.status == ItemStatus::MissingFromActive) .unwrap_or(false) } + // --------------------------------------------------------------- history + /// Saves a snapshot of the current state to the undo history tree. pub fn save_undo_state(&mut self) { self.undo_tree.push(self.vars.clone(), self.selected); @@ -591,31 +868,172 @@ impl App { /// Reverts the application state to the previous history point. pub fn undo(&mut self) { - if let Some(action) = self.undo_tree.undo() { - self.vars = action.state.clone(); - self.selected = action.selected; - if self.selected >= self.vars.len() && !self.vars.is_empty() { - self.selected = self.vars.len() - 1; + match self.undo_tree.undo() { + Some(action) => { + self.vars = action.state.clone(); + self.selected = action.selected; + self.after_history_move(); + let msg = match self.undo_tree.branch_position() { + Some((cur, total)) if total > 1 => { + format!("Undo — branch {}/{}", cur, total) + } + _ => "Undo applied".to_string(), + }; + self.set_status(msg); } - self.sync_input_with_selected(); - self.status_message = Some("Undo applied".to_string()); - } else { - self.status_message = Some("Nothing to undo".to_string()); + None => self.set_status("Nothing to undo"), } } /// Advances the application state to the next history point in the active branch. pub fn redo(&mut self) { - if let Some(action) = self.undo_tree.redo() { - self.vars = action.state.clone(); - self.selected = action.selected; - if self.selected >= self.vars.len() && !self.vars.is_empty() { - self.selected = self.vars.len() - 1; + match self.undo_tree.redo() { + Some(action) => { + self.vars = action.state.clone(); + self.selected = action.selected; + self.after_history_move(); + self.set_status("Redo applied"); } - self.sync_input_with_selected(); - self.status_message = Some("Redo applied".to_string()); - } else { - self.status_message = Some("Nothing to redo".to_string()); + None => self.set_status("Nothing to redo"), } } + + /// Switches between sibling branches of the undo tree at the current point. + pub fn cycle_undo_branch(&mut self, forward: bool) { + match self.undo_tree.cycle_branch(forward) { + Some((cur, total)) => { + self.set_status(format!("Redo branch {}/{}", cur, total)); + } + None => self.set_status("No alternative history here"), + } + } + + /// Shared bookkeeping after the state is replaced by an undo/redo. + fn after_history_move(&mut self) { + if self.selected >= self.vars.len() { + self.selected = self.vars.len().saturating_sub(1); + } + self.pending_new = None; + self.is_dirty = true; + self.reveal_selected(); + self.sync_input_with_selected(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(key: &str, depth: usize, is_group: bool) -> ConfigItem { + let path: Vec = key + .split('.') + .map(|p| PathSegment::Key(p.to_string())) + .collect(); + let name = key.rsplit('.').next().unwrap(); + if is_group { + ConfigItem::group(name, path, depth) + } else { + ConfigItem::leaf(name, path, "v", depth, ValueType::String) + } + } + + fn app_with(vars: Vec) -> App { + App::new(vars, PathBuf::from("out.json"), None) + } + + #[test] + fn folding_hides_descendants() { + let mut app = app_with(vec![ + item("server", 0, true), + item("server.host", 1, false), + item("server.port", 1, false), + item("debug", 0, false), + ]); + + assert_eq!(app.visible_indices(), vec![0, 1, 2, 3]); + + app.selected = 0; + app.toggle_fold(); + assert_eq!(app.visible_indices(), vec![0, 3]); + + // Moving down from a folded group skips straight past its children. + app.next(); + assert_eq!(app.selected, 3); + + app.selected = 0; + app.toggle_fold(); + assert_eq!(app.visible_indices(), vec![0, 1, 2, 3]); + } + + #[test] + fn folding_everything_keeps_selection_visible() { + let mut app = app_with(vec![ + item("server", 0, true), + item("server.host", 1, false), + ]); + app.selected = 1; + app.set_all_folds(true); + assert_eq!(app.selected, 0); + } + + #[test] + fn search_matches_values_too() { + let mut app = app_with(vec![item("host", 0, false), item("port", 0, false)]); + app.vars[0].value = Some("localhost".to_string()); + app.vars[1].value = Some("8080".to_string()); + + app.search_query = "8080".to_string(); + assert_eq!(app.matching_indices(), vec![1]); + + app.search_query = "localh".to_string(); + assert_eq!(app.matching_indices(), vec![0]); + } + + #[test] + fn cancelled_new_item_is_removed() { + let mut app = app_with(vec![item("a", 0, false)]); + app.add_item(true, false, false); + assert_eq!(app.vars.len(), 2); + + app.cancel_insert(); + assert_eq!(app.vars.len(), 1); + assert_eq!(app.selected, 0); + } + + #[test] + fn accepting_missing_takes_the_template_default() { + let mut app = app_with(vec![item("token", 0, false)]); + app.vars[0].status = ItemStatus::MissingFromActive; + app.vars[0].value = None; + app.vars[0].template_value = Some("changeme".to_string()); + + app.accept_selected(); + + assert_eq!(app.vars[0].status, ItemStatus::Present); + assert_eq!(app.vars[0].value.as_deref(), Some("changeme")); + assert!(app.is_dirty); + assert_eq!(app.missing_count(), 0); + } + + #[test] + fn editing_a_number_with_text_demotes_the_type() { + let mut app = app_with(vec![item("port", 0, false)]); + app.vars[0].value_type = ValueType::Number; + app.vars[0].value = Some("8080".to_string()); + + app.enter_insert(InsertVariant::Substitute); + app.input = Input::new("not-a-port".to_string()); + app.enter_normal(); + + assert_eq!(app.vars[0].value_type, ValueType::String); + assert_eq!(app.vars[0].value.as_deref(), Some("not-a-port")); + assert!(app.status_text().unwrap().contains("not a number")); + } + + #[test] + fn merge_status_does_not_mark_items_dirty() { + let app = app_with(vec![item("a", 0, false)]); + assert_eq!(app.changed_count(), 0); + assert!(!app.is_dirty); + } } diff --git a/src/cli.rs b/src/cli.rs index 9932add..1aa5ef2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,13 @@ use clap::Parser; use std::path::PathBuf; -/// mould: A TUI tool to generate and edit configuration files (.env, json, yaml, toml) +/// mould: A TUI tool to generate and edit configuration files. #[derive(Parser, Debug)] #[command( author, version, - about = "mould: A TUI tool to generate and edit configuration files (.env, json, yaml, toml)", - long_about = "mould allows you to interactively edit and generate configuration files using templates. It supports various formats including .env, JSON, YAML, and TOML. It features a modern TUI with Vim-inspired keybindings and out-of-the-box support for theming.", + about = "mould: A TUI tool to generate and edit configuration files", + long_about = "mould allows you to interactively edit and generate configuration files using templates. It supports .env, JSON, YAML, TOML, XML, INI and Java properties files. It features a modern TUI with Vim-inspired keybindings and out-of-the-box support for theming.", after_help = "EXAMPLES:\n mould .env.example\n mould docker-compose.yml\n mould config.template.json -o config.json" )] pub struct Cli { @@ -19,8 +19,13 @@ pub struct Cli { #[arg(short, long, value_name = "OUTPUT_FILE")] pub output: Option, - /// Override the format detection (env, json, yaml, toml) - #[arg(short, long, value_name = "FORMAT", value_parser = ["env", "json", "yaml", "toml"])] + /// Override the format detection + #[arg( + short, + long, + value_name = "FORMAT", + value_parser = ["env", "json", "yaml", "yml", "toml", "xml", "ini", "properties"] + )] pub format: Option, /// Increase verbosity for logging (can be used multiple times) diff --git a/src/config.rs b/src/config.rs index 2dadd70..db3d3d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 (`",za"`). +/// Non-character keys are written in angle brackets: ``, ``. #[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: "".to_string(), + accept_all: "A".to_string(), + toggle_fold: ",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 { + 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) { + 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::(&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 == "" && *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), ""); } - Config::default() } diff --git a/src/format/env.rs b/src/format/env.rs index c31b3d3..afb2ba9 100644 --- a/src/format/env.rs +++ b/src/format/env.rs @@ -2,8 +2,13 @@ //! //! This handler manages simple `KEY=VALUE` pairs. It does not support //! native nesting or grouping, treating all entries as root-level variables. +//! +//! Comments and blank lines are preserved: each entry remembers the lines that +//! preceded it, and they are replayed on write. `.env.example` files carry most +//! of their documentation in comments, so dropping them on save would throw away +//! the very thing that makes the template useful. -use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment}; +use super::{ConfigItem, FormatHandler, ValueType, PathSegment}; use std::fs; use std::io::Write; use std::path::Path; @@ -20,29 +25,30 @@ impl FormatHandler for EnvHandler { let content = fs::read_to_string(path)?; let mut vars = Vec::new(); + let mut trivia: Vec = Vec::new(); - for line in content.lines() { - let line = line.trim(); - // Skip empty lines and comments. + for raw_line in content.lines() { + let line = raw_line.trim(); + + // Hold comments and blank lines until the entry they document. if line.is_empty() || line.starts_with('#') { + trivia.push(line.to_string()); continue; } if let Some((key, value)) = line.split_once('=') { let key = key.trim().to_string(); let value = value.trim().to_string(); - - vars.push(ConfigItem { - key: key.clone(), - path: vec![PathSegment::Key(key)], - value: Some(value.clone()), - template_value: Some(value.clone()), - default_value: Some(value.clone()), - depth: 0, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }); + + let mut item = ConfigItem::leaf( + key.clone(), + vec![PathSegment::Key(key)], + value, + 0, + ValueType::String, + ); + item.leading_trivia = std::mem::take(&mut trivia); + vars.push(item); } } @@ -53,13 +59,15 @@ impl FormatHandler for EnvHandler { fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> { let mut file = fs::File::create(path)?; for var in vars { - // .env files ignore structural groups. - if !var.is_group { - let val = var.value.as_deref() - .or(var.template_value.as_deref()) - .unwrap_or(""); - writeln!(file, "{}={}", var.key, val)?; + // Groups are structural only, and template-only entries the user has + // not accepted must not leak into the output. + if !var.should_write() { + continue; } + for line in &var.leading_trivia { + writeln!(file, "{}", line)?; + } + writeln!(file, "{}={}", var.key, var.write_value())?; } Ok(()) } @@ -68,6 +76,7 @@ impl FormatHandler for EnvHandler { #[cfg(test)] mod tests { use super::*; + use crate::format::ItemStatus; use tempfile::NamedTempFile; #[test] @@ -88,17 +97,13 @@ mod tests { #[test] fn test_write_env() { let file = NamedTempFile::new().unwrap(); - let vars = vec![ConfigItem { - key: "KEY1".to_string(), - path: vec![PathSegment::Key("KEY1".to_string())], - value: Some("value1".to_string()), - template_value: None, - default_value: None, - depth: 0, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }]; + let vars = vec![ConfigItem::leaf( + "KEY1", + vec![PathSegment::Key("KEY1".to_string())], + "value1", + 0, + ValueType::String, + )]; let handler = EnvHandler; handler.write(file.path(), &vars).unwrap(); @@ -112,26 +117,23 @@ mod tests { let template = NamedTempFile::new().unwrap(); writeln!(template.as_file(), "KEY1=template_val\nKEY2=default_val").unwrap(); - let mut active_vars = vec![ConfigItem { - key: "KEY1".to_string(), - path: vec![PathSegment::Key("KEY1".to_string())], - value: Some("active_val".to_string()), - template_value: None, - default_value: None, - depth: 0, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }]; + let mut active_vars = vec![ConfigItem::leaf( + "KEY1", + vec![PathSegment::Key("KEY1".to_string())], + "active_val", + 0, + ValueType::String, + )]; let handler = EnvHandler; handler.merge(template.path(), &mut active_vars).unwrap(); assert_eq!(active_vars.len(), 2); - // KEY1 should be marked modified + // KEY1 differs from the template default, but the user has not touched it. let key1 = active_vars.iter().find(|v| v.key == "KEY1").unwrap(); - assert_eq!(key1.status, ItemStatus::Modified); + assert_eq!(key1.status, ItemStatus::Overridden); + assert!(!key1.dirty); assert_eq!(key1.value.as_deref(), Some("active_val")); assert_eq!(key1.template_value.as_deref(), Some("template_val")); @@ -141,4 +143,54 @@ mod tests { assert_eq!(key2.value, None); assert_eq!(key2.template_value.as_deref(), Some("default_val")); } + + #[test] + fn test_comments_survive_roundtrip() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# database settings\nDB_HOST=localhost\n\n# api key, keep secret\nAPI_KEY=abc").unwrap(); + + let handler = EnvHandler; + let vars = handler.parse(file.path()).unwrap(); + assert_eq!(vars[0].leading_trivia, vec!["# database settings"]); + assert_eq!(vars[1].leading_trivia, vec!["", "# api key, keep secret"]); + + let out = NamedTempFile::new().unwrap(); + handler.write(out.path(), &vars).unwrap(); + let content = fs::read_to_string(out.path()).unwrap(); + + assert!(content.contains("# database settings")); + assert!(content.contains("# api key, keep secret")); + assert!(content.contains("DB_HOST=localhost")); + } + + #[test] + fn test_missing_items_are_not_written() { + let handler = EnvHandler; + let mut accepted = ConfigItem::leaf( + "KEEP", + vec![PathSegment::Key("KEEP".to_string())], + "yes", + 0, + ValueType::String, + ); + accepted.status = ItemStatus::Present; + + let mut missing = ConfigItem::leaf( + "SKIP", + vec![PathSegment::Key("SKIP".to_string())], + "", + 0, + ValueType::String, + ); + missing.status = ItemStatus::MissingFromActive; + missing.value = None; + missing.template_value = Some("from_template".to_string()); + + let file = NamedTempFile::new().unwrap(); + handler.write(file.path(), &[accepted, missing]).unwrap(); + + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("KEEP=yes")); + assert!(!content.contains("SKIP")); + } } diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs index f8ba8d7..6b72f82 100644 --- a/src/format/hierarchical.rs +++ b/src/format/hierarchical.rs @@ -6,7 +6,7 @@ //! `ConfigItem`s for the TUI, and "unflattens" them back into their //! nested form when saving. -use super::{ConfigItem, FormatHandler, FormatType, ItemStatus, ValueType, PathSegment}; +use super::{ConfigItem, FormatHandler, FormatType, ValueType, PathSegment}; use serde_json::{Map, Value}; use std::fs; use std::path::Path; @@ -262,17 +262,7 @@ fn flatten(value: &Value, current_path: Vec, key_name: Option { if !next_path.is_empty() { - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: None, - template_value: None, - default_value: None, - depth, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }); + vars.push(ConfigItem::group(display_key, next_path.clone(), depth)); } let next_depth = if next_path.is_empty() { depth } else { depth + 1 }; for (k, v) in map { @@ -281,17 +271,7 @@ fn flatten(value: &Value, current_path: Vec, key_name: Option { if !next_path.is_empty() { - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: None, - template_value: None, - default_value: None, - depth, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }); + vars.push(ConfigItem::group(display_key, next_path.clone(), depth)); } let next_depth = if next_path.is_empty() { depth } else { depth + 1 }; for (i, v) in arr.iter().enumerate() { @@ -301,58 +281,18 @@ fn flatten(value: &Value, current_path: Vec, key_name: Option { - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: Some(s.clone()), - template_value: Some(s.clone()), - default_value: Some(s.clone()), - depth, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }); + vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::String)); } Value::Number(n) => { let s = n.to_string(); - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: Some(s.clone()), - template_value: Some(s.clone()), - default_value: Some(s.clone()), - depth, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::Number, - }); + vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::Number)); } Value::Bool(b) => { let s = b.to_string(); - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: Some(s.clone()), - template_value: Some(s.clone()), - default_value: Some(s.clone()), - depth, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::Bool, - }); + vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::Bool)); } Value::Null => { - vars.push(ConfigItem { - key: display_key, - path: next_path.clone(), - value: Some("".to_string()), - template_value: Some("".to_string()), - default_value: Some("".to_string()), - depth, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::Null, - }); + vars.push(ConfigItem::leaf(display_key, next_path.clone(), "", depth, ValueType::Null)); } } } @@ -368,11 +308,8 @@ impl FormatHandler for HierarchicalHandler { fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> { let mut root = Value::Object(Map::new()); for var in vars { - if !var.is_group { - let val = var.value.as_deref() - .or(var.template_value.as_deref()) - .unwrap_or(""); - insert_into_value(&mut root, &var.path, val, var.value_type); + if var.should_write() { + insert_into_value(&mut root, &var.path, var.write_value(), var.value_type); } } self.write_value(path, &root) @@ -478,6 +415,7 @@ fn insert_into_value(root: &mut Value, path: &[PathSegment], new_val_str: &str, #[cfg(test)] mod tests { use super::*; + use crate::format::ItemStatus; #[test] fn test_json_flatten_unflatten() { @@ -616,22 +554,18 @@ enabled = true flatten(&json, Vec::new(), Some("".to_string()), 0, &mut vars); assert_eq!(vars.len(), 2); assert_eq!(vars[0].key, "old_group"); - assert_eq!(vars[0].is_group, true); + assert!(vars[0].is_group); assert_eq!(vars[1].key, "key"); assert_eq!(vars[1].path_string(), "old_group.key"); // Manually simulate a rename of "old_group" to "new_group" - let old_path = vars[0].path.clone(); - let new_key = "new_group".to_string(); - let mut new_path = vec![PathSegment::Key(new_key.clone())]; - - vars[0].key = new_key; - vars[0].path = new_path.clone(); - - // Update child path - vars[1].path = vec![PathSegment::Key("new_group".to_string()), PathSegment::Key("key".to_string())]; - - let handler = HierarchicalHandler::new(FormatType::Json); + vars[0].key = "new_group".to_string(); + vars[0].path = vec![PathSegment::Key("new_group".to_string())]; + vars[1].path = vec![ + PathSegment::Key("new_group".to_string()), + PathSegment::Key("key".to_string()), + ]; + let mut root = Value::Object(Map::new()); for var in &vars { if !var.is_group { @@ -644,4 +578,70 @@ enabled = true assert!(out.contains("\"key\":\"val\"")); assert!(!out.contains("\"old_group\"")); } + + #[test] + fn missing_nested_keys_are_spliced_beside_their_siblings() { + use std::io::Write; + let handler = HierarchicalHandler::new(FormatType::Json); + + let mut template = tempfile::NamedTempFile::new().unwrap(); + write!( + template, + r#"{{"server":{{"host":"localhost","port":8080}},"debug":false}}"# + ) + .unwrap(); + + // The active config is missing `server.port` entirely. + let mut active = Vec::new(); + flatten( + &serde_json::json!({"server": {"host": "example.com"}, "debug": true}), + Vec::new(), + Some("".to_string()), + 0, + &mut active, + ); + + handler.merge(template.path(), &mut active).unwrap(); + + let order: Vec = active.iter().map(|v| v.path_string()).collect(); + assert_eq!(order, vec!["server", "server.host", "server.port", "debug"]); + + let port = active.iter().find(|v| v.key == "port").unwrap(); + assert_eq!(port.status, ItemStatus::MissingFromActive); + assert_eq!(port.depth, 1); + assert_eq!(port.template_value.as_deref(), Some("8080")); + } + + #[test] + fn a_malformed_file_is_an_error_not_an_empty_document() { + use std::io::Write; + let handler = HierarchicalHandler::new(FormatType::Json); + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!(file, "{{ not json").unwrap(); + + assert!(handler.parse(file.path()).is_err()); + } + + #[test] + fn unaccepted_template_entries_are_not_written() { + let handler = HierarchicalHandler::new(FormatType::Json); + let mut vars = Vec::new(); + flatten( + &serde_json::json!({"kept": "yes", "skipped": "no"}), + Vec::new(), + Some("".to_string()), + 0, + &mut vars, + ); + let skipped = vars.iter_mut().find(|v| v.key == "skipped").unwrap(); + skipped.status = ItemStatus::MissingFromActive; + skipped.template_value = skipped.value.take(); + + let file = tempfile::NamedTempFile::new().unwrap(); + handler.write(file.path(), &vars).unwrap(); + + let content = std::fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("kept")); + assert!(!content.contains("skipped")); + } } diff --git a/src/format/ini.rs b/src/format/ini.rs index 3e3581f..99cc277 100644 --- a/src/format/ini.rs +++ b/src/format/ini.rs @@ -1,4 +1,4 @@ -use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment}; +use super::{ConfigItem, FormatHandler, ValueType, PathSegment}; use ini::Ini; use std::path::Path; @@ -13,17 +13,11 @@ impl FormatHandler for IniHandler { let section_name = section.unwrap_or_default(); if !section_name.is_empty() { - vars.push(ConfigItem { - key: section_name.to_string(), - path: vec![PathSegment::Key(section_name.to_string())], - value: None, - template_value: None, - default_value: None, - depth: 0, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }); + vars.push(ConfigItem::group( + section_name, + vec![PathSegment::Key(section_name.to_string())], + 0, + )); } for (key, value) in prop { @@ -33,17 +27,13 @@ impl FormatHandler for IniHandler { vec![PathSegment::Key(section_name.to_string()), PathSegment::Key(key.to_string())] }; - vars.push(ConfigItem { - key: key.to_string(), + vars.push(ConfigItem::leaf( + key, path, - value: Some(value.to_string()), - template_value: Some(value.to_string()), - default_value: Some(value.to_string()), - depth: if section_name.is_empty() { 0 } else { 1 }, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }); + value, + if section_name.is_empty() { 0 } else { 1 }, + ValueType::String, + )); } } @@ -53,11 +43,9 @@ impl FormatHandler for IniHandler { fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> { let mut conf = Ini::new(); for var in vars { - if !var.is_group { - let val = var.value.as_deref() - .or(var.template_value.as_deref()) - .unwrap_or(""); - + if var.should_write() { + let val = var.write_value(); + if var.path.len() == 2 { if let (PathSegment::Key(section), PathSegment::Key(key)) = (&var.path[0], &var.path[1]) { conf.with_section(Some(section)).set(key, val); @@ -77,34 +65,19 @@ impl FormatHandler for IniHandler { mod tests { use super::*; use tempfile::NamedTempFile; - use std::io::Write; #[test] fn test_section_rename_write() { let handler = IniHandler; let mut vars = vec![ - ConfigItem { - key: "server".to_string(), - path: vec![PathSegment::Key("server".to_string())], - value: None, - template_value: None, - default_value: None, - depth: 0, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }, - ConfigItem { - key: "port".to_string(), - path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], - value: Some("8080".to_string()), - template_value: Some("8080".to_string()), - default_value: Some("8080".to_string()), - depth: 1, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - } + ConfigItem::group("server", vec![PathSegment::Key("server".to_string())], 0), + ConfigItem::leaf( + "port", + vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], + "8080", + 1, + ValueType::String, + ), ]; // Rename "server" to "srv" diff --git a/src/format/mod.rs b/src/format/mod.rs index 3c70175..ad6f76d 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -12,15 +12,20 @@ pub mod hierarchical; pub mod ini; pub mod properties; -/// Represents the status of a configuration item relative to a template. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Represents the status of a configuration item relative to its template. +/// +/// This describes the *template comparison* only. Whether the user has edited +/// an item during this session is tracked separately by [`ConfigItem::dirty`], +/// so that a config which legitimately differs from its example file does not +/// render as if every line had just been touched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ItemStatus { /// Item exists in the active configuration and matches the template (or no template exists). Present, /// Item exists in the template but is missing from the active configuration. MissingFromActive, - /// Item has been changed by the user during the current session. - Modified, + /// Item exists in both, but the active value differs from the template default. + Overridden, } /// Hints about the original data type to ensure correct serialization during writes. @@ -82,9 +87,71 @@ pub struct ConfigItem { pub status: ItemStatus, /// Metadata about the original data type. pub value_type: ValueType, + /// True if the user changed this item during the current session. + pub dirty: bool, + /// True if this group's children are folded away in the tree view. + pub collapsed: bool, + /// Comment and blank lines that preceded this entry in the source file. + /// + /// Replayed verbatim on write by formats that carry inline documentation + /// (currently `.env`), so that annotated templates survive a save. + pub leading_trivia: Vec, +} + +impl Default for ConfigItem { + fn default() -> Self { + Self { + key: String::new(), + path: Vec::new(), + value: None, + template_value: None, + default_value: None, + depth: 0, + is_group: false, + status: ItemStatus::Present, + value_type: ValueType::String, + dirty: false, + collapsed: false, + leading_trivia: Vec::new(), + } + } } impl ConfigItem { + /// Builds a structural node (object, array, or section). + pub fn group(key: impl Into, path: Vec, depth: usize) -> Self { + Self { + key: key.into(), + path, + depth, + is_group: true, + value_type: ValueType::Null, + ..Default::default() + } + } + + /// Builds a leaf entry holding an actual value. + /// + /// `template_value` and `default_value` are intentionally left unset: they are + /// populated by [`FormatHandler::merge`] from the template file, and mean + /// nothing when a file is parsed on its own. + pub fn leaf( + key: impl Into, + path: Vec, + value: impl Into, + depth: usize, + value_type: ValueType, + ) -> Self { + Self { + key: key.into(), + path, + value: Some(value.into()), + depth, + value_type, + ..Default::default() + } + } + /// Returns a human-readable string representation of the full path (e.g., `server.port`). pub fn path_string(&self) -> String { let mut s = String::new(); @@ -103,6 +170,25 @@ impl ConfigItem { } s } + + /// True if this entry belongs in the output file. + /// + /// Items that exist only in the template and have not been accepted by the + /// user are deliberately omitted, which is what makes the "(missing)" marker + /// mean something: nothing is written until it is pulled in explicitly. + pub fn should_write(&self) -> bool { + !self.is_group && self.status != ItemStatus::MissingFromActive + } + + /// The string that should be serialized for this entry. + pub fn write_value(&self) -> &str { + self.value.as_deref().unwrap_or("") + } + + /// True if this item sits inside the subtree rooted at `prefix` (and is not `prefix` itself). + pub fn is_descendant_of(&self, prefix: &[PathSegment]) -> bool { + self.path.len() > prefix.len() && self.path.starts_with(prefix) + } } /// Supported configuration file formats. @@ -126,38 +212,52 @@ pub trait FormatHandler { /// Merges an active configuration with a template file. /// - /// This identifies missing keys, marks modifications, and syncs default values. + /// This identifies missing keys, records template defaults, and splices + /// template-only entries in at their natural position so that the tree view + /// keeps reflecting the real structure. fn merge(&self, path: &Path, vars: &mut Vec) -> anyhow::Result<()> { if !path.exists() { return Ok(()); } - let template_vars = self.parse(path).unwrap_or_default(); + let template_vars = self.parse(path)?; for var in vars.iter_mut() { if let Some(template_var) = template_vars.iter().find(|v| v.path == var.path) { var.template_value = template_var.value.clone(); var.default_value = template_var.value.clone(); - - if var.value != template_var.value { - var.status = ItemStatus::Modified; + + var.status = if var.value == template_var.value { + ItemStatus::Present } else { - var.status = ItemStatus::Present; - } + ItemStatus::Overridden + }; } else { - // Exists in active, but not in template + // Exists in active, but not in template. var.status = ItemStatus::Present; } } - - // Add items from template that are missing in active - for template_var in template_vars { - if !vars.iter().any(|v| v.path == template_var.path) { - let mut new_item = template_var.clone(); - new_item.status = ItemStatus::MissingFromActive; - new_item.value = None; - vars.push(new_item); + + // Splice in template-only entries. `template_vars` is in document order + // (a pre-order walk of the tree), so tracking an anchor as we go keeps + // each missing entry next to the siblings it belongs with instead of + // stranding it at the end of the list. + let mut anchor = 0usize; + for template_var in &template_vars { + if let Some(pos) = vars.iter().position(|v| v.path == template_var.path) { + anchor = pos + 1; + continue; } + + let mut new_item = template_var.clone(); + new_item.status = ItemStatus::MissingFromActive; + new_item.template_value = template_var.value.clone(); + new_item.default_value = template_var.value.clone(); + new_item.value = None; + + let at = anchor.min(vars.len()); + vars.insert(at, new_item); + anchor = at + 1; } Ok(()) diff --git a/src/format/properties.rs b/src/format/properties.rs index 64cbc59..5010f5d 100644 --- a/src/format/properties.rs +++ b/src/format/properties.rs @@ -1,4 +1,4 @@ -use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment}; +use super::{ConfigItem, FormatHandler, ValueType, PathSegment}; use java_properties::{LineContent, PropertiesIter, PropertiesWriter}; use std::fs::File; use std::io::{BufReader, BufWriter}; @@ -27,17 +27,7 @@ impl FormatHandler for PropertiesHandler { current_path.push(PathSegment::Key(part.to_string())); if groups.insert(current_path.clone()) { - vars.push(ConfigItem { - key: part.to_string(), - path: current_path.clone(), - value: None, - template_value: None, - default_value: None, - depth: i, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }); + vars.push(ConfigItem::group(*part, current_path.clone(), i)); } } @@ -45,17 +35,13 @@ impl FormatHandler for PropertiesHandler { let last_key = parts.last().unwrap_or(&"").to_string(); final_path.push(PathSegment::Key(last_key.clone())); - vars.push(ConfigItem { - key: last_key, - path: final_path, - value: Some(value.clone()), - template_value: Some(value.clone()), - default_value: Some(value.clone()), - depth: parts.len().saturating_sub(1), - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - }); + vars.push(ConfigItem::leaf( + last_key, + final_path, + value, + parts.len().saturating_sub(1), + ValueType::String, + )); } } @@ -69,11 +55,8 @@ impl FormatHandler for PropertiesHandler { let mut prop_writer = PropertiesWriter::new(writer); for var in vars { - if !var.is_group { - let val = var.value.as_deref() - .or(var.template_value.as_deref()) - .unwrap_or(""); - prop_writer.write(&var.path_string(), val)?; + if var.should_write() { + prop_writer.write(&var.path_string(), var.write_value())?; } } @@ -86,34 +69,19 @@ impl FormatHandler for PropertiesHandler { mod tests { use super::*; use tempfile::NamedTempFile; - use std::io::Write; #[test] fn test_group_rename_write() { let handler = PropertiesHandler; let mut vars = vec![ - ConfigItem { - key: "server".to_string(), - path: vec![PathSegment::Key("server".to_string())], - value: None, - template_value: None, - default_value: None, - depth: 0, - is_group: true, - status: ItemStatus::Present, - value_type: ValueType::Null, - }, - ConfigItem { - key: "port".to_string(), - path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], - value: Some("8080".to_string()), - template_value: Some("8080".to_string()), - default_value: Some("8080".to_string()), - depth: 1, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - } + ConfigItem::group("server", vec![PathSegment::Key("server".to_string())], 0), + ConfigItem::leaf( + "port", + vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], + "8080", + 1, + ValueType::String, + ), ]; // Rename "server" to "srv" diff --git a/src/main.rs b/src/main.rs index c8476a2..e9be6c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,8 @@ use log::{error, info, warn}; use runner::AppRunner; use std::io; +use anyhow::Context; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -73,9 +73,15 @@ fn main() -> anyhow::Result<()> { info!("Output: {}", output_path.display()); - // 1. Load active config if it exists + // 1. Load active config if it exists. + // + // A parse failure must be fatal: opening an editor over a file we could not + // read would present an empty document, and the next `:w` would truncate a + // perfectly good config. let mut vars = if let Some(active) = &active_path { - handler.parse(active).unwrap_or_default() + handler + .parse(active) + .with_context(|| format!("could not parse {}", active.display()))? } else { Vec::new() }; @@ -83,57 +89,57 @@ fn main() -> anyhow::Result<()> { // 2. Load template config and merge if let Some(template) = &template_path { info!("Comparing with template: {}", template.display()); - let template_vars = handler.parse(template).unwrap_or_default(); if vars.is_empty() { - vars = template_vars; - // If we only have template, everything is missing from active initially - for v in vars.iter_mut() { - v.status = crate::format::ItemStatus::MissingFromActive; - v.value = None; - } + vars = handler + .parse(template) + .with_context(|| format!("could not parse template {}", template.display()))?; + // Only the template exists, so nothing has been accepted into the + // active config yet. Keep each template value as the default on offer. + for v in vars.iter_mut() { + if !v.is_group { + v.status = crate::format::ItemStatus::MissingFromActive; + v.template_value = v.value.take(); + v.default_value = v.template_value.clone(); + } + } } else { - // Merge template into active - handler.merge(template, &mut vars).unwrap_or_default(); + handler + .merge(template, &mut vars) + .with_context(|| format!("could not parse template {}", template.display()))?; } } else if vars.is_empty() { // Fallback if no template and active is empty - vars = handler.parse(&input_path)?; + vars = handler + .parse(&input_path) + .with_context(|| format!("could not parse {}", input_path.display()))?; } if vars.is_empty() { warn!("No variables found."); } - let config = load_config(); - let mut app = App::new(vars); + let (config, config_error) = load_config(); + let mut app = App::new(vars, output_path.clone(), template_path.clone()); + if let Some(err) = config_error { + app.set_status(format!("config.toml ignored: {}", err)); + } // Terminal lifecycle enable_raw_mode() .map_err(|e| MouldError::Terminal(format!("Failed to enable raw mode: {}", e)))?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + execute!(stdout, EnterAlternateScreen) .map_err(|e| MouldError::Terminal(format!("Failed to enter alternate screen: {}", e)))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) .map_err(|e| MouldError::Terminal(format!("Failed to create terminal backend: {}", e)))?; - let mut runner = AppRunner::new( - &mut terminal, - &mut app, - &config, - &output_path, - handler.as_ref(), - ); + let mut runner = AppRunner::new(&mut terminal, &mut app, &config, handler.as_ref()); let res = runner.run(); // Restoration disable_raw_mode().ok(); - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .ok(); + execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); terminal.show_cursor().ok(); match res { diff --git a/src/runner.rs b/src/runner.rs index 06b04f9..21fe85e 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -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, 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 (``) 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 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 { + 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) -> 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 + )); + } } diff --git a/src/ui.rs b/src/ui.rs index 41f56bc..f264806 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,24 +1,21 @@ //! Renders the hierarchical Terminal User Interface (TUI). //! -//! This module uses `ratatui` to compose the visual layout of the application, -//! providing a navigable tree-view of configuration items. +//! The screen is four things stacked: a header naming the files in play, the +//! item tree, a detail/edit pane for the selection, and a status bar. Nothing +//! here mutates state beyond the widget list itself. use crate::app::{App, Mode}; -use crate::config::Config; +use crate::config::{Action, Config, ThemeConfig}; +use crate::format::{ItemStatus, ValueType}; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, }; /// Renders the main application interface using ratatui. -/// -/// The interface is composed of: -/// 1. A hierarchical list of configuration items. -/// 2. An active input field for editing values/keys. -/// 3. A status bar showing the current mode and available keybinds. pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let theme = &config.theme; let size = f.area(); @@ -47,47 +44,48 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .constraints([ Constraint::Length(1), // Top margin Constraint::Min(3), // Main tree list - Constraint::Length(3), // Focused input area + Constraint::Length(3), // Detail / edit pane Constraint::Length(1), // Spacer Constraint::Length(1), // Status bar ]) .split(outer_layout[1]); - // Construct the interactive hierarchical list. - let matching_indices = app.matching_indices(); - let items: Vec = app - .vars + draw_tree(f, chunks[1], app, config); + draw_detail(f, chunks[2], app, theme); + draw_status(f, chunks[4], app, config); + + if app.mode == Mode::Help { + draw_help(f, size, config, app.help_scroll); + } +} + +/// Renders the item tree, skipping anything inside a folded group. +fn draw_tree(f: &mut Frame, area: Rect, app: &App, config: &Config) { + let theme = &config.theme; + let matching = app.matching_indices(); + let visible = app.visible_indices(); + + let items: Vec = visible .iter() - .enumerate() - .map(|(i, var)| { + .map(|&i| { + let var = &app.vars[i]; let is_selected = i == app.selected; - let is_match = matching_indices.contains(&i); + let is_match = matching.contains(&i); + let is_missing = var.status == ItemStatus::MissingFromActive; - // Indentation and tree-branch markers. let indent = " ".repeat(var.depth); - let prefix = if var.is_group { "+ " } else { " " }; - - // Determine depth-based coloring for the key name. - let depth_color = if is_selected { - theme.bg_normal() + // A group that can be folded says so; leaves are just indented. + let prefix = if var.is_group { + if var.collapsed { "▸ " } else { "▾ " } } else { - match var.depth % 4 { - 0 => theme.tree_depth_1(), - 1 => theme.tree_depth_2(), - 2 => theme.tree_depth_3(), - 3 => theme.tree_depth_4(), - _ => theme.fg_normal(), - } + " " }; - let text_color = if is_selected { - theme.fg_highlight() - } else { - match var.status { - crate::format::ItemStatus::MissingFromActive if !var.is_group => theme.fg_dimmed(), - crate::format::ItemStatus::Modified => theme.fg_modified(), - _ => theme.fg_normal(), - } + let depth_color = match var.depth % 4 { + 0 => theme.tree_depth_1(), + 1 => theme.tree_depth_2(), + 2 => theme.tree_depth_3(), + _ => theme.tree_depth_4(), }; let key_style = if is_selected { @@ -98,7 +96,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Style::default() .fg(theme.bg_search()) .add_modifier(Modifier::UNDERLINED) - } else if var.status == crate::format::ItemStatus::MissingFromActive && !var.is_group { + } else if is_missing { Style::default() .fg(theme.fg_dimmed()) .add_modifier(Modifier::DIM) @@ -107,77 +105,90 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { }; let mut key_spans = vec![ - Span::raw(indent), + Span::raw(indent.clone()), Span::styled(prefix, Style::default().fg(theme.border_normal())), Span::styled(&var.key, key_style), ]; - // Add semantic status labels (missing, modified). - match var.status { - crate::format::ItemStatus::MissingFromActive if !var.is_group => { - let missing_style = if is_selected { - Style::default().fg(theme.fg_highlight()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg_warning()).add_modifier(Modifier::BOLD) - }; - key_spans.push(Span::styled(" (missing)", missing_style)); + let tag_style = |color| { + if is_selected { + Style::default() + .fg(theme.fg_highlight()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(color).add_modifier(Modifier::BOLD) } - crate::format::ItemStatus::MissingFromActive if var.is_group => { - let missing_style = if is_selected { - Style::default().fg(theme.fg_highlight()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg_warning()).add_modifier(Modifier::BOLD) - }; - key_spans.push(Span::styled(" (missing group)", missing_style)); - } - crate::format::ItemStatus::Modified => { - if !is_selected { - key_spans.push(Span::styled(" (*)", Style::default().fg(theme.fg_modified()))); - } - } - _ => {} + }; + + if is_missing { + let label = if var.is_group { " (missing group)" } else { " (missing)" }; + key_spans.push(Span::styled(label, tag_style(theme.fg_warning()))); + } + // "changed" means changed by you, in this session — not merely + // different from the template, which is the normal case. + if var.dirty { + key_spans.push(Span::styled(" (*)", tag_style(theme.fg_modified()))); + } + if var.is_group && var.collapsed { + let hidden = app.vars.iter().filter(|v| v.is_descendant_of(&var.path)).count(); + key_spans.push(Span::styled( + format!(" {} hidden", hidden), + Style::default().fg(theme.fg_dimmed()), + )); } let item_style = if is_selected { Style::default().bg(theme.bg_highlight()) } else { - Style::default().fg(text_color) + Style::default().fg(theme.fg_normal()) }; if var.is_group { - ListItem::new(Line::from(key_spans)).style(item_style) - } else { - // Determine which value to display (live input vs. stored value). - let val = if is_selected && matches!(app.mode, Mode::Insert) { - app.input.value() - } else { - var.value.as_deref().unwrap_or("") - }; - - let value_style = if is_selected { - Style::default().fg(theme.fg_highlight()) - } else { - Style::default().fg(theme.fg_normal()) - }; - - let mut val_spans = vec![ - Span::raw(format!("{} └─ ", " ".repeat(var.depth))), - Span::styled(val, value_style), - ]; - - // Show default value if it differs from current. - if let Some(t_val) = &var.template_value - && Some(t_val) != var.value.as_ref() { - let t_style = if is_selected { - Style::default().fg(theme.bg_normal()).add_modifier(Modifier::DIM) - } else { - Style::default().fg(theme.fg_dimmed()).add_modifier(Modifier::ITALIC) - }; - val_spans.push(Span::styled(format!(" [Def: {}]", t_val), t_style)); - } - - ListItem::new(vec![Line::from(key_spans), Line::from(val_spans)]).style(item_style) + return ListItem::new(Line::from(key_spans)).style(item_style); } + + // Value line. + let live_edit = is_selected && app.mode == Mode::Insert; + let value_style = if is_selected { + Style::default().fg(theme.fg_highlight()) + } else { + Style::default().fg(theme.fg_normal()) + }; + + let mut val_spans = vec![Span::raw(format!("{} └─ ", indent))]; + + if live_edit { + val_spans.push(Span::styled(app.input.value().to_string(), value_style)); + } else if is_missing { + // Nothing has been accepted yet, so show what is on offer. + let offer = var.template_value.as_deref().unwrap_or(""); + val_spans.push(Span::styled( + format!("({})", offer), + Style::default() + .fg(theme.fg_dimmed()) + .add_modifier(Modifier::ITALIC), + )); + } else { + val_spans.push(Span::styled( + var.value.as_deref().unwrap_or("").to_string(), + value_style, + )); + + if let Some(default) = &var.template_value + && Some(default) != var.value.as_ref() + { + let style = if is_selected { + Style::default().fg(theme.bg_normal()).add_modifier(Modifier::DIM) + } else { + Style::default() + .fg(theme.fg_dimmed()) + .add_modifier(Modifier::ITALIC) + }; + val_spans.push(Span::styled(format!(" [default: {}]", default), style)); + } + } + + ListItem::new(vec![Line::from(key_spans), Line::from(val_spans)]).style(item_style) }) .collect(); @@ -185,160 +196,505 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .title(" Config Variables ") - .title_style( - Style::default() - .fg(theme.fg_accent()) - .add_modifier(Modifier::BOLD), - ) + .title(file_header(app, theme)) + .title(counts_header(app, theme)) .border_style(Style::default().fg(theme.border_normal())), ); let mut state = ListState::default(); - state.select(Some(app.selected)); - f.render_stateful_widget(list, chunks[1], &mut state); + state.select(Some(app.visible_position())); + f.render_stateful_widget(list, area, &mut state); +} - // Compose the focused input area details. - let current_var = app.vars.get(app.selected); - let mut input_title = " Input ".to_string(); - let mut extra_info = String::new(); +/// `output ← template`, so the user always knows what `:w` will touch. +fn file_header<'a>(app: &'a App, theme: &ThemeConfig) -> Line<'a> { + let mut spans = vec![ + Span::raw(" "), + Span::styled( + display_path(&app.output_path), + Style::default() + .fg(theme.fg_accent()) + .add_modifier(Modifier::BOLD), + ), + ]; - if let Some(var) = current_var { - if matches!(app.mode, Mode::InsertKey) { - input_title = format!(" Rename Key: {} ", var.path_string()); - } else if var.is_group { - input_title = format!(" Group: {} ", var.path_string()); - } else { - input_title = format!(" Editing: {} ", var.path_string()); - if let Some(t_val) = &var.template_value { - extra_info = format!(" [Template: {}]", t_val); - } - } + if let Some(template) = &app.template_path { + spans.push(Span::styled(" ← ", Style::default().fg(theme.fg_dimmed()))); + spans.push(Span::styled( + display_path(template), + Style::default().fg(theme.fg_dimmed()), + )); } + spans.push(Span::raw(" ")); + Line::from(spans) +} - let input_border_color = match app.mode { - Mode::Insert | Mode::InsertKey => theme.border_active(), - _ => theme.border_normal(), +/// Right-aligned summary of what still needs attention. +fn counts_header<'a>(app: &App, theme: &ThemeConfig) -> Line<'a> { + let mut spans = vec![Span::raw(" ")]; + let missing = app.missing_count(); + let changed = app.changed_count(); + + if missing > 0 { + spans.push(Span::styled( + format!("{} missing ", missing), + Style::default() + .fg(theme.fg_warning()) + .add_modifier(Modifier::BOLD), + )); + } + if changed > 0 { + spans.push(Span::styled( + format!("{} changed ", changed), + Style::default().fg(theme.fg_modified()), + )); + } + if missing == 0 && changed == 0 { + spans.push(Span::styled( + "in sync ", + Style::default().fg(theme.fg_dimmed()), + )); + } + Line::from(spans).alignment(Alignment::Right) +} + +/// Shortens a path to its file name when it sits in the working directory. +fn display_path(path: &std::path::Path) -> String { + let cwd = std::env::current_dir().unwrap_or_default(); + path.strip_prefix(&cwd) + .unwrap_or(path) + .display() + .to_string() +} + +/// The pane under the tree: a read-only summary in Normal mode, the editor otherwise. +fn draw_detail(f: &mut Frame, area: Rect, app: &App, theme: &ThemeConfig) { + let editing = matches!(app.mode, Mode::Insert | Mode::InsertKey); + let current = app.vars.get(app.selected); + + let title = match (current, app.mode) { + (Some(var), Mode::InsertKey) => format!(" Rename: {} ", var.path_string()), + (Some(var), Mode::Insert) => format!(" Editing: {} ", var.path_string()), + (Some(var), _) => format!(" {} ", var.path_string()), + (None, _) => " Empty ".to_string(), }; - let input_text = app.input.value(); - let cursor_pos = app.input.visual_cursor(); + let body: Line = if editing { + Line::from(app.input.value().to_string()) + } else if let Some(var) = current { + // Read-only facts about the selection, so the box never looks like a + // second place to type when it is not one. + let mut spans = vec![Span::styled( + match var.value_type { + ValueType::String => "text", + ValueType::Number => "number", + ValueType::Bool => "bool", + ValueType::Null => if var.is_group { "group" } else { "empty" }, + }, + Style::default().fg(theme.fg_accent()), + )]; - let display_text = if let Some(var) = current_var { - if matches!(app.mode, Mode::InsertKey) { - input_text.to_string() - } else if var.is_group { - "".to_string() - } else if matches!(app.mode, Mode::Normal) { - format!("{}{}", input_text, extra_info) - } else { - input_text.to_string() + let status = match var.status { + ItemStatus::MissingFromActive => Some(("not in output yet", theme.fg_warning())), + ItemStatus::Overridden => Some(("differs from template", theme.fg_dimmed())), + ItemStatus::Present => None, + }; + if let Some((label, color)) = status { + spans.push(Span::raw(" · ")); + spans.push(Span::styled(label, Style::default().fg(color))); } + if var.dirty { + spans.push(Span::raw(" · ")); + spans.push(Span::styled( + "edited", + Style::default().fg(theme.fg_modified()), + )); + } + if let Some(default) = &var.template_value { + spans.push(Span::raw(" · ")); + spans.push(Span::styled( + format!("template: {}", default), + Style::default().fg(theme.fg_dimmed()), + )); + } + Line::from(spans) } else { - input_text.to_string() + Line::from(Span::styled( + "no entries — press o to add one", + Style::default().fg(theme.fg_dimmed()), + )) }; - let input = Paragraph::new(display_text) + let border_color = if editing { + theme.border_active() + } else { + theme.border_normal() + }; + + let pane = Paragraph::new(body) .style(Style::default().fg(theme.fg_normal())) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .title(input_title) + .title(title) .title_style( Style::default() .fg(theme.fg_accent()) .add_modifier(Modifier::BOLD), ) - .border_style(Style::default().fg(input_border_color)), + .border_style(Style::default().fg(border_color)), ); - f.render_widget(input, chunks[2]); + f.render_widget(pane, area); - // Position terminal cursor during active input. - if matches!(app.mode, Mode::Insert) || matches!(app.mode, Mode::InsertKey) { + if editing { f.set_cursor_position(ratatui::layout::Position::new( - chunks[2].x + 1 + cursor_pos as u16, - chunks[2].y + 1, + area.x + 1 + app.input.visual_cursor() as u16, + area.y + 1, )); } +} - // Render the status bar with mode-specific help hints. - let (mode_str, mode_style) = match app.mode { - Mode::Normal => ( - " NORMAL ", - Style::default() - .bg(theme.bg_highlight()) - .fg(theme.bg_normal()) - .add_modifier(Modifier::BOLD), - ), - Mode::Insert => ( - " INSERT ", - Style::default() - .bg(theme.bg_active()) - .fg(theme.bg_normal()) - .add_modifier(Modifier::BOLD), - ), - Mode::InsertKey => ( - " RENAME ", - Style::default() - .bg(theme.bg_active()) - .fg(theme.bg_normal()) - .add_modifier(Modifier::BOLD), - ), - Mode::Search => ( - " SEARCH ", - Style::default() - .bg(theme.bg_search()) - .fg(theme.bg_normal()) - .add_modifier(Modifier::BOLD), - ), +/// Mode indicator plus either the active prompt, a transient message, or hints. +fn draw_status(f: &mut Frame, area: Rect, app: &App, config: &Config) { + let theme = &config.theme; + let (mode_str, mode_bg) = match app.mode { + Mode::Normal => (" NORMAL ", theme.bg_highlight()), + Mode::Insert => (" INSERT ", theme.bg_active()), + Mode::InsertKey => (" RENAME ", theme.bg_active()), + Mode::Search => (" SEARCH ", theme.bg_search()), + Mode::Help => (" HELP ", theme.bg_search()), }; - let status_msg = if let Some(msg) = &app.status_message { - msg.clone() - } else { - let kb = &config.keybinds; - match app.mode { - Mode::Normal => { - let mut parts = vec![ - format!("{}/{} move", kb.down, kb.up), - format!("{}/{} jump", kb.jump_top, kb.jump_bottom), - format!("{} search", kb.search), - ]; - if !app.selected_is_group() { - parts.push(format!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute)); - } - parts.push(format!("{} rename", kb.rename)); - parts.push(format!("{} toggle", kb.toggle_group)); - if app.selected_is_missing() { - parts.push(format!("{} add", "a")); - } - if app.selected_is_array() { - parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item)); - } else { - parts.push(format!("{}/{} add", kb.append_item, kb.prepend_item)); - parts.push(format!("{}/{} group", kb.append_group, kb.prepend_group)); - } - parts.push(format!("{} del", kb.delete_item)); - parts.push(format!("{} undo", kb.undo)); - parts.push(format!("{} save", kb.save)); - parts.push(format!("{} quit", kb.quit)); - parts.join(" · ") - } - Mode::Insert => "Esc cancel · Enter commit".to_string(), - Mode::InsertKey => "Esc cancel · Enter rename".to_string(), - Mode::Search => "Esc normal · type to filter".to_string(), - } - }; + let message = app + .prompt + .clone() + .or_else(|| app.status_text().map(str::to_string)) + .unwrap_or_else(|| hints(app, config)); let status_line = Line::from(vec![ - Span::styled(mode_str, mode_style), Span::styled( - format!(" {} ", status_msg), - Style::default().bg(theme.border_normal()).fg(theme.fg_normal()), + mode_str, + Style::default() + .bg(mode_bg) + .fg(theme.bg_normal()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {} ", message), + Style::default() + .bg(theme.border_normal()) + .fg(theme.fg_normal()), ), ]); - let status = Paragraph::new(status_line).style(Style::default().bg(theme.border_normal())); - f.render_widget(status, chunks[4]); + f.render_widget( + Paragraph::new(status_line).style(Style::default().bg(theme.border_normal())), + area, + ); } + +/// A handful of hints that fit on one line; the rest lives behind the help key. +fn hints(app: &App, config: &Config) -> String { + let kb = &config.keybinds; + let help = format!("{} help", kb.key_for(Action::Help)); + + match app.mode { + Mode::Insert => "Esc cancel · Enter commit".to_string(), + Mode::InsertKey => "Esc cancel · Enter rename".to_string(), + Mode::Search => match app.match_progress() { + Some((_, 0)) => "no matches · Esc cancel".to_string(), + Some((cur, total)) => format!("match {}/{} · Enter keep · Esc cancel", cur, total), + None => "type to search keys and values · Esc cancel".to_string(), + }, + Mode::Help => format!( + "{}/{} scroll · any other key closes", + kb.key_for(Action::Down), + kb.key_for(Action::Up) + ), + Mode::Normal => { + let mut parts = vec![format!("{}/{} move", kb.key_for(Action::Down), kb.key_for(Action::Up))]; + + if app.selected_is_missing() { + parts.push(format!("{} take default", kb.key_for(Action::AddMissing))); + } else if app.selected_is_group() { + parts.push(format!("{} fold", kb.key_for(Action::ToggleFold))); + parts.push(format!("{} rename", kb.key_for(Action::Rename))); + } else { + parts.push(format!("{} edit", kb.key_for(Action::Edit))); + parts.push(format!("{} rename", kb.key_for(Action::Rename))); + } + + parts.push(format!("{} search", kb.key_for(Action::Search))); + parts.push(format!("{} save", kb.save)); + parts.push(help); + parts.join(" · ") + } + } +} + +/// One line of the help overlay. +enum HelpRow { + Section(&'static str), + Bind(String, &'static str), +} + +/// Builds the full reference: every configured binding, then the `:` commands. +fn help_rows(config: &Config) -> Vec { + let kb = &config.keybinds; + let mut rows = Vec::new(); + let mut section = ""; + + for (spec, action) in kb.table() { + if action.section() != section { + section = action.section(); + rows.push(HelpRow::Section(section)); + } + rows.push(HelpRow::Bind(spec.to_string(), action.label())); + } + + rows.push(HelpRow::Section("Commands")); + rows.push(HelpRow::Bind(kb.save.clone(), "write the output file")); + rows.push(HelpRow::Bind(format!("{} ", kb.save), "write somewhere else")); + rows.push(HelpRow::Bind(kb.quit.clone(), "quit (refused if unsaved)")); + rows.push(HelpRow::Bind(":q!".to_string(), "quit, discarding changes")); + rows.push(HelpRow::Bind(":wq".to_string(), "write and quit")); + rows +} + +/// Renders one help row into styled spans of a fixed width. +fn help_spans<'a>(row: Option<&'a HelpRow>, theme: &ThemeConfig, width: usize) -> Vec> { + let key_width = 11; + match row { + Some(HelpRow::Section(name)) => vec![Span::styled( + format!("{: { + let label_width = width.saturating_sub(key_width + 2); + vec![ + Span::styled( + format!(" {: vec![Span::raw(" ".repeat(width))], + } +} + +/// Clips a label to the space available, with an ellipsis when it does not fit. +fn truncate(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + text.chars().take(width.saturating_sub(1)).collect::() + "…" +} + +/// Full keybinding reference, laid out in two columns so it fits a short terminal. +fn draw_help(f: &mut Frame, area: Rect, config: &Config, scroll_offset: usize) { + let theme = &config.theme; + let rows = help_rows(config); + + // Break into two columns at the section boundary nearest the midpoint, so a + // group of related keys is never split across the gutter. + let midpoint = rows.len().div_ceil(2); + let split = (0..rows.len()) + .filter(|&i| matches!(rows[i], HelpRow::Section(_))) + .min_by_key(|&i| i.abs_diff(midpoint)) + .unwrap_or(midpoint); + + let (left, right) = rows.split_at(split); + let column_width = 36usize; + let gutter = 2usize; + + let lines: Vec = (0..left.len().max(right.len())) + .map(|i| { + let mut spans = help_spans(left.get(i), theme, column_width); + spans.push(Span::raw(" ".repeat(gutter))); + spans.extend(help_spans(right.get(i), theme, column_width)); + Line::from(spans) + }) + .collect(); + + let popup = centered_rect( + (column_width * 2 + gutter + 2) as u16, + area, + lines.len() as u16 + 2, + ); + // On a terminal too short for the whole reference, scroll rather than clip + // silently: a key the user cannot see is a key they do not have. + let rows_shown = popup.height.saturating_sub(2) as usize; + let max_scroll = lines.len().saturating_sub(rows_shown); + let scroll = scroll_offset.min(max_scroll); + let title = if max_scroll > 0 { + format!(" Keys — {}/{} ", (scroll + rows_shown).min(lines.len()), lines.len()) + } else { + " Keys ".to_string() + }; + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(lines) + .scroll((scroll as u16, 0)) + .style(Style::default().bg(theme.bg_normal()).fg(theme.fg_normal())) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(title) + .title_style( + Style::default() + .fg(theme.fg_accent()) + .add_modifier(Modifier::BOLD), + ) + .border_style(Style::default().fg(theme.border_active())), + ), + popup, + ); +} + +/// Centers a popup of the given size inside `area`, clamped to what fits. +fn centered_rect(width: u16, area: Rect, height: u16) -> Rect { + let w = width.min(area.width.saturating_sub(2)).max(1); + let h = height.min(area.height.saturating_sub(2)).max(1); + Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::{ConfigItem, PathSegment}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::path::PathBuf; + + /// Renders one frame and returns it as plain text lines. + fn render(app: &mut App, config: &Config) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal.draw(|f| draw(f, app, config)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect() + } + + fn sample_app() -> App { + let mut token = ConfigItem::leaf( + "API_KEY", + vec![PathSegment::Key("API_KEY".to_string())], + "", + 0, + ValueType::String, + ); + token.status = ItemStatus::MissingFromActive; + token.value = None; + token.template_value = Some("changeme".to_string()); + + App::new( + vec![ + ConfigItem::leaf( + "DB_HOST", + vec![PathSegment::Key("DB_HOST".to_string())], + "prod.internal", + 0, + ValueType::String, + ), + token, + ], + PathBuf::from(".env"), + Some(PathBuf::from(".env.example")), + ) + } + + #[test] + fn header_names_the_files_and_counts_the_work() { + let config = Config::default(); + let mut app = sample_app(); + let frame = render(&mut app, &config).join("\n"); + + assert!(frame.contains(".env"), "{frame}"); + assert!(frame.contains(".env.example"), "{frame}"); + assert!(frame.contains("1 missing"), "{frame}"); + assert!(frame.contains("(missing)"), "{frame}"); + } + + #[test] + fn status_bar_offers_the_help_key_and_fits_the_terminal() { + let config = Config::default(); + let mut app = sample_app(); + app.selected = 1; // the entry that is missing from the output + let frame = render(&mut app, &config); + let status = frame.last().unwrap(); + + assert!(status.contains("NORMAL"), "{status}"); + assert!(status.contains("? help"), "{status}"); + // The hint line must survive an 80-column terminal without truncation. + assert!(status.chars().count() <= 80, "{status}"); + // On a missing entry the offer to take the default is what matters. + assert!(status.contains("take default"), "{status}"); + } + + #[test] + fn help_overlay_lists_bindings() { + let config = Config::default(); + let mut app = sample_app(); + app.toggle_help(); + let frame = render(&mut app, &config).join("\n"); + + assert!(frame.contains("Navigate"), "{frame}"); + assert!(frame.contains("Template"), "{frame}"); + assert!(frame.contains("take template default"), "{frame}"); + assert!(frame.contains("History"), "{frame}"); + assert!(frame.contains("Session"), "{frame}"); + } + + #[test] + fn help_overlay_scrolls_when_the_terminal_is_short() { + let config = Config::default(); + let mut app = sample_app(); + app.toggle_help(); + + // Too short to show everything at once: say so, and let the rest be reached. + let frame = render(&mut app, &config).join("\n"); + assert!(frame.contains("Keys — "), "{frame}"); + assert!(!frame.contains(":q!"), "{frame}"); + + for _ in 0..10 { + app.scroll_help(true); + } + let frame = render(&mut app, &config).join("\n"); + assert!(frame.contains(":q!"), "{frame}"); + } + + #[test] + fn transient_status_replaces_hints_then_gives_them_back() { + let config = Config::default(); + let mut app = sample_app(); + app.set_status("Saved to .env"); + + let frame = render(&mut app, &config); + assert!(frame.last().unwrap().contains("Saved to .env")); + + app.clear_status(); + let frame = render(&mut app, &config); + assert!(frame.last().unwrap().contains("? help")); + } +} + diff --git a/src/undo.rs b/src/undo.rs index 58de381..3b1a9a8 100644 --- a/src/undo.rs +++ b/src/undo.rs @@ -122,25 +122,82 @@ impl UndoTree { } None } + + /// Discards the current node, moving back to its parent. + /// + /// Used when an action is rolled back before the user ever saw it as a + /// distinct step, such as a newly added item abandoned at the rename prompt. + pub fn pop(&mut self) { + let Some(current) = self.nodes.get(&self.current_node) else { return }; + let Some(parent_id) = current.parent else { return }; + + let removed = self.current_node; + self.nodes.remove(&removed); + if let Some(parent) = self.nodes.get_mut(&parent_id) { + parent.children.retain(|&c| c != removed); + } + if self.latest_branch.get(&parent_id) == Some(&removed) { + match self.nodes.get(&parent_id).and_then(|p| p.children.last()) { + Some(&fallback) => { + self.latest_branch.insert(parent_id, fallback); + } + None => { + self.latest_branch.remove(&parent_id); + } + } + } + self.latest_branch.remove(&removed); + self.current_node = parent_id; + } + + /// Which forward branch redo would take, as `(index, total)`, both 1-based. + /// + /// `None` when the current node has no children at all. + pub fn branch_position(&self) -> Option<(usize, usize)> { + let current = self.nodes.get(&self.current_node)?; + if current.children.is_empty() { + return None; + } + let preferred = self + .latest_branch + .get(&self.current_node) + .copied() + .or_else(|| current.children.last().copied())?; + let index = current + .children + .iter() + .position(|&c| c == preferred) + .unwrap_or(0); + Some((index + 1, current.children.len())) + } + + /// Picks a different forward branch at the current point. + /// + /// Returns the new `(index, total)` position, or `None` when there is no + /// alternative history to choose from. + pub fn cycle_branch(&mut self, forward: bool) -> Option<(usize, usize)> { + let (index, total) = self.branch_position()?; + if total < 2 { + return None; + } + let next = if forward { + index % total + } else { + (index + total - 2) % total + }; + let child = self.nodes.get(&self.current_node)?.children[next]; + self.latest_branch.insert(self.current_node, child); + Some((next + 1, total)) + } } #[cfg(test)] mod tests { use super::*; - use crate::format::{ItemStatus, ValueType}; + use crate::format::ValueType; fn dummy_item(key: &str) -> ConfigItem { - ConfigItem { - key: key.to_string(), - path: vec![], - value: Some(key.to_string()), - template_value: None, - default_value: None, - depth: 0, - is_group: false, - status: ItemStatus::Present, - value_type: ValueType::String, - } + ConfigItem::leaf(key, vec![], key, 0, ValueType::String) } #[test] @@ -213,4 +270,33 @@ mod tests { // (since it's already at the latest child) assert!(tree.redo().is_none()); } + + #[test] + fn test_pop_discards_current_state() { + let mut tree = UndoTree::new(vec![dummy_item("A")], 0); + tree.push(vec![dummy_item("B")], 1); + + tree.pop(); + + // Back at A, with nothing left to redo into. + assert!(tree.redo().is_none()); + assert!(tree.undo().is_none()); + } + + #[test] + fn test_branch_cycling() { + let mut tree = UndoTree::new(vec![dummy_item("A")], 0); + tree.push(vec![dummy_item("B")], 0); + tree.undo(); + tree.push(vec![dummy_item("C")], 0); + tree.undo(); + + // Two futures hang off the root; redo prefers the newest. + assert_eq!(tree.branch_position(), Some((2, 2))); + assert_eq!(tree.redo().unwrap().state[0].key, "C"); + + tree.undo(); + tree.cycle_branch(true); + assert_eq!(tree.redo().unwrap().state[0].key, "B"); + } }