diff --git a/Cargo.lock b/Cargo.lock index d30f1be..453b358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -17,6 +29,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -183,6 +201,9 @@ name = "bitflags" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +dependencies = [ + "serde", +] [[package]] name = "bstr" @@ -214,6 +235,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.95" @@ -360,6 +390,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa 1.0.2", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "console" version = "0.15.7" @@ -418,31 +462,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "crossterm" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" -dependencies = [ - "bitflags 1.3.2", - "crossterm_winapi", - "libc", - "mio", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" -dependencies = [ - "winapi", -] - [[package]] name = "csv" version = "1.1.6" @@ -609,6 +628,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +[[package]] +name = "edit" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f364860e764787163c8c8f58231003839be31276e821e2ad2092ddf496b1aa09" +dependencies = [ + "tempfile", + "which", +] + [[package]] name = "either" version = "1.11.0" @@ -821,11 +850,21 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "heck" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" @@ -857,6 +896,7 @@ dependencies = [ "dialoguer", "dirs", "dotenv", + "edit", "enum-iterator", "eyre", "h2", @@ -872,7 +912,7 @@ dependencies = [ "serde_yaml", "simple_logger", "tempfile", - "termion", + "termion 4.0.0", "thiserror", "tokio", "url", @@ -1011,7 +1051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.11.2", ] [[package]] @@ -1110,7 +1150,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "windows-targets 0.48.1", + "windows-targets 0.52.5", +] + +[[package]] +name = "libredox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607" +dependencies = [ + "bitflags 2.5.0", + "libc", + "redox_syscall 0.4.1", ] [[package]] @@ -1150,6 +1201,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "memchr" version = "2.6.4" @@ -1326,7 +1386,7 @@ checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.2.13", "smallvec", "windows-sys 0.36.1", ] @@ -1441,16 +1501,21 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.22.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8285baa38bdc9f879d92c0e37cb562ef38aa3aeefca22b3200186bc39242d3d5" +checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" dependencies = [ "bitflags 2.5.0", "cassowary", - "crossterm", + "compact_str", "indoc", + "itertools", + "lru", "paste", - "termion", + "serde", + "stability", + "strum", + "termion 3.0.0", "unicode-segmentation", "unicode-width", ] @@ -1465,14 +1530,20 @@ dependencies = [ ] [[package]] -name = "redox_termios" -version = "0.1.2" +name = "redox_syscall" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "redox_syscall", + "bitflags 1.3.2", ] +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + [[package]] name = "redox_users" version = "0.4.3" @@ -1480,7 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ "getrandom", - "redox_syscall", + "redox_syscall 0.2.13", "thiserror", ] @@ -1792,27 +1863,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -1863,12 +1913,50 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stability" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" +dependencies = [ + "quote", + "syn 2.0.60", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.60", +] + [[package]] name = "subtle" version = "2.5.0" @@ -1906,7 +1994,7 @@ dependencies = [ "cfg-if", "fastrand", "libc", - "redox_syscall", + "redox_syscall 0.2.13", "remove_dir_all", "winapi", ] @@ -1933,13 +2021,25 @@ dependencies = [ [[package]] name = "termion" -version = "2.0.1" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417813675a504dfbbf21bfde32c03e5bf9f2413999962b479023c02848c1c7a5" +dependencies = [ + "libc", + "libredox", + "numtoa", + "redox_termios", +] + +[[package]] +name = "termion" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90" +checksum = "7af3fa9183465b9af93355585a6b1e24cc2ff25938b52aa6956f647b6490257a" dependencies = [ "libc", + "libredox", "numtoa", - "redox_syscall", "redox_termios", ] @@ -2184,6 +2284,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "want" version = "0.3.0" @@ -2609,6 +2715,26 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 4281ab4..9eea74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ path = "src/main.rs" clap = { version = "4.3.22", features = ["derive"] } # pretty dialogues in terminal dialoguer = "0.10.3" -termion = "2.0.1" +termion = "4.0.0" # Yaml support to save/load command pallettes serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.8" @@ -43,7 +43,10 @@ log = "0.4" eyre = "0.6" simple_logger = "4.1.0" prettytable-rs = "0.10.0" -ratatui = { version = "0.22.0", features = ["termion"] } +ratatui = { version = "=0.26.2", default-features = false, features = [ + 'termion', + 'serde', +] } chrono = { version = "0.4", features = ["serde"] } rand = { version = "0.8.4", features = ["std"] } thiserror = "1.0" @@ -59,6 +62,7 @@ chatgpt_blocking_rs = "0.1.2" dotenv = "0.15.0" h2 = "0.3.20" regex = "1.10.2" +edit = "0.1.5" [dev-dependencies] tempfile = "3.3.0" diff --git a/src/cli_commands.rs b/src/cli_commands.rs index 73868b1..6f36b1d 100644 --- a/src/cli_commands.rs +++ b/src/cli_commands.rs @@ -115,6 +115,9 @@ pub enum Commands { name: String, }, + /// Edit the all your commands in your default editor + EditTrove {}, + /// Print shell config ShellConfig { /// shell type to print the config for diff --git a/src/config.rs b/src/config.rs index 3e9a953..0c8fb83 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::gui::prompts::prompt_input; +use crate::control::prompts::prompt_input; use anyhow::{anyhow, Error, Result}; use log::info; use serde::{Deserialize, Serialize}; diff --git a/src/control/merge.rs b/src/control/merge.rs new file mode 100644 index 0000000..8125ad6 --- /dev/null +++ b/src/control/merge.rs @@ -0,0 +1,39 @@ +use crate::control::prompts::prompt_select_with_options; +use enum_iterator::{all, Sequence}; + +#[derive(Sequence)] +pub enum ConflictResolve { + /// Describes the mode of how to handle a merge conflict by adding in a command to a trove with a name that is already present + + /// Replace the old command with the new one + Replace, + /// Keep the old command and discard the new one + Keep, + /// Find a new name for the new command and keep the old one + New, +} + +impl ConflictResolve { + const fn as_str(&self) -> &'static str { + match self { + Self::Replace => "Replace your local command with the new one", + Self::Keep => "Keep your local command and ignore the new one", + Self::New => "Keep both, but choose a new name", + } + } +} + +pub fn with_conflict_resolve_prompt( + name: &str, + namespace: &str, + command_string: &str, + colliding_command_string: &str, +) -> ConflictResolve { + let conflict_prompt = format!( + "You already have a command with the name: {name} in namespace: {namespace}\nYour local command: {colliding_command_string}\nIncoming command: {command_string}\nWhat do you want to do?" + ); + let conflict_modes = all::().collect::>(); + let items: Vec<&str> = conflict_modes.iter().map(ConflictResolve::as_str).collect(); + let selection = prompt_select_with_options(&conflict_prompt, &items); + conflict_modes.into_iter().nth(selection).unwrap() +} diff --git a/src/control/mod.rs b/src/control/mod.rs new file mode 100644 index 0000000..0630b5c --- /dev/null +++ b/src/control/mod.rs @@ -0,0 +1,2 @@ +pub mod prompts; +pub mod merge; diff --git a/src/control/prompts.rs b/src/control/prompts.rs new file mode 100644 index 0000000..9459edb --- /dev/null +++ b/src/control/prompts.rs @@ -0,0 +1,135 @@ +use dialoguer::{Input, MultiSelect, Password, Select}; +pub enum Confirmation { + Yes, + No, +} + +pub fn prompt_multiselect_options( + question: &str, + selection_prompt: &str, + options: &[T], + text_extractor: F, +) -> Vec +where + F: FnMut(&T) -> S, + T: Clone, + S: ToString, +{ + let options_texts: Vec = options.iter().map(text_extractor).collect(); + + if matches!(prompt_yes_or_no(question), Confirmation::Yes) { + let selected_indices = MultiSelect::default() + .with_prompt(selection_prompt) + .items(&options_texts) + .interact() + .unwrap(); + + take_elements_by_indices(options, &selected_indices) + } else { + options.to_vec() + } +} + +pub fn prompt_yes_or_no(text: &str) -> Confirmation { + const YES_ANSWER: usize = 0; + + let answer = Select::new() + .with_prompt(text) + .items(&["Yes", "No"]) + .default(YES_ANSWER) + .interact() + .unwrap(); + + if answer == YES_ANSWER { + Confirmation::Yes + } else { + Confirmation::No + } +} + +pub fn prompt_select_with_options(text_prompt: &str, options: &[&str]) -> usize { + Select::new() + .with_prompt(text_prompt) + .items(options) + .default(0) + .interact() + .unwrap() +} + +pub fn prompt_input(text: &str, allow_empty: bool, default_value: Option) -> String { + // Just calls `prompt_input_validate` to not keep on typing `None` for the validator + prompt_input_validate( + text, + allow_empty, + default_value, + None:: Result<(), String>>>, + ) +} + +pub fn prompt_input_validate( + text: &str, + allow_empty: bool, + default_value: Option, + validator: Option, +) -> String +where + F: FnMut(&String) -> Result<(), String>, +{ + let mut input: Input = Input::new(); + // Add default value to input prompt + if let Some(val) = default_value { + input.default(val); + } + // Add validator if any + if let Some(val) = validator { + input.validate_with(val); + } + input.allow_empty(allow_empty); + input.with_prompt(text).interact_text().unwrap() +} + +pub fn prompt_password_repeat(text: &str) -> String { + Password::new() + .with_prompt(text) + .with_confirmation("Repeat password", "Error: the passwords don't match.") + .interact() + .unwrap() +} + +pub fn prompt_password(text: &str) -> String { + Password::new() + .with_prompt(text) + .interact() + .unwrap() +} + +fn take_elements_by_indices(elements: &[T], indices: &[usize]) -> Vec +where + T: Clone, +{ + elements + .iter() + .enumerate() + .filter_map(|(i, val)| { + if indices.contains(&i) { + Some(val.clone()) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod test_prompts { + use super::*; + + #[test] + fn elements_by_indices() { + let items = vec!["item1", "item2", "item3", "item4"]; + let indices = vec![1, 3]; + let expected_items = vec![items[1], items[3]]; + + assert_eq!(expected_items, take_elements_by_indices(&items, &indices)); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 507119a..b8bab32 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,8 +4,8 @@ pub mod trove; use crate::core::error::HoardErr; use crate::core::trove::Trove; -use crate::gui::merge::{with_conflict_resolve_prompt, ConflictResolve}; -use crate::gui::prompts::{prompt_input, prompt_input_validate, prompt_select_with_options}; +use crate::control::merge::{with_conflict_resolve_prompt, ConflictResolve}; +use crate::control::prompts::{prompt_input, prompt_input_validate, prompt_select_with_options}; use rand::distributions::Alphanumeric; use rand::Rng; use serde::{Deserialize, Serialize}; diff --git a/src/core/parameters.rs b/src/core/parameters.rs index 6e4227e..50870ea 100644 --- a/src/core/parameters.rs +++ b/src/core/parameters.rs @@ -1,7 +1,7 @@ use regex::Regex; use crate::core::HoardCmd; -use crate::gui::prompts::prompt_input; +use crate::control::prompts::prompt_input; pub trait Parameterized { /// Checks if the command string contains a specific token. diff --git a/src/hoard.rs b/src/hoard.rs index 19589ec..5f943ed 100644 --- a/src/hoard.rs +++ b/src/hoard.rs @@ -1,3 +1,5 @@ +extern crate edit; + use crate::cli_commands::{Cli, Commands}; use base64::engine::general_purpose; use clap::Parser; @@ -15,12 +17,8 @@ use crate::config::{load_or_build_config, save_hoard_config_file, save_parameter use crate::core::trove::Trove; use crate::core::HoardCmd; use crate::filter::query_trove; -use crate::gui::commands_gui; -use crate::gui::prompts::{ - prompt_input, prompt_multiselect_options, prompt_password, prompt_password_repeat, - prompt_yes_or_no, Confirmation, -}; use crate::sync_models::TokenResponse; +use crate::ui; use crate::util::rem_first_and_last; use base64::Engine as _; #[derive(Default, Debug)] @@ -94,16 +92,21 @@ impl Hoard { self.import_trove(uri); } Commands::Export { path } => { - self.export_command(path); + //self.export_command(path); + println!("Exporting is not yet implemented"); } Commands::Edit { name } => { self.edit_command(name); } + Commands::EditTrove { } => { + self.edit_trove(); + } Commands::ShellConfig { shell } => { Self::shell_config_command(shell); } Commands::Sync { command } => { - self.sync(*command); + //self.sync(*command); + println!("Syncing is not yet implemented"); } } @@ -163,18 +166,24 @@ impl Hoard { let filtered_trove = query_trove(&self.trove, &query_string); return Some(filtered_trove.to_yaml()); } else { - match commands_gui::run(&mut self.trove, &self.config) { - Ok(selected_command) => { - self.save_trove(None); - if let Some(c) = selected_command { - // Is set if a command is selected in GUI - if !c.command.is_empty() { - //TODO: If run as cli program, copy command into clipboard, else will be written to READLINE_LINE - return Some(c.command); + match ui::run(&mut self.trove, &self.config) { + Ok(cmd) => { + match self.trove.update_command_meta(&cmd) { + Ok(_) => { + self.save_trove(None); + // Is set if a command is selected in GUI + if !cmd.command.is_empty() { + return Some(cmd.command); + } + } + Err(e) => { + self.save_trove(None); + println!("{e}"); } } } Err(e) => { + self.save_trove(None); println!("{e}"); } } @@ -237,7 +246,7 @@ impl Hoard { } } } - + /* fn export_command(&self, path: &str) { let target_path = PathBuf::from(path); if target_path.file_name().is_some() { @@ -284,6 +293,7 @@ impl Hoard { println!("No valid path with filename provided."); } } + */ pub fn set_parameter_token(&self, parameter_token: &str) { if let Some(config_path) = self.config.config_home_path.clone() { @@ -293,6 +303,14 @@ impl Hoard { } } + fn edit_trove(&mut self) { + let trove_path = self.config.trove_path.clone().unwrap(); + let trove_content = fs::read_to_string(trove_path.clone()).unwrap(); + + let edited_trove = edit::edit(trove_content).unwrap(); + fs::write(trove_path, edited_trove).unwrap(); + } + fn edit_command(&mut self, command_name: &str) { println!("Editing {command_name}"); let command_to_edit = self.trove.pick_command(&self.config, command_name); @@ -352,69 +370,69 @@ impl Hoard { self.trove.save_trove_file(path_to_save); } - fn revert_trove(&self) { - let trove_path = self.config.trove_path.as_ref().unwrap(); - let backup_trove_path_str = format!("{}.bk", trove_path.to_str().unwrap()); - let backup_trove_path = PathBuf::from_str(&backup_trove_path_str).ok().unwrap(); - if backup_trove_path.exists() { - if matches!(prompt_yes_or_no("Found a backup from just before the last time you ran `hoard sync`. Are you sure you want to revert to this state?"), Confirmation::Yes) { - let e = fs::remove_file(trove_path); - // make clippy happy - drop(e); - fs::rename(backup_trove_path_str, trove_path).unwrap(); - println!("Done!"); - } else { - println!("Keeping current trove file..."); + /* + fn revert_trove(&self) { + let trove_path = self.config.trove_path.as_ref().unwrap(); + let backup_trove_path_str = format!("{}.bk", trove_path.to_str().unwrap()); + let backup_trove_path = PathBuf::from_str(&backup_trove_path_str).ok().unwrap(); + if backup_trove_path.exists() { + if matches!(prompt_yes_or_no("Found a backup from just before the last time you ran `hoard sync`. Are you sure you want to revert to this state?"), Confirmation::Yes) { + let e = fs::remove_file(trove_path); + // make clippy happy + drop(e); + fs::rename(backup_trove_path_str, trove_path).unwrap(); + println!("Done!"); + } else { + println!("Keeping current trove file..."); + } } } - } - - fn register_user(&mut self) { - println!("Registering account.."); - let user_email = prompt_input("Email: ", false, None); - let user_pw: String = prompt_password_repeat("Password: "); - let client = reqwest::blocking::Client::new(); - let register_url = format!("{}register", self.config.sync_server_url.clone().unwrap()); - let register_body = format!("{{\"password\": \"{user_pw}\",\"email\": \"{user_email}\"}}"); - let body = client - .post(register_url) - .body(register_body) - .header("Content-Type", "application/json") - .send() - .unwrap(); - if body.status() == StatusCode::CREATED { - println!("Created new user! Verification not needed for now. Run `hoard sync login` next.\n\nPlease consider supporting further development and help offset server costs here:\nbuy.stripe.com/9AQ9Bm6Nx4qb6YwaEE\nThis is the only time this message will pop up :)"); - } else { - println!("Something went all wrong. Try another email."); + fn register_user(&mut self) { + println!("Registering account.."); + let user_email = prompt_input("Email: ", false, None); + let user_pw: String = prompt_password_repeat("Password: "); + let client = reqwest::blocking::Client::new(); + let register_url = format!("{}register", self.config.sync_server_url.clone().unwrap()); + let register_body = format!("{{\"password\": \"{user_pw}\",\"email\": \"{user_email}\"}}"); + let body = client + .post(register_url) + .body(register_body) + .header("Content-Type", "application/json") + .send() + .unwrap(); + if body.status() == StatusCode::CREATED { + println!("Created new user! Verification not needed for now. Run `hoard sync login` next.\n\nPlease consider supporting further development and help offset server costs here:\nbuy.stripe.com/9AQ9Bm6Nx4qb6YwaEE\nThis is the only time this message will pop up :)"); + } else { + println!("Something went all wrong. Try another email."); + } } - } - fn login(&mut self) { - println!("Logging in.."); - let user_email = prompt_input("Email: ", false, None); - let user_pw: String = prompt_password("Password: "); - let register_body = format!("{{\"password\": \"{user_pw}\",\"email\": \"{user_email}\"}}"); - let client = reqwest::blocking::Client::new(); - let register_url = format!("{}token/new", self.config.sync_server_url.clone().unwrap()); - let body = client - .get(register_url) - .body(register_body) - .header("Content-Type", "application/json") - .send() - .unwrap(); - if body.status() == StatusCode::CREATED { - let response_text = body.text().unwrap(); - let token = serde_yaml::from_str::(&response_text).unwrap(); - let b64_token = general_purpose::STANDARD.encode(token.token); - self.config.api_token = Some(b64_token); - save_hoard_config_file(&self.config, &self.config.clone().config_home_path.unwrap()) + fn login(&mut self) { + println!("Logging in.."); + let user_email = prompt_input("Email: ", false, None); + let user_pw: String = prompt_password("Password: "); + let register_body = format!("{{\"password\": \"{user_pw}\",\"email\": \"{user_email}\"}}"); + let client = reqwest::blocking::Client::new(); + let register_url = format!("{}token/new", self.config.sync_server_url.clone().unwrap()); + let body = client + .get(register_url) + .body(register_body) + .header("Content-Type", "application/json") + .send() .unwrap(); - println!("Success!"); - } else { - println!("Invalid Email and password combination."); + if body.status() == StatusCode::CREATED { + let response_text = body.text().unwrap(); + let token = serde_yaml::from_str::(&response_text).unwrap(); + let b64_token = general_purpose::STANDARD.encode(token.token); + self.config.api_token = Some(b64_token); + save_hoard_config_file(&self.config, &self.config.clone().config_home_path.unwrap()) + .unwrap(); + println!("Success!"); + } else { + println!("Invalid Email and password combination."); + } } - } - + */ fn get_trove_file(&self) -> Option { println!("Syncing ..."); let token = self.config.api_token.clone(); @@ -458,62 +476,63 @@ impl Hoard { println!("{}", body.text().unwrap()); } } - - pub fn sync(&mut self, command: Mode) { - // Check if user is logged in - // Else inform the user to run `hoard sync login` first and break - match command { - Mode::Register => self.register_user(), - Mode::Login => { - if self.is_logged_in() { - println!("You are already logged in."); - return; + /* + pub fn sync(&mut self, command: Mode) { + // Check if user is logged in + // Else inform the user to run `hoard sync login` first and break + match command { + Mode::Register => self.register_user(), + Mode::Login => { + if self.is_logged_in() { + println!("You are already logged in."); + return; + } + self.login(); } - self.login(); - } - Mode::Logout => { - println!("Logging out.."); - self.config.api_token = None; - save_hoard_config_file( - &self.config.clone(), - &self.config.config_home_path.clone().unwrap(), - ) - .unwrap(); - } - Mode::Save => { - if !self.is_logged_in() { - println!("Please log in [hoard sync login] or register an account [hoard sync register] to use the sync feature!"); - return; + Mode::Logout => { + println!("Logging out.."); + self.config.api_token = None; + save_hoard_config_file( + &self.config.clone(), + &self.config.config_home_path.clone().unwrap(), + ) + .unwrap(); } - self.sync_safe(); - } - Mode::Get => { - // `hoard sync` is run - // Pull trove - if !self.is_logged_in() { - println!("Please log in [hoard sync login] or register an account [hoard sync register] to use the sync feature!"); - return; + Mode::Save => { + if !self.is_logged_in() { + println!("Please log in [hoard sync login] or register an account [hoard sync register] to use the sync feature!"); + return; + } + self.sync_safe(); } - let trove = self.get_trove_file(); - if let Some(t) = trove { - // Prepare backup trove to enable reverting if merge goes all wrong, or user incorrectly removes commands they wanted to keep - self.save_backup_trove(None); - let was_dirty = self.trove.merge_trove(&t); - if was_dirty { - self.save_trove(None); - println!("All done!"); + Mode::Get => { + // `hoard sync` is run + // Pull trove + if !self.is_logged_in() { + println!("Please log in [hoard sync login] or register an account [hoard sync register] to use the sync feature!"); return; } - println!("No changes"); - } else { - println!("Could not fetch trove file from your account!"); + let trove = self.get_trove_file(); + if let Some(t) = trove { + // Prepare backup trove to enable reverting if merge goes all wrong, or user incorrectly removes commands they wanted to keep + self.save_backup_trove(None); + let was_dirty = self.trove.merge_trove(&t); + if was_dirty { + self.save_trove(None); + println!("All done!"); + return; + } + println!("No changes"); + } else { + println!("Could not fetch trove file from your account!"); + } + } + Mode::Revert => { + self.revert_trove(); } - } - Mode::Revert => { - self.revert_trove(); } } - } + */ const fn is_logged_in(&self) -> bool { self.config.api_token.is_some() diff --git a/src/main.rs b/src/main.rs index a409cd3..e675bf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,8 @@ mod config; mod core; mod filter; mod gpt; -mod gui; +mod ui; +mod control; mod hoard; mod sync_models; mod util; diff --git a/src/ui/event.rs b/src/ui/event.rs new file mode 100644 index 0000000..ce344c1 --- /dev/null +++ b/src/ui/event.rs @@ -0,0 +1,69 @@ +use crossbeam_channel::unbounded; +use std::thread; +use std::time::Duration; + +use termion::event::Key; +use termion::input::TermRead; + +pub enum Event { + Input(I), + Tick, +} + +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +#[allow(dead_code)] +pub struct Events { + rx: crossbeam_channel::Receiver>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Self { + Self { + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + #[allow(dead_code)] + pub fn new() -> Self { + Self::with_config(Config::default()) + } + + #[allow(clippy::manual_flatten)] + pub fn with_config(config: Config) -> Self { + let (tx, rx) = unbounded(); + + { + let tx = tx.clone(); + thread::spawn(move || { + let tty = termion::get_tty().expect("Could not find tty session"); + for key in tty.keys().flatten() { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{err}"); + return; + } + } + }) + }; + + thread::spawn(move || loop { + if let Err(err) = tx.send(Event::Tick) { + eprintln!("{err}"); + break; + } + thread::sleep(config.tick_rate); + }); + Self { rx } + } + + pub fn next(&self) -> Result, crossbeam_channel::RecvError> { + self.rx.recv() + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100755 index 0000000..53b2c66 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,290 @@ +mod event; +mod search; + +use clap::Parser; +use eyre::Result; +use ratatui::{prelude::*, widgets::*}; +use std::io::stdout; +use std::time::{Duration, Instant}; +use termion::event::Key; +use termion::raw::IntoRawMode; +use termion::screen::IntoAlternateScreen; + +use crate::config::HoardConfig; +use crate::core::trove::Trove; +use crate::core::HoardCmd; +use crate::ui::event::{Config, Event, Events}; +use crate::ui::search::controls::{draw_search_key_handler, next_index, previous_index}; +use crate::ui::search::render::draw_search_screen; + +const DEFAULT_COLLECTIONS: [&str; 1] = ["All"]; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum DrawState { + Search, + Explore, + About, +} + +#[derive(Debug, Clone)] +pub struct App { + /// If set to true, the UI will exit on the next loop iteration + pub should_exit: bool, + // ratatui list of commands to display + pub commands: ListState, + // vertical scroll position of the command list + pub vertical_scroll: usize, + // ratatui list of collections to display + pub collections: ListState, + + pub current_collection: String, + // current screen to draw + pub screen: DrawState, + // search string to filter commands displayed at the bottom + pub search_string: String, + + // Temporary trove that actually gets displayed in the UI + // This is used to filter the base trove based on search string + pub search_trove: Trove, + + // Base trove that is used to filter the search_trove + pub base_trove: Trove, + + pub frame_size: Rect, +} + +impl Default for App { + fn default() -> Self { + let mut state = Self { + should_exit: false, + commands: ListState::default(), + collections: ListState::default(), + screen: DrawState::Search, + search_trove: Trove::default(), + base_trove: Trove::default(), + search_string: String::new(), + current_collection: String::from(DEFAULT_COLLECTIONS[0]), + vertical_scroll: 0, + frame_size: Rect::default(), + }; + state.commands.select(Some(0)); + state.collections.select(Some(0)); + state + } +} + +impl App { + pub fn with_trove(&mut self, trove: Trove) -> Self { + Self { + base_trove: trove.clone(), + search_trove: trove, + ..self.clone() + } + } + + fn apply_cmd_filter(cmd: &HoardCmd, search_string: &str, current_collection: &str) -> bool { + let search_string = search_string.to_lowercase(); + let current_collection = current_collection.to_lowercase(); + + let in_current_collection = + current_collection == "all" || cmd.namespace.to_lowercase() == current_collection; + let contains_search_string = cmd.name.to_lowercase().contains(&search_string); + + if search_string.is_empty() { + return in_current_collection; + } + + in_current_collection && contains_search_string + } + + pub fn filter_trove(&mut self) { + let search_string = self.search_string.to_lowercase(); + + let filtered: Vec = self + .base_trove + .commands + .iter() + .filter(|cmd| Self::apply_cmd_filter(cmd, &search_string, &self.current_collection)) + .cloned() + .collect(); + self.search_trove = Trove::from_commands(&filtered); + + self.clip_commands_selection(); + } + + pub fn next_collection(&mut self) { + // Get the namespaces of the Trove and transform hashset to a list + let trove_collection = self.base_trove.namespaces.clone(); + let mut collections: Vec = trove_collection.into_iter().collect(); + collections.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + // Prepend all DEFAULT_COLLECTIONS to the list of collections + collections.splice(0..0, DEFAULT_COLLECTIONS.iter().map(|s| s.to_string())); + // Based on current self.current_collection get the index of the next collection + let current_index = collections + .iter() + .position(|s| s == &self.current_collection) + .unwrap(); + let next_index = next_index(current_index, collections.len()); + self.collections.select(Some(next_index)); + self.current_collection = collections[next_index].clone(); + self.filter_trove(); + } + + /// Clips the commands selected index to the highest possible index + /// of the current search_trove commands len + pub fn clip_commands_selection(&mut self) { + let selected = self.commands.selected().unwrap_or(0); + let max = self.search_trove.commands.len().saturating_sub(1); + if selected > max { + self.commands.select(Some(max)); + } + } + + /// Get the selected hoard command from the current search_trove + pub fn get_selected_hoard_command(&self) -> Option { + self.commands.selected().and_then(|index| { + if index < self.search_trove.commands.len() { + Some(self.search_trove.commands[index].clone()) + } else { + None + } + }) + } +} + +/// The main entry point for the UI +/// Handles setting up the UI, running the main loop +/// and switching between different screens based on events it recieves +pub fn run(trove: &mut Trove, config: &HoardConfig) -> Result { + // Setup terminal + let stdout = stdout().into_raw_mode()?; + let stdout = stdout.into_alternate_screen().unwrap(); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // create app and run it + let tick_rate = Duration::from_millis(200); + let app = App::default().with_trove(trove.clone()); + let res = run_app(&mut terminal, app, tick_rate); + + // Cleanup + terminal.show_cursor()?; + + match res { + Ok(Some(cmd)) => Ok(cmd), + Ok(None) => { + // If the UI exited without a command, return an error just to not + Err(eyre::eyre!("Exited without a command")) + } + Err(err) => Err(err), + } +} + +fn run_app( + terminal: &mut Terminal, + mut app: App, + tick_rate: Duration, +) -> Result> { + let mut last_tick = Instant::now(); + let events = Events::with_config(Config { + tick_rate: Duration::from_millis(tick_rate.as_millis() as u64), + }); + + // Main loop of the UI + // first match the current DrawState, and draw correct screen + // then check for any events that might have happened and handle them + loop { + let screen = match app.screen { + DrawState::Search => draw_search_screen, + DrawState::Explore => not_implemented_ui, + DrawState::About => not_implemented_ui, + }; + + terminal.draw(|f| screen(f, &mut app))?; + + if let Event::Input(input) = events.next()? { + match app.screen { + DrawState::Search => { + if let Some(cmd) = draw_search_key_handler(input, &mut app) { + return Ok(Some(cmd)); + } + } + DrawState::Explore => { + if let Some(cmd) = not_implemented_key_handler(input, &mut app) { + return Ok(Some(cmd)); + } + } + DrawState::About => { + if let Some(cmd) = not_implemented_key_handler(input, &mut app) { + return Ok(Some(cmd)); + } + } + } + } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + + if app.should_exit { + terminal.show_cursor()?; + return Ok(None); + } + } +} + +pub fn not_implemented_key_handler(input: Key, app: &mut App) -> Option { + match input { + Key::Esc | Key::Ctrl('c' | 'd' | 'g') => { + app.should_exit = true; + None + } + _ => None, + } +} + +fn not_implemented_ui(frame: &mut Frame, _app: &mut App) { + frame.render_widget( + Paragraph::new("Not implemented :(") + .dark_gray() + .alignment(Alignment::Center), + frame.size(), + ); +} + +fn partial_highlighted_line<'a>( + text_input: &'a str, + search: &'a str, + highlighted_bg: bool, +) -> Line<'a> { + // find the index of the search string in the text + let index = text_input.find(&search); + + match index { + Some(i) => { + let (left, right) = text_input.split_at(i); + let (highlight, rest) = right.split_at(search.len()); + let left_span = Span::raw(left).bg(if highlighted_bg { + Color::LightBlue + } else { + Color::Reset + }); + let highlight_span = Span::styled( + highlight, + Style::default().fg(Color::LightRed).bg(if highlighted_bg { + Color::LightBlue + } else { + Color::Reset + }), + ); + let rest_span = Span::raw(rest).bg(if highlighted_bg { + Color::LightBlue + } else { + Color::Reset + }); + Line::from(vec![left_span, highlight_span, rest_span]) + } + None => Line::from(text_input), + } +} diff --git a/src/ui/search/controls.rs b/src/ui/search/controls.rs new file mode 100644 index 0000000..6d3e9ec --- /dev/null +++ b/src/ui/search/controls.rs @@ -0,0 +1,155 @@ +use crate::core::HoardCmd; +use crate::ui::App; + +use termion::event::Key; + +#[allow(clippy::too_many_lines)] +pub fn draw_search_key_handler(input: Key, app: &mut App) -> Option { + match input { + // + // Exit the application + // Does not return a command and just quits + // + Key::Esc | Key::Ctrl('c' | 'd' | 'g') => { + app.should_exit = true; + None + } + // + // Remove the last typed search string character + // 'This is a search' -> 'This is a searc' + // + Key::Backspace => { + app.search_string.pop(); + app.filter_trove(); + None + } + // + // Selects the currently highlighted command + // Returns the command to the caller + // If no command is selected, returns None + // + Key::Char('\n') => { + app.commands.selected().map(|index| app.search_trove.commands[index].clone()) + } + // + // Switches context for currently shown collection + // Iterates through [All, Local, ] + // All and Local are built in collections + // Trove namespaces are user defined collections based on each command + // + Key::Char('\t') => { + app.next_collection(); + None + } + // + // Deletes the last word from the searchstring + // 'This is a search' -> 'This is a' + // + Key::Ctrl('w') => { + // Delete the last word from searchstring + let mut search_string = app.search_string.split_whitespace(); + search_string.next_back(); + // Collect with spaces + app.search_string = search_string.collect::>().join(" "); + app.filter_trove(); + None + } + // + // Deletes the entire search string + // 'This is a search' -> '' + // + Key::Ctrl('u') => { + // Deletes the entire search string + app.search_string.clear(); + app.filter_trove(); + None + } + // + // Moves the selection down by one command + // If the selection is at the bottom, it wraps around to the top + // + Key::Down => { + increment_selected_command(app); + None + } + // + // Moves the selection up by one command + // If the selection is at the top, it wraps around to the bottom + // + Key::Up => { + decrement_selected_command(app); + None + } + // + // Adds the typed character to the search string + // 'This is a search' -> 'This is a searchc' + // After the search string is edited, + // the trove is filtered to match the search string + // + Key::Char(c) => { + app.search_string.push(c); + app.filter_trove(); + None + } + _ => None, + } +} + +fn increment_selected_command(app: &mut App) { + if app.search_trove.commands.is_empty() { + return; + } + + let current_selected = app.commands.selected().unwrap_or(0); + let next_selected = next_index(current_selected, app.search_trove.commands.len()); + app.commands.select(Some(next_selected)); + // Update the scroll state based on how close the selected command is to the top or bottom + let actual_position = current_selected - app.vertical_scroll; + + // If we increment at the last element, reset the scroll to 0 + if next_selected == 0 { + app.vertical_scroll = 0; + } else if actual_position >= app.frame_size.height as usize - 6 { + app.vertical_scroll = app.vertical_scroll + 1; + } +} + +fn decrement_selected_command(app: &mut App) { + if app.search_trove.commands.is_empty() { + return; + } + if let Some(selected) = app.commands.selected() { + let new_selected = previous_index(selected, app.search_trove.commands.len()); + app.commands.select(Some(new_selected)); + // If we jump to the end, just scroll all the way to the bottom + if new_selected == app.search_trove.commands.len() - 1 { + app.vertical_scroll = app.search_trove.commands.len().saturating_sub(1); + return; + } + } + + // Update the scroll state based on how close the selected command is to the top or bottom + let selected = app.commands.selected().unwrap_or(0); + let max_scroll = app.search_trove.commands.len().saturating_sub(1); + if selected < app.vertical_scroll { + app.vertical_scroll = selected; + } else if selected > app.vertical_scroll + max_scroll { + app.vertical_scroll = selected.saturating_sub(max_scroll); + } +} + +pub const fn next_index(current_index: usize, collection_length: usize) -> usize { + if current_index >= collection_length - 1 { + 0 + } else { + current_index + 1 + } +} + +pub const fn previous_index(current_index: usize, collection_length: usize) -> usize { + if current_index == 0 { + collection_length - 1 + } else { + current_index - 1 + } +} diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs new file mode 100644 index 0000000..9db4b6b --- /dev/null +++ b/src/ui/search/mod.rs @@ -0,0 +1,2 @@ +pub mod controls; +pub mod render; diff --git a/src/ui/search/render.rs b/src/ui/search/render.rs new file mode 100644 index 0000000..359cf76 --- /dev/null +++ b/src/ui/search/render.rs @@ -0,0 +1,238 @@ +extern crate chrono; + +use crate::config::HoardConfig; +use crate::core::HoardCmd; +use crate::ui::{partial_highlighted_line, App}; +use chrono::offset::Utc; +use chrono::DateTime; +use ratatui::{prelude::*, widgets::*}; +use std::time::SystemTime; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Draw the search screen +/// +/// # Arguments +/// * `frame` - The frame to draw the UI components on +/// * `app` - The application state +/// +/// The header of the screen will display the current version of the application +/// The main screen will display the list of commands and details of the selected command +/// The footer will display the search string and the current collection +pub fn draw_search_screen(frame: &mut Frame, app: &mut App) { + let main_layout = Layout::new( + Direction::Vertical, + [ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ], + ) + .split(frame.size()); + + app.frame_size = frame.size(); + + render_version_header_widget(frame, main_layout[0]); + + render_main_screen(frame, main_layout[1], app); + + render_search_field_widget(frame, main_layout[2], app); +} + +/// Draw the main screen +/// +/// # Arguments +/// * `frame` - The frame to draw the UI components +/// * `rect` - The area to draw the main screen +/// * `app` - The application state +fn render_main_screen(frame: &mut Frame, rect: Rect, app: &mut App) { + let main_screen_layout = Layout::new( + Direction::Horizontal, + [ + Constraint::Percentage(30), // Scrollable tray for a list of available commands + Constraint::Percentage(70), // Detail view for the "hovered" command by the selector + ], + ) + .split(rect); + + render_commands_list_widget(frame, main_screen_layout[0], app); + + render_command_detail(frame, main_screen_layout[1], app); +} + +fn render_command_detail(frame: &mut Frame, rect: Rect, app: &mut App) { + let detail_layout = Layout::new( + Direction::Vertical, + [ + Constraint::Percentage(20), + Constraint::Percentage(30), + Constraint::Percentage(50), + ], + ) + .split(rect); + + render_command_string_widget(frame, detail_layout[0], app); + + render_command_description_widget(frame, detail_layout[1], app); + + render_command_subdetails_widget(frame, detail_layout[2], app); +} + +/// Draw the version header +/// +/// # Arguments +/// * `frame` - The frame to draw the UI components +/// * `rect` - The area to draw the version header +fn render_version_header_widget(frame: &mut Frame, rect: Rect) { + let version = format!("Hoard v{}", VERSION); + + frame.render_widget(Paragraph::new(version), rect); +} + +/// Draw the search field +/// +/// # Arguments +/// * `frame` - The frame to draw the UI components +/// * `rect` - The area to draw the search field +/// * `app` - The application state +fn render_search_field_widget(frame: &mut Frame, rect: Rect, app: &mut App) { + let search_string = format!("[ {} ] > {}", app.current_collection, app.search_string); + + frame.render_widget(Paragraph::new(search_string), rect); +} + +fn render_commands_list_widget(frame: &mut Frame, rect: Rect, app: &mut App) { + let vertical_scroll = app.vertical_scroll; // from app state + let items = build_command_list_items(app); + let paragraph = Paragraph::new(items.clone()) + .scroll((vertical_scroll as u16, 0)) + .block(Block::new().borders(Borders::ALL)); // to show a background for the scrollbar + + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + let mut scrollbar_state = ScrollbarState::new(items.len()).position(vertical_scroll); + + frame.render_widget(paragraph, rect); + + // and the scrollbar, those are separate widgets + frame.render_stateful_widget( + scrollbar, + rect.inner(&Margin { + // using an inner vertical margin of 1 unit makes the scrollbar inside the block + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); +} + +fn render_command_string_widget(frame: &mut Frame, rect: Rect, app: &mut App) { + let selected_command = app + .get_selected_hoard_command() + .unwrap_or(HoardCmd::default()); + frame.render_widget( + Paragraph::new(selected_command.command.clone()) + .block(Block::default().borders(Borders::ALL).title(" Command ")) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }), + rect, + ); +} + +fn render_command_description_widget(frame: &mut Frame, rect: Rect, app: &mut App) { + let selected_command = app + .get_selected_hoard_command() + .unwrap_or(HoardCmd::default()); + frame.render_widget( + Paragraph::new(selected_command.description.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Description "), + ) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }), + rect, + ); +} + +fn render_command_subdetails_widget(frame: &mut Frame, rect: Rect, app: &mut App) { + if let Some(selected_command) = app.get_selected_hoard_command() { + let usage_count = format!( + "Usage count: ({}){} {}", + selected_command.usage_count, + usage_count_to_emoji(selected_command.usage_count), + usage_count_to_ticks(selected_command.usage_count) + ); + let usage_count_span = Span::styled(usage_count, Style::default()); + + let tags = format!("Tags: {}", selected_command.tags.join(", ")); + let tags_span = Span::styled(tags, Style::default()); + + let last_used_dt: DateTime = selected_command.last_used.into(); + let last_used = format!("Last used: {}", last_used_dt); + let last_used_span = Span::styled(last_used, Style::default().fg(Color::DarkGray)); + + let created_dt: DateTime = selected_command.created.into(); + let created = format!("Created : {}", created_dt); + let created_span = Span::styled(created, Style::default().fg(Color::DarkGray)); + + let updated_dt: DateTime = selected_command.modified.into(); + let updated = format!("Updated : {}", updated_dt); + let updated_span = Span::styled(updated, Style::default().fg(Color::DarkGray)); + + let text = vec![Line::from(usage_count_span), Line::from(tags_span), Line::from(last_used_span), Line::from(created_span), Line::from(updated_span)]; + + frame.render_widget( + Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(" Details ")), + rect + ); + } else { + frame.render_widget( + Paragraph::new("No command selected") + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }), + rect, + ); + } +} + +/// Build the list of commands to display based on the trove in the current +/// app state. The list of commands is highlighted based on the search string +fn build_command_list_items(app: &App) -> Vec { + let selected_index = app.commands.selected().unwrap_or(0); + app.search_trove + .commands + .iter() + .enumerate() + .map(|(index, command)| { + partial_highlighted_line(&command.name, &app.search_string, selected_index == index) + }) + .collect() +} + +fn usage_count_to_ticks(usage_count: usize) -> String { + let usage_count_max_repeat = 30; + let usage_count = usage_count.min(usage_count_max_repeat); + let ticks = "▌"; + let ticks = ticks.repeat(usage_count); + // if usage_count max is reached, add "..." to the end + if usage_count == usage_count_max_repeat { + return format!("{}...", ticks); + } else { + return format!("{}", ticks); + } +} + +fn usage_count_to_emoji(usage_count: usize) -> String { + match usage_count { + 0 => "", + 1..=2 => "🌱", + 3..=4 => "🌿", + 5..=30 => "🔥", + _ => "💀", + } + .to_string() +}