Descriptions for completion suggestions (#358)

* add description to completion suggestions

* completion menu with help

* corrected doc tests

* moved menu functions to module

* menu type with its own completer

* corrected doc tests

* function for menus to quick complete
This commit is contained in:
Fernando Herrera 2022-03-27 08:30:08 +01:00 committed by GitHub
parent bc528de132
commit 69fad67b90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 755 additions and 490 deletions

View File

@ -30,5 +30,16 @@ impl Span {
pub trait Completer: Send {
/// the action that will take the line and position and convert it to a vector of completions, which include the
/// span to replace and the contents of that replacement
fn complete(&self, line: &str, pos: usize) -> Vec<(Span, String)>;
fn complete(&self, line: &str, pos: usize) -> Vec<Suggestion>;
}
/// Suggestion returned by the Completer
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Suggestion {
/// String replacement that will be introduced to the the buffer
pub value: String,
/// Optional description for the replacement
pub description: Option<String>,
/// Replacement span in the buffer
pub span: Span,
}

View File

@ -52,13 +52,13 @@ impl CircularCompletionHandler {
match self.index {
index if index < completions.len() => {
self.index += 1;
let span = completions[index].0;
let span = completions[index].span;
let mut offset = present_buffer.insertion_point();
offset += completions[index].1.len() - (span.end - span.start);
offset += completions[index].value.len() - (span.end - span.start);
// TODO improve the support for multiline replace
present_buffer.replace(span.start..span.end, &completions[index].1);
present_buffer.replace(span.start..span.end, &completions[index].value);
present_buffer.set_insertion_point(offset);
}
_ => {

View File

@ -1,4 +1,4 @@
use crate::{Completer, Span};
use crate::{Completer, Span, Suggestion};
use std::{
collections::{BTreeMap, BTreeSet},
str::Chars,
@ -48,27 +48,27 @@ impl Completer for DefaultCompleter {
///
/// # Example
/// ```
/// use reedline::{DefaultCompleter,Completer,Span};
/// use reedline::{DefaultCompleter,Completer,Span,Suggestion};
///
/// let mut completions = DefaultCompleter::default();
/// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect());
/// assert_eq!(
/// completions.complete("bat",3),
/// vec![
/// (Span { start: 0, end: 3 }, "batcave".into()),
/// (Span { start: 0, end: 3 }, "batman".into()),
/// (Span { start: 0, end: 3 }, "batmobile".into()),
/// Suggestion {value: "batcave".into(), description: None, span: Span { start: 0, end: 3 }},
/// Suggestion {value: "batman".into(), description: None, span: Span { start: 0, end: 3 }},
/// Suggestion {value: "batmobile".into(), description: None, span: Span { start: 0, end: 3 }},
/// ]);
///
/// assert_eq!(
/// completions.complete("to the bat",10),
/// vec![
/// (Span { start: 7, end: 10 }, "batcave".into()),
/// (Span { start: 7, end: 10 }, "batman".into()),
/// (Span { start: 7, end: 10 }, "batmobile".into()),
/// Suggestion {value: "batcave".into(), description: None, span: Span { start: 7, end: 10 }},
/// Suggestion {value: "batman".into(), description: None, span: Span { start: 7, end: 10 }},
/// Suggestion {value: "batmobile".into(), description: None, span: Span { start: 7, end: 10 }},
/// ]);
/// ```
fn complete(&self, line: &str, pos: usize) -> Vec<(Span, String)> {
fn complete(&self, line: &str, pos: usize) -> Vec<Suggestion> {
let mut span_line_whitespaces = 0;
let mut completions = vec![];
if !line.is_empty() {
@ -91,16 +91,19 @@ impl Completer for DefaultCompleter {
extensions
.iter()
.map(|ext| {
(
Span::new(
pos - span_line.len() - span_line_whitespaces,
pos,
),
format!("{}{}", span_line, ext),
)
let span = Span::new(
pos - span_line.len() - span_line_whitespaces,
pos,
);
Suggestion {
value: format!("{}{}", span_line, ext),
description: None,
span,
}
})
.filter(|t| t.1.len() > (t.0.end - t.0.start))
.collect::<Vec<(Span, String)>>(),
.filter(|t| t.value.len() > (t.span.end - t.span.start))
.collect::<Vec<Suggestion>>(),
);
}
}
@ -162,21 +165,21 @@ impl DefaultCompleter {
///
/// # Example
/// ```
/// use reedline::{DefaultCompleter,Completer,Span};
/// use reedline::{DefaultCompleter,Completer,Span,Suggestion};
///
/// let mut completions = DefaultCompleter::default();
/// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect());
/// assert_eq!(
/// completions.complete("te",2),
/// vec![(Span { start: 0, end: 2 }, "test".into())]);
/// vec![Suggestion {value: "test".into(), description: None, span: Span { start: 0, end: 2 }}]);
///
/// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']);
/// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect());
/// assert_eq!(
/// completions.complete("te",2),
/// vec![
/// (Span { start: 0, end: 2 }, "test-hyphen".into()),
/// (Span { start: 0, end: 2 }, "test_underscore".into()),
/// Suggestion {value: "test-hyphen".into(), description: None, span: Span { start: 0, end: 2 }},
/// Suggestion {value: "test_underscore".into(), description: None, span: Span { start: 0, end: 2 }},
/// ]);
/// ```
pub fn with_inclusions(incl: &[char]) -> Self {

View File

@ -2,6 +2,6 @@ mod base;
mod circular;
mod default;
pub use base::{Completer, Span};
pub use base::{Completer, Span, Suggestion};
pub use circular::CircularCompletionHandler;
pub use default::DefaultCompleter;

View File

@ -1,5 +1,3 @@
use crate::{menu::IndexDirection, utils::text_manipulation};
use {
crate::{
completion::{CircularCompletionHandler, Completer, DefaultCompleter},
@ -9,9 +7,13 @@ use {
highlighter::SimpleMatchHighlighter,
hinter::{DefaultHinter, Hinter},
history::{FileBackedHistory, History, HistoryNavigationQuery},
menu::{parse_selection_char, Menu, MenuEvent},
menu::{
menu_functions::{parse_selection_char, IndexDirection},
Menu, MenuEvent, MenuType,
},
painting::{Painter, PromptLines},
prompt::{PromptEditMode, PromptHistorySearchStatus},
utils::text_manipulation,
DefaultValidator, EditCommand, ExampleHighlighter, Highlighter, Prompt,
PromptHistorySearch, Signal, ValidationResult, Validator,
},
@ -112,7 +114,7 @@ pub struct Reedline {
use_ansi_coloring: bool,
// Engine Menus
menus: Vec<Box<dyn Menu>>,
menus: Vec<MenuType>,
}
impl Drop for Reedline {
@ -311,8 +313,13 @@ impl Reedline {
/// A builder that appends a menu to the engine
#[must_use]
pub fn with_menu(mut self, menu: Box<dyn Menu>) -> Self {
self.menus.push(menu);
pub fn with_menu(mut self, menu: Box<dyn Menu>, completer: Option<Box<dyn Completer>>) -> Self {
if let Some(completer) = completer {
self.menus.push(MenuType::WithCompleter { menu, completer })
} else {
self.menus.push(MenuType::EngineCompleter(menu))
}
self
}
@ -573,7 +580,7 @@ impl Reedline {
if let Some(menu) = self.menus.iter_mut().find(|menu| menu.name() == name) {
menu.menu_event(MenuEvent::Activate(self.quick_completions));
if self.quick_completions {
if self.quick_completions && menu.can_quick_complete() {
menu.update_values(
self.editor.line_buffer(),
self.history.as_ref(),
@ -759,7 +766,7 @@ impl Reedline {
ReedlineEvent::Edit(commands) => {
self.run_edit_commands(&commands);
if let Some(menu) = self.menus.iter_mut().find(|men| men.is_active()) {
if self.quick_completions {
if self.quick_completions && menu.can_quick_complete() {
menu.menu_event(MenuEvent::Edit(self.quick_completions));
menu.update_values(
self.editor.line_buffer(),
@ -860,8 +867,8 @@ impl Reedline {
}
}
fn active_menu(&mut self) -> Option<&mut Box<dyn Menu>> {
self.menus.iter_mut().find(|men| men.is_active())
fn active_menu(&mut self) -> Option<&mut MenuType> {
self.menus.iter_mut().find(|menu| menu.is_active())
}
fn previous_history(&mut self) {
@ -1176,11 +1183,7 @@ impl Reedline {
}
}
let menu = self
.menus
.iter()
.find(|menu| menu.is_active())
.map(|menu| menu.as_ref());
let menu = self.menus.iter().find(|menu| menu.is_active());
self.painter
.repaint_buffer(prompt, &lines, menu, self.use_ansi_coloring)

View File

@ -121,7 +121,7 @@
//! // Use the interactive menu to select options from the completer
//! let completion_menu = Box::new(CompletionMenu::default());
//!
//! let mut line_editor = Reedline::create()?.with_completer(completer).with_menu(completion_menu);
//! let mut line_editor = Reedline::create()?.with_completer(completer).with_menu(completion_menu, None);
//! # Ok::<(), io::Error>(())
//! ```
//!
@ -222,7 +222,7 @@ mod highlighter;
pub use highlighter::{ExampleHighlighter, Highlighter, SimpleMatchHighlighter};
mod completion;
pub use completion::{Completer, DefaultCompleter, Span};
pub use completion::{Completer, DefaultCompleter, Span, Suggestion};
mod hinter;
pub use hinter::{DefaultHinter, Hinter};
@ -231,7 +231,7 @@ mod validator;
pub use validator::{DefaultValidator, ValidationResult, Validator};
mod menu;
pub use menu::{CompletionMenu, HistoryMenu, Menu, MenuEvent};
pub use menu::{menu_functions, CompletionMenu, HistoryMenu, Menu, MenuEvent, MenuTextStyle};
mod utils;
pub use utils::{

View File

@ -80,8 +80,8 @@ fn main() -> Result<()> {
let completion_menu = Box::new(CompletionMenu::default());
let history_menu = Box::new(HistoryMenu::default());
line_editor = line_editor
.with_menu(completion_menu)
.with_menu(history_menu);
.with_menu(completion_menu, None)
.with_menu(history_menu, None);
let edit_mode: Box<dyn EditMode> = if vi_mode {
let mut normal_keybindings = default_vi_normal_keybindings();

View File

@ -1,5 +1,5 @@
use super::{find_common_string, Menu, MenuEvent, MenuTextStyle};
use crate::{painting::Painter, Completer, History, LineBuffer, Span};
use super::{menu_functions::find_common_string, Menu, MenuEvent, MenuTextStyle};
use crate::{painting::Painter, Completer, History, LineBuffer, Suggestion};
use nu_ansi_term::{ansi::RESET, Style};
/// Default values used as reference for the menu. These values are set during
@ -48,7 +48,7 @@ pub struct CompletionMenu {
/// Working column details keep changing based on the collected values
working_details: ColumnDetails,
/// Menu cached values
values: Vec<(Span, String)>,
values: Vec<Suggestion>,
/// column position of the cursor. Starts from 0
col_pos: u16,
/// row position in the menu. Starts from 0
@ -91,6 +91,13 @@ impl CompletionMenu {
self
}
/// Menu builder with new value for text style
#[must_use]
pub fn with_description_text_style(mut self, description_text_style: Style) -> Self {
self.color.description_style = description_text_style;
self
}
/// Menu builder with new columns value
#[must_use]
pub fn with_columns(mut self, columns: u16) -> Self {
@ -226,7 +233,7 @@ impl CompletionMenu {
}
/// Get selected value from the menu
fn get_value(&self) -> Option<(Span, String)> {
fn get_value(&self) -> Option<Suggestion> {
self.get_values().get(self.index()).cloned()
}
@ -286,50 +293,73 @@ impl CompletionMenu {
}
}
/// Text style for menu
fn text_style(&self, index: usize) -> String {
if index == self.index() {
self.color.selected_text_style.prefix().to_string()
} else {
self.color.text_style.prefix().to_string()
}
}
/// Creates default string that represents one line from a menu
/// Creates default string that represents one suggestion from the menu
fn create_string(
&self,
line: &str,
suggestion: &Suggestion,
index: usize,
column: u16,
empty_space: usize,
use_ansi_coloring: bool,
) -> String {
if use_ansi_coloring {
let description = if let Some(description) = &suggestion.description {
format!(
"{}{}{}{:empty$}{}",
self.text_style(index),
&line,
RESET,
"{:empty$}{}",
"",
self.end_of_line(column),
empty = empty_space
description,
empty = self.default_details.col_padding
)
} else {
"".to_string()
};
if use_ansi_coloring {
if index == self.index() {
format!(
"{}{}{:>empty$}{}{}",
self.color.selected_text_style.prefix(),
&suggestion.value,
description,
RESET,
self.end_of_line(column),
empty = empty_space,
)
} else {
format!(
"{}{}{}{}{:>empty$}{}{}",
self.color.text_style.prefix(),
&suggestion.value,
RESET,
self.color.description_style.prefix(),
description,
RESET,
self.end_of_line(column),
empty = empty_space,
)
}
} else {
// If no ansi coloring is found, then the selection word is
// the line in uppercase
let line_str = if index == self.index() {
format!(">{}", line.to_uppercase())
let (marker, empty_space) = if index == self.index() {
(">", empty_space.saturating_sub(1))
} else {
line.to_string()
("", empty_space)
};
// Final string with formatting
format!(
"{:width$}{}",
line_str,
let line = format!(
"{}{}{:>empty$}{}",
marker,
&suggestion.value,
description,
self.end_of_line(column),
width = self.get_width()
)
empty = empty_space,
);
if index == self.index() {
line.to_uppercase()
} else {
line
}
}
}
}
@ -350,6 +380,11 @@ impl Menu for CompletionMenu {
self.active
}
/// The completion menu can to quick complete if there is only one element
fn can_quick_complete(&self) -> bool {
true
}
/// The completion menu can try to find the common string and replace it
/// in the given line buffer
fn can_partially_complete(
@ -366,7 +401,7 @@ impl Menu for CompletionMenu {
}
let values = self.get_values();
if let (Some((span, value)), Some(index)) = find_common_string(values) {
if let (Some(Suggestion { value, span, .. }), Some(index)) = find_common_string(values) {
let index = index.min(value.len());
let matching = &value[0..index];
@ -432,6 +467,56 @@ impl Menu for CompletionMenu {
painter: &Painter,
) {
if let Some(event) = self.event.take() {
// The working value for the menu are updated first before executing any of the
// menu events
//
// If there is at least one suggestion that contains a description, then the layout
// is changed to one column to fit the description
let exist_description = self
.get_values()
.iter()
.any(|suggestion| suggestion.description.is_some());
if exist_description {
self.working_details.columns = 1;
self.working_details.col_width = painter.screen_width() as usize;
} else {
let max_width = self.get_values().iter().fold(0, |acc, suggestion| {
let str_len = suggestion.value.len() + self.default_details.col_padding;
if str_len > acc {
str_len
} else {
acc
}
});
// If no default width is found, then the total screen width is used to estimate
// the column width based on the default number of columns
let default_width = if let Some(col_width) = self.default_details.col_width {
col_width
} else {
let col_width = painter.screen_width() / self.default_details.columns;
col_width as usize
};
// Adjusting the working width of the column based the max line width found
// in the menu values
if max_width > default_width {
self.working_details.col_width = max_width;
} else {
self.working_details.col_width = default_width;
};
// The working columns is adjusted based on possible number of columns
// that could be fitted in the screen with the calculated column width
let possible_cols = painter.screen_width() / self.working_details.col_width as u16;
if possible_cols > self.default_details.columns {
self.working_details.columns = self.default_details.columns.max(1);
} else {
self.working_details.columns = possible_cols;
}
}
match event {
MenuEvent::Activate(updated) => {
self.active = true;
@ -459,47 +544,12 @@ impl Menu for CompletionMenu {
// The completion menu doest have the concept of pages, yet
}
}
let max_width = self.get_values().iter().fold(0, |acc, (_, string)| {
let str_len = string.len() + self.default_details.col_padding;
if str_len > acc {
str_len
} else {
acc
}
});
// If no default width is found, then the total screen width is used to estimate
// the column width based on the default number of columns
let default_width = if let Some(col_width) = self.default_details.col_width {
col_width
} else {
let col_width = painter.screen_width() / self.default_details.columns;
col_width as usize
};
// Adjusting the working width of the column based the max line width found
// in the menu values
if max_width > default_width {
self.working_details.col_width = max_width;
} else {
self.working_details.col_width = default_width;
};
// The working columns is adjusted based on possible number of columns
// that could be fitted in the screen with the calculated column width
let possible_cols = painter.screen_width() / self.working_details.col_width as u16;
if possible_cols > self.default_details.columns {
self.working_details.columns = self.default_details.columns.max(1);
} else {
self.working_details.columns = possible_cols;
}
}
}
/// The buffer gets replaced in the Span location
fn replace_in_buffer(&self, line_buffer: &mut LineBuffer) {
if let Some((span, value)) = self.get_value() {
if let Some(Suggestion { value, span, .. }) = self.get_value() {
let mut offset = line_buffer.insertion_point();
offset += value.len() - (span.end - span.start);
@ -514,7 +564,7 @@ impl Menu for CompletionMenu {
}
/// Gets values from filler that will be displayed in the menu
fn get_values(&self) -> &[(Span, String)] {
fn get_values(&self) -> &[Suggestion] {
&self.values
}
@ -544,13 +594,13 @@ impl Menu for CompletionMenu {
.skip(skip_values)
.take(available_values)
.enumerate()
.map(|(index, (_, line))| {
.map(|(index, suggestion)| {
// Correcting the enumerate index based on the number of skipped values
let index = index + skip_values;
let column = index as u16 % self.get_cols();
let empty_space = self.get_width().saturating_sub(line.len());
let empty_space = self.get_width().saturating_sub(suggestion.value.len());
self.create_string(line, index, column, empty_space, use_ansi_coloring)
self.create_string(suggestion, index, column, empty_space, use_ansi_coloring)
})
.collect()
}

View File

@ -1,7 +1,10 @@
use super::{parse_selection_char, Menu, MenuEvent, MenuTextStyle};
use super::{
menu_functions::{parse_selection_char, string_difference},
Menu, MenuEvent, MenuTextStyle,
};
use crate::{
painting::{estimate_single_line_wraps, Painter},
Completer, History, LineBuffer, Span,
Completer, History, LineBuffer, Span, Suggestion,
};
use nu_ansi_term::{ansi::RESET, Style};
use std::iter::Sum;
@ -48,7 +51,7 @@ pub struct HistoryMenu {
/// page_size records.
/// When performing a query to the history object, the cached values will
/// be the result from such query
values: Vec<(Span, String)>,
values: Vec<Suggestion>,
/// row position in the menu. Starts from 0
row_position: u16,
/// Max size of the history when querying without a search buffer
@ -187,7 +190,7 @@ impl HistoryMenu {
}
/// Get selected value from the menu
fn get_value(&self) -> Option<(Span, String)> {
fn get_value(&self) -> Option<Suggestion> {
self.get_values().get(self.index()).cloned()
}
@ -207,11 +210,11 @@ impl HistoryMenu {
.iter()
.fold(
(0, Some(0)),
|(lines, total_lines), (_, entry)| match total_lines {
|(lines, total_lines), suggestion| match total_lines {
None => (lines, None),
Some(total_lines) => {
let new_total_lines =
total_lines + self.number_of_lines(entry, painter.screen_width());
let new_total_lines = total_lines
+ self.number_of_lines(&suggestion.value, painter.screen_width());
if new_total_lines < available_lines {
(lines + 1, Some(new_total_lines))
@ -332,6 +335,11 @@ impl Menu for HistoryMenu {
self.active
}
/// There is no use for quick complete for the history menu
fn can_quick_complete(&self) -> bool {
false
}
/// The history menu should not try to auto complete to avoid comparing
/// all registered values
fn can_partially_complete(
@ -389,20 +397,23 @@ impl Menu for HistoryMenu {
self.values = values
.into_iter()
.map(|s| {
(
Span {
start,
end: start + input.len(),
},
s,
)
.map(|value| {
let span = Span {
start,
end: start + input.len(),
};
Suggestion {
value,
description: None,
span,
}
})
.collect();
}
/// Gets values from cached values that will be displayed in the menu
fn get_values(&self) -> &[(Span, String)] {
fn get_values(&self) -> &[Suggestion] {
if self.history_size.is_some() {
// When there is a history size value it means that only a chunk of the
// chronological data from the database was collected
@ -430,7 +441,7 @@ impl Menu for HistoryMenu {
/// The buffer gets cleared with the actual value
fn replace_in_buffer(&self, line_buffer: &mut LineBuffer) {
if let Some((span, value)) = self.get_value() {
if let Some(Suggestion { value, span, .. }) = self.get_value() {
line_buffer.replace(span.start..span.end, &value);
let mut offset = line_buffer.insertion_point();
@ -546,8 +557,8 @@ impl Menu for HistoryMenu {
/// Calculates the real required lines for the menu considering how many lines
/// wrap the terminal and if an entry is larger than the remaining lines
fn menu_required_lines(&self, terminal_columns: u16) -> u16 {
self.get_values().iter().fold(0, |acc, (_, entry)| {
acc + self.number_of_lines(entry, terminal_columns)
self.get_values().iter().fold(0, |acc, suggestion| {
acc + self.number_of_lines(&suggestion.value, terminal_columns)
}) + 1
}
@ -561,8 +572,9 @@ impl Menu for HistoryMenu {
.iter()
.take(page.size)
.enumerate()
.map(|(index, (_, line))| {
.map(|(index, suggestion)| {
// Final string with colors
let line = &suggestion.value;
let line = if line.lines().count() > self.max_lines as usize {
let lines = line
.lines()
@ -620,143 +632,10 @@ fn number_of_lines(entry: &str, max_lines: usize, terminal_columns: u16) -> u16
lines
}
fn string_difference<'a>(new_string: &'a str, old_string: &str) -> (usize, &'a str) {
if old_string.is_empty() {
return (0, new_string);
}
let old_chars = old_string.chars().collect::<Vec<char>>();
let (_, start, end) = new_string.chars().enumerate().fold(
(0, None, None),
|(old_index, start, end), (index, c)| {
let equal = if start.is_some() {
if (old_string.len() - old_index) == (new_string.len() - index) {
let new_iter = new_string.chars().skip(index);
let old_iter = old_string.chars().skip(old_index);
new_iter.zip(old_iter).all(|(new, old)| new == old)
} else {
false
}
} else {
c == old_chars[old_index]
};
if equal {
let old_index = (old_index + 1).min(old_string.len() - 1);
let end = match (start, end) {
(Some(_), Some(_)) => end,
(Some(_), None) => Some(index),
_ => None,
};
(old_index, start, end)
} else {
let start = match start {
Some(_) => start,
None => Some(index),
};
(old_index, start, end)
}
},
);
match (start, end) {
(Some(start), Some(end)) => (start, &new_string[start..end]),
(Some(start), None) => (start, &new_string[start..new_string.len()]),
(None, None) => (new_string.len(), ""),
(None, Some(_)) => unreachable!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn string_difference_test() {
let new_string = "this is a new string";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new "));
}
#[test]
fn string_difference_new_larger() {
let new_string = "this is a new string";
let old_string = "this is";
let res = string_difference(new_string, old_string);
assert_eq!(res, (7, " a new string"));
}
#[test]
fn string_difference_new_shorter() {
let new_string = "this is the";
let old_string = "this is the original";
let res = string_difference(new_string, old_string);
assert_eq!(res, (11, ""));
}
#[test]
fn string_difference_longer_string() {
let new_string = "this is a new another";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new another"));
}
#[test]
fn string_difference_start_same() {
let new_string = "this is a new something string";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new something "));
}
#[test]
fn string_difference_empty_old() {
let new_string = "this new another";
let old_string = "";
let res = string_difference(new_string, old_string);
assert_eq!(res, (0, "this new another"));
}
#[test]
fn string_difference_very_difference() {
let new_string = "this new another";
let old_string = "complete different string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (0, "this new another"));
}
#[test]
fn string_difference_both_equal() {
let new_string = "this new another";
let old_string = "this new another";
let res = string_difference(new_string, old_string);
assert_eq!(res, (16, ""));
}
#[test]
fn string_difference_with_non_ansi() {
let new_string = "let b = ñ ";
let old_string = "let a =";
let res = string_difference(new_string, old_string);
assert_eq!(res, (4, "b = ñ "));
}
#[test]
fn number_of_lines_test() {
let input = "let a: another:\nsomething\nanother";

406
src/menu/menu_functions.rs Normal file
View File

@ -0,0 +1,406 @@
//! Collection of common functions that can be used to create menus
use crate::Suggestion;
/// Index result obtained from parsing a string with an index marker
/// For example, the next string:
/// "this is an example :10"
///
/// Contains an index marker :10. This marker indicates that the user
/// may want to select the 10th element from a list
#[derive(Debug, PartialEq)]
pub struct ParseResult<'buffer> {
/// Text before the marker
pub remainder: &'buffer str,
/// Parsed value from the marker
pub index: Option<usize>,
/// Marker representation as string
pub marker: Option<&'buffer str>,
/// Direction of the search based on the marker
pub direction: IndexDirection,
}
/// Direction of the index found in the string
#[derive(Debug, PartialEq)]
pub enum IndexDirection {
/// Forward index
Forward,
/// Backward index
Backward,
}
/// Splits a string that contains a marker character
///
/// ## Example usage
/// ```
/// use reedline::menu_functions::{parse_selection_char, IndexDirection, ParseResult};
///
/// let parsed = parse_selection_char("this is an example!10", '!');
///
/// assert_eq!(
/// parsed,
/// ParseResult {
/// remainder: "this is an example",
/// index: Some(10),
/// marker: Some("!10"),
/// direction: IndexDirection::Forward
/// }
/// )
///
/// ```
pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult {
if buffer.is_empty() {
return ParseResult {
remainder: buffer,
index: None,
marker: None,
direction: IndexDirection::Forward,
};
}
let mut input = buffer.chars().peekable();
let mut index = 0;
let mut direction = IndexDirection::Forward;
while let Some(char) = input.next() {
if char == marker {
match input.peek() {
Some(&x) if x == marker => {
return ParseResult {
remainder: &buffer[0..index],
index: Some(0),
marker: Some(&buffer[index..index + 2]),
direction: IndexDirection::Backward,
}
}
Some(&x) if x.is_ascii_digit() || x == '-' => {
let mut count: usize = 0;
let mut size: usize = 1;
while let Some(&c) = input.peek() {
if c == '-' {
let _ = input.next();
size += 1;
direction = IndexDirection::Backward;
} else if c.is_ascii_digit() {
let c = c.to_digit(10).expect("already checked if is a digit");
let _ = input.next();
count *= 10;
count += c as usize;
size += 1;
} else {
return ParseResult {
remainder: &buffer[0..index],
index: Some(count),
marker: Some(&buffer[index..index + size]),
direction,
};
}
}
return ParseResult {
remainder: &buffer[0..index],
index: Some(count),
marker: Some(&buffer[index..index + size]),
direction,
};
}
None => {
return ParseResult {
remainder: &buffer[0..index],
index: Some(0),
marker: Some(&buffer[index..buffer.len()]),
direction,
}
}
_ => {
index += 1;
continue;
}
}
}
index += 1;
}
ParseResult {
remainder: buffer,
index: None,
marker: None,
direction,
}
}
/// Finds index for the common string in a list of suggestions
pub fn find_common_string(values: &[Suggestion]) -> (Option<&Suggestion>, Option<usize>) {
let first = values.iter().next();
let index = first.and_then(|first| {
values.iter().skip(1).fold(None, |index, suggestion| {
if suggestion.value.starts_with(&first.value) {
Some(first.value.len())
} else {
first
.value
.chars()
.zip(suggestion.value.chars())
.position(|(mut lhs, mut rhs)| {
lhs.make_ascii_lowercase();
rhs.make_ascii_lowercase();
lhs != rhs
})
.map(|new_index| match index {
Some(index) => {
if index <= new_index {
index
} else {
new_index
}
}
None => new_index,
})
}
})
});
(first, index)
}
/// Finds different string between two strings
///
/// ## Example usage
/// ```
/// use reedline::menu_functions::string_difference;
///
/// let new_string = "this is a new string";
/// let old_string = "this is a string";
///
/// let res = string_difference(new_string, old_string);
/// assert_eq!(res, (10, "new "));
/// ```
pub fn string_difference<'a>(new_string: &'a str, old_string: &str) -> (usize, &'a str) {
if old_string.is_empty() {
return (0, new_string);
}
let old_chars = old_string.chars().collect::<Vec<char>>();
let (_, start, end) = new_string.chars().enumerate().fold(
(0, None, None),
|(old_index, start, end), (index, c)| {
let equal = if start.is_some() {
if (old_string.len() - old_index) == (new_string.len() - index) {
let new_iter = new_string.chars().skip(index);
let old_iter = old_string.chars().skip(old_index);
new_iter.zip(old_iter).all(|(new, old)| new == old)
} else {
false
}
} else {
c == old_chars[old_index]
};
if equal {
let old_index = (old_index + 1).min(old_string.len() - 1);
let end = match (start, end) {
(Some(_), Some(_)) => end,
(Some(_), None) => Some(index),
_ => None,
};
(old_index, start, end)
} else {
let start = match start {
Some(_) => start,
None => Some(index),
};
(old_index, start, end)
}
},
);
match (start, end) {
(Some(start), Some(end)) => (start, &new_string[start..end]),
(Some(start), None) => (start, &new_string[start..new_string.len()]),
(None, None) => (new_string.len(), ""),
(None, Some(_)) => unreachable!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_row_test() {
let input = "search:6";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(6));
assert_eq!(res.marker, Some(":6"));
}
#[test]
fn parse_double_char() {
let input = "search!!";
let res = parse_selection_char(input, '!');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(0));
assert_eq!(res.marker, Some("!!"));
assert!(matches!(res.direction, IndexDirection::Backward));
}
#[test]
fn parse_row_other_marker_test() {
let input = "search?9";
let res = parse_selection_char(input, '?');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(9));
assert_eq!(res.marker, Some("?9"));
}
#[test]
fn parse_row_double_test() {
let input = "ls | where:16";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "ls | where");
assert_eq!(res.index, Some(16));
assert_eq!(res.marker, Some(":16"));
}
#[test]
fn parse_row_empty_test() {
let input = ":10";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "");
assert_eq!(res.index, Some(10));
assert_eq!(res.marker, Some(":10"));
}
#[test]
fn parse_row_fake_indicator_test() {
let input = "let a: another :10";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "let a: another ");
assert_eq!(res.index, Some(10));
assert_eq!(res.marker, Some(":10"));
}
#[test]
fn parse_row_no_number_test() {
let input = "let a: another:";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "let a: another");
assert_eq!(res.index, Some(0));
assert_eq!(res.marker, Some(":"));
}
#[test]
fn parse_empty_buffer_test() {
let input = "";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "");
assert_eq!(res.index, None);
assert_eq!(res.marker, None);
}
#[test]
fn parse_negative_direction() {
let input = "!-2";
let res = parse_selection_char(input, '!');
assert_eq!(res.remainder, "");
assert_eq!(res.index, Some(2));
assert_eq!(res.marker, Some("!-2"));
assert!(matches!(res.direction, IndexDirection::Backward));
}
#[test]
fn string_difference_test() {
let new_string = "this is a new string";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new "));
}
#[test]
fn string_difference_new_larger() {
let new_string = "this is a new string";
let old_string = "this is";
let res = string_difference(new_string, old_string);
assert_eq!(res, (7, " a new string"));
}
#[test]
fn string_difference_new_shorter() {
let new_string = "this is the";
let old_string = "this is the original";
let res = string_difference(new_string, old_string);
assert_eq!(res, (11, ""));
}
#[test]
fn string_difference_longer_string() {
let new_string = "this is a new another";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new another"));
}
#[test]
fn string_difference_start_same() {
let new_string = "this is a new something string";
let old_string = "this is a string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (10, "new something "));
}
#[test]
fn string_difference_empty_old() {
let new_string = "this new another";
let old_string = "";
let res = string_difference(new_string, old_string);
assert_eq!(res, (0, "this new another"));
}
#[test]
fn string_difference_very_difference() {
let new_string = "this new another";
let old_string = "complete different string";
let res = string_difference(new_string, old_string);
assert_eq!(res, (0, "this new another"));
}
#[test]
fn string_difference_both_equal() {
let new_string = "this new another";
let old_string = "this new another";
let res = string_difference(new_string, old_string);
assert_eq!(res, (16, ""));
}
#[test]
fn string_difference_with_non_ansi() {
let new_string = "let b = ñ ";
let old_string = "let a =";
let res = string_difference(new_string, old_string);
assert_eq!(res, (4, "b = ñ "));
}
}

View File

@ -1,15 +1,20 @@
mod completion_menu;
mod history_menu;
pub mod menu_functions;
use crate::{painting::Painter, Completer, History, LineBuffer, Span};
use crate::{painting::Painter, Completer, History, LineBuffer, Suggestion};
pub use completion_menu::CompletionMenu;
pub use history_menu::HistoryMenu;
use nu_ansi_term::{Color, Style};
/// Struct to store the menu style
struct MenuTextStyle {
selected_text_style: Style,
text_style: Style,
pub struct MenuTextStyle {
/// Text style for selected text in a menu
pub selected_text_style: Style,
/// Text style for not selected text in the menu
pub text_style: Style,
/// Text style for the item description
pub description_style: Style,
}
impl Default for MenuTextStyle {
@ -17,6 +22,7 @@ impl Default for MenuTextStyle {
Self {
selected_text_style: Color::Green.bold().reverse(),
text_style: Color::DarkGray.normal(),
description_style: Color::Yellow.normal(),
}
}
}
@ -56,9 +62,7 @@ pub trait Menu: Send {
fn name(&self) -> &str;
/// Menu indicator
fn indicator(&self) -> &str {
"% "
}
fn indicator(&self) -> &str;
/// Checks if the menu is active
fn is_active(&self) -> bool;
@ -66,6 +70,10 @@ pub trait Menu: Send {
/// Selects what type of event happened with the menu
fn menu_event(&mut self, event: MenuEvent);
/// A menu may not be allowed to quick complete because it needs to stay
/// active even with one element
fn can_quick_complete(&self) -> bool;
/// The completion menu can try to find the common string and replace it
/// in the given line buffer
fn can_partially_complete(
@ -115,234 +123,131 @@ pub trait Menu: Send {
fn min_rows(&self) -> u16;
/// Gets cached values from menu that will be displayed
fn get_values(&self) -> &[(Span, String)];
fn get_values(&self) -> &[Suggestion];
}
pub(crate) enum IndexDirection {
Forward,
Backward,
/// Type of menu that can be used with Reedline engine
pub(crate) enum MenuType {
/// Menu that uses the engine completer to update its values
EngineCompleter(Box<dyn Menu>),
/// Menu that has its own Completer
WithCompleter {
menu: Box<dyn Menu>,
completer: Box<dyn Completer>,
},
}
pub(crate) struct ParseResult<'buffer> {
pub remainder: &'buffer str,
pub index: Option<usize>,
pub marker: Option<&'buffer str>,
pub direction: IndexDirection,
}
/// Splits a string that contains a marker character
/// e.g: this is an example!10
/// returns:
/// this is an example
/// (10, "!10") (index and index as string)
pub(crate) fn parse_selection_char(buffer: &str, marker: char) -> ParseResult {
if buffer.is_empty() {
return ParseResult {
remainder: buffer,
index: None,
marker: None,
direction: IndexDirection::Forward,
};
}
let mut input = buffer.chars().peekable();
let mut index = 0;
let mut direction = IndexDirection::Forward;
while let Some(char) = input.next() {
if char == marker {
match input.peek() {
Some(&x) if x == marker => {
return ParseResult {
remainder: &buffer[0..index],
index: Some(0),
marker: Some(&buffer[index..index + 2]),
direction: IndexDirection::Backward,
}
}
Some(&x) if x.is_ascii_digit() || x == '-' => {
let mut count: usize = 0;
let mut size: usize = 1;
while let Some(&c) = input.peek() {
if c == '-' {
let _ = input.next();
size += 1;
direction = IndexDirection::Backward;
} else if c.is_ascii_digit() {
let c = c.to_digit(10).expect("already checked if is a digit");
let _ = input.next();
count *= 10;
count += c as usize;
size += 1;
} else {
return ParseResult {
remainder: &buffer[0..index],
index: Some(count),
marker: Some(&buffer[index..index + size]),
direction,
};
}
}
return ParseResult {
remainder: &buffer[0..index],
index: Some(count),
marker: Some(&buffer[index..index + size]),
direction,
};
}
None => {
return ParseResult {
remainder: &buffer[0..index],
index: Some(0),
marker: Some(&buffer[index..buffer.len()]),
direction,
}
}
_ => {
index += 1;
continue;
}
}
impl MenuType {
fn as_ref(&self) -> &dyn Menu {
match self {
Self::EngineCompleter(menu) => menu.as_ref(),
Self::WithCompleter { menu, .. } => menu.as_ref(),
}
index += 1;
}
ParseResult {
remainder: buffer,
index: None,
marker: None,
direction,
fn as_mut(&mut self) -> &mut dyn Menu {
match self {
Self::EngineCompleter(menu) => menu.as_mut(),
Self::WithCompleter { menu, .. } => menu.as_mut(),
}
}
}
/// Finds common string in a list of values
fn find_common_string(values: &[(Span, String)]) -> (Option<&(Span, String)>, Option<usize>) {
let first = values.iter().next();
impl Menu for MenuType {
fn name(&self) -> &str {
self.as_ref().name()
}
let index = first.and_then(|(_, first_string)| {
values.iter().skip(1).fold(None, |index, (_, value)| {
if value.starts_with(first_string) {
Some(first_string.len())
} else {
first_string
.chars()
.zip(value.chars())
.position(|(mut lhs, mut rhs)| {
lhs.make_ascii_lowercase();
rhs.make_ascii_lowercase();
fn indicator(&self) -> &str {
self.as_ref().indicator()
}
lhs != rhs
})
.map(|new_index| match index {
Some(index) => {
if index <= new_index {
index
} else {
new_index
}
}
None => new_index,
})
fn is_active(&self) -> bool {
self.as_ref().is_active()
}
fn menu_event(&mut self, event: MenuEvent) {
self.as_mut().menu_event(event)
}
fn can_quick_complete(&self) -> bool {
self.as_ref().can_quick_complete()
}
fn can_partially_complete(
&mut self,
values_updated: bool,
line_buffer: &mut LineBuffer,
history: &dyn History,
completer: &dyn Completer,
) -> bool {
match self {
Self::EngineCompleter(menu) => {
menu.can_partially_complete(values_updated, line_buffer, history, completer)
}
})
});
(first, index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_row_test() {
let input = "search:6";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(6));
assert_eq!(res.marker, Some(":6"));
Self::WithCompleter {
menu,
completer: own_completer,
} => menu.can_partially_complete(
values_updated,
line_buffer,
history,
own_completer.as_ref(),
),
}
}
#[test]
fn parse_double_char() {
let input = "search!!";
let res = parse_selection_char(input, '!');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(0));
assert_eq!(res.marker, Some("!!"));
assert!(matches!(res.direction, IndexDirection::Backward));
fn update_values(
&mut self,
line_buffer: &mut LineBuffer,
history: &dyn History,
completer: &dyn Completer,
) {
match self {
Self::EngineCompleter(menu) => menu.update_values(line_buffer, history, completer),
Self::WithCompleter {
menu,
completer: own_completer,
} => menu.update_values(line_buffer, history, own_completer.as_ref()),
}
}
#[test]
fn parse_row_other_marker_test() {
let input = "search?9";
let res = parse_selection_char(input, '?');
assert_eq!(res.remainder, "search");
assert_eq!(res.index, Some(9));
assert_eq!(res.marker, Some("?9"));
fn update_working_details(
&mut self,
line_buffer: &mut LineBuffer,
history: &dyn History,
completer: &dyn Completer,
painter: &Painter,
) {
match self {
Self::EngineCompleter(menu) => {
menu.update_working_details(line_buffer, history, completer, painter)
}
Self::WithCompleter {
menu,
completer: own_completer,
} => menu.update_working_details(line_buffer, history, own_completer.as_ref(), painter),
}
}
#[test]
fn parse_row_double_test() {
let input = "ls | where:16";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "ls | where");
assert_eq!(res.index, Some(16));
assert_eq!(res.marker, Some(":16"));
fn replace_in_buffer(&self, line_buffer: &mut LineBuffer) {
self.as_ref().replace_in_buffer(line_buffer)
}
#[test]
fn parse_row_empty_test() {
let input = ":10";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "");
assert_eq!(res.index, Some(10));
assert_eq!(res.marker, Some(":10"));
fn menu_required_lines(&self, terminal_columns: u16) -> u16 {
self.as_ref().menu_required_lines(terminal_columns)
}
#[test]
fn parse_row_fake_indicator_test() {
let input = "let a: another :10";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "let a: another ");
assert_eq!(res.index, Some(10));
assert_eq!(res.marker, Some(":10"));
fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String {
self.as_ref()
.menu_string(available_lines, use_ansi_coloring)
}
#[test]
fn parse_row_no_number_test() {
let input = "let a: another:";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "let a: another");
assert_eq!(res.index, Some(0));
assert_eq!(res.marker, Some(":"));
fn min_rows(&self) -> u16 {
self.as_ref().min_rows()
}
#[test]
fn parse_empty_buffer_test() {
let input = "";
let res = parse_selection_char(input, ':');
assert_eq!(res.remainder, "");
assert_eq!(res.index, None);
assert_eq!(res.marker, None);
}
#[test]
fn parse_negative_direction() {
let input = "!-2";
let res = parse_selection_char(input, '!');
assert_eq!(res.remainder, "");
assert_eq!(res.index, Some(2));
assert_eq!(res.marker, Some("!-2"));
assert!(matches!(res.direction, IndexDirection::Backward));
fn get_values(&self) -> &[Suggestion] {
self.as_ref().get_values()
}
}

View File

@ -1,7 +1,10 @@
use super::utils::{coerce_crlf, line_width};
use {
crate::{menu::Menu, painting::PromptLines, Prompt},
super::utils::{coerce_crlf, line_width},
crate::{
menu::{Menu, MenuType},
painting::PromptLines,
Prompt,
},
crossterm::{
cursor::{self, MoveTo, RestorePosition, SavePosition},
style::{Print, ResetColor, SetForegroundColor},
@ -73,7 +76,8 @@ impl Painter {
self.terminal_size.0
}
fn remaining_lines(&self) -> u16 {
/// Returns the available lines from the prompt down
pub fn remaining_lines(&self) -> u16 {
self.screen_height() - self.prompt_start_row
}
@ -124,7 +128,7 @@ impl Painter {
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
menu: Option<&dyn Menu>,
menu: Option<&MenuType>,
use_ansi_coloring: bool,
) -> Result<()> {
self.stdout.queue(cursor::Hide)?;
@ -219,7 +223,7 @@ impl Painter {
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
menu: Option<&dyn Menu>,
menu: Option<&MenuType>,
use_ansi_coloring: bool,
) -> Result<()> {
// print our prompt with color
@ -261,7 +265,7 @@ impl Painter {
&mut self,
prompt: &dyn Prompt,
lines: &PromptLines,
menu: Option<&dyn Menu>,
menu: Option<&MenuType>,
use_ansi_coloring: bool,
) -> Result<()> {
let screen_width = self.screen_width();

View File

@ -1,5 +1,9 @@
use super::utils::{coerce_crlf, estimate_required_lines, line_width};
use crate::{menu::Menu, prompt::PromptEditMode, Prompt, PromptHistorySearch};
use crate::{
menu::{Menu, MenuType},
prompt::PromptEditMode,
Prompt, PromptHistorySearch,
};
use std::borrow::Cow;
/// Aggregate of prompt and input string used by `Painter`
@ -49,7 +53,7 @@ impl<'prompt> PromptLines<'prompt> {
/// The required lines to paint the buffer are calculated by counting the
/// number of newlines in all the strings that form the prompt and buffer.
/// The plus 1 is to indicate that there should be at least one line.
pub(crate) fn required_lines(&self, terminal_columns: u16, menu: Option<&dyn Menu>) -> u16 {
pub(crate) fn required_lines(&self, terminal_columns: u16, menu: Option<&MenuType>) -> u16 {
let input = if menu.is_none() {
self.prompt_str_left.to_string()
+ &self.prompt_indicator