diff --git a/src/app.rs b/src/app.rs index 11a7bc7..bac4047 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,21 +1,32 @@ use crate::format::EnvVar; use tui_input::Input; +/// Represents the current operating mode of the application. pub enum Mode { + /// Standard navigation and command mode. Normal, + /// Active text entry mode for modifying values. Insert, } +/// The core application state, holding all configuration variables and UI status. pub struct App { + /// The list of configuration variables being edited. pub vars: Vec, + /// Index of the currently selected variable in the list. pub selected: usize, + /// The current interaction mode (Normal or Insert). pub mode: Mode, + /// Whether the main application loop should continue running. pub running: bool, + /// An optional message to display in the status bar (e.g., "Saved to .env"). pub status_message: Option, + /// The active text input buffer for the selected variable. pub input: Input, } impl App { + /// Initializes a new application instance with the provided variables. pub fn new(vars: Vec) -> Self { let initial_input = vars.get(0).map(|v| v.value.clone()).unwrap_or_default(); Self { @@ -28,6 +39,7 @@ impl App { } } + /// Moves the selection to the next variable in the list, wrapping around if necessary. pub fn next(&mut self) { if !self.vars.is_empty() { self.selected = (self.selected + 1) % self.vars.len(); @@ -35,6 +47,7 @@ impl App { } } + /// Moves the selection to the previous variable in the list, wrapping around if necessary. pub fn previous(&mut self) { if !self.vars.is_empty() { if self.selected == 0 { @@ -46,30 +59,29 @@ impl App { } } + /// 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) { self.input = Input::new(var.value.clone()); } } + /// Commits the current text in the input buffer back to the selected variable's value. pub fn commit_input(&mut self) { if let Some(var) = self.vars.get_mut(self.selected) { var.value = self.input.value().to_string(); } } + /// Transitions the application into Insert Mode. pub fn enter_insert(&mut self) { self.mode = Mode::Insert; self.status_message = None; } + /// Commits the current input and transitions the application into Normal Mode. pub fn enter_normal(&mut self) { self.commit_input(); self.mode = Mode::Normal; } - - #[allow(dead_code)] - pub fn quit(&mut self) { - self.running = false; - } } diff --git a/src/cli.rs b/src/cli.rs index 447e9d5..1a19274 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,14 @@ use clap::Parser; use std::path::PathBuf; +/// mould: A TUI tool to generate and edit configuration files (.env, json, yaml, toml) #[derive(Parser, Debug)] -#[command(author, version, about = "mould: A TUI tool to generate and edit configuration files (.env, json, yaml, toml)")] +#[command(author, version, about)] pub struct Cli { /// The input template file (e.g., .env.example, config.json.template, docker-compose.yml) pub input: PathBuf, - /// Optional output file. If not provided, it will be inferred (e.g., .env.example -> .env, docker-compose.yml -> docker-compose.override.yml) + /// Optional output file. If not provided, it will be inferred. #[arg(short, long)] pub output: Option, @@ -16,6 +17,7 @@ pub struct Cli { pub format: Option, } +/// Parses and returns the command-line arguments. pub fn parse() -> Cli { Cli::parse() } diff --git a/src/config.rs b/src/config.rs index 8029cd2..8ea7df6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,22 +2,34 @@ use serde::{Deserialize, Serialize}; use std::fs; use ratatui::style::Color; +/// Configuration for the application's appearance. #[derive(Debug, Deserialize, Serialize, Clone)] 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, + /// Dark surface color for UI elements like the status bar. pub surface0: String, + /// Light surface color for borders and dividers. pub surface1: String, + /// Default text color. pub text: String, + /// Color for selection highlighting and normal mode tag. pub blue: String, + /// Color for insert mode highlighting and success status. pub green: String, + /// Accent color for configuration keys. pub lavender: String, + /// Accent color for primary section titles. pub mauve: String, + /// Accent color for input field titles. pub peach: String, } impl ThemeConfig { + /// Internal helper to parse a hex color string ("#RRGGBB") into a TUI Color. fn parse_hex(hex: &str) -> Color { let hex = hex.trim_start_matches('#'); if hex.len() == 6 { @@ -42,6 +54,7 @@ impl ThemeConfig { } impl Default for ThemeConfig { + /// Default theme: Catppuccin Mocha. fn default() -> Self { Self { transparent: false, @@ -58,6 +71,7 @@ impl Default for ThemeConfig { } } +/// Custom keybindings for navigation and application control. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct KeybindsConfig { pub down: String, @@ -81,6 +95,7 @@ impl Default for KeybindsConfig { } } +/// Root configuration structure for mould. #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct Config { #[serde(default)] @@ -89,6 +104,8 @@ 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"); diff --git a/src/main.rs b/src/main.rs index ed076ab..d5e067c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,23 +20,32 @@ use crossterm::{ }; use ratatui::{backend::CrosstermBackend, Terminal}; +/// 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(); + + // .env.example -> .env if file_name == ".env.example" { return input.with_file_name(".env"); } + + // docker-compose.yml -> docker-compose.override.yml if file_name == "docker-compose.yml" { return input.with_file_name("docker-compose.override.yml"); } if file_name == "docker-compose.yaml" { return input.with_file_name("docker-compose.override.yaml"); } + + // config.example.json -> config.json 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")); } + + // Fallback: append .out to the extension input.with_extension(format!( "{}.out", input.extension().unwrap_or_default().to_string_lossy() @@ -44,6 +53,7 @@ fn determine_output_path(input: &Path) -> PathBuf { } fn main() -> Result<(), Box> { + // Parse CLI arguments let args = cli::parse(); let input_path = args.input; @@ -52,13 +62,16 @@ fn main() -> Result<(), Box> { return Ok(()); } + // Detect format and select appropriate handler let format_type = detect_format(&input_path, args.format); let handler = get_handler(format_type); + // Determine where to save the result let output_path = args .output .unwrap_or_else(|| determine_output_path(&input_path)); + // Initial parsing of the template file let mut vars = handler.parse(&input_path).unwrap_or_else(|err| { println!("Error parsing input file: {}", err); vec![] @@ -72,22 +85,27 @@ fn main() -> Result<(), Box> { return Ok(()); } + // Merge values from an existing output file if it exists if let Err(e) = handler.merge(&output_path, &mut vars) { println!("Warning: Could not merge existing output file: {}", e); } + // Load user configuration and initialize application state let config = load_config(); let mut app = App::new(vars); + // Initialize terminal into raw mode and enter alternate screen enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + // Instantiate the runner and start the application loop let mut runner = AppRunner::new(&mut terminal, &mut app, &config, &output_path, handler.as_ref()); let res = runner.run(); + // Clean up terminal state on exit disable_raw_mode()?; execute!( terminal.backend_mut(), diff --git a/src/runner.rs b/src/runner.rs index 5598d63..c31e3a4 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -8,12 +8,19 @@ use std::io; use std::path::Path; use tui_input::backend::crossterm::EventHandler; +/// Manages the main application execution loop, event handling, and terminal interaction. pub struct AppRunner<'a, B: Backend> { + /// Reference to the terminal instance. terminal: &'a mut Terminal, + /// Mutable reference to the application state. app: &'a mut App, + /// Loaded user configuration. 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, + /// Buffer for storing active command entry (e.g., ":w"). command_buffer: String, } @@ -21,6 +28,7 @@ impl<'a, B: Backend> AppRunner<'a, B> where io::Error: From, { + /// Creates a new runner instance. pub fn new( terminal: &'a mut Terminal, app: &'a mut App, @@ -38,9 +46,11 @@ where } } + /// Starts the main application loop. pub fn run(&mut self) -> io::Result<()> { while self.app.running { - self.terminal.draw(|f| crate::ui::draw(f, self.app, self.config))?; + self.terminal + .draw(|f| crate::ui::draw(f, self.app, self.config))?; if let Event::Key(key) = event::read()? { self.handle_key_event(key)?; @@ -49,6 +59,7 @@ where Ok(()) } + /// Primary dispatcher for all keyboard events. fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { match self.app.mode { Mode::Normal => self.handle_normal_mode(key), @@ -56,6 +67,7 @@ where } } + /// Handles keys in Normal mode, separating navigation from command entry. fn handle_normal_mode(&mut self, key: KeyEvent) -> io::Result<()> { if !self.command_buffer.is_empty() { self.handle_command_mode(key) @@ -64,6 +76,7 @@ where } } + /// Logic for entering and executing ":" style commands. fn handle_command_mode(&mut self, key: KeyEvent) -> io::Result<()> { match key.code { KeyCode::Enter => { @@ -90,6 +103,7 @@ where } } + /// Handles primary navigation (j/k) and transitions to insert or command modes. fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { if let KeyCode::Char(c) = key.code { let c_str = c.to_string(); @@ -116,6 +130,7 @@ where Ok(()) } + /// 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 | KeyCode::Enter => { @@ -128,6 +143,7 @@ where 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 { self.save_file() @@ -144,6 +160,7 @@ where } } + /// 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())); @@ -153,6 +170,7 @@ where 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; diff --git a/src/ui.rs b/src/ui.rs index b35743e..8beb046 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,36 +8,42 @@ use ratatui::{ Frame, }; +/// Renders the main application interface using ratatui. pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let theme = &config.theme; let size = f.area(); - // Background + // Render the main background (optional based on transparency config). if !theme.transparent { - f.render_widget(Block::default().style(Style::default().bg(theme.crust())), size); + f.render_widget( + Block::default().style(Style::default().bg(theme.crust())), + size, + ); } - // Main layout with horizontal padding + // Horizontal layout with 1-character side margins. let outer_layout = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(1), // Left padding - Constraint::Min(0), // Content - Constraint::Length(1), // Right padding + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), ]) .split(size); + // Vertical layout for the main UI components. let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Top padding - Constraint::Min(3), // List - Constraint::Length(3), // Input area - Constraint::Length(1), // Bottom padding + Constraint::Length(1), // Top margin + Constraint::Min(3), // Main list + Constraint::Length(3), // Focused input field + Constraint::Length(1), // Spacer Constraint::Length(1), // Status bar ]) .split(outer_layout[1]); + // Calculate alignment for the key-value separator based on the longest key. let max_key_len = app .vars .iter() @@ -46,14 +52,15 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .unwrap_or(20) .min(40); - // List + // Build the interactive list of configuration variables. let items: Vec = app .vars .iter() .enumerate() .map(|(i, var)| { let is_selected = i == app.selected; - + + // Show live input text for the selected item if in Insert mode. let val = if is_selected && matches!(app.mode, Mode::Insert) { app.input.value() } else { @@ -61,7 +68,9 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { }; let key_style = if is_selected { - Style::default().fg(theme.crust()).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.crust()) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.lavender()) }; @@ -73,7 +82,10 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { }; let line = Line::from(vec![ - Span::styled(format!(" {: ( " NORMAL ", @@ -159,16 +171,17 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { ), }; - 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_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), - Span::styled(format!(" {} ", status_msg), Style::default().bg(theme.surface0()).fg(theme.text())), + Span::styled( + format!(" {} ", status_msg), + Style::default().bg(theme.surface0()).fg(theme.text()), + ), ]); let status = Paragraph::new(status_line).style(Style::default().bg(theme.surface0()));