diff --git a/.typos.toml b/.typos.toml index 4035ad9d..a1802fae 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,5 +6,8 @@ extend-exclude = ["src/core_editor/line_buffer.rs"] iterm = "iterm" # For testing completion of the word build bui = "bui" +# For testing string truncation +descriptio = "descriptio" +ot = "ot" # for sqlite backed history wheres = "wheres" diff --git a/examples/ide_completions.rs b/examples/ide_completions.rs new file mode 100644 index 00000000..d0b608c0 --- /dev/null +++ b/examples/ide_completions.rs @@ -0,0 +1,58 @@ +// Create a reedline object with tab completions support +// cargo run --example completions +// +// "t" [Tab] will allow you to select the completions "test" and "this is the reedline crate" +// [Enter] to select the chosen alternative + +use reedline::{ + default_emacs_keybindings, DefaultCompleter, DefaultPrompt, Emacs, IdeMenu, KeyCode, + KeyModifiers, Keybindings, Reedline, ReedlineEvent, ReedlineMenu, Signal, +}; +use std::io; + +fn add_menu_keybindings(keybindings: &mut Keybindings) { + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); +} +fn main() -> io::Result<()> { + let commands = vec![ + "test".into(), + "hello world".into(), + "hello world reedline".into(), + "this is the reedline crate".into(), + ]; + let completer = Box::new(DefaultCompleter::new_with_wordlen(commands, 2)); + // Use the interactive menu to select options from the completer + let completion_menu = Box::new(IdeMenu::default().with_name("completion_menu")); + + let mut keybindings = default_emacs_keybindings(); + add_menu_keybindings(&mut keybindings); + + let edit_mode = Box::new(Emacs::new(keybindings)); + + let mut line_editor = Reedline::create() + .with_completer(completer) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(edit_mode); + + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + } + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + } + } + } +} diff --git a/src/engine.rs b/src/engine.rs index 8e51af06..50f57acb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1688,7 +1688,7 @@ impl Reedline { // Needs to add return carriage to newlines because when not in raw mode // some OS don't fully return the carriage - let lines = PromptLines::new( + let mut lines = PromptLines::new( prompt, self.prompt_edit_mode(), None, @@ -1700,6 +1700,11 @@ impl Reedline { // Updating the working details of the active menu for menu in self.menus.iter_mut() { if menu.is_active() { + lines.prompt_indicator = menu.indicator().to_owned().into(); + // If the menu requires the cursor position, update it (ide menu) + let cursor_pos = lines.cursor_pos(self.painter.screen_width()); + menu.set_cursor_pos(cursor_pos); + menu.update_working_details( &mut self.editor, self.completer.as_mut(), diff --git a/src/lib.rs b/src/lib.rs index 24eeaae1..893207ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -277,7 +277,7 @@ pub use validator::{DefaultValidator, ValidationResult, Validator}; mod menu; pub use menu::{ - menu_functions, ColumnarMenu, ListMenu, Menu, MenuEvent, MenuTextStyle, ReedlineMenu, + menu_functions, ColumnarMenu, IdeMenu, ListMenu, Menu, MenuEvent, MenuTextStyle, ReedlineMenu, }; mod terminal_extensions; diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 980c706e..b30ce360 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -728,6 +728,10 @@ impl Menu for ColumnarMenu { .collect() } } + + fn set_cursor_pos(&mut self, _pos: (u16, u16)) { + // The columnar menu does not need the cursor position + } } #[cfg(test)] diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs new file mode 100644 index 00000000..c931833b --- /dev/null +++ b/src/menu/ide_menu.rs @@ -0,0 +1,1465 @@ +use super::{menu_functions::find_common_string, Menu, MenuEvent, MenuTextStyle}; +use crate::{ + core_editor::Editor, menu_functions::string_difference, painting::Painter, Completer, + Suggestion, UndoBehavior, +}; +use itertools::{ + EitherOrBoth::{Both, Left, Right}, + Itertools, +}; +use nu_ansi_term::{ansi::RESET, Style}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub enum DescriptionMode { + /// Description is always shown on the left + Left, + /// Description is always shown on the right + Right, + /// Description is shown on the right of the completion if there is enough space + /// otherwise it is shown on the left + PreferRight, +} + +/// Symbols used for the border of the menu +struct BorderSymbols { + pub top_left: char, + pub top_right: char, + pub bottom_left: char, + pub bottom_right: char, + pub horizontal: char, + pub vertical: char, +} + +impl Default for BorderSymbols { + fn default() -> Self { + Self { + top_left: '╭', + top_right: '╮', + bottom_left: '╰', + bottom_right: '╯', + horizontal: '─', + vertical: '│', + } + } +} + +/// Default values used as reference for the menu. These values are set during +/// the initial declaration of the menu and are always kept as reference for the +/// changeable [`IdeMenuDetails`] values. +struct DefaultIdeMenuDetails { + /// Minimum width of the completion box, including the border + pub min_completion_width: u16, + /// max width of the completion box, including the border + pub max_completion_width: u16, + /// max height of the completion box, including the border + /// this will be capped by the lines available in the terminal + pub max_completion_height: u16, + /// Padding to the left and right of the suggestions + pub padding: u16, + /// Whether the menu has a border or not + pub border: Option, + /// Horizontal offset from the cursor. + /// 0 means the top left corner of the menu is below the cursor + pub cursor_offset: i16, + /// How the description is shown + pub description_mode: DescriptionMode, + /// Min width of the description, including the border + /// this will be applied, when the description is "squished" + /// by the completion box + pub min_description_width: u16, + /// Max width of the description, including the border + pub max_description_width: u16, + /// Max height of the description, including the border + pub max_description_height: u16, + /// Offset from the suggestion box to the description box + pub description_offset: u16, +} + +impl Default for DefaultIdeMenuDetails { + fn default() -> Self { + Self { + min_completion_width: 0, + max_completion_width: 50, + max_completion_height: u16::MAX, // will be limited by the available lines + padding: 0, + border: None, + cursor_offset: 0, + description_mode: DescriptionMode::PreferRight, + min_description_width: 0, + max_description_width: 50, + max_description_height: 10, + description_offset: 1, + } + } +} + +#[derive(Default)] +struct IdeMenuDetails { + /// Column of the cursor + pub cursor_col: u16, + /// Width of the menu, including the padding and border and the description + pub menu_width: u16, + /// width of the completion box, including the padding and border + pub completion_width: u16, + /// width of the description box, including the padding and border + pub description_width: u16, + /// Where the description box should be shown based on the description mode + /// and the available space + pub description_is_right: bool, + /// Distance from the left side of the terminal to the menu + pub space_left: u16, + /// Distance from the right side of the terminal to the menu + pub space_right: u16, + /// Corrected description offset, based on the available space + pub description_offset: u16, +} + +/// Menu to present suggestions like similar to Ide completion menus +pub struct IdeMenu { + /// Menu name + name: String, + /// Ide menu active status + active: bool, + /// Menu coloring + color: MenuTextStyle, + /// Default ide menu details that are set when creating the menu + /// These values are the reference for the working details + default_details: DefaultIdeMenuDetails, + /// Working ide menu details keep changing based on the collected values + working_details: IdeMenuDetails, + /// Menu cached values + values: Vec, + /// Selected value. Starts at 0 + selected: u16, + /// Menu marker when active + marker: String, + /// Event sent to the menu + event: Option, + /// Longest suggestion found in the values + longest_suggestion: usize, + /// String collected after the menu is activated + input: Option, + /// Calls the completer using only the line buffer difference difference + /// after the menu was activated + only_buffer_difference: bool, +} + +impl Default for IdeMenu { + fn default() -> Self { + Self { + name: "ide_completion_menu".to_string(), + active: false, + color: MenuTextStyle::default(), + default_details: DefaultIdeMenuDetails::default(), + working_details: IdeMenuDetails::default(), + values: Vec::new(), + selected: 0, + marker: "| ".to_string(), + event: None, + longest_suggestion: 0, + input: None, + only_buffer_difference: false, + } + } +} + +impl IdeMenu { + /// Menu builder with new name + #[must_use] + pub fn with_name(mut self, name: &str) -> Self { + self.name = name.into(); + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_text_style(mut self, text_style: Style) -> Self { + self.color.text_style = text_style; + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_selected_text_style(mut self, selected_text_style: Style) -> Self { + self.color.selected_text_style = selected_text_style; + 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 value for min completion width value + #[must_use] + pub fn with_min_completion_width(mut self, width: u16) -> Self { + self.default_details.min_completion_width = width; + self + } + + /// Menu builder with new value for max completion width value + #[must_use] + pub fn with_max_completion_width(mut self, width: u16) -> Self { + self.default_details.max_completion_width = width; + self + } + + /// Menu builder with new value for max completion height value + #[must_use] + pub fn with_max_completion_height(mut self, height: u16) -> Self { + self.default_details.max_completion_height = height; + self + } + + /// Menu builder with new value for padding value + #[must_use] + pub fn with_padding(mut self, padding: u16) -> Self { + self.default_details.padding = padding; + self + } + + /// Menu builder with the default border value + #[must_use] + pub fn with_default_border(mut self) -> Self { + self.default_details.border = Some(BorderSymbols::default()); + self + } + + /// Menu builder with new value for border value + #[must_use] + pub fn with_border( + mut self, + top_right: char, + top_left: char, + bottom_right: char, + bottom_left: char, + horizontal: char, + vertical: char, + ) -> Self { + self.default_details.border = Some(BorderSymbols { + top_right, + top_left, + bottom_right, + bottom_left, + horizontal, + vertical, + }); + self + } + + /// Menu builder with new value for cursor offset value + #[must_use] + pub fn with_cursor_offset(mut self, cursor_offset: i16) -> Self { + self.default_details.cursor_offset = cursor_offset; + self + } + + /// Menu builder with marker + #[must_use] + pub fn with_marker(mut self, marker: String) -> Self { + self.marker = marker; + self + } + + /// Menu builder with new only buffer difference + #[must_use] + pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self { + self.only_buffer_difference = only_buffer_difference; + self + } + + /// Menu builder with new description mode + #[must_use] + pub fn with_description_mode(mut self, description_mode: DescriptionMode) -> Self { + self.default_details.description_mode = description_mode; + self + } + + /// Menu builder with new min description width + #[must_use] + pub fn with_min_description_width(mut self, min_description_width: u16) -> Self { + self.default_details.min_description_width = min_description_width; + self + } + + /// Menu builder with new max description width + #[must_use] + pub fn with_max_description_width(mut self, max_description_width: u16) -> Self { + self.default_details.max_description_width = max_description_width; + self + } + + /// Menu builder with new max description height + #[must_use] + pub fn with_max_description_height(mut self, max_description_height: u16) -> Self { + self.default_details.max_description_height = max_description_height; + self + } + + /// Menu builder with new description offset + #[must_use] + pub fn with_description_offset(mut self, description_offset: u16) -> Self { + self.default_details.description_offset = description_offset; + self + } +} + +// Menu functionality +impl IdeMenu { + fn move_next(&mut self) { + if self.selected < (self.values.len() as u16).saturating_sub(1) { + self.selected += 1; + } else { + self.selected = 0; + } + } + + fn move_previous(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } else { + self.selected = self.values.len().saturating_sub(1) as u16; + } + } + + fn index(&self) -> usize { + self.selected as usize + } + + fn get_value(&self) -> Option { + self.values.get(self.index()).cloned() + } + + /// Calculates how many rows the Menu will try to use (if available) + fn get_rows(&self) -> u16 { + let mut values = self.get_values().len() as u16; + + if values == 0 { + // When the values are empty the no_records_msg is shown, taking 1 line + return 1; + } + + if self.default_details.border.is_some() { + // top and bottom border take 1 line each + values += 2; + } + + let description_height = self + .get_value() + .and_then(|value| value.description) + .map(|description| { + self.description_dims( + description, + self.working_details.description_width, + self.default_details.max_description_height, + 0, + ) + .1 + }) + .unwrap_or(0) + .min(self.default_details.max_description_height); + + values.max(description_height) + } + + /// Returns working details width + fn get_width(&self) -> u16 { + self.working_details.menu_width + } + + fn reset_position(&mut self) { + self.selected = 0; + } + + fn no_records_msg(&self, use_ansi_coloring: bool) -> String { + let msg = "NO RECORDS FOUND"; + if use_ansi_coloring { + format!( + "{}{}{}", + self.color.selected_text_style.prefix(), + msg, + RESET + ) + } else { + msg.to_string() + } + } + + fn create_description( + &self, + description: String, + use_ansi_coloring: bool, + available_width: u16, + available_height: u16, + min_width: u16, + ) -> Vec { + if description.is_empty() || available_width == 0 || available_height == 0 { + return Vec::new(); + } + + let border_width = if self.default_details.border.is_some() { + 2 + } else { + 0 + }; + + let content_width = available_width.saturating_sub(border_width); + let content_height = available_height.saturating_sub(border_width); + + let mut description_lines = split_string(&description, content_width as usize); + + // panic!("{:?}", description_lines); + + if description_lines.len() > content_height as usize { + description_lines.truncate(content_height as usize); + truncate_string_list(&mut description_lines, "..."); + } + + let content_width = description_lines + .iter() + .map(|s| s.width()) + .max() + .unwrap_or_default() + .max(min_width.saturating_sub(border_width) as usize); + + // let needs_padding = description_lines.len() > 1 + + if let Some(border) = &self.default_details.border { + let horizontal_border = border.horizontal.to_string().repeat(content_width); + + for line in &mut description_lines { + let padding = " ".repeat(content_width.saturating_sub(line.width())); + + if use_ansi_coloring { + *line = format!( + "{}{}{}{}{}{}", + border.vertical, + self.color.description_style.prefix(), + line, + padding, + RESET, + border.vertical + ); + } else { + *line = format!("{}{}{}{}", border.vertical, line, padding, border.vertical); + } + } + + description_lines.insert( + 0, + format!( + "{}{}{}", + border.top_left, horizontal_border, border.top_right + ), + ); + description_lines.push(format!( + "{}{}{}", + border.bottom_left, horizontal_border, border.bottom_right + )); + } else { + for line in &mut description_lines { + let padding = " ".repeat(content_width.saturating_sub(line.width())); + + if use_ansi_coloring { + *line = format!( + "{}{}{}{}", + self.color.description_style.prefix(), + line, + padding, + RESET + ); + } else { + *line = format!("{}{}", line, padding); + } + } + } + + description_lines + } + + /// Returns width and height of the description, including the border + fn description_dims( + &self, + description: String, + max_width: u16, + max_height: u16, + min_width: u16, + ) -> (u16, u16) { + // we will calculate the uncapped height, the real height + // will be capped by the available lines + + let lines = self.create_description(description, false, max_width, max_height, min_width); + let height = lines.len() as u16; + let string = lines.first().cloned().unwrap_or_default(); + let width = string.width() as u16; + (width, height) + } + + fn create_value_string( + &self, + suggestion: &Suggestion, + index: usize, + use_ansi_coloring: bool, + padding: usize, + ) -> String { + let border_width = if self.default_details.border.is_some() { + 2 + } else { + 0 + }; + + let vertical_border = self + .default_details + .border + .as_ref() + .map(|border| border.vertical) + .unwrap_or_default(); + + let padding_right = (self.working_details.completion_width as usize) + .saturating_sub(suggestion.value.chars().count() + border_width + padding); + + let max_string_width = + (self.working_details.completion_width as usize).saturating_sub(border_width + padding); + + let string = if suggestion.value.chars().count() > max_string_width { + let mut chars = suggestion + .value + .chars() + .take(max_string_width.saturating_sub(3)) + .collect::(); + chars.push_str("..."); + chars + } else { + suggestion.value.clone() + }; + + if use_ansi_coloring { + if index == self.index() { + format!( + "{}{}{}{}{}{}{}", + vertical_border, + self.color.selected_text_style.prefix(), + " ".repeat(padding), + string, + " ".repeat(padding_right), + RESET, + vertical_border, + ) + } else { + format!( + "{}{}{}{}{}{}{}", + vertical_border, + self.color.text_style.prefix(), + " ".repeat(padding), + string, + " ".repeat(padding_right), + RESET, + vertical_border, + ) + } + } else { + let marker = if index == self.index() { ">" } else { "" }; + + format!( + "{}{}{}{}{}{}", + vertical_border, + " ".repeat(padding), + marker, + string, + " ".repeat(padding_right), + vertical_border, + ) + } + } +} + +impl Menu for IdeMenu { + /// Menu name + fn name(&self) -> &str { + self.name.as_str() + } + + /// Menu indicator + fn indicator(&self) -> &str { + self.marker.as_str() + } + + /// Deactivates context menu + fn is_active(&self) -> bool { + self.active + } + + /// The ide menu can to quick complete if there is only one element + fn can_quick_complete(&self) -> bool { + true + } + + fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + ) -> bool { + // If the values were already updated (e.g. quick completions are true) + // there is no need to update the values from the menu + if !values_updated { + self.update_values(editor, completer); + } + + let values = self.get_values(); + if let (Some(Suggestion { value, span, .. }), Some(index)) = find_common_string(values) { + let index = index.min(value.len()); + let matching = &value[0..index]; + + // make sure that the partial completion does not overwrite user entered input + let extends_input = matching.starts_with(&editor.get_buffer()[span.start..span.end]); + + if !matching.is_empty() && extends_input { + let mut line_buffer = editor.line_buffer().clone(); + line_buffer.replace_range(span.start..span.end, matching); + + let offset = if matching.len() < (span.end - span.start) { + line_buffer + .insertion_point() + .saturating_sub((span.end - span.start) - matching.len()) + } else { + line_buffer.insertion_point() + matching.len() - (span.end - span.start) + }; + + line_buffer.set_insertion_point(offset); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + + // The values need to be updated because the spans need to be + // recalculated for accurate replacement in the string + self.update_values(editor, completer); + + true + } else { + false + } + } else { + false + } + } + + /// Selects what type of event happened with the menu + fn menu_event(&mut self, event: MenuEvent) { + match &event { + MenuEvent::Activate(_) => self.active = true, + MenuEvent::Deactivate => { + self.active = false; + self.input = None; + } + _ => {} + } + + self.event = Some(event); + } + + /// Update menu values + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { + self.values = if self.only_buffer_difference { + if let Some(old_string) = &self.input { + let (start, input) = string_difference(editor.get_buffer(), old_string); + if !input.is_empty() { + completer.complete(input, start + input.len()) + } else { + completer.complete("", editor.insertion_point()) + } + } else { + completer.complete("", editor.insertion_point()) + } + } else { + // If there is a new line character in the line buffer, the completer + // doesn't calculate the suggested values correctly. This happens when + // editing a multiline buffer. + // Also, by replacing the new line character with a space, the insert + // position is maintain in the line buffer. + let trimmed_buffer = editor.get_buffer().replace('\n', " "); + completer.complete( + &trimmed_buffer[..editor.insertion_point()], + editor.insertion_point(), + ) + }; + + self.reset_position(); + } + + /// The working details for the menu changes based on the size of the lines + /// collected from the completer + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ) { + if let Some(event) = self.event.take() { + // The working value for the menu are updated first before executing any of the + match event { + MenuEvent::Activate(updated) => { + self.active = true; + self.reset_position(); + + self.input = if self.only_buffer_difference { + Some(editor.get_buffer().to_string()) + } else { + None + }; + + if !updated { + self.update_values(editor, completer); + } + } + MenuEvent::Deactivate => self.active = false, + MenuEvent::Edit(updated) => { + self.reset_position(); + + if !updated { + self.update_values(editor, completer); + } + } + MenuEvent::NextElement | MenuEvent::MoveDown => self.move_next(), + MenuEvent::PreviousElement | MenuEvent::MoveUp => self.move_previous(), + MenuEvent::MoveLeft + | MenuEvent::MoveRight + | MenuEvent::PreviousPage + | MenuEvent::NextPage => {} + } + + self.longest_suggestion = self.get_values().iter().fold(0, |prev, suggestion| { + if prev >= suggestion.value.len() { + prev + } else { + suggestion.value.len() + } + }); + + let terminal_width = painter.screen_width(); + let cursor_pos = self.working_details.cursor_col; + + let border_width = if self.default_details.border.is_some() { + 2 + } else { + 0 + }; + + let description = self + .get_value() + .map(|v| { + if let Some(v) = v.description { + if v.is_empty() { + return None; + } else { + return Some(v); + } + } + None + }) + .unwrap_or_default(); + + let mut min_description_width = if description.is_some() { + self.default_details.min_description_width + } else { + 0 + }; + + let completion_width = ((self.longest_suggestion.min(u16::MAX as usize) as u16) + + 2 * self.default_details.padding + + border_width) + .min(self.default_details.max_completion_width) + .max(self.default_details.min_completion_width) + .min(terminal_width.saturating_sub(min_description_width)) + .max(3 + border_width); // Big enough to show "..." + + let available_description_width = terminal_width + .saturating_sub(completion_width) + .min(self.default_details.max_description_width) + .max(self.default_details.min_description_width) + .min(terminal_width.saturating_sub(completion_width)); + + min_description_width = min_description_width.min(available_description_width); + + let description_width = if let Some(description) = description { + self.description_dims( + description, + available_description_width, + u16::MAX, + min_description_width, + ) + .0 + } else { + 0 + }; + + let max_offset = terminal_width.saturating_sub(completion_width + description_width); + + let description_offset = self.default_details.description_offset.min(max_offset); + + self.working_details.completion_width = completion_width; + self.working_details.description_width = description_width; + self.working_details.description_offset = description_offset; + self.working_details.menu_width = + completion_width + description_offset + description_width; + + let cursor_offset = self.default_details.cursor_offset; + + self.working_details.description_is_right = match self.default_details.description_mode + { + DescriptionMode::Left => false, + DescriptionMode::Right => true, + DescriptionMode::PreferRight => { + // if there is enough space to the right of the cursor, the description is shown on the right + // otherwise it is shown on the left + let potential_right_distance = (terminal_width as i16) + .saturating_sub( + cursor_pos as i16 + + cursor_offset + + description_offset as i16 + + completion_width as i16, + ) + .max(0) as u16; + + potential_right_distance >= description_width + } + }; + + let space_left = (if self.working_details.description_is_right { + cursor_pos as i16 + cursor_offset + } else { + (cursor_pos as i16 + cursor_offset) + .saturating_sub(description_width as i16 + description_offset as i16) + } + .max(0) as u16) + .min(terminal_width.saturating_sub(self.get_width())); + + let space_right = terminal_width.saturating_sub(space_left + self.get_width()); + + self.working_details.space_left = space_left; + self.working_details.space_right = space_right; + } + } + + /// The buffer gets replaced in the Span location + fn replace_in_buffer(&self, editor: &mut Editor) { + if let Some(Suggestion { + mut value, + span, + append_whitespace, + .. + }) = self.get_value() + { + let start = span.start.min(editor.line_buffer().len()); + let end = span.end.min(editor.line_buffer().len()); + if append_whitespace { + value.push(' '); + } + let mut line_buffer = editor.line_buffer().clone(); + line_buffer.replace_range(start..end, &value); + + let mut offset = line_buffer.insertion_point(); + offset = offset.saturating_add(value.len()); + offset = offset.saturating_sub(end.saturating_sub(start)); + line_buffer.set_insertion_point(offset); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + } + } + + /// Minimum rows that should be displayed by the menu + fn min_rows(&self) -> u16 { + self.get_rows() + } + + fn get_values(&self) -> &[Suggestion] { + &self.values + } + + fn menu_required_lines(&self, _terminal_columns: u16) -> u16 { + self.get_rows() + } + + fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { + if self.get_values().is_empty() { + self.no_records_msg(use_ansi_coloring) + } else { + let border_width = if self.default_details.border.is_some() { + 2 + } else { + 0 + }; + + let available_lines = available_lines.min(self.default_details.max_completion_height); + // The skip values represent the number of lines that should be skipped + // while printing the menu + let skip_values = if self.selected >= available_lines.saturating_sub(border_width) { + let skip_lines = self + .selected + .saturating_sub(available_lines.saturating_sub(border_width)) + + 1; + skip_lines as usize + } else { + 0 + }; + + let available_values = available_lines.saturating_sub(border_width) as usize; + + let max_padding = self.working_details.completion_width.saturating_sub( + self.longest_suggestion.min(u16::MAX as usize) as u16 + border_width, + ) / 2; + + let corrected_padding = self.default_details.padding.min(max_padding) as usize; + + let mut strings = self + .get_values() + .iter() + .skip(skip_values) + .take(available_values) + .enumerate() + .map(|(index, suggestion)| { + // Correcting the enumerate index based on the number of skipped values + + let index = index + skip_values; + self.create_value_string( + suggestion, + index, + use_ansi_coloring, + corrected_padding, + ) + }) + .collect::>(); + + // Add top and bottom border + if let Some(border) = &self.default_details.border { + let inner_width = self.working_details.completion_width.saturating_sub(2) as usize; + + strings.insert( + 0, + format!( + "{}{}{}", + border.top_left, + border.horizontal.to_string().repeat(inner_width), + border.top_right, + ), + ); + + strings.push(format!( + "{}{}{}", + border.bottom_left, + border.horizontal.to_string().repeat(inner_width), + border.bottom_right, + )); + } + + let decsription_height = + available_lines.min(self.default_details.max_description_height); + let description_lines = self + .get_value() + .and_then(|value| value.clone().description) + .map(|description| { + self.create_description( + description, + use_ansi_coloring, + self.working_details.description_width, + decsription_height, + self.working_details.description_width, // the width has already been calculated + ) + }) + .unwrap_or_default(); + + let distance_left = &" ".repeat(self.working_details.space_left as usize); + + // Horizontally join the description lines with the suggestion lines + if self.working_details.description_is_right { + for (idx, pair) in strings + .clone() + .iter() + .zip_longest(description_lines.iter()) + .enumerate() + { + match pair { + Both(_suggestion_line, description_line) => { + strings[idx] = format!( + "{}{}{}{}", + distance_left, + strings[idx], + " ".repeat(self.working_details.description_offset as usize), + description_line, + ) + } + Left(suggestion_line) => { + strings[idx] = format!("{}{}", distance_left, suggestion_line); + } + Right(description_line) => strings.push(format!( + "{}{}", + " ".repeat( + (self.working_details.completion_width + + self.working_details.description_offset) + as usize + ) + distance_left, + description_line, + )), + } + } + } else { + for (idx, pair) in strings + .clone() + .iter() + .zip_longest(description_lines.iter()) + .enumerate() + { + match pair { + Both(suggestion_line, description_line) => { + strings[idx] = format!( + "{}{}{}{}", + distance_left, + description_line, + " ".repeat(self.working_details.description_offset as usize), + suggestion_line, + ) + } + Left(suggestion_line) => { + strings[idx] = format!( + "{}{}", + " ".repeat( + (self.working_details.description_width + + self.working_details.description_offset) + as usize + ) + distance_left, + suggestion_line, + ); + } + Right(description_line) => { + strings.push(format!("{}{}", distance_left, description_line,)) + } + } + } + } + + strings.join("\r\n") + } + } + + fn set_cursor_pos(&mut self, pos: (u16, u16)) { + self.working_details.cursor_col = pos.0; + } +} + +/// Split the input into strings that are at most `max_length` (in columns, not in chars) long +/// The split is done at whitespace if possible +fn split_string(input_str: &str, max_length: usize) -> Vec { + let whitespace_split = input_str.split_whitespace(); + let mut words = Vec::new(); + + for word in whitespace_split { + let word_len_cols = word.width(); + + if word_len_cols > max_length { + let mut width = 0; + let mut substring = String::new(); + for grapheme in word.graphemes(true) { + let grapheme_width = grapheme.width(); + // Some unicode characters can have a width of multiple rows + if grapheme_width > max_length { + continue; + } + if width + grapheme_width > max_length { + words.push(substring); + substring = String::from(grapheme); + width = grapheme_width; + } else { + substring.push_str(grapheme); + width += grapheme_width; + } + } + if !substring.is_empty() { + words.push(substring); + } + } else { + words.push(word.to_string()); + } + } + + let mut result = Vec::new(); + let mut string = String::new(); + + for word in words { + if string.width() + word.width() > max_length { + result.push(string.trim_end().to_string()); + string = word; + string.push(' '); + } else { + string.push_str(&word); + string.push(' '); + } + } + + if !string.trim_end().is_empty() { + result.push(string.trim_end().to_string()); + } + + result +} + +/// Truncate a list of strings using the provided truncation characters +fn truncate_string_list(list: &mut [String], truncation_chars: &str) { + let truncation_chars: Vec = truncation_chars.chars().rev().collect(); + let truncation_len = truncation_chars.len(); + let mut to_replace = truncation_len; + + 'outer: for line in list.iter_mut().rev() { + let chars = UnicodeSegmentation::graphemes(line.as_str(), true).collect::>(); + let mut new_line = String::new(); + for grapheme in chars.into_iter().rev() { + if to_replace > 0 { + new_line.insert_str( + 0, + &truncation_chars[truncation_len - to_replace].to_string(), + ); + to_replace -= 1; + } else { + new_line.insert_str(0, grapheme); + } + } + *line = new_line; + if to_replace == 0 { + break 'outer; + } + } +} + +#[cfg(test)] +mod tests { + use crate::Span; + + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + #[case( + "", + 10, + vec![] + )] + #[case( + "description", + 15, + vec![ + "description".into(), + ] + )] + #[case( + "this is a description", + 10, + vec![ + "this is a".into(), + "descriptio".into(), + "n".into(), + ] + )] + #[case( + "this is another description", + 2, + vec![ + "th".into(), + "is".into(), + "is".into(), + "an".into(), + "ot".into(), + "he".into(), + "r".into(), + "de".into(), + "sc".into(), + "ri".into(), + "pt".into(), + "io".into(), + "n".into(), + ] + )] + #[case( + "this is a description", + 10, + vec![ + "this is a".into(), + "descriptio".into(), + "n".into(), + ] + )] + #[case( + "this is a description", + 10, + vec![ + "this is a".into(), + "descriptio".into(), + "n".into(), + ] + )] + #[case( + "this is a description", + 12, + vec![ + "this is a".into(), + "description".into(), + ] + )] + #[case( + "test", + 1, + vec![ + "t".into(), + "e".into(), + "s".into(), + "t".into(), + ] + )] + #[case( + "😊a😊 😊bc de😊fg", + 2, + vec![ + "😊".into(), + "a".into(), + "😊".into(), + "😊".into(), + "bc".into(), + "de".into(), + "😊".into(), + "fg".into(), + ] + )] + #[case( + "😊", + 1, + vec![], + )] + #[case( + "t😊e😊s😊t", + 1, + vec![ + "t".into(), + "e".into(), + "s".into(), + "t".into(), + ] + )] + + fn test_split_string( + #[case] input: &str, + #[case] max_width: usize, + #[case] expected: Vec, + ) { + let result = split_string(input, max_width); + + assert_eq!(result, expected) + } + + #[rstest] + #[case( + &mut vec![ + "this is a description".into(), + "that will be truncate".into(), + "d".into(), + ], + "...", + vec![ + "this is a description".into(), + "that will be trunca..".into(), + ".".into(), + ] + )] + #[case( + &mut vec![ + "this is a description".into(), + "that will be truncate".into(), + "d".into(), + ], + "....", + vec![ + "this is a description".into(), + "that will be trunc...".into(), + ".".into(), + ] + )] + #[case( + &mut vec![ + "😊a😊 😊bc de😊fg".into(), + "😊a😊 😊bc de😊fg".into(), + "😊a😊 😊bc de😊fg".into(), + ], + "...", + vec![ + "😊a😊 😊bc de😊fg".into(), + "😊a😊 😊bc de😊fg".into(), + "😊a😊 😊bc de...".into(), + ] + )] + #[case( + &mut vec![ + "t".into(), + "e".into(), + "s".into(), + "t".into(), + ], + "..", + vec![ + "t".into(), + "e".into(), + ".".into(), + ".".into(), + ] + )] + #[case( + &mut vec![ + "😊".into(), + "😊".into(), + "s".into(), + "t".into(), + ], + "..😊", + vec![ + "😊".into(), + ".".into(), + ".".into(), + "😊".into(), + ] + )] + #[case( + &mut vec![ + "".into(), + ], + "test", + vec![ + "".into() + ], + )] + #[case( + &mut vec![ + "t".into(), + "e".into(), + "s".into(), + "t".into() + ], + "", + vec![ + "t".into(), + "e".into(), + "s".into(), + "t".into() + ], + )] + + fn test_truncate_list_string( + #[case] input: &mut Vec, + #[case] truncation_chars: &str, + #[case] expected: Vec, + ) { + truncate_string_list(input, truncation_chars); + + assert_eq!(*input, expected) + } + + macro_rules! partial_completion_tests { + (name: $test_group_name:ident, completions: $completions:expr, test_cases: $($name:ident: $value:expr,)*) => { + mod $test_group_name { + use crate::{menu::Menu, ColumnarMenu, core_editor::Editor, enums::UndoBehavior}; + use super::FakeCompleter; + + $( + #[test] + fn $name() { + let (input, expected) = $value; + let mut menu = ColumnarMenu::default(); + let mut editor = Editor::default(); + editor.set_buffer(input.to_string(), UndoBehavior::CreateUndoPoint); + let mut completer = FakeCompleter::new(&$completions); + + menu.can_partially_complete(false, &mut editor, &mut completer); + + assert_eq!(editor.get_buffer(), expected); + } + )* + } + } + } + + partial_completion_tests! { + name: partial_completion_prefix_matches, + completions: ["build.rs", "build-all.sh"], + + test_cases: + empty_completes_prefix: ("", "build"), + partial_completes_shared_prefix: ("bui", "build"), + full_prefix_completes_nothing: ("build", "build"), + } + + partial_completion_tests! { + name: partial_completion_fuzzy_matches, + completions: ["build.rs", "build-all.sh", "prepare-build.sh"], + + test_cases: + no_shared_prefix_completes_nothing: ("", ""), + shared_prefix_completes_nothing: ("bui", "bui"), + } + + partial_completion_tests! { + name: partial_completion_fuzzy_same_prefix_matches, + completions: ["build.rs", "build-all.sh", "build-all-tests.sh"], + + test_cases: + // assure "all" does not get replaced with shared prefix "build" + completes_no_shared_prefix: ("all", "all"), + } + + struct FakeCompleter { + completions: Vec, + } + + impl FakeCompleter { + fn new(completions: &[&str]) -> Self { + Self { + completions: completions.iter().map(|c| c.to_string()).collect(), + } + } + } + + impl Completer for FakeCompleter { + fn complete(&mut self, _line: &str, pos: usize) -> Vec { + self.completions + .iter() + .map(|c| fake_suggestion(c, pos)) + .collect() + } + } + + fn fake_suggestion(name: &str, pos: usize) -> Suggestion { + Suggestion { + value: name.to_string(), + description: None, + extra: None, + span: Span { start: 0, end: pos }, + append_whitespace: false, + } + } + + #[test] + fn test_menu_replace_backtick() { + // https://github.com/nushell/nushell/issues/7885 + let mut completer = FakeCompleter::new(&["file1.txt", "file2.txt"]); + let mut menu = IdeMenu::default().with_name("testmenu"); + let mut editor = Editor::default(); + + // backtick at the end of the line + editor.set_buffer("file1.txt`".to_string(), UndoBehavior::CreateUndoPoint); + + menu.update_values(&mut editor, &mut completer); + + menu.replace_in_buffer(&mut editor); + + // After replacing the editor, make sure insertion_point is at the right spot + assert!( + editor.is_cursor_at_buffer_end(), + "cursor should be at the end after completion" + ); + } +} diff --git a/src/menu/list_menu.rs b/src/menu/list_menu.rs index d90e3672..81f12c29 100644 --- a/src/menu/list_menu.rs +++ b/src/menu/list_menu.rs @@ -670,6 +670,10 @@ impl Menu for ListMenu { fn min_rows(&self) -> u16 { self.max_lines + 1 } + + fn set_cursor_pos(&mut self, _pos: (u16, u16)) { + // The list menu does not need the cursor position + } } fn number_of_lines(entry: &str, max_lines: usize, terminal_columns: u16) -> u16 { diff --git a/src/menu/mod.rs b/src/menu/mod.rs index fb559583..65a56470 100644 --- a/src/menu/mod.rs +++ b/src/menu/mod.rs @@ -1,4 +1,5 @@ mod columnar_menu; +mod ide_menu; mod list_menu; pub mod menu_functions; @@ -6,6 +7,7 @@ use crate::core_editor::Editor; use crate::History; use crate::{completion::history::HistoryCompleter, painting::Painter, Completer, Suggestion}; pub use columnar_menu::ColumnarMenu; +pub use ide_menu::IdeMenu; pub use list_menu::ListMenu; use nu_ansi_term::{Color, Style}; @@ -119,6 +121,8 @@ pub trait Menu: Send { /// Gets cached values from menu that will be displayed fn get_values(&self) -> &[Suggestion]; + /// Sets the position of the cursor (currently only required by the IDE menu) + fn set_cursor_pos(&mut self, pos: (u16, u16)); } /// Allowed menus in Reedline @@ -312,4 +316,8 @@ impl Menu for ReedlineMenu { fn get_values(&self) -> &[Suggestion] { self.as_ref().get_values() } + + fn set_cursor_pos(&mut self, pos: (u16, u16)) { + self.as_mut().set_cursor_pos(pos); + } } diff --git a/src/painting/painter.rs b/src/painting/painter.rs index 1c6769be..0432b3b0 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -271,17 +271,13 @@ impl Painter { self.stdout .queue(Print(&coerce_crlf(&lines.prompt_str_left)))?; - let prompt_indicator = match menu { - Some(menu) => menu.indicator(), - None => &lines.prompt_indicator, - }; - if use_ansi_coloring { self.stdout .queue(SetForegroundColor(prompt.get_indicator_color()))?; } - self.stdout.queue(Print(&coerce_crlf(prompt_indicator)))?; + self.stdout + .queue(Print(&coerce_crlf(&lines.prompt_indicator)))?; if use_ansi_coloring { self.stdout @@ -327,12 +323,7 @@ impl Painter { // indicator is printed in the same line as the first line of the buffer let prompt_lines = lines.prompt_lines_with_wrap(screen_width) as usize; - let prompt_indicator = match menu { - Some(menu) => menu.indicator(), - None => &lines.prompt_indicator, - }; - - 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; @@ -357,7 +348,7 @@ impl Painter { // 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(&coerce_crlf(indicator_skipped)))?; if use_ansi_coloring { diff --git a/src/painting/prompt_lines.rs b/src/painting/prompt_lines.rs index aa9c35de..2df8964b 100644 --- a/src/painting/prompt_lines.rs +++ b/src/painting/prompt_lines.rs @@ -88,6 +88,22 @@ impl<'prompt> PromptLines<'prompt> { lines.saturating_sub(1) as u16 } + /// Calculate the cursor pos, based on the buffer and prompt. + /// The height is relative to the prompt + pub(crate) fn cursor_pos(&self, terminal_columns: u16) -> (u16, u16) { + // If we have a multiline prompt (e.g starship), we expect the cursor to be on the last line + let prompt_str = self.prompt_str_left.lines().last().unwrap_or_default(); + let prompt_width = line_width(&format!("{}{}", prompt_str, self.prompt_indicator)); + let buffer_width = line_width(&self.before_cursor); + + let total_width = prompt_width + buffer_width; + + let cursor_x = (total_width % terminal_columns as usize) as u16; + let cursor_y = (total_width / terminal_columns as usize) as u16; + + (cursor_x, cursor_y) + } + /// Total lines that the prompt uses considering that it may wrap the screen pub(crate) fn prompt_lines_with_wrap(&self, screen_width: u16) -> u16 { let complete_prompt = self.prompt_str_left.to_string() + &self.prompt_indicator;