From 68cd6543b38379f7bd2084026187b70a54bb3119 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 09:15:21 +0100 Subject: [PATCH 1/5] fixed long nested vars in the ui --- Cargo.lock | 2 +- README.md | 16 +++++++- config.example.toml | 46 ++++++++++++++++++++++ src/cli.rs | 4 +- src/config.rs | 38 +++++++++++++----- src/error.rs | 2 +- src/format/hierarchical.rs | 42 +++++++++++++------- src/format/mod.rs | 2 +- src/main.rs | 36 ++++++++++------- src/runner.rs | 2 +- src/ui.rs | 81 ++++++++++++++++++++++++++------------ 11 files changed, 200 insertions(+), 71 deletions(-) create mode 100644 config.example.toml diff --git a/Cargo.lock b/Cargo.lock index 65986b8..75cf015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "mould" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "clap", diff --git a/README.md b/README.md index f4ec789..62fa897 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ mould is a modern Terminal User Interface (TUI) tool designed for interactively - **Modern UI**: A polished, rounded interface featuring the Catppuccin Mocha palette. - **Highly Configurable**: Customize keybindings and themes via a simple TOML configuration. - **Dynamic Alignment**: Automatically aligns keys and values for perfect vertical readability. +- **Default Value Visibility**: Keep track of original template values while editing. +- **Incremental Merging**: Load existing values from an output file to continue where you left off. ## Installation @@ -72,7 +74,19 @@ quit = ":q" normal_mode = "Esc" [theme] -name = "catppuccin_mocha" +# Enable transparency to let your terminal background show through +transparent = false + +# Custom color palette (Catppuccin Mocha defaults) +crust = "#11111b" +surface0 = "#313244" +surface1 = "#45475a" +text = "#cdd6f4" +blue = "#89b4fa" +green = "#a6e3a1" +lavender = "#b4befe" +mauve = "#cba6f7" +peach = "#fab387" ``` ## License diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..e48616c --- /dev/null +++ b/config.example.toml @@ -0,0 +1,46 @@ +# mould Configuration Example +# Place this file at ~/.config/mould/config.toml (Linux/macOS) +# or %AppData%\mould\config.toml (Windows) + +[theme] +# If true, skip rendering the background block to let the terminal's transparency show. +transparent = false + +# Colors are specified in hex format ("#RRGGBB"). +# Default values follow the Catppuccin Mocha palette. + +# Main background color (used when transparent = false). +crust = "#11111b" + +# Status bar background and other secondary UI elements. +surface0 = "#313244" + +# Border and separator color. +surface1 = "#45475a" + +# Default text color. +text = "#cdd6f4" + +# Selection highlight and "NORMAL" mode tag color. +blue = "#89b4fa" + +# Insert mode highlight and "INSERT" mode tag color. +green = "#a6e3a1" + +# Keys/labels color. +lavender = "#b4befe" + +# Section titles color. +mauve = "#cba6f7" + +# Input field titles color. +peach = "#fab387" + +[keybinds] +# Keybindings for navigation and application control. +down = "j" +up = "k" +edit = "i" +save = ":w" +quit = ":q" +normal_mode = "Esc" diff --git a/src/cli.rs b/src/cli.rs index 1d4c73d..0e8751f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; /// mould: A TUI tool to generate and edit configuration files (.env, json, yaml, toml) #[derive(Parser, Debug)] #[command( - author, - version, + 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.", after_help = "EXAMPLES:\n mould .env.example\n mould docker-compose.yml\n mould config.template.json -o config.json" diff --git a/src/config.rs b/src/config.rs index 8ea7df6..223785e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ +use ratatui::style::Color; use serde::{Deserialize, Serialize}; use std::fs; -use ratatui::style::Color; /// Configuration for the application's appearance. #[derive(Debug, Deserialize, Serialize, Clone)] @@ -42,15 +42,33 @@ impl ThemeConfig { } } - pub fn crust(&self) -> Color { Self::parse_hex(&self.crust) } - pub fn surface0(&self) -> Color { Self::parse_hex(&self.surface0) } - pub fn surface1(&self) -> Color { Self::parse_hex(&self.surface1) } - pub fn text(&self) -> Color { Self::parse_hex(&self.text) } - pub fn blue(&self) -> Color { Self::parse_hex(&self.blue) } - pub fn green(&self) -> Color { Self::parse_hex(&self.green) } - pub fn lavender(&self) -> Color { Self::parse_hex(&self.lavender) } - pub fn mauve(&self) -> Color { Self::parse_hex(&self.mauve) } - pub fn peach(&self) -> Color { Self::parse_hex(&self.peach) } + pub fn crust(&self) -> Color { + Self::parse_hex(&self.crust) + } + pub fn surface0(&self) -> Color { + Self::parse_hex(&self.surface0) + } + pub fn surface1(&self) -> Color { + Self::parse_hex(&self.surface1) + } + pub fn text(&self) -> Color { + Self::parse_hex(&self.text) + } + pub fn blue(&self) -> Color { + Self::parse_hex(&self.blue) + } + pub fn green(&self) -> Color { + Self::parse_hex(&self.green) + } + pub fn lavender(&self) -> Color { + Self::parse_hex(&self.lavender) + } + pub fn mauve(&self) -> Color { + Self::parse_hex(&self.mauve) + } + pub fn peach(&self) -> Color { + Self::parse_hex(&self.peach) + } } impl Default for ThemeConfig { diff --git a/src/error.rs b/src/error.rs index db04b67..1ee2036 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,5 @@ -use thiserror::Error; use std::io; +use thiserror::Error; /// Custom error types for the mould application. #[derive(Error, Debug)] diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs index ca7fdd1..d124e58 100644 --- a/src/format/hierarchical.rs +++ b/src/format/hierarchical.rs @@ -16,9 +16,12 @@ impl HierarchicalHandler { fn read_value(&self, path: &Path) -> io::Result { let content = fs::read_to_string(path)?; let value = match self.format_type { - FormatType::Json => serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, - FormatType::Yaml => serde_yaml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, - FormatType::Toml => toml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Json => serde_json::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Yaml => serde_yaml::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Toml => toml::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, _ => unreachable!(), }; Ok(value) @@ -26,15 +29,22 @@ impl HierarchicalHandler { fn write_value(&self, path: &Path, value: &Value) -> io::Result<()> { let content = match self.format_type { - FormatType::Json => serde_json::to_string_pretty(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, - FormatType::Yaml => serde_yaml::to_string(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Json => serde_json::to_string_pretty(value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + FormatType::Yaml => serde_yaml::to_string(value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, FormatType::Toml => { // toml requires the root to be a table if value.is_object() { - let toml_value: toml::Value = serde_json::from_value(value.clone()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - toml::to_string_pretty(&toml_value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? + let toml_value: toml::Value = serde_json::from_value(value.clone()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + toml::to_string_pretty(&toml_value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? } else { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Root of TOML must be an object")); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Root of TOML must be an object", + )); } } _ => unreachable!(), @@ -143,7 +153,7 @@ fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) { *current = Value::Object(Map::new()); } let map = current.as_object_mut().unwrap(); - + let next_node = map.entry(key.to_string()).or_insert_with(|| { if idx.is_some() { Value::Array(Vec::new()) @@ -171,7 +181,7 @@ fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) { *current = Value::Object(Map::new()); } let map = current.as_object_mut().unwrap(); - + // Attempt basic type inference let final_val = if let Ok(n) = new_val_str.parse::() { Value::Number(n.into()) @@ -182,7 +192,9 @@ fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) { }; if let Some(i) = final_idx { - let next_node = map.entry(final_key.to_string()).or_insert_with(|| Value::Array(Vec::new())); + let next_node = map + .entry(final_key.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); if !next_node.is_array() { *next_node = Value::Array(Vec::new()); } @@ -200,7 +212,7 @@ fn parse_array_key(part: &str) -> (&str, Option) { if part.ends_with(']') && part.contains('[') { let start_idx = part.find('[').unwrap(); let key = &part[..start_idx]; - let idx = part[start_idx+1..part.len()-1].parse::().ok(); + let idx = part[start_idx + 1..part.len() - 1].parse::().ok(); (key, idx) } else { (part, None) @@ -224,15 +236,15 @@ mod tests { } } }); - + flatten(&json, "", &mut vars); assert_eq!(vars.len(), 2); - + let mut root = Value::Object(Map::new()); for var in vars { insert_into_value(&mut root, &var.key, &var.value); } - + // When unflattening, it parses bool back let unflattened_json = serde_json::to_string(&root).unwrap(); assert!(unflattened_json.contains("\"8080:80\"")); diff --git a/src/format/mod.rs b/src/format/mod.rs index 9950c4d..d8814ab 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -1,5 +1,5 @@ -use std::path::Path; use std::io; +use std::path::Path; pub mod env; pub mod hierarchical; diff --git a/src/main.rs b/src/main.rs index 27ecd2c..222991a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,32 +18,32 @@ use std::path::{Path, PathBuf}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::{Terminal, backend::CrosstermBackend}; /// Helper to automatically determine the output file path based on common naming conventions. fn determine_output_path(input: &Path) -> PathBuf { let file_name = input.file_name().unwrap_or_default().to_string_lossy(); - + if file_name == ".env.example" { return input.with_file_name(".env"); } - - if file_name == "docker-compose.yml" { - return input.with_file_name("docker-compose.override.yml"); + + if file_name == "docker-compose.yml" || file_name == "compose.yml" { + return input.with_file_name("compose.override.yml"); } - if file_name == "docker-compose.yaml" { - return input.with_file_name("docker-compose.override.yaml"); + if file_name == "docker-compose.yaml" || file_name == "compose.yaml" { + return input.with_file_name("compose.override.yaml"); } - + if file_name.ends_with(".example.json") { return input.with_file_name(file_name.replace(".example.json", ".json")); } if file_name.ends_with(".template.json") { return input.with_file_name(file_name.replace(".template.json", ".json")); } - + input.with_extension(format!( "{}.out", input.extension().unwrap_or_default().to_string_lossy() @@ -98,7 +98,8 @@ fn main() -> anyhow::Result<()> { let mut app = App::new(vars); // Terminal lifecycle - enable_raw_mode().map_err(|e| MouldError::Terminal(format!("Failed to enable raw mode: {}", e)))?; + enable_raw_mode() + .map_err(|e| MouldError::Terminal(format!("Failed to enable raw mode: {}", e)))?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) .map_err(|e| MouldError::Terminal(format!("Failed to enter alternate screen: {}", e)))?; @@ -106,7 +107,13 @@ fn main() -> anyhow::Result<()> { 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, + &output_path, + handler.as_ref(), + ); let res = runner.run(); // Restoration @@ -115,14 +122,15 @@ fn main() -> anyhow::Result<()> { terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture - ).ok(); + ) + .ok(); terminal.show_cursor().ok(); match res { Ok(_) => { info!("Successfully finished mould session."); Ok(()) - }, + } Err(e) => { error!("Application error during run: {}", e); Err(e.into()) diff --git a/src/runner.rs b/src/runner.rs index c31e3a4..7084de8 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,8 +2,8 @@ use crate::app::{App, Mode}; use crate::config::Config; use crate::format::FormatHandler; use crossterm::event::{self, Event, KeyCode, KeyEvent}; -use ratatui::backend::Backend; use ratatui::Terminal; +use ratatui::backend::Backend; use std::io; use std::path::Path; use tui_input::backend::crossterm::EventHandler; diff --git a/src/ui.rs b/src/ui.rs index 8beb046..4b59599 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,11 +1,11 @@ use crate::app::{App, Mode}; use crate::config::Config; use ratatui::{ + Frame, layout::{Constraint, Direction, Layout}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph}, - Frame, }; /// Renders the main application interface using ratatui. @@ -43,15 +43,6 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { ]) .split(outer_layout[1]); - // Calculate alignment for the key-value separator based on the longest key. - let max_key_len = app - .vars - .iter() - .map(|v| v.key.len()) - .max() - .unwrap_or(20) - .min(40); - // Build the interactive list of configuration variables. let items: Vec = app .vars @@ -81,14 +72,25 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Style::default().fg(theme.text()) }; - let line = Line::from(vec![ - Span::styled( - format!(" {: " navigation | i: edit | :w: save | :q: quit ", - Mode::Insert => " Esc: back to normal | Enter: commit ", - }); + let status_msg = app + .status_message + .as_deref() + .unwrap_or_else(|| match app.mode { + Mode::Normal => " navigation | i: edit | :w: save | :q: quit ", + Mode::Insert => " Esc: back to normal | Enter: commit ", + }); let status_line = Line::from(vec![ Span::styled(mode_str, mode_style), -- 2.49.1 From 7b2217886ca938e85594e11af589eb7308136a42 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 09:19:36 +0100 Subject: [PATCH 2/5] added auto discovery and bumped version --- Cargo.toml.out | 33 ++++++++++++++ src/cli.rs | 4 +- src/main.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 Cargo.toml.out diff --git a/Cargo.toml.out b/Cargo.toml.out new file mode 100644 index 0000000..8a1a38f --- /dev/null +++ b/Cargo.toml.out @@ -0,0 +1,33 @@ +[[bin]] +name = "mould" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.102" +crossterm = "0.29.0" +dirs = "6.0.0" +env_logger = "0.11.9" +log = "0.4.29" +ratatui = "0.30.0" +serde_json = "1.0.149" +serde_yaml = "0.9.34" +thiserror = "2.0.18" +toml = "1.0.6" +tui-input = "0.15.0" + +[dependencies.clap] +features = ["derive"] +version = "4.6.0" + +[dependencies.serde] +features = ["derive"] +version = "1.0.228" + +[dev-dependencies] +tempfile = "3.27.0" + +[package] +authors = ["Nils Pukropp "] +edition = 2024 +name = "mould" +version = "0.3.0" diff --git a/src/cli.rs b/src/cli.rs index 0e8751f..9932add 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,8 +12,8 @@ use std::path::PathBuf; )] pub struct Cli { /// The input template file (e.g., .env.example, config.json.template, docker-compose.yml) - #[arg(required = true, value_name = "INPUT_FILE")] - pub input: PathBuf, + #[arg(required = false, value_name = "INPUT_FILE")] + pub input: Option, /// Optional output file. If not provided, it will be inferred. #[arg(short, long, value_name = "OUTPUT_FILE")] diff --git a/src/main.rs b/src/main.rs index 222991a..c1f1eb7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ use ratatui::{Terminal, backend::CrosstermBackend}; fn determine_output_path(input: &Path) -> PathBuf { let file_name = input.file_name().unwrap_or_default().to_string_lossy(); - if file_name == ".env.example" { + // Standard mappings + if file_name == ".env.example" || file_name == ".env.template" { return input.with_file_name(".env"); } @@ -37,11 +38,36 @@ fn determine_output_path(input: &Path) -> PathBuf { return input.with_file_name("compose.override.yaml"); } - if file_name.ends_with(".example.json") { - return input.with_file_name(file_name.replace(".example.json", ".json")); + // Pattern-based mappings + if let Some(base) = file_name.strip_suffix(".env.example") { + return input.with_file_name(format!("{}.env", base)); } - if file_name.ends_with(".template.json") { - return input.with_file_name(file_name.replace(".template.json", ".json")); + if let Some(base) = file_name.strip_suffix(".env.template") { + return input.with_file_name(format!("{}.env", base)); + } + if let Some(base) = file_name.strip_suffix(".example.json") { + return input.with_file_name(format!("{}.json", base)); + } + if let Some(base) = file_name.strip_suffix(".template.json") { + return input.with_file_name(format!("{}.json", base)); + } + if let Some(base) = file_name.strip_suffix(".example.yml") { + return input.with_file_name(format!("{}.yml", base)); + } + if let Some(base) = file_name.strip_suffix(".template.yml") { + return input.with_file_name(format!("{}.yml", base)); + } + if let Some(base) = file_name.strip_suffix(".example.yaml") { + return input.with_file_name(format!("{}.yaml", base)); + } + if let Some(base) = file_name.strip_suffix(".template.yaml") { + return input.with_file_name(format!("{}.yaml", base)); + } + if let Some(base) = file_name.strip_suffix(".example.toml") { + return input.with_file_name(format!("{}.toml", base)); + } + if let Some(base) = file_name.strip_suffix(".template.toml") { + return input.with_file_name(format!("{}.toml", base)); } input.with_extension(format!( @@ -50,6 +76,60 @@ fn determine_output_path(input: &Path) -> PathBuf { )) } +/// Discovers common configuration template files in the current directory. +fn find_input_file() -> Option { + let candidates = [ + ".env.example", + "compose.yml", + "docker-compose.yml", + ".env.template", + "compose.yaml", + "docker-compose.yaml", + ]; + + // Priority 1: Exact matches for well-known defaults + for name in &candidates { + let path = PathBuf::from(name); + if path.exists() { + return Some(path); + } + } + + // Priority 2: Pattern matches + if let Ok(entries) = std::fs::read_dir(".") { + let mut fallback = None; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.ends_with(".env.example") + || name_str.ends_with(".env.template") + || name_str.ends_with(".example.json") + || name_str.ends_with(".template.json") + || name_str.ends_with(".example.yml") + || name_str.ends_with(".template.yml") + || name_str.ends_with(".example.yaml") + || name_str.ends_with(".template.yaml") + || name_str.ends_with(".example.toml") + || name_str.ends_with(".template.toml") + { + // Prefer .env.* or compose.* if multiple matches + if name_str.contains(".env") || name_str.contains("compose") { + return Some(entry.path()); + } + if fallback.is_none() { + fallback = Some(entry.path()); + } + } + } + if let Some(path) = fallback { + return Some(path); + } + } + + None +} + fn main() -> anyhow::Result<()> { let args = cli::parse(); @@ -64,11 +144,27 @@ fn main() -> anyhow::Result<()> { .format_timestamp(None) .init(); - let input_path = args.input; - if !input_path.exists() { - error!("Input file not found: {}", input_path.display()); - return Err(MouldError::FileNotFound(input_path.display().to_string()).into()); - } + let input_path = match args.input { + Some(path) => { + if !path.exists() { + error!("Input file not found: {}", path.display()); + return Err(MouldError::FileNotFound(path.display().to_string()).into()); + } + path + } + None => match find_input_file() { + Some(path) => { + info!("Discovered template: {}", path.display()); + path + } + None => { + error!("No template file provided and none discovered in current directory."); + println!("Usage: mould "); + println!("Supported defaults: .env.example, compose.yml, docker-compose.yml, etc."); + return Err(MouldError::FileNotFound("None".to_string()).into()); + } + }, + }; info!("Input: {}", input_path.display()); -- 2.49.1 From 0c217c5129fe9cb7468dc98f71457f9cc2852ce2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 09:24:58 +0100 Subject: [PATCH 3/5] added search functionality --- README.md | 3 +++ config.example.toml | 3 +++ src/app.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 6 +++++ src/runner.rs | 30 ++++++++++++++++++++++++ src/ui.rs | 18 +++++++++++++-- 6 files changed, 114 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62fa897..723bbbc 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ mould config.template.json -o config.json - `j` / `Down`: Move selection down - `k` / `Up`: Move selection up - `i`: Edit the value of the currently selected key (Enter Insert Mode) + - `/`: Search for configuration keys (Jump to matches) + - `n`: Jump to the next search match + - `N`: Jump to the previous search match - `:w` or `Enter`: Save the current configuration to the output file - `:q` or `q`: Quit the application - `:wq`: Save and quit diff --git a/config.example.toml b/config.example.toml index e48616c..7a9be3f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -44,3 +44,6 @@ edit = "i" save = ":w" quit = ":q" normal_mode = "Esc" +search = "/" +next_match = "n" +previous_match = "N" diff --git a/src/app.rs b/src/app.rs index bac4047..2fba0f1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,6 +7,8 @@ pub enum Mode { Normal, /// Active text entry mode for modifying values. Insert, + /// Active search mode for filtering keys. + Search, } /// The core application state, holding all configuration variables and UI status. @@ -23,6 +25,8 @@ pub struct App { pub status_message: Option, /// The active text input buffer for the selected variable. pub input: Input, + /// The current search query for filtering keys. + pub search_query: String, } impl App { @@ -36,9 +40,24 @@ impl App { running: true, status_message: None, input: Input::new(initial_input), + search_query: String::new(), } } + /// Returns the indices of variables that match the search query. + 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) + .collect() + } + /// Moves the selection to the next variable in the list, wrapping around if necessary. pub fn next(&mut self) { if !self.vars.is_empty() { @@ -59,6 +78,43 @@ impl App { } } + /// Jumps to the next variable that matches the search query. + pub fn jump_next_match(&mut self) { + let indices = self.matching_indices(); + if indices.is_empty() { + return; + } + + let next_match = indices + .iter() + .find(|&&i| i > self.selected) + .or_else(|| indices.first()); + + if let Some(&index) = next_match { + self.selected = index; + self.sync_input_with_selected(); + } + } + + /// Jumps to the previous variable that matches the search query. + pub fn jump_previous_match(&mut self) { + let indices = self.matching_indices(); + if indices.is_empty() { + return; + } + + let prev_match = indices + .iter() + .rev() + .find(|&&i| i < self.selected) + .or_else(|| indices.last()); + + if let Some(&index) = prev_match { + self.selected = index; + self.sync_input_with_selected(); + } + } + /// Updates the input buffer to reflect the value of the currently selected variable. pub fn sync_input_with_selected(&mut self) { if let Some(var) = self.vars.get(self.selected) { diff --git a/src/config.rs b/src/config.rs index 223785e..83883ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,6 +98,9 @@ pub struct KeybindsConfig { pub save: String, pub quit: String, pub normal_mode: String, + pub search: String, + pub next_match: String, + pub previous_match: String, } impl Default for KeybindsConfig { @@ -109,6 +112,9 @@ impl Default for KeybindsConfig { save: ":w".to_string(), quit: ":q".to_string(), normal_mode: "Esc".to_string(), + search: "/".to_string(), + next_match: "n".to_string(), + previous_match: "N".to_string(), } } } diff --git a/src/runner.rs b/src/runner.rs index 7084de8..ca3a3d2 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -64,6 +64,7 @@ where match self.app.mode { Mode::Normal => self.handle_normal_mode(key), Mode::Insert => self.handle_insert_mode(key), + Mode::Search => self.handle_search_mode(key), } } @@ -118,6 +119,13 @@ where self.sync_command_status(); } else if c_str == "q" { self.app.running = false; + } else if c_str == self.config.keybinds.search { + self.app.mode = Mode::Search; + self.app.status_message = Some(format!("{} ", self.config.keybinds.search)); + } else if c_str == self.config.keybinds.next_match { + self.app.jump_next_match(); + } else if c_str == self.config.keybinds.previous_match { + self.app.jump_previous_match(); } } else { match key.code { @@ -143,6 +151,28 @@ where Ok(()) } + /// Handles search mode key events. + 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; + } + KeyCode::Backspace => { + self.app.search_query.pop(); + self.app.status_message = Some(format!("{}{}", self.config.keybinds.search, self.app.search_query)); + 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.app.jump_next_match(); + } + _ => {} + } + Ok(()) + } + /// Logic to map command strings (like ":w") to internal application actions. fn execute_command(&mut self, cmd: &str) -> io::Result<()> { if cmd == self.config.keybinds.save { diff --git a/src/ui.rs b/src/ui.rs index 4b59599..b92efe4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -44,12 +44,14 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .split(outer_layout[1]); // Build the interactive list of configuration variables. + let matching_indices = app.matching_indices(); let items: Vec = app .vars .iter() .enumerate() .map(|(i, var)| { let is_selected = i == app.selected; + let is_match = matching_indices.contains(&i); // Show live input text for the selected item if in Insert mode. let val = if is_selected && matches!(app.mode, Mode::Insert) { @@ -62,6 +64,10 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Style::default() .fg(theme.crust()) .add_modifier(Modifier::BOLD) + } else if is_match { + Style::default() + .fg(theme.mauve()) + .add_modifier(Modifier::UNDERLINED) } else { Style::default().fg(theme.lavender()) }; @@ -151,7 +157,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let input_border_color = match app.mode { Mode::Insert => theme.green(), - Mode::Normal => theme.surface1(), + Mode::Normal | Mode::Search => theme.surface1(), }; let input_text = app.input.value(); @@ -197,14 +203,22 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .fg(theme.crust()) .add_modifier(Modifier::BOLD), ), + Mode::Search => ( + " SEARCH ", + Style::default() + .bg(theme.mauve()) + .fg(theme.crust()) + .add_modifier(Modifier::BOLD), + ), }; let status_msg = app .status_message .as_deref() .unwrap_or_else(|| match app.mode { - Mode::Normal => " navigation | i: edit | :w: save | :q: quit ", + Mode::Normal => " navigation | i: edit | /: search | :w: save | :q: quit ", Mode::Insert => " Esc: back to normal | Enter: commit ", + Mode::Search => " Esc: back to normal | type to filter ", }); let status_line = Line::from(vec![ -- 2.49.1 From 84945b9d83f42bc8697cccb66c6d12067eaba842 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 09:29:12 +0100 Subject: [PATCH 4/5] clear search term everytime --- src/runner.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runner.rs b/src/runner.rs index ca3a3d2..6ee8107 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -121,6 +121,7 @@ where self.app.running = false; } else if c_str == self.config.keybinds.search { self.app.mode = Mode::Search; + self.app.search_query.clear(); self.app.status_message = Some(format!("{} ", self.config.keybinds.search)); } else if c_str == self.config.keybinds.next_match { self.app.jump_next_match(); -- 2.49.1 From 0ce858da5cacdaeafb6ce65378f51eee56aa2d78 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 09:30:33 +0100 Subject: [PATCH 5/5] fixed transparent defaulting to true --- src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 83883ca..07badb3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,9 +4,9 @@ use std::fs; /// Configuration for the application's appearance. #[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(default)] pub struct ThemeConfig { /// If true, skip rendering the background block to let the terminal's transparency show. - #[serde(default)] pub transparent: bool, /// Color for standard background areas (when not transparent). pub crust: String, @@ -91,6 +91,7 @@ impl Default for ThemeConfig { /// Custom keybindings for navigation and application control. #[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(default)] pub struct KeybindsConfig { pub down: String, pub up: String, -- 2.49.1