Context menu (#243)

* vi keybinding events

* context menu for reedline

* corrected tests

* text style for menu
This commit is contained in:
Fernando Herrera 2022-01-10 20:17:31 +00:00 committed by GitHub
parent a5b6cc079b
commit af19a7bfc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 607 additions and 237 deletions

191
src/context_menu.rs Normal file
View File

@ -0,0 +1,191 @@
use nu_ansi_term::Color;
/// Struct to store coloring for the menu
struct MenuTextColor {
selection_style: String,
text_style: String,
}
impl Default for MenuTextColor {
fn default() -> Self {
Self {
selection_style: Color::Red.bold().prefix().to_string(),
text_style: Color::DarkGray.prefix().to_string(),
}
}
}
/// Context menu definition
pub struct ContextMenu {
filler: Box<dyn MenuFiller>,
active: bool,
/// Context menu coloring
color: MenuTextColor,
/// Number of minimum rows that are displayed when
/// the required lines is larger than the available lines
min_rows: u16,
/// column position of the cursor. Starts from 0
pub col_pos: u16,
/// row position in the menu. Starts from 0
pub row_pos: u16,
/// Number of columns that the menu will have
pub cols: u16,
/// Column width
pub col_width: usize,
}
impl Default for ContextMenu {
fn default() -> Self {
let filler = Box::new(ExampleData::new());
Self::new_with(filler)
}
}
impl ContextMenu {
/// Creates a context menu with a filler
pub fn new_with(filler: Box<dyn MenuFiller>) -> Self {
Self {
filler,
active: false,
color: MenuTextColor::default(),
min_rows: 3,
col_pos: 0,
row_pos: 0,
cols: 4,
col_width: 15,
}
}
/// Activates context menu
pub fn activate(&mut self) {
self.active = true;
self.reset_position();
}
/// Deactivates context menu
pub fn deactivate(&mut self) {
self.active = false
}
/// Deactivates context menu
pub fn is_active(&mut self) -> bool {
self.active
}
/// Gets values from filler that will be displayed in the menu
pub fn get_values(&self) -> Vec<&str> {
self.filler.context_values()
}
/// Calculates how many rows the Menu will use
pub fn get_rows(&self) -> u16 {
let rows = self.get_values().len() as f64 / self.cols as f64;
rows.ceil() as u16
}
/// Minimum rows that should be displayed by the menu
pub fn min_rows(&self) -> u16 {
self.get_rows().min(self.min_rows)
}
/// Reset menu position
pub fn reset_position(&mut self) {
self.col_pos = 0;
self.row_pos = 0;
}
/// Menu index based on column and row position
pub fn position(&self) -> usize {
let position = self.row_pos * self.cols + self.col_pos;
position as usize
}
/// Move menu cursor up
pub fn move_up(&mut self) {
self.row_pos = if let Some(row) = self.row_pos.checked_sub(1) {
row
} else {
self.get_rows().saturating_sub(1)
}
}
/// Move menu cursor left
pub fn move_down(&mut self) {
let new_row = self.row_pos + 1;
self.row_pos = if new_row >= self.get_rows() {
0
} else {
new_row
}
}
/// Move menu cursor left
pub fn move_left(&mut self) {
self.col_pos = if let Some(row) = self.col_pos.checked_sub(1) {
row
} else {
self.cols.saturating_sub(1)
}
}
/// Move menu cursor right
pub fn move_right(&mut self) {
let new_col = self.col_pos + 1;
self.col_pos = if new_col >= self.cols { 0 } else { new_col }
}
/// Get selected value from filler
pub fn get_value(&self) -> Option<&str> {
self.get_values().get(self.position()).copied()
}
/// Text style for menu
pub fn text_style(&self, index: usize) -> &str {
if index == self.position() {
&self.color.selection_style
} else {
&self.color.text_style
}
}
/// End of line for menu
pub fn end_of_line(&self, column: u16) -> &str {
if column == self.cols.saturating_sub(1) {
"\r\n"
} else {
""
}
}
/// Printable width for a line
pub fn printable_width(&self, line: &str) -> usize {
let printable_width = (self.col_width - 2) as usize;
printable_width.min(line.len())
}
}
/// The MenuFiller is a trait that defines how the data for the context menu
/// will be collected.
pub trait MenuFiller: Send {
/// Collects menu values
fn context_values(&self) -> Vec<&str>;
}
/// Data example for Reedline ContextMenu
struct ExampleData {}
impl MenuFiller for ExampleData {
fn context_values(&self) -> Vec<&str> {
vec![
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
]
}
}
impl ExampleData {
/// Creates new instance of Example Menu
pub fn new() -> Self {
ExampleData {}
}
}

View File

@ -25,26 +25,30 @@ impl EditMode for Emacs {
fn parse_event(&mut self, event: Event) -> ReedlineEvent {
match event {
Event::Key(KeyEvent { code, modifiers }) => match (modifiers, code) {
(KeyModifiers::NONE, KeyCode::Char(c)) => {
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)])
(modifier, KeyCode::Char(c)) => {
// Note. The modifier can also be a combination of modifiers, for
// example:
// KeyModifiers::CONTROL | KeyModifiers::ALT
// KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
//
// Mixed modifiers are used by non american keyboards that have extra
// keys like 'alt gr'. Keep this in mind if in the future there are
// cases where an event is not being captured
if modifier == KeyModifiers::SHIFT {
let char = c.to_ascii_uppercase();
ReedlineEvent::Edit(vec![EditCommand::InsertChar(char)])
} else if modifier == KeyModifiers::NONE
|| modifier == KeyModifiers::CONTROL | KeyModifiers::ALT
|| modifier
== KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
{
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)])
} else {
self.keybindings
.find_binding(modifier, code)
.unwrap_or(ReedlineEvent::None)
}
}
// This combination of modifiers (CONTROL | ALT) is needed for non american keyboards.
// There is a special key called 'alt gr' that is captured with the combination
// of those two modifiers
(m, KeyCode::Char(c)) if m == KeyModifiers::CONTROL | KeyModifiers::ALT => {
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)])
}
(m, KeyCode::Char(c))
if m == KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT =>
{
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)])
}
(KeyModifiers::SHIFT, KeyCode::Char(c)) => {
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c.to_ascii_uppercase())])
}
(KeyModifiers::NONE, KeyCode::Enter) => ReedlineEvent::Enter,
_ => self
.keybindings
@ -92,7 +96,7 @@ mod test {
keybindings.add_binding(
KeyModifiers::CONTROL,
KeyCode::Char('l'),
ReedlineEvent::HandleTab,
ReedlineEvent::Complete,
);
let mut emacs = Emacs::new(keybindings);
@ -102,7 +106,7 @@ mod test {
});
let result = emacs.parse_event(ctrl_l);
assert_eq!(result, ReedlineEvent::HandleTab);
assert_eq!(result, ReedlineEvent::Complete);
}
#[test]

