diff --git a/src/context_menu.rs b/src/context_menu.rs new file mode 100644 index 0000000..1ca77c5 --- /dev/null +++ b/src/context_menu.rs @@ -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, + 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) -> 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 {} + } +} diff --git a/src/edit_mode/emacs.rs b/src/edit_mode/emacs.rs index 59c8cb0..56bff86 100644 --- a/src/edit_mode/emacs.rs +++ b/src/edit_mode/emacs.rs @@ -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] diff --git a/src/edit_mode/keybindings.rs b/src/edit_mode/keybindings.rs index e15034c..f23036b 100644 --- a/src/edit_mode/keybindings.rs +++ b/src/edit_mode/keybindings.rs @@ -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 -} diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 4b166cf..c11735c 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -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; diff --git a/src/edit_mode/vi/vi_keybindings.rs b/src/edit_mode/vi/vi_keybindings.rs new file mode 100644 index 0000000..2c98b8c --- /dev/null +++ b/src/edit_mode/vi/vi_keybindings.rs @@ -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 +} diff --git a/src/engine.rs b/src/engine.rs index e070628..5dcef53 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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> { 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) } } diff --git a/src/enums.rs b/src/enums.rs index 1dd4484..c1aae68 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 6bfbc90..6c4083a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/painter.rs b/src/painter.rs index 3ab6e53..373b4c4 100644 --- a/src/painter.rs +++ b/src/painter.rs @@ -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, 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) -> &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, + 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::(); + + 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::*;