diff --git a/Cargo.lock b/Cargo.lock index cbaf9fc..d6b15b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,24 +96,12 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cc" version = "1.0.104" @@ -184,11 +172,11 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.25.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crossterm_winapi", "libc", "mio", @@ -228,12 +216,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dyn-clone" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" - [[package]] name = "env_filter" version = "0.1.0" @@ -272,15 +254,6 @@ dependencies = [ "thread_local", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "getrandom" version = "0.2.15" @@ -332,23 +305,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inquire" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" -dependencies = [ - "bitflags 2.6.0", - "crossterm", - "dyn-clone", - "fuzzy-matcher", - "fxhash", - "newline-converter", - "once_cell", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -373,7 +329,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags", "libc", ] @@ -464,15 +420,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "newline-converter" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "num_cpus" version = "1.16.0" @@ -553,6 +500,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "promptuity" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee2f12259223aa9c32490603aad8d1d3148213b03f510727fbf383d18757f2a" +dependencies = [ + "crossterm", + "strip-ansi-escapes", + "thiserror", + "unicode-width", +] + [[package]] name = "quote" version = "1.0.36" @@ -564,11 +523,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -691,9 +650,10 @@ dependencies = [ "colored", "directories", "env_logger", - "inquire", + "fuzzy-matcher", "log", "merge2", + "promptuity", "serde", "tokio", "toml", @@ -705,6 +665,15 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -815,12 +784,6 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" -[[package]] -name = "unicode-segmentation" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" - [[package]] name = "unicode-width" version = "0.1.13" @@ -833,6 +796,26 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index c35d084..7b6ea5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,9 +23,10 @@ clap_derive = { version = "4.0.0-rc.1" } colored = "2.1.0" directories = "5.0.1" env_logger = "0.11.3" -inquire = "0.7.1" +fuzzy-matcher = "0.3.7" log = "0.4.21" merge2 = "0.3.0" +promptuity = "0.0.5" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "macros", "sync", "rt-multi-thread"] } toml = "0.8.8" diff --git a/sc.toml b/sc.toml index ee08c19..eb4ea35 100644 --- a/sc.toml +++ b/sc.toml @@ -22,6 +22,9 @@ description = "" name = "handler" description = "" +[[scopes]] +name = "crab" + [git] skip_preview = false skip_emojis = false diff --git a/src/config/mod.rs b/src/config/mod.rs index 856176d..9e85d5b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -110,5 +110,7 @@ pub fn get_config() -> SimpleCommitsConfig { } pub fn start_logging() { - env_logger::init(); + env_logger::builder() + // .filter_level(log::LevelFilter::Trace) + .init(); } diff --git a/src/git/commit.rs b/src/git/commit.rs index e4063a3..12fc282 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -2,10 +2,6 @@ use colored::Colorize; use crate::tui::State; -pub trait Builder { - fn build(self) -> T; -} - #[derive(Clone)] pub struct Commit { pub _type: String, @@ -16,61 +12,6 @@ pub struct Commit { pub struct ColoredCommit(Commit); -#[derive(Default)] -pub struct CommitBuilder { - _type: Option, - emoji: Option, - scope: Option, - msg: Option, -} - -impl CommitBuilder { - pub fn set_type(self, _type: String) -> CommitBuilder { - Self { - _type: Some(_type), - ..self - } - } - pub fn set_emoji(self, emoji: String) -> CommitBuilder { - Self { - emoji: Some(emoji), - ..self - } - } - pub fn set_scope(self, scope: String) -> CommitBuilder { - Self { - scope: Some(scope), - ..self - } - } - pub fn set_msg(self, msg: String) -> CommitBuilder { - Self { - msg: Some(msg), - ..self - } - } -} - -impl Builder for CommitBuilder { - fn build(self) -> Commit { - let Self { - _type: Some(_type), - emoji, - scope, - msg: Some(msg), - } = self - else { - panic!("Cannot build because the type or msg of the commmit wasn't assigned") - }; - Commit { - _type, - emoji, - scope, - msg, - } - } -} - impl std::fmt::Display for Commit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { diff --git a/src/tui/config.rs b/src/tui/config.rs index d720c46..7d50bdc 100644 --- a/src/tui/config.rs +++ b/src/tui/config.rs @@ -1,22 +1,18 @@ -use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled}; +use promptuity::themes::FancyTheme; +use promptuity::{Promptuity, Term, Terminal, Theme}; /// Generates the config for the TUI /// /// Returns a [`RenderConfig`] from the inquire TUI. -pub fn generate_tui_config<'config>() -> RenderConfig<'config> { - let prefix = Styled::new("").with_fg(Color::White); - let selected = Styled::new("►").with_fg(Color::DarkGrey); - let skipped = Styled::new("*skipped*").with_fg(Color::DarkGrey); - let option = StyleSheet::empty().with_fg(Color::DarkGrey); - let selected_option = StyleSheet::empty().with_fg(Color::Grey); - let msg = StyleSheet::empty() - .with_fg(Color::DarkGrey) - .with_attr(Attributes::BOLD); - RenderConfig::default_colored() - .with_prompt_prefix(prefix) - .with_highlighted_option_prefix(selected) - .with_selected_option(Some(selected_option)) - .with_option(option) - .with_canceled_prompt_indicator(skipped) - .with_help_message(msg) +pub fn generate_prompt<'a, W>( + term: &'a mut dyn Terminal, + theme: &'a mut dyn Theme, +) -> Promptuity<'a, W> +where + W: std::io::Write, +{ + Promptuity::new(term, theme) +} +pub fn generate_config() -> (Term, FancyTheme) { + (Term::default(), FancyTheme::default()) } diff --git a/src/tui/helpers.rs b/src/tui/helpers.rs index a6801f9..6274955 100644 --- a/src/tui/helpers.rs +++ b/src/tui/helpers.rs @@ -1,32 +1,7 @@ -use std::error::Error; - -use inquire::list_option::ListOption; -use inquire::validator::Validation; - -type ValidationError = Box; - -use crate::gen::Emoji; - -use crate::tui::structs::Commit; - -pub fn format_emojis(list: ListOption<&Emoji>) -> String { - let Emoji { emoji, .. } = list.value; - let correct = '\u{2705}'; - format!("{emoji} | {correct}") -} - -pub fn format_commits(list: ListOption<&Commit<'_>>) -> String { - let Commit { label, .. } = list.value; - let correct = '\u{2705}'; - format!("{label} | {correct}") -} - -pub fn valid_length(text: &str) -> Result { - if !text.is_empty() { - Ok(Validation::Valid) +pub fn valid_length(text: &str, min: usize, msg: &str) -> Result<(), String> { + if text.len() > min { + Ok(()) } else { - Ok(Validation::Invalid( - "Commit must contain 1 letter at least".into(), - )) + Err(msg.to_owned()) } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8759dda..43fb31a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,21 +1,23 @@ +use std::io::Stderr; + use crate::config::{get_config, SimpleCommitsConfig}; pub mod config; pub mod helpers; pub mod steps; pub mod structs; +pub mod widgets; -use config as tui_config; -use inquire::InquireError; - -use self::steps::ExecType; +use config as tui; +use promptuity::{Error, Promptuity}; +use steps::exec::Action; /// initialize the configuration and setup the steps pub fn init() { - let render_config = tui_config::generate_tui_config(); - inquire::set_global_render_config(render_config); + let (mut term, mut theme) = tui::generate_config(); + let mut prompt = tui::generate_prompt(&mut term, &mut theme); let mut config = get_config(); - steps::init(&mut config); + let _ = steps::init(&mut prompt, &mut config); } #[derive(Clone, Default, Debug)] @@ -24,22 +26,19 @@ pub struct State { pub scope: Option, pub emoji: Option, pub msg: String, - pub exec_type: Option, -} - -#[derive(Debug)] -pub enum StepError { - InvalidMsg, - NoMsg, - NoCommit(InquireError), - InvalidEmoji, + pub exec_type: Option, } -pub type StepResult = Result<(), StepError>; +pub type StepResult = Result<(), Error>; /// A trait to setup steps along the TUI app. pub trait Step { - fn run(&self, state: &mut State, config: &mut SimpleCommitsConfig) -> StepResult; + fn run( + &self, + prompt: &mut Promptuity, + state: &mut State, + config: &mut SimpleCommitsConfig, + ) -> StepResult; } #[macro_export] diff --git a/src/tui/steps/commit.rs b/src/tui/steps/commit.rs index 69f8de9..079b4c9 100644 --- a/src/tui/steps/commit.rs +++ b/src/tui/steps/commit.rs @@ -1,22 +1,32 @@ -use inquire::Select; +use std::io::Stderr; +use promptuity::{prompts::SelectOption, Promptuity}; + +use crate::tui::widgets::Autocomplete; use crate::{ config::SimpleCommitsConfig, - tui::{helpers::format_commits, structs::COMMIT_TYPES, Step, StepError, StepResult}, + tui::{structs::COMMIT_TYPES, Step, StepResult}, }; #[derive(Default)] pub struct _Step; impl Step for _Step { - fn run(&self, state: &mut crate::tui::State, _: &mut SimpleCommitsConfig) -> StepResult { - let commit = Select::new("Select a commit:", COMMIT_TYPES.to_vec()) - .with_formatter(&format_commits) - .prompt(); + fn run( + &self, + p: &mut Promptuity, + state: &mut crate::tui::State, + _: &mut SimpleCommitsConfig, + ) -> StepResult { + let commit = p.prompt(&mut Autocomplete::new( + "Select a type", + COMMIT_TYPES + .map(|c| SelectOption::new(c, c.label.to_owned()).with_hint(c.hint)) + .to_vec(), + true, + )); - state._type = commit - .map(|c| c.label.to_string()) - .map_err(StepError::NoCommit)?; + state._type = commit?; Ok(()) } } diff --git a/src/tui/steps/emoji.rs b/src/tui/steps/emoji.rs index 4895e75..415a9d3 100644 --- a/src/tui/steps/emoji.rs +++ b/src/tui/steps/emoji.rs @@ -1,30 +1,38 @@ -use inquire::Select; +use promptuity::prompts::SelectOption; use crate::config::SimpleCommitsConfig; use crate::gen::EMOJIS; -use crate::tui::{helpers::format_emojis, Step, StepError, StepResult}; +use crate::tui::widgets::Autocomplete; +use crate::tui::{Step, StepResult}; #[derive(Default)] pub struct _Step; impl Step for _Step { - fn run(&self, state: &mut crate::tui::State, config: &mut SimpleCommitsConfig) -> StepResult { - // early return if skip_emojis is enabled - if config - .git - .as_ref() - .is_some_and(|git_cfg| git_cfg.skip_emojis) - { + fn run( + &self, + p: &mut promptuity::Promptuity, + state: &mut crate::tui::State, + config: &mut SimpleCommitsConfig, + ) -> StepResult { + if config.git.as_ref().is_some_and(|cfg| cfg.skip_emojis) { return Ok(()); } - - let emoji = Select::new("Select an emoji (optional):", EMOJIS.to_vec()) - .with_formatter(&format_emojis) - .prompt_skippable(); - - state.emoji = emoji - .map(|emoji| emoji.map(|e| e.emoji.to_string())) - .map_err(|_| StepError::InvalidEmoji)?; + let emojis_mapped = EMOJIS + .map(|emoji| { + SelectOption::new( + format!("{} {}", emoji.emoji, emoji.description), + emoji.emoji.to_string(), + ) + .with_hint(emoji.name) + }) + .to_vec(); + let emoji = p.prompt(&mut Autocomplete::new( + "Select an emoji (optional)", + emojis_mapped, + false, + ))?; + state.emoji = Some(emoji); Ok(()) } } diff --git a/src/tui/steps/exec.rs b/src/tui/steps/exec.rs index 51a03e1..c29d342 100644 --- a/src/tui/steps/exec.rs +++ b/src/tui/steps/exec.rs @@ -1,4 +1,7 @@ -use inquire::Confirm; +use std::io::Stderr; + +use promptuity::prompts::Confirm; +use promptuity::Promptuity; use crate::git::commit::{ColoredCommit, Commit}; use crate::{ @@ -6,17 +9,42 @@ use crate::{ tui::{Step, StepResult}, }; +#[allow(dead_code)] #[derive(Clone, Debug)] -pub enum ExecType { - Message(String), - Command(String, Vec), +pub enum Action { + DryRun(String), + Commit(String, Vec), +} + +impl Action { + /// Returns the execute action of this [`Action`]. + /// + /// # Panics + /// + /// Panics if . + pub fn execute_action(&self) { + match self { + Self::DryRun(msg) => println!("{msg}"), + Self::Commit(cmd, args) => { + std::process::Command::new(cmd) + .args(&args[..]) + .spawn() + .unwrap(); + } + } + } } #[derive(Default)] pub struct _Step; impl Step for _Step { - fn run(&self, state: &mut crate::tui::State, config: &mut SimpleCommitsConfig) -> StepResult { + fn run( + &self, + p: &mut Promptuity, + state: &mut crate::tui::State, + config: &mut SimpleCommitsConfig, + ) -> StepResult { let commit: Commit = state.clone().into(); let mut command = vec![ "git".to_string(), @@ -40,23 +68,26 @@ impl Step for _Step { .first() .expect("The commit template cannot be empty"); if git.skip_preview { - state.exec_type = Some(ExecType::Command(cmd.clone(), (command[1..]).to_vec())); + state.exec_type = Some(Action::Commit(cmd.clone(), (command[1..]).to_vec())); return Ok(()); } } - let execute = Confirm::new(&format!("Command to run: {}", commit)) - .with_default(true) - .with_help_message("Do you want run these command?") - .prompt() - .is_ok_and(|c| c); - + let execute = + p.prompt(Confirm::new("Do you want to execute this command?").with_default(true))?; if !execute { let commit: ColoredCommit = state.clone().into(); - state.exec_type = Some(ExecType::Message(commit.to_string())); - return Ok(()); - } - state.exec_type = Some(ExecType::Command(cmd.clone(), (command[1..]).to_vec())); + p.step("Commit preview")?; + p.log(commit.to_string())?; + } else { + state.exec_type = Some(Action::Commit(cmd.clone(), (command[1..]).to_vec())); + + state + .exec_type + .as_ref() + .expect("At this point the exec type is filled") + .execute_action(); + }; Ok(()) } diff --git a/src/tui/steps/message.rs b/src/tui/steps/message.rs index 48951fa..0d21b24 100644 --- a/src/tui/steps/message.rs +++ b/src/tui/steps/message.rs @@ -1,25 +1,30 @@ -use inquire::Text; +use promptuity::prompts::Input; use crate::{ config::SimpleCommitsConfig, - tui::{helpers::valid_length, Step, StepError, StepResult}, + tui::{helpers::valid_length, Step, StepResult}, }; #[derive(Default)] pub struct _Step; impl Step for _Step { - fn run(&self, state: &mut crate::tui::State, _: &mut SimpleCommitsConfig) -> StepResult { - let msg = Text::new("Commit message:") - .with_validator(valid_length) - .prompt(); - match msg { - Ok(msg) if !msg.is_empty() => { - state.msg = msg; - Ok(()) - } - Ok(_) => Err(StepError::NoMsg), - Err(_) => Err(StepError::InvalidMsg), - } + fn run( + &self, + p: &mut promptuity::Promptuity, + state: &mut crate::tui::State, + _: &mut SimpleCommitsConfig, + ) -> StepResult { + let msg = p.prompt(Input::new("Enter the commit message").with_validator( + |text: &String| { + valid_length( + text, + 5, + "The commit message must have at least 5 characters", + ) + }, + ))?; + state.msg = msg; + Ok(()) } } diff --git a/src/tui/steps/mod.rs b/src/tui/steps/mod.rs index fb417d6..ac27a1e 100644 --- a/src/tui/steps/mod.rs +++ b/src/tui/steps/mod.rs @@ -1,38 +1,42 @@ +use std::io::Stderr; + use super::{State, Step}; use crate::{config::SimpleCommitsConfig, gen_steps}; use colored::Colorize; -mod commit; -mod emoji; -mod exec; -mod message; -mod scopes; - -pub use exec::ExecType; -use log::info; +pub mod commit; +pub mod emoji; +pub mod exec; +pub mod message; +pub mod scopes; +use log::{error, info}; +use promptuity::{Error, Promptuity}; -pub fn init(config: &mut SimpleCommitsConfig) { +pub fn init( + prompt: &mut Promptuity, + config: &mut SimpleCommitsConfig, +) -> Result<(), Error> { let mut state = State::default(); let steps = gen_steps![commit, scopes, emoji, message, exec]; + prompt.with_intro("Simple Commit").begin()?; + for step in steps { - let res = step.run(&mut state, config); + let res = step.run(prompt, &mut state, config); if let Err(err) = res { let msg = format!("❌ Error: {:?}", err); - println!("{}", msg.bright_red()); - return; + error!(target: "tui::steps", "{}", msg.bright_red()); + return Err(Error::Prompt(String::from("Error"))); } info!(target: "tui::steps", "steps: {state:#?}"); } - match state.exec_type { - Some(ExecType::Message(msg)) => println!("Preview of your commit:\n> {msg}"), - Some(ExecType::Command(cmd, args)) => { - std::process::Command::new(cmd) - .args(&args[..]) - .spawn() - .unwrap(); - } - _ => {} - } + prompt + .with_outro(concat!( + "In case of issues, please report it to https://github.com/romancitodev/simple-commits\n", + "\u{2764} Thanks for use this tool!", + )) + .finish()?; + + Ok(()) } diff --git a/src/tui/steps/scopes.rs b/src/tui/steps/scopes.rs index 16fa05b..0553084 100644 --- a/src/tui/steps/scopes.rs +++ b/src/tui/steps/scopes.rs @@ -1,31 +1,46 @@ -use inquire::Text; +use log::{debug, error}; +use promptuity::prompts::SelectOption; use crate::{ config::SimpleCommitsConfig, - tui::{structs::ScopesAutoComplete, Step, StepError, StepResult}, + tui::{widgets::Autocomplete, Step, StepResult}, }; #[derive(Default)] pub struct _Step; impl Step for _Step { - fn run(&self, state: &mut crate::tui::State, config: &mut SimpleCommitsConfig) -> StepResult { + fn run( + &self, + p: &mut promptuity::Promptuity, + state: &mut crate::tui::State, + config: &mut SimpleCommitsConfig, + ) -> StepResult { let scopes = config.scopes.clone().unwrap_or_default(); - let autocomplete: ScopesAutoComplete = scopes.clone().into(); - let scope = Text::new("Select a scope:") - .with_placeholder("app") - .with_autocomplete(autocomplete) - .prompt_skippable(); + let mapped_scopes = scopes + .scopes() + .iter() + .map(|scope| { + SelectOption::new(scope.name(), scope.name().to_owned()) + .with_hint(scope.description().clone().unwrap_or_default()) + }) + .collect::>(); + let scope = p.prompt(&mut Autocomplete::new( + "Select an scope", + mapped_scopes, + false, + ))?; - state.scope = scope.map_err(StepError::NoCommit).unwrap_or_default(); - let scope = state.scope.clone(); + let scope = (!scope.is_empty()).then_some(scope); + state.scope.clone_from(&scope); if let Some(scope) = scope { if let Some(scopes) = &mut config.scopes { if !scopes.exists(&scope) { - scopes.add_scope(scope); + debug!(target: "steps::scope", "This shit works"); + scopes.add_scope(scope.clone()); if let Err(err) = config.update() { - eprintln!("{err}"); + error!(target: "step::scope", "This shit aint work! {}", err); } } } diff --git a/src/tui/structs.rs b/src/tui/structs.rs index 2fac223..fed59d0 100644 --- a/src/tui/structs.rs +++ b/src/tui/structs.rs @@ -1,7 +1,6 @@ -use inquire::Autocomplete; use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Copy, Clone, Default)] pub struct Commit<'c> { pub emoji: char, pub label: &'c str, @@ -13,36 +12,11 @@ pub struct Scope { scopes: Vec, } -#[derive(Clone, Serialize, Deserialize, Default, Debug)] -pub struct InnerScope { - name: String, - description: Option, -} - -impl InnerScope { - pub fn name(&self) -> &str { - &self.name - } - - pub fn description(&self) -> Option<&String> { - self.description.as_ref() - } -} - -impl std::fmt::Display for InnerScope { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let name = &self.name; - let description = self.description.clone().unwrap_or_default(); - writeln!(f, "{name} ({description})") +impl Scope { + pub fn scopes(&self) -> &Vec { + &self.scopes } -} -#[derive(Clone)] -pub struct ScopesAutoComplete { - scopes: Vec, -} - -impl Scope { pub fn exists(&self, scope: &str) -> bool { self.scopes .iter() @@ -57,50 +31,27 @@ impl Scope { } } -impl From for ScopesAutoComplete { - fn from(Scope { scopes }: Scope) -> Self { - Self { scopes } - } +#[derive(Clone, Serialize, Deserialize, Default, Debug)] +pub struct InnerScope { + name: String, + description: Option, } -impl ScopesAutoComplete { - pub fn get_scope(&self, input: &str) -> Option<&InnerScope> { - self.scopes.iter().find(|s| { - s.name - .to_lowercase() - .trim() - .contains(input.to_lowercase().trim()) - }) +impl InnerScope { + pub fn name(&self) -> &str { + &self.name } - pub fn filter_scopes(&mut self, input: &str) -> Vec { - self.scopes - .iter() - .filter(|s| { - s.name - .to_lowercase() - .trim() - .contains(input.to_lowercase().trim()) - }) - .map(|s| s.name.to_string()) - .collect() + pub fn description(&self) -> &Option { + &self.description } } -impl Autocomplete for ScopesAutoComplete { - fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { - Ok(self.filter_scopes(input)) - } - - fn get_completion( - &mut self, - input: &str, - highlighted_suggestion: Option, - ) -> Result { - Ok(self - .get_scope(input) - .map(|s| s.name.to_string()) - .or(highlighted_suggestion)) +impl std::fmt::Display for InnerScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = &self.name; + let description = self.description.clone().unwrap_or_default(); + writeln!(f, "{name} ({description})") } } @@ -148,7 +99,7 @@ pub const COMMIT_TYPES: [Commit; 9] = [ impl<'c> std::fmt::Display for Commit<'c> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { emoji, label, hint } = self; - writeln!(f, "{emoji} {label} ({hint})") + let Self { emoji, label, .. } = self; + write!(f, "{emoji} {label}") } } diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs new file mode 100644 index 0000000..ace56f0 --- /dev/null +++ b/src/tui/widgets.rs @@ -0,0 +1,3 @@ +mod autocompleter; + +pub use autocompleter::Autocomplete; diff --git a/src/tui/widgets/autocompleter.rs b/src/tui/widgets/autocompleter.rs new file mode 100644 index 0000000..b8e7b13 --- /dev/null +++ b/src/tui/widgets/autocompleter.rs @@ -0,0 +1,227 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use promptuity::event::*; +use promptuity::pagination::paginate; +use promptuity::prompts::{DefaultSelectFormatter, SelectFormatter, SelectOption}; +use promptuity::style::*; +use promptuity::{Error, InputCursor, Prompt, PromptBody, PromptInput, PromptState, RenderPayload}; +pub struct Autocomplete { + formatter: DefaultSelectFormatter, + message: String, + page_size: usize, + options: Vec>, + filtered_options: Vec, + index: usize, + input: InputCursor, + matcher: SkimMatcherV2, + strict: bool, + skip: bool, +} + +impl Autocomplete { + pub fn new( + message: impl std::fmt::Display, + options: Vec>, + strict: bool, + ) -> Self { + Self { + formatter: DefaultSelectFormatter::new(), + message: message.to_string(), + page_size: 8, + options, + filtered_options: Vec::new(), + index: 0, + input: InputCursor::default(), + matcher: SkimMatcherV2::default(), + strict, + skip: false, + } + } + + fn run_filter(&mut self) { + let pattern = self.input.value(); + + self.filtered_options = self + .options + .iter() + .enumerate() + .filter_map(|(i, option)| { + let label = &option.label; + let hint = option.hint.clone().unwrap_or_default(); + self.matcher + .fuzzy_match(&format!("{label} {hint}"), &pattern) + .map(|_| i) + }) + .collect::>(); + + self.index = std::cmp::min(self.filtered_options.len().saturating_sub(1), self.index); + } + + fn current_option(&self) -> Option<&SelectOption> { + self.filtered_options + .get(self.index) + .and_then(|idx| self.options.get(*idx)) + } +} + +impl AsMut for Autocomplete { + fn as_mut(&mut self) -> &mut Self { + self + } +} + +impl Prompt for Autocomplete { + type Output = String; + + fn setup(&mut self) -> Result<(), Error> { + if self.options.is_empty() { + return Err(Error::Config("options cannot be empty.".into())); + } + + self.filtered_options = (0..self.options.len()).collect(); + + Ok(()) + } + + fn handle(&mut self, code: KeyCode, modifiers: KeyModifiers) -> promptuity::PromptState { + match (code, modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => PromptState::Cancel, + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { + if self.strict { + PromptState::Active + } else { + self.skip = true; + PromptState::Submit + } + } + (KeyCode::Enter, _) => match self.current_option() { + Some(_) => PromptState::Submit, + _ => { + if self.strict { + PromptState::Error("No matches found".into()) + } else { + PromptState::Submit + } + } + }, + (KeyCode::Up, _) + | (KeyCode::Char('k'), KeyModifiers::CONTROL) + | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.index = self.index.saturating_sub(1); + PromptState::Active + } + (KeyCode::Down, _) + | (KeyCode::Char('j'), KeyModifiers::CONTROL) + | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + self.index = std::cmp::min( + self.filtered_options.len().saturating_sub(1), + self.index.saturating_add(1), + ); + PromptState::Active + } + (KeyCode::Left, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + self.input.move_left(); + PromptState::Active + } + (KeyCode::Right, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL) => { + self.input.move_right(); + PromptState::Active + } + (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { + self.input.move_home(); + PromptState::Active + } + (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { + self.input.move_end(); + PromptState::Active + } + (KeyCode::Backspace, _) | (KeyCode::Char('h'), KeyModifiers::CONTROL) => { + self.input.delete_left_char(); + self.run_filter(); + PromptState::Active + } + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.input.delete_left_word(); + self.run_filter(); + PromptState::Active + } + (KeyCode::Delete, _) | (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + self.input.delete_right_char(); + self.run_filter(); + PromptState::Active + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.input.delete_line(); + self.run_filter(); + PromptState::Active + } + (KeyCode::Char(c), _) => { + self.input.insert(c); + self.run_filter(); + PromptState::Active + } + _ => PromptState::Active, + } + } + + fn submit(&mut self) -> Self::Output { + if self.skip { + return String::new(); + }; + if self.strict { + self.current_option().unwrap().value.clone() + } else { + self.current_option() + .map_or(self.input.value(), |option| option.value.clone()) + } + } + + fn render(&mut self, state: &PromptState) -> Result { + let hint = (!self.strict).then_some(String::from("Ctrl + S to skip")); + let payload = RenderPayload::new(self.message.clone(), hint, None); + + match state { + PromptState::Submit => { + if self.skip { + return Ok(payload.input(PromptInput::Raw(String::from("skipped")))); + } + let option = self + .current_option() + .map_or(self.input.value(), |option| option.value.clone()); + Ok(payload.input(PromptInput::Raw(option))) + } + + _ => { + let page = paginate(self.page_size, &self.filtered_options, self.index); + let options = page + .items + .iter() + .enumerate() + .map(|(i, idx)| { + let option = self.options.get(*idx).unwrap(); + let active = i == page.cursor; + self.formatter.option( + self.formatter.option_icon(active), + self.formatter.option_label(option.label.clone(), active), + self.formatter.option_hint(option.hint.clone(), active), + active, + ) + }) + .collect::>() + .join("\n"); + + let raw = if options.is_empty() { + Styled::new("") + .fg(Color::DarkGrey) + .to_string() + } else { + options.to_string() + }; + + Ok(payload + .input(PromptInput::Cursor(self.input.clone())) + .body(PromptBody::Raw(raw))) + } + } + } +}