View File

@ -52,7 +52,7 @@ impl Keybindings {
}
}
fn edit_bind(command: EditCommand) -> ReedlineEvent {
pub fn edit_bind(command: EditCommand) -> ReedlineEvent {
ReedlineEvent::Edit(vec![command])
}
@ -105,7 +105,7 @@ pub fn default_emacs_keybindings() -> Keybindings {
kb.add_binding(KM::ALT, KC::Backspace, edit_bind(EC::BackspaceWord));
kb.add_binding(KM::NONE, KC::End, edit_bind(EC::MoveToLineEnd));
kb.add_binding(KM::NONE, KC::Home, edit_bind(EC::MoveToLineStart));
kb.add_binding(KM::NONE, KC::Tab, ReedlineEvent::HandleTab);
kb.add_binding(KM::NONE, KC::Tab, ReedlineEvent::Complete);
kb.add_binding(KM::NONE, KC::Up, ReedlineEvent::Up);
kb.add_binding(KM::NONE, KC::Down, ReedlineEvent::Down);
kb.add_binding(KM::NONE, KC::Left, edit_bind(EC::MoveLeft));
@ -113,28 +113,8 @@ pub fn default_emacs_keybindings() -> Keybindings {
kb.add_binding(KM::NONE, KC::Delete, edit_bind(EC::Delete));
kb.add_binding(KM::NONE, KC::Backspace, edit_bind(EC::Backspace));
kb.add_binding(KM::ALT, KC::Char('1'), ReedlineEvent::ContextMenu);
kb.add_binding(KM::NONE, KC::Esc, ReedlineEvent::Esc);
kb
}
pub fn default_vi_normal_keybindings() -> Keybindings {
Keybindings::new()
}
pub fn default_vi_insert_keybindings() -> Keybindings {
use EditCommand as EC;
use KeyCode as KC;
use KeyModifiers as KM;
let mut keybindings = Keybindings::new();
keybindings.add_binding(KM::NONE, KC::Up, ReedlineEvent::Up);
keybindings.add_binding(KM::NONE, KC::Down, ReedlineEvent::Down);
keybindings.add_binding(KM::NONE, KC::Left, edit_bind(EC::MoveLeft));
keybindings.add_binding(KM::NONE, KC::Right, edit_bind(EC::MoveRight));
keybindings.add_binding(KM::NONE, KC::Backspace, edit_bind(EC::Backspace));
keybindings.add_binding(KM::NONE, KC::Delete, edit_bind(EC::Delete));
keybindings.add_binding(KM::NONE, KC::End, edit_bind(EC::MoveToLineEnd));
keybindings.add_binding(KM::NONE, KC::Home, edit_bind(EC::MoveToLineStart));
keybindings
}

View File

@ -1,15 +1,14 @@
mod command;
mod motion;
mod parser;
mod vi_keybindings;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use vi_keybindings::{default_vi_insert_keybindings, default_vi_normal_keybindings};
use super::EditMode;
use crate::{
edit_mode::{
keybindings::{default_vi_insert_keybindings, default_vi_normal_keybindings, Keybindings},
vi::parser::parse,
},
edit_mode::{keybindings::Keybindings, vi::parser::parse},
enums::{EditCommand, ReedlineEvent},
PromptEditMode, PromptViMode,
};
@ -54,34 +53,40 @@ impl EditMode for Vi {
}
}
let char = if let KeyModifiers::SHIFT = modifier {
c.to_ascii_uppercase()
} else {
c
};
self.cache.push(char);
if modifier == KeyModifiers::NONE || modifier == KeyModifiers::SHIFT {
let char = if let KeyModifiers::SHIFT = modifier {
c.to_ascii_uppercase()
} else {
c
};
self.cache.push(char);
let res = parse(&mut self.cache.iter().peekable());
let res = parse(&mut self.cache.iter().peekable());
if res.enter_insert_mode() {
self.mode = Mode::Insert;
}
if res.enter_insert_mode() {
self.mode = Mode::Insert;
}
let event = res.to_reedline_event();
match event {
ReedlineEvent::None => {
if !res.is_valid() {
let event = res.to_reedline_event();
match event {
ReedlineEvent::None => {
if !res.is_valid() {
self.cache.clear();
}
}
_ => {
self.cache.clear();
}
}
_ => {
self.cache.clear();
}
};
};
self.previous = Some(event.clone());
self.previous = Some(event.clone());
event
event
} else {
self.normal_keybindings
.find_binding(modifiers, code)
.unwrap_or(ReedlineEvent::None)
}
}
(Mode::Insert, modifier, KeyCode::Char(c)) => {
// Note. The modifier can also be a combination of modifiers, for
@ -92,19 +97,25 @@ impl EditMode for Vi {
// Mixed modifiers are used by non american keyboards that have extra
// keys like 'alt gr'. Keep this in mind if in the future there are
// cases where an event is not being captured
let char = if let KeyModifiers::SHIFT = modifier {
c.to_ascii_uppercase()
if modifier == KeyModifiers::SHIFT {
let char = c.to_ascii_uppercase();
ReedlineEvent::Edit(vec![EditCommand::InsertChar(char)])
} else if modifier == KeyModifiers::NONE
|| modifier == KeyModifiers::CONTROL | KeyModifiers::ALT
|| modifier
== KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
{
ReedlineEvent::Edit(vec![EditCommand::InsertChar(c)])
} else {
c
};
ReedlineEvent::Edit(vec![EditCommand::InsertChar(char)])
self.insert_keybindings
.find_binding(modifier, code)
.unwrap_or(ReedlineEvent::None)
}
}
(_, KeyModifiers::NONE, KeyCode::Tab) => ReedlineEvent::HandleTab,
(_, KeyModifiers::NONE, KeyCode::Esc) => {
self.cache.clear();
self.mode = Mode::Normal;
ReedlineEvent::Repaint
ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint])
}
(_, KeyModifiers::NONE, KeyCode::Enter) => {
self.mode = Mode::Insert;

View File

@ -0,0 +1,35 @@
use crate::{
edit_mode::{keybindings::edit_bind, Keybindings},
ReedlineEvent,
};
use {
crate::EditCommand as EC,
crossterm::event::{KeyCode as KC, KeyModifiers as KM},
};
pub fn default_vi_normal_keybindings() -> Keybindings {
let mut kb = Keybindings::new();
kb.add_binding(KM::CONTROL, KC::Char('c'), ReedlineEvent::CtrlC);
kb
}
pub fn default_vi_insert_keybindings() -> Keybindings {
let mut kb = Keybindings::new();
kb.add_binding(KM::NONE, KC::Up, ReedlineEvent::Up);
kb.add_binding(KM::NONE, KC::Down, ReedlineEvent::Down);
kb.add_binding(KM::NONE, KC::Left, edit_bind(EC::MoveLeft));
kb.add_binding(KM::NONE, KC::Right, edit_bind(EC::MoveRight));
kb.add_binding(KM::NONE, KC::Backspace, edit_bind(EC::Backspace));
kb.add_binding(KM::NONE, KC::Delete, edit_bind(EC::Delete));
kb.add_binding(KM::NONE, KC::End, edit_bind(EC::MoveToLineEnd));
kb.add_binding(KM::NONE, KC::Home, edit_bind(EC::MoveToLineStart));
kb.add_binding(KM::CONTROL, KC::Char('c'), ReedlineEvent::CtrlC);
kb.add_binding(KM::NONE, KC::Tab, ReedlineEvent::Complete);
kb.add_binding(KM::ALT, KC::Char('1'), ReedlineEvent::ContextMenu);
kb
}

View File

@ -1,6 +1,7 @@
use {
crate::{
completion::{CircularCompletionHandler, CompletionActionHandler},
context_menu::ContextMenu,
core_editor::Editor,
edit_mode::{EditMode, Emacs},
enums::{ReedlineEvent, UndoBehavior},
@ -97,6 +98,9 @@ pub struct Reedline {
// Use ansi coloring or not
use_ansi_coloring: bool,
// Context Menu
context_menu: ContextMenu,
}
impl Drop for Reedline {
@ -115,6 +119,7 @@ impl Reedline {
let buffer_highlighter = Box::new(ExampleHighlighter::default());
let hinter = Box::new(DefaultHinter::default());
let validator = Box::new(DefaultValidator);
let context_menu = ContextMenu::default();
let edit_mode = Box::new(Emacs::default());
@ -130,6 +135,7 @@ impl Reedline {
validator,
animate: true,
use_ansi_coloring: true,
context_menu,
};
Ok(reedline)
@ -456,7 +462,7 @@ impl Reedline {
Ok(Some(Signal::CtrlC))
}
ReedlineEvent::ClearScreen => Ok(Some(Signal::CtrlL)),
ReedlineEvent::Enter | ReedlineEvent::HandleTab => {
ReedlineEvent::Enter | ReedlineEvent::Complete => {
if let Some(string) = self.history.string_at_cursor() {
self.editor.set_buffer(string);
self.editor.remember_undo_state(true);
@ -509,6 +515,10 @@ impl Reedline {
// Default no operation
Ok(None)
}
ReedlineEvent::ContextMenu | ReedlineEvent::Esc => {
// No context menu action when pressing Tab in history mode
Ok(None)
}
}
}
@ -518,7 +528,13 @@ impl Reedline {
event: ReedlineEvent,
) -> io::Result<Option<Signal>> {
match event {
ReedlineEvent::HandleTab => {
ReedlineEvent::ContextMenu => {
self.context_menu.activate();
self.buffer_paint(prompt)?;
Ok(None)
}
ReedlineEvent::Complete => {
let line_buffer = self.editor.line_buffer();
self.tab_handler.handle(line_buffer);
@ -526,6 +542,11 @@ impl Reedline {
self.buffer_paint(prompt)?;
Ok(None)
}
ReedlineEvent::Esc => {
self.context_menu.deactivate();
self.buffer_paint(prompt)?;
Ok(None)
}
ReedlineEvent::CtrlD => {
if self.editor.is_empty() {
self.editor.reset_undo_stack();
@ -542,23 +563,36 @@ impl Reedline {
}
ReedlineEvent::ClearScreen => Ok(Some(Signal::CtrlL)),
ReedlineEvent::Enter => {
let buffer = self.editor.get_buffer().to_string();
if matches!(self.validator.validate(&buffer), ValidationResult::Complete) {
self.append_to_history();
self.run_edit_commands(&[EditCommand::Clear])?;
self.painter.print_crlf()?;
self.editor.reset_undo_stack();
Ok(Some(Signal::Success(buffer)))
} else {
#[cfg(windows)]
{
self.run_edit_commands(&[EditCommand::InsertChar('\r')])?;
if self.context_menu.is_active() {
let value = self.context_menu.get_value();
if let Some(value) = value {
let value = value.to_string();
self.run_edit_commands(&[EditCommand::InsertString(value)])?;
}
self.run_edit_commands(&[EditCommand::InsertChar('\n')])?;
self.context_menu.deactivate();
self.buffer_paint(prompt)?;
Ok(None)
} else {
let buffer = self.editor.get_buffer().to_string();
if matches!(self.validator.validate(&buffer), ValidationResult::Complete) {
self.append_to_history();
self.run_edit_commands(&[EditCommand::Clear])?;
self.painter.print_crlf()?;
self.editor.reset_undo_stack();
Ok(Some(Signal::Success(buffer)))
} else {
#[cfg(windows)]
{
self.run_edit_commands(&[EditCommand::InsertChar('\r')])?;
}
self.run_edit_commands(&[EditCommand::InsertChar('\n')])?;
self.buffer_paint(prompt)?;
Ok(None)
}
}
}
ReedlineEvent::Edit(commands) => {
@ -590,13 +624,21 @@ impl Reedline {
Ok(None)
}
ReedlineEvent::Up => {
self.up_command();
if self.context_menu.is_active() {
self.context_menu.move_up()
} else {
self.up_command();
}
self.buffer_paint(prompt)?;
Ok(None)
}
ReedlineEvent::Down => {
self.down_command();
if self.context_menu.is_active() {
self.context_menu.move_down()
} else {
self.down_command();
}
self.buffer_paint(prompt)?;
Ok(None)
@ -803,8 +845,20 @@ impl Reedline {
EditCommand::MoveToEnd => self.editor.move_to_end(),
EditCommand::MoveToLineStart => self.editor.move_to_line_start(),
EditCommand::MoveToLineEnd => self.editor.move_to_line_end(),
EditCommand::MoveLeft => self.editor.move_left(),
EditCommand::MoveRight => self.editor.move_right(),
EditCommand::MoveLeft => {
if self.context_menu.is_active() {
self.context_menu.move_left()
} else {
self.editor.move_left()
}
}
EditCommand::MoveRight => {
if self.context_menu.is_active() {
self.context_menu.move_right()
} else {
self.editor.move_right()
}
}
EditCommand::MoveWordLeft => self.editor.move_word_left(),
EditCommand::MoveWordRight => self.editor.move_word_right(),
@ -927,13 +981,17 @@ impl Reedline {
res_string
};
self.painter.repaint_buffer(
let lines = PromptLines::new(
prompt,
self.prompt_edit_mode(),
PromptLines::new(&res_string, "", ""),
Some(prompt_history_search),
self.use_ansi_coloring,
)?;
&res_string,
"",
"",
);
self.painter
.repaint_buffer(prompt, lines, None, self.use_ansi_coloring)?;
}
Ok(())
@ -972,13 +1030,23 @@ impl Reedline {
let after_cursor = after_cursor.replace("\n", "\r\n");
let hint = hint.replace("\n", "\r\n");
self.painter.repaint_buffer(
let context_menu = if self.context_menu.is_active() {
Some(&self.context_menu)
} else {
None
};
let lines = PromptLines::new(
prompt,
self.prompt_edit_mode(),
PromptLines::new(&before_cursor, &after_cursor, &hint),
None,
self.use_ansi_coloring,
)
&before_cursor,
&after_cursor,
&hint,
);
self.painter
.repaint_buffer(prompt, lines, context_menu, self.use_ansi_coloring)
}
}

View File

@ -213,8 +213,11 @@ pub enum ReedlineEvent {
/// No op event
None,
/// Trigger Tab
HandleTab,
/// Trigger context menu
ContextMenu,
/// Completes hint
Complete,
/// Handle EndOfLine event
///
@ -240,6 +243,9 @@ pub enum ReedlineEvent {
/// Handle enter event
Enter,
/// Esc event
Esc,
/// Mouse
Mouse, // Fill in details later

View File

@ -228,3 +228,6 @@ pub use hinter::{DefaultHinter, Hinter};
mod validator;
pub use validator::{DefaultValidator, ValidationResult, Validator};
mod context_menu;
pub use context_menu::{ContextMenu, MenuFiller};

View File

@ -1,5 +1,8 @@
use crate::PromptHistorySearch;
use std::borrow::Cow;
use crate::{context_menu::ContextMenu, PromptHistorySearch};
use crossterm::{cursor::MoveToRow, style::ResetColor, terminal::ScrollUp};
use nu_ansi_term::ansi::RESET;
use {
crate::{prompt::PromptEditMode, Prompt},
@ -25,6 +28,9 @@ impl PromptCoordinates {
}
pub struct PromptLines<'prompt> {
prompt_str_left: Cow<'prompt, str>,
prompt_str_right: Cow<'prompt, str>,
prompt_indicator: Cow<'prompt, str>,
before_cursor: &'prompt str,
after_cursor: &'prompt str,
hint: &'prompt str,
@ -35,11 +41,25 @@ impl<'prompt> PromptLines<'prompt> {
/// This vector with the str are used to calculate how many lines are
/// required to print after the prompt
pub fn new(
prompt: &'prompt dyn Prompt,
prompt_mode: PromptEditMode,
history_indicator: Option<PromptHistorySearch>,
before_cursor: &'prompt str,
after_cursor: &'prompt str,
hint: &'prompt str,
) -> Self {
let prompt_str_left = prompt.render_prompt_left();
let prompt_str_right = prompt.render_prompt_right();
let prompt_indicator = match history_indicator {
Some(prompt_search) => prompt.render_prompt_history_search_indicator(prompt_search),
None => prompt.render_prompt_indicator(prompt_mode),
};
Self {
prompt_str_left,
prompt_str_right,
prompt_indicator,
before_cursor,
after_cursor,
hint,
@ -49,17 +69,16 @@ 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.
fn required_lines(
&self,
prompt_str: &str,
prompt_indicator: &str,
terminal_columns: u16,
) -> u16 {
let input = prompt_str.to_string()
+ prompt_indicator
+ self.before_cursor
+ self.hint
+ self.after_cursor;
fn required_lines(&self, terminal_columns: u16, context_menu: Option<&ContextMenu>) -> u16 {
let input = if context_menu.is_none() {
self.prompt_str_left.to_string()
+ &self.prompt_indicator
+ self.before_cursor
+ self.hint
+ self.after_cursor
} else {
self.prompt_str_left.to_string() + &self.prompt_indicator + self.before_cursor
};
let lines = input.lines().fold(0, |acc, line| {
let wrap = if let Ok(line) = strip_ansi_escapes::strip(line) {
@ -71,16 +90,17 @@ impl<'prompt> PromptLines<'prompt> {
acc + 1 + wrap
});
lines as u16
if let Some(context_menu) = context_menu {
lines as u16 + context_menu.get_rows()
} else {
lines as u16
}
}
fn distance_from_prompt(
&self,
prompt_str: &str,
prompt_indicator: &str,
terminal_columns: u16,
) -> u16 {
let input = prompt_str.to_string() + prompt_indicator + self.before_cursor;
/// Estimated distance of the cursor to the prompt.
/// This considers line wrapping
fn distance_from_prompt(&self, terminal_columns: u16) -> u16 {
let input = self.prompt_str_left.to_string() + &self.prompt_indicator + self.before_cursor;
let lines = input.lines().fold(0, |acc, line| {
let wrap = if let Ok(line) = strip_ansi_escapes::strip(line) {
@ -98,6 +118,40 @@ impl<'prompt> PromptLines<'prompt> {
fn concatenate_lines(&self) -> String {
self.before_cursor.to_string() + self.after_cursor + self.hint
}
/// Total lines that the prompt uses considering that it may wrap the screen
fn prompt_lines_with_wrap(&self, screen_width: u16) -> u16 {
let complete_prompt = self.prompt_str_left.to_string() + &self.prompt_indicator;
let prompt_wrap = estimated_wrapped_line_count(&complete_prompt, screen_width);
(self.prompt_str_left.matches('\n').count() + prompt_wrap) as u16
}
/// Estimated width of the actual input
fn estimate_first_input_line_width(&self) -> u16 {
let last_line_left_prompt = self.prompt_str_left.lines().last();
let prompt_lines_total = self.concatenate_lines();
let prompt_lines_first = prompt_lines_total.lines().next();
let mut estimate = 0; // space in front of the input
if let Some(last_line_left_prompt) = last_line_left_prompt {
estimate += line_width(last_line_left_prompt);
}
estimate += line_width(&self.prompt_indicator);
if let Some(prompt_lines_first) = prompt_lines_first {
estimate += line_width(prompt_lines_first);
}
if estimate > u16::MAX as usize {
u16::MAX
} else {
estimate as u16
}
}
}
fn estimated_wrapped_line_count(line: &str, terminal_columns: u16) -> usize {
@ -148,17 +202,7 @@ fn skip_buffer_lines(string: &str, skip: usize, offset: Option<usize>) -> &str {
None => string.len(),
};
let line = &string[index..limit];
line.trim_end_matches('\n')
}
/// Total lines that the prompt uses considering that it may wrap the screen
fn prompt_lines_with_wrap(prompt_str: &str, prompt_indicator: &str, screen_width: u16) -> u16 {
let complete_prompt = prompt_str.to_string() + prompt_indicator;
let prompt_wrap = estimated_wrapped_line_count(&complete_prompt, screen_width);
(prompt_str.matches('\n').count() + prompt_wrap) as u16
string[index..limit].trim_end_matches('\n')
}
pub struct Painter {
@ -248,27 +292,17 @@ impl Painter {
pub fn repaint_buffer(
&mut self,
prompt: &dyn Prompt,
prompt_mode: PromptEditMode,
lines: PromptLines,
history_indicator: Option<PromptHistorySearch>,
context_menu: Option<&ContextMenu>,
use_ansi_coloring: bool,
) -> Result<()> {
self.stdout.queue(cursor::Hide)?;
// String representation of the prompt
let (screen_width, screen_height) = self.terminal_size;
let prompt_str_left = prompt.render_prompt_left();
let prompt_str_right = prompt.render_prompt_right();
// The prompt indicator could be normal one or the history indicator
let prompt_indicator = match history_indicator {
Some(prompt_search) => prompt.render_prompt_history_search_indicator(prompt_search),
None => prompt.render_prompt_indicator(prompt_mode),
};
// Lines and distance parameters
let required_lines =
lines.required_lines(&prompt_str_left, &prompt_indicator, screen_width);
let required_lines = lines.required_lines(screen_width, context_menu);
let remaining_lines = self.remaining_lines();
// Marking the painter state as larger buffer to avoid animations
@ -291,19 +325,9 @@ impl Painter {
.queue(Clear(ClearType::FromCursorDown))?;
if self.large_buffer {
self.print_large_buffer(
prompt,
(&prompt_str_left, &prompt_str_right, &prompt_indicator),
&lines,
use_ansi_coloring,
)?
self.print_large_buffer(prompt, &lines, context_menu, use_ansi_coloring)?
} else {
self.print_small_buffer(
prompt,
(&prompt_str_left, &prompt_str_right, &prompt_indicator),
&lines,
use_ansi_coloring,
)?
self.print_small_buffer(prompt, &lines, context_menu, use_ansi_coloring)?
}
// The last_required_lines is used to move the cursor at the end where stdout
@ -312,12 +336,11 @@ impl Painter {
// In debug mode a string with position information is printed at the end of the buffer
if self.debug_mode {
let cursor_distance =
lines.distance_from_prompt(&prompt_str_left, &prompt_indicator, screen_width);
let prompt_lines =
prompt_lines_with_wrap(&prompt_str_left, &prompt_indicator, screen_width);
let prompt_length = prompt_str_left.len() + prompt_indicator.len();
let estimated_prompt = estimated_wrapped_line_count(&prompt_str_left, screen_width);
let cursor_distance = lines.distance_from_prompt(screen_width);
let prompt_lines = lines.prompt_lines_with_wrap(screen_width);
let prompt_length = lines.prompt_str_left.len() + lines.prompt_indicator.len();
let estimated_prompt =
estimated_wrapped_line_count(&lines.prompt_str_left, screen_width);
self.stdout
.queue(Print(format!(" [h{}:", screen_height)))?
@ -338,10 +361,11 @@ impl Painter {
self.flush()
}
fn print_right_prompt(&mut self, prompt_str_right: &str, input_width: u16) -> Result<()> {
fn print_right_prompt(&mut self, lines: &PromptLines) -> Result<()> {
let (screen_width, _) = self.terminal_size;
let prompt_length_right = line_width(prompt_str_right);
let prompt_length_right = line_width(&lines.prompt_str_right);
let start_position = screen_width.saturating_sub(prompt_length_right as u16);
let input_width = lines.estimate_first_input_line_width();
if input_width <= start_position {
self.stdout
@ -350,22 +374,82 @@ impl Painter {
start_position,
self.prompt_coords.prompt_start.1,
))?
.queue(Print(&prompt_str_right))?
.queue(Print(&lines.prompt_str_right))?
.queue(RestorePosition)?;
}
Ok(())
}
fn print_context_menu(
&mut self,
context_menu: &ContextMenu,
lines: &PromptLines,
) -> Result<()> {
let (screen_width, screen_height) = self.terminal_size;
let cursor_distance = lines.distance_from_prompt(screen_width);
// If there is not enough space to print the menu, then the starting
// drawing point for the menu will overwrite the last rows in the buffer
let starting_row = if cursor_distance >= screen_height.saturating_sub(1) {
screen_height.saturating_sub(context_menu.min_rows())
} else {
self.prompt_coords.prompt_start.1 + cursor_distance + 1
};
// The skip values represent the number of lines that should be skipped
// while printing the menu
let remaining_lines = screen_height.saturating_sub(starting_row);
let skip_values = if context_menu.row_pos >= remaining_lines {
let skip_lines = context_menu.row_pos.saturating_sub(remaining_lines) + 1;
(skip_lines * context_menu.cols) as usize
} else {
0
};
// It seems that crossterm prefers to have a complete string ready to be printed
// rather than looping through the values and printing multiple things
// This reduces the flickering when printing the menu
let available_values = (remaining_lines * context_menu.cols) as usize;
let values = context_menu
.get_values()
.iter()
.skip(skip_values)
.take(available_values)
.enumerate()
.map(|(index, line)| {
// Correcting the enumerate index based on the number of skipped values
let index = index + skip_values;
let column = index as u16 % context_menu.cols;
let printable_width = context_menu.printable_width(line);
// Final string with colors
format!(
"{}{:width$}{}{}",
context_menu.text_style(index),
&line[..printable_width],
RESET,
context_menu.end_of_line(column),
width = context_menu.col_width
)
})
.collect::<String>();
self.stdout
.queue(cursor::MoveTo(0, starting_row))?
.queue(Clear(ClearType::FromCursorDown))?
.queue(Print(values.trim_end_matches('\n')))?;
Ok(())
}
fn print_small_buffer(
&mut self,
prompt: &dyn Prompt,
prompt_str: (&str, &str, &str),
lines: &PromptLines,
context_menu: Option<&ContextMenu>,
use_ansi_coloring: bool,
) -> Result<()> {
let (prompt_str_left, prompt_str_right, prompt_indicator) = prompt_str;
// print our prompt with color
if use_ansi_coloring {
self.stdout
@ -373,13 +457,10 @@ impl Painter {
}
self.stdout
.queue(Print(&prompt_str_left))?
.queue(Print(&prompt_indicator))?;
.queue(Print(&lines.prompt_str_left))?
.queue(Print(&lines.prompt_indicator))?;
let estimate_input_total_width =
estimate_first_input_line_width(prompt_str_left, prompt_indicator, lines);
self.print_right_prompt(prompt_str_right, estimate_input_total_width)?;
self.print_right_prompt(lines)?;
if use_ansi_coloring {
self.stdout.queue(ResetColor)?;
@ -387,9 +468,14 @@ impl Painter {
self.stdout
.queue(Print(&lines.before_cursor))?
.queue(SavePosition)?
.queue(Print(&lines.hint))?
.queue(Print(&lines.after_cursor))?;
.queue(SavePosition)?;
if let Some(context_menu) = context_menu {
self.print_context_menu(context_menu, lines)?;
} else {
self.stdout
.queue(Print(format!("{}{}", &lines.hint, &lines.after_cursor)))?;
}
Ok(())
}
@ -397,23 +483,20 @@ impl Painter {
fn print_large_buffer(
&mut self,
prompt: &dyn Prompt,
prompt_str: (&str, &str, &str),
lines: &PromptLines,
context_menu: Option<&ContextMenu>,
use_ansi_coloring: bool,
) -> Result<()> {
let (prompt_str_left, prompt_str_right, prompt_indicator) = prompt_str;
let (screen_width, screen_height) = self.terminal_size;
let cursor_distance =
lines.distance_from_prompt(prompt_str_left, prompt_indicator, screen_width);
let cursor_distance = lines.distance_from_prompt(screen_width);
let remaining_lines = screen_height.saturating_sub(cursor_distance);
// Calculating the total lines before the cursor
// The -1 in the total_lines_before is there because the at least one line of the prompt
// indicator is printed in the same line as the first line of the buffer
let prompt_lines =
prompt_lines_with_wrap(prompt_str_left, prompt_indicator, screen_width) as usize;
let prompt_lines = lines.prompt_lines_with_wrap(screen_width) as usize;
let prompt_indicator_lines = prompt_indicator.lines().count();
let prompt_indicator_lines = lines.prompt_indicator.lines().count();
let before_cursor_lines = lines.before_cursor.lines().count();
let total_lines_before = prompt_lines + prompt_indicator_lines + before_cursor_lines - 1;
@ -428,42 +511,60 @@ impl Painter {
// In case the prompt is made out of multiple lines, the prompt is split by
// lines and only the required ones are printed
let prompt_skipped = skip_buffer_lines(prompt_str_left, extra_rows, None);
let prompt_skipped = skip_buffer_lines(&lines.prompt_str_left, extra_rows, None);
self.stdout.queue(Print(prompt_skipped))?;
let estimate_input_total_width =
estimate_first_input_line_width(prompt_str_left, prompt_indicator, lines);
if extra_rows == 0 {
self.print_right_prompt(prompt_str_right, estimate_input_total_width)?;
self.print_right_prompt(lines)?;
}
// Adjusting extra_rows base on the calculated prompt line size
let extra_rows = extra_rows.saturating_sub(prompt_lines);
let indicator_skipped = skip_buffer_lines(prompt_indicator, extra_rows, None);
let indicator_skipped = skip_buffer_lines(&lines.prompt_indicator, extra_rows, None);
self.stdout.queue(Print(indicator_skipped))?;
if use_ansi_coloring {
self.stdout.queue(ResetColor)?;
}
// The minimum number of lines from the menu are removed from the buffer if there is no more
// space to print the menu. This will only happen if the cursor is at the last line and
// it is a large buffer
let offset = context_menu.and_then(|context_menu| {
if cursor_distance >= screen_height.saturating_sub(1) {
let rows = lines
.before_cursor
.lines()
.count()
.saturating_sub(extra_rows)
.saturating_sub(context_menu.min_rows() as usize);
Some(rows)
} else {
None
}
});
// Selecting the lines before the cursor that will be printed
let before_cursor_skipped = skip_buffer_lines(lines.before_cursor, extra_rows, None);
let before_cursor_skipped = skip_buffer_lines(lines.before_cursor, extra_rows, offset);
self.stdout.queue(Print(before_cursor_skipped))?;
self.stdout.queue(SavePosition)?;
// Selecting lines for the hint
// The -1 subtraction is done because the remaining lines consider the line where the
// cursor is located as a remaining line. That has to be removed to get the correct offset
// for the hint and after cursor lines
let offset = remaining_lines.saturating_sub(1) as usize;
let hint_skipped = skip_buffer_lines(lines.hint, 0, Some(offset));
self.stdout.queue(Print(hint_skipped))?;
if let Some(context_menu) = context_menu {
self.print_context_menu(context_menu, lines)?;
} else {
// Selecting lines for the hint
// The -1 subtraction is done because the remaining lines consider the line where the
// cursor is located as a remaining line. That has to be removed to get the correct offset
// for the hint and after cursor lines
let offset = remaining_lines.saturating_sub(1) as usize;
let hint_skipped = skip_buffer_lines(lines.hint, 0, Some(offset));
self.stdout.queue(Print(hint_skipped))?;
// Selecting lines after the cursor
let after_cursor_skipped = skip_buffer_lines(lines.after_cursor, 0, Some(offset));
self.stdout.queue(Print(after_cursor_skipped))?;
// Selecting lines after the cursor
let after_cursor_skipped = skip_buffer_lines(lines.after_cursor, 0, Some(offset));
self.stdout.queue(Print(after_cursor_skipped))?;
}
Ok(())
}
@ -545,35 +646,6 @@ impl Painter {
}
}
fn estimate_first_input_line_width(
left_prompt: &str,
indicator: &str,
prompt_lines: &PromptLines,
) -> u16 {
let last_line_left_prompt = left_prompt.lines().last();
let prompt_lines_total = prompt_lines.concatenate_lines();
let prompt_lines_first = prompt_lines_total.lines().next();
let mut estimate = 0; // space in front of the input
if let Some(last_line_left_prompt) = last_line_left_prompt {
estimate += line_width(last_line_left_prompt);
}
estimate += line_width(indicator);
if let Some(prompt_lines_first) = prompt_lines_first {
estimate += line_width(prompt_lines_first);
}
if estimate > u16::MAX as usize {
u16::MAX
} else {
estimate as u16
}
}
#[cfg(test)]
mod tests {
use super::*;