Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added 'History' support, along with default SimpleHistory implementation and tests #229

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## [Unreleased] <!-- ReleaseDate -->

- 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

Expand Down
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
173 changes: 173 additions & 0 deletions inquire/src/history.rs
Original file line number Diff line number Diff line change
@@ -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<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.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");
}
}
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::*;
4 changes: 3 additions & 1 deletion inquire/src/prompts/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down
20 changes: 20 additions & 0 deletions inquire/src/prompts/text/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::{
terminal::get_default_terminal,
ui::{Backend, RenderConfig, TextBackend},
validator::StringValidator,
History,
};

use self::prompt::TextPrompt;
Expand Down Expand Up @@ -100,6 +101,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 Down Expand Up @@ -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);
Expand Down Expand Up @@ -188,6 +199,15 @@ impl<'a> Text<'a> {
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 {
self.formatter = formatter;
Expand Down
66 changes: 61 additions & 5 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,6 +26,7 @@ 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>,
}
Expand All @@ -47,6 +49,7 @@ 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 +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<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> {
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,
Expand Down Expand Up @@ -187,7 +214,12 @@ 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 +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)
Expand Down
Loading