Skip to content

Commit

Permalink
Added 'History' support, along with default SimpleHistory implementat…
Browse files Browse the repository at this point in the history
…ion and tests
  • Loading branch information
mikecvet committed Mar 10, 2024
1 parent 62dfcb9 commit a8dc49d
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 9 deletions.
1 change: 1 addition & 0 deletions inquire/examples/text_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fn main() {
validators: Vec::new(),
page_size: Text::DEFAULT_PAGE_SIZE,
autocompleter: None,
history: None,
render_config: RenderConfig::default(),
}
.prompt()
Expand Down
160 changes: 160 additions & 0 deletions inquire/src/history.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! 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<String>;

/// 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<Replacement, CustomUserError>;

/// Update internal state and return the nezt more-recent history entry
fn later_element(&mut self) -> Result<Replacement, CustomUserError>;

/// 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<dyn History> {
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<Replacement, CustomUserError> {
Ok(Replacement::None)
}

fn later_element(&mut self) -> Result<Replacement, CustomUserError> {
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<String>,
index: isize
}

impl SimpleHistory {
/// Reeturn a new SimpleHistory, bootstrappd with the given entries
pub fn new(initial_history: Vec<String>) -> Self {
SimpleHistory {
history: initial_history,
index: -1,
}
}
}

impl History for SimpleHistory {
fn earlier_element(&mut self) -> Result<Replacement, CustomUserError> {
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<Replacement, CustomUserError> {
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.len() > 0 {
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");
}
}
2 changes: 2 additions & 0 deletions inquire/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
25 changes: 22 additions & 3 deletions inquire/src/prompts/text/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::{
prompts::prompt::Prompt,
terminal::get_default_terminal,
ui::{Backend, RenderConfig, TextBackend},
validator::StringValidator,
validator::StringValidator, History,
};

use self::prompt::TextPrompt;
Expand Down Expand Up @@ -100,6 +100,9 @@ pub struct Text<'a> {
/// Autocompleter responsible for handling suggestions and input completions.
pub autocompleter: Option<Box<dyn Autocomplete>>,

/// History, responsible for producing previously-entered text entries
pub history: Option<Box<dyn History>>,

/// 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
Expand All @@ -119,7 +122,7 @@ pub struct Text<'a> {
/// When overriding the config in a prompt, NO_COLOR is no longer considered and your
/// config is treated as the only source of truth. If you want to customize colors
/// and still support NO_COLOR, you will have to do this on your end.
pub render_config: RenderConfig<'a>,
pub render_config: RenderConfig<'a>
}

impl<'a> Text<'a> {
Expand Down Expand Up @@ -147,10 +150,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);
Expand Down Expand Up @@ -186,7 +196,16 @@ impl<'a> Text<'a> {
{
self.autocompleter = Some(Box::new(ac));
self
}
}

/// Sets a new history
pub fn with_history<H>(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 {
Expand Down
72 changes: 66 additions & 6 deletions inquire/src/prompts/text/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -25,8 +26,9 @@ pub struct TextPrompt<'a> {
validators: Vec<Box<dyn StringValidator>>,
error: Option<ErrorMessage>,
autocompleter: Box<dyn Autocomplete>,
history: Box<dyn History>,
suggested_options: Vec<String>,
suggestion_cursor_index: Option<usize>,
suggestion_cursor_index: Option<usize>
}

impl<'a> From<Text<'a>> for TextPrompt<'a> {
Expand All @@ -47,6 +49,9 @@ impl<'a> From<Text<'a>> for TextPrompt<'a> {
autocompleter: so
.autocompleter
.unwrap_or_else(|| Box::<NoAutoCompletion>::default()),
history: so
.history
.unwrap_or_else(|| Box::<NoHistory>::default()),
input,
error: None,
suggestion_cursor_index: None,
Expand Down Expand Up @@ -79,6 +84,33 @@ 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<ActionResult> {
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<ActionResult> {
match self.history.later_element()? {
HistoryReplacement::Some(value) => {
self.input = Input::new_with(value);
Ok(ActionResult::NeedsRedraw)
},
_ => {
// 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,
Expand Down Expand Up @@ -187,7 +219,11 @@ where

fn submit(&mut self) -> InquireResult<Option<String>> {
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
Expand All @@ -208,13 +244,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)
Expand Down

0 comments on commit a8dc49d

Please sign in to comment.