diff --git a/CHANGELOG.md b/CHANGELOG.md index 9997cfe9..5262b3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## [Unreleased] -- No changes since the latest release below. +- Added `history.rs` and modified `Text` to allow the addition of prompt-history navigation ## [0.7.0] - 2024-02-24 diff --git a/inquire/examples/text_options.rs b/inquire/examples/text_options.rs index 28fd3006..e53053d1 100644 --- a/inquire/examples/text_options.rs +++ b/inquire/examples/text_options.rs @@ -20,6 +20,7 @@ fn main() { validators: Vec::new(), page_size: Text::DEFAULT_PAGE_SIZE, autocompleter: None, + history: None, render_config: RenderConfig::default(), } .prompt() diff --git a/inquire/src/history.rs b/inquire/src/history.rs new file mode 100644 index 00000000..d969068e --- /dev/null +++ b/inquire/src/history.rs @@ -0,0 +1,173 @@ +//! Trait and structs used by prompts to provide prompt-entry history navigation features. +//! +//! History is triggered upon up or down keystroke, which iterates through a historical +//! list of prompt entries. Each keystroke rewrites the contents of the prompt with the next +//! item from that history. + +use dyn_clone::DynClone; + +use crate::CustomUserError; + +/// Used when iterating through the user's prompt history +/// +/// `None` means that no completion will be made. +/// `Some(String)` will replace the current text input with the `String` in `Some`. +pub type Replacement = Option; + +/// Mechanism to implement history navigation +/// +/// - `earlier_element` is called whenever the user presses the `up` key, and navigates towards less recent prompt entries +/// - `later_element` is called whenever the user presses the `down` key, and navigates towards more recent prompt entries +/// - `prepend_element` is called whenever the user hits `enter` commits a new entry into the prompt; this adds the entry to the prompt history +pub trait History: DynClone { + /// Update internal state and return the next less-recent history entry + fn earlier_element(&mut self) -> Result; + + /// Update internal state and return the nezt more-recent history entry + fn later_element(&mut self) -> Result; + + /// Add the given string to the history, at the beginning of the history, which is the most recent + fn prepend_element(&mut self, _: String); +} + +impl Clone for Box { + fn clone(&self) -> Self { + dyn_clone::clone_box(&**self) + } +} + +/// Empty struct and implementation of History trait. Used for the default +/// history of `Text` prompts. +#[derive(Clone, Default)] +pub struct NoHistory; + +impl History for NoHistory { + fn earlier_element(&mut self) -> Result { + Ok(Replacement::None) + } + + fn later_element(&mut self) -> Result { + Ok(Replacement::None) + } + + fn prepend_element(&mut self, _: String) {} +} + +/// Simple `History` implementation. Stores a vector of strings representing prompt entries, and navigates through the +/// vector. Prepends new entries into the vector and resets internal index state. +#[derive(Clone, Debug)] +pub struct SimpleHistory { + history: Vec, + index: isize, +} + +impl SimpleHistory { + /// Reeturn a new SimpleHistory, bootstrappd with the given entries + pub fn new(initial_history: Vec) -> Self { + SimpleHistory { + history: initial_history, + index: -1, + } + } +} + +impl History for SimpleHistory { + fn earlier_element(&mut self) -> Result { + if self.index < self.history.len() as isize - 1 { + self.index += 1; + Ok(Replacement::Some(self.history[self.index as usize].clone())) + } else if !self.history.is_empty() && self.index == self.history.len() as isize - 1 { + // return last item in the history + Ok(Replacement::Some(self.history[self.index as usize].clone())) + } else { + Ok(Replacement::None) + } + } + + fn later_element(&mut self) -> Result { + if self.index >= 1 { + self.index -= 1; + let e = self.history[self.index as usize].clone(); + Ok(Replacement::Some(e)) + } else { + if self.index == 0 { + self.index -= 1; + } + + Ok(Replacement::None) + } + } + + fn prepend_element(&mut self, e: String) { + if !e.is_empty() { + self.history.insert(0, e); + self.index = -1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_history() { + let history = SimpleHistory::new(vec!["first".into(), "second".into()]); + assert_eq!(history.history.len(), 2); + assert_eq!(history.index, -1); + } + + #[test] + fn test_prepend_element() { + let mut history = SimpleHistory::new(vec![]); + history.prepend_element("new".into()); + assert_eq!(history.history.len(), 1); + assert_eq!(history.history[0], "new"); + } + + #[test] + fn test_earlier_then_later_element() { + let mut history = SimpleHistory::new(vec!["first".into(), "second".into()]); + assert_eq!( + history.earlier_element().unwrap(), + Replacement::Some("first".into()) + ); + assert_eq!(history.later_element().unwrap(), Replacement::None); + assert_eq!( + history.earlier_element().unwrap(), + Replacement::Some("first".into()) + ); + } + + #[test] + fn test_sequence_of_operations() { + let mut history = SimpleHistory::new(vec!["first".into(), "second".into()]); + // Prepend a new element + history.prepend_element("zero".into()); + // Move to the earlier element, which should now be "zero" + assert_eq!( + history.earlier_element().unwrap(), + Replacement::Some("zero".into()) + ); + // Move to an even earlier element, which should now be "second" + assert_eq!( + history.earlier_element().unwrap(), + Replacement::Some("first".into()) + ); + // Try to move later, back to "first" + assert_eq!( + history.later_element().unwrap(), + Replacement::Some("zero".into()) + ); + // And now, since we're at the start, it should return None + assert_eq!(history.later_element().unwrap(), Replacement::None); + } + + #[test] + fn test_no_prepend_of_empty_string() { + let mut history = SimpleHistory::new(vec!["initial".into()]); + history.prepend_element("".into()); + assert_eq!(history.history.len(), 1); + assert_eq!(history.history[0], "initial"); + } +} diff --git a/inquire/src/lib.rs b/inquire/src/lib.rs index a932f237..c44aa100 100644 --- a/inquire/src/lib.rs +++ b/inquire/src/lib.rs @@ -72,6 +72,7 @@ mod config; mod date_utils; pub mod error; pub mod formatter; +pub mod history; mod input; pub mod list_option; pub mod parser; @@ -85,5 +86,6 @@ pub mod validator; pub use crate::autocompletion::Autocomplete; pub use crate::config::set_global_render_config; pub use crate::error::{CustomUserError, InquireError}; +pub use crate::history::History; pub use crate::input::action::*; pub use crate::prompts::*; diff --git a/inquire/src/prompts/action.rs b/inquire/src/prompts/action.rs index dde0d74c..b3da5b33 100644 --- a/inquire/src/prompts/action.rs +++ b/inquire/src/prompts/action.rs @@ -39,7 +39,9 @@ where Key::Enter | Key::Char('\n', KeyModifiers::NONE) | Key::Char('j', KeyModifiers::CONTROL) => Some(Action::Submit), - Key::Escape | Key::Char('g', KeyModifiers::CONTROL) => Some(Action::Cancel), + Key::Escape + | Key::Char('g', KeyModifiers::CONTROL) + | Key::Char('d', KeyModifiers::CONTROL) => Some(Action::Cancel), Key::Char('c', KeyModifiers::CONTROL) => Some(Action::Interrupt), key => I::from_key(key, config).map(Action::Inner), } diff --git a/inquire/src/prompts/text/mod.rs b/inquire/src/prompts/text/mod.rs index aeda72d2..3c41f29e 100644 --- a/inquire/src/prompts/text/mod.rs +++ b/inquire/src/prompts/text/mod.rs @@ -16,6 +16,7 @@ use crate::{ terminal::get_default_terminal, ui::{Backend, RenderConfig, TextBackend}, validator::StringValidator, + History, }; use self::prompt::TextPrompt; @@ -100,6 +101,9 @@ pub struct Text<'a> { /// Autocompleter responsible for handling suggestions and input completions. pub autocompleter: Option>, + /// History, responsible for producing previously-entered text entries + pub history: Option>, + /// Collection of validators to apply to the user input. /// /// Validators are executed in the order they are stored, stopping at and displaying to the user @@ -147,10 +151,17 @@ impl<'a> Text<'a> { formatter: Self::DEFAULT_FORMATTER, page_size: Self::DEFAULT_PAGE_SIZE, autocompleter: None, + history: None, render_config: get_configuration(), } } + /// Sets the help message of the prompt. + pub fn with_prompt_message(mut self, message: &'a str) -> Self { + self.message = message; + self + } + /// Sets the help message of the prompt. pub fn with_help_message(mut self, message: &'a str) -> Self { self.help_message = Some(message); @@ -188,6 +199,15 @@ impl<'a> Text<'a> { self } + /// Sets a new history + pub fn with_history(mut self, h: H) -> Self + where + H: History + 'static, + { + self.history = Some(Box::new(h)); + self + } + /// Sets the formatter. pub fn with_formatter(mut self, formatter: StringFormatter<'a>) -> Self { self.formatter = formatter; diff --git a/inquire/src/prompts/text/prompt.rs b/inquire/src/prompts/text/prompt.rs index 1954a2e5..1a07d688 100644 --- a/inquire/src/prompts/text/prompt.rs +++ b/inquire/src/prompts/text/prompt.rs @@ -4,13 +4,14 @@ use crate::{ autocompletion::{NoAutoCompletion, Replacement}, error::InquireResult, formatter::StringFormatter, + history::{NoHistory, Replacement as HistoryReplacement}, input::{Input, InputActionResult}, list_option::ListOption, prompts::prompt::{ActionResult, Prompt}, ui::TextBackend, utils::paginate, validator::{ErrorMessage, StringValidator, Validation}, - Autocomplete, InquireError, Text, + Autocomplete, History, InquireError, Text, }; use super::{action::TextPromptAction, config::TextConfig, DEFAULT_HELP_MESSAGE_WITH_AC}; @@ -25,6 +26,7 @@ pub struct TextPrompt<'a> { validators: Vec>, error: Option, autocompleter: Box, + history: Box, suggested_options: Vec, suggestion_cursor_index: Option, } @@ -47,6 +49,7 @@ impl<'a> From> for TextPrompt<'a> { autocompleter: so .autocompleter .unwrap_or_else(|| Box::::default()), + history: so.history.unwrap_or_else(|| Box::::default()), input, error: None, suggestion_cursor_index: None, @@ -79,6 +82,30 @@ impl<'a> TextPrompt<'a> { } } + /// Navigate earlier (or 'upwards') through the history and redraw with the next element, if available + fn history_iterate_earlier(&mut self) -> InquireResult { + match self.history.earlier_element()? { + HistoryReplacement::Some(value) => { + self.input = Input::new_with(value); + Ok(ActionResult::NeedsRedraw) + } + _ => Ok(ActionResult::Clean), + } + } + + /// Navigate later (or 'downwards') through the history and redraw with the next element, if available. + /// If the user has navigated beyond the most recently-available history item, clear the prompt + fn history_iterate_later(&mut self) -> InquireResult { + if let HistoryReplacement::Some(value) = self.history.later_element()? { + self.input = Input::new_with(value); + Ok(ActionResult::NeedsRedraw) + } else { + // if empty, clear the prompt on down-key + self.input = Input::new_with(""); + Ok(ActionResult::NeedsRedraw) + } + } + fn move_cursor_up(&mut self, qty: usize) -> ActionResult { let new_cursor_index = match self.suggestion_cursor_index { None => None, @@ -187,7 +214,12 @@ where fn submit(&mut self) -> InquireResult> { let result = match self.validate_current_answer()? { - Validation::Valid => Some(self.get_current_answer().to_owned()), + Validation::Valid => { + // On enter, add the new entry into the history object + self.history + .prepend_element(self.get_current_answer().to_owned()); + Some(self.get_current_answer().to_owned()) + } Validation::Invalid(msg) => { self.error = Some(msg); None @@ -208,13 +240,37 @@ where result.into() } - TextPromptAction::MoveToSuggestionAbove => self.move_cursor_up(1), - TextPromptAction::MoveToSuggestionBelow => self.move_cursor_down(1), + TextPromptAction::MoveToSuggestionAbove => { + // If the user is navigated suggested options, move the cursor through + // those suggestions. Otherwise, assume the user is looking through + // prompt history. + if self.suggested_options.is_empty() { + self.history_iterate_earlier()? + } else { + self.move_cursor_up(1) + } + } + TextPromptAction::MoveToSuggestionBelow => { + // If the user is navigated suggested options, move the cursor through + // those suggestions. Otherwise, assume the user is looking through + // prompt history. + if self.suggested_options.is_empty() { + self.history_iterate_later()? + } else { + self.move_cursor_down(1) + } + } TextPromptAction::MoveToSuggestionPageUp => self.move_cursor_up(self.config.page_size), TextPromptAction::MoveToSuggestionPageDown => { self.move_cursor_down(self.config.page_size) } - TextPromptAction::UseCurrentSuggestion => self.use_current_suggestion()?, + TextPromptAction::UseCurrentSuggestion => { + if self.suggested_options.is_empty() { + Ok(ActionResult::Clean) + } else { + self.use_current_suggestion() + }? + } }; Ok(result)