diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index ee357aa3..f73b121c 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1,4 +1,4 @@ -use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; +use super::{menu_functions::split_suggestion, Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, @@ -6,6 +6,7 @@ use crate::{ Completer, Suggestion, }; use nu_ansi_term::ansi::RESET; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; /// Default values used as reference for the menu. These values are set during @@ -38,8 +39,8 @@ struct ColumnDetails { pub columns: u16, /// Column width pub col_width: usize, - /// The shortest of the strings, which the suggestions are based on - pub shortest_base_string: String, + /// Number of graphemes in the shortest string, which the suggestions are based on + pub match_len: usize, } /// Menu to present suggestions in a columnar fashion @@ -299,23 +300,8 @@ impl ColumnarMenu { use_ansi_coloring: bool, ) -> String { if use_ansi_coloring { - // strip quotes - let is_quote = |c: char| "`'\"".contains(c); - let shortest_base = &self.working_details.shortest_base_string; - let shortest_base = shortest_base - .strip_prefix(is_quote) - .unwrap_or(shortest_base); - let match_len = shortest_base.len(); - - // Split string so the match text can be styled - let skip_len = suggestion - .value - .chars() - .take_while(|c| is_quote(*c)) - .count(); - let (match_str, remaining_str) = suggestion - .value - .split_at((match_len + skip_len).min(suggestion.value.len())); + let (match_str, remaining_str) = + split_suggestion(&suggestion.value, self.working_details.match_len); let suggestion_style_prefix = suggestion .style @@ -510,10 +496,16 @@ impl Menu for ColumnarMenu { let (values, base_ranges) = completer.complete_with_base_ranges(&input, pos); self.values = values; - self.working_details.shortest_base_string = base_ranges + self.working_details.match_len = base_ranges .iter() - .map(|range| editor.get_buffer()[range.clone()].to_string()) - .min_by_key(|s| s.width()) + .map(|range| { + let s = &editor.get_buffer()[range.clone()]; + s.strip_prefix(['`', '\'', '"']) + .unwrap_or(s) + .graphemes(true) + .count() + }) + .min() .unwrap_or_default(); self.reset_position(); diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index 0e8c03bc..216854b0 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -1,4 +1,4 @@ -use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; +use super::{menu_functions::split_suggestion, Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, @@ -125,8 +125,8 @@ struct IdeMenuDetails { pub space_right: u16, /// Corrected description offset, based on the available space pub description_offset: u16, - /// The shortest of the strings, which the suggestions are based on - pub shortest_base_string: String, + /// Number of graphemes in the shortest string, which the suggestions are based on + pub match_len: usize, } /// Menu to present suggestions like similar to Ide completion menus @@ -512,18 +512,8 @@ impl IdeMenu { }; if use_ansi_coloring { - // strip quotes - let is_quote = |c: char| "`'\"".contains(c); - let shortest_base = &self.working_details.shortest_base_string; - let shortest_base = shortest_base - .strip_prefix(is_quote) - .unwrap_or(shortest_base); - let match_len = shortest_base.len().min(string.len()); - - // Split string so the match text can be styled - let skip_len = string.chars().take_while(|c| is_quote(*c)).count(); let (match_str, remaining_str) = - string.split_at((match_len + skip_len).min(string.len())); + split_suggestion(&suggestion.value, self.working_details.match_len); let suggestion_style_prefix = suggestion .style @@ -642,10 +632,16 @@ impl Menu for IdeMenu { let (values, base_ranges) = completer.complete_with_base_ranges(&input, pos); self.values = values; - self.working_details.shortest_base_string = base_ranges + self.working_details.match_len = base_ranges .iter() - .map(|range| editor.get_buffer()[range.clone()].to_string()) - .min_by_key(|s| s.len()) + .map(|range| { + let s = &editor.get_buffer()[range.clone()]; + s.strip_prefix(['`', '\'', '"']) + .unwrap_or(s) + .graphemes(true) + .count() + }) + .min() .unwrap_or_default(); self.reset_position(); @@ -703,9 +699,7 @@ impl Menu for IdeMenu { let mut cursor_pos = self.working_details.cursor_col; if self.default_details.correct_cursor_pos { - let base_string = &self.working_details.shortest_base_string; - - cursor_pos = cursor_pos.saturating_sub(base_string.width() as u16); + cursor_pos = cursor_pos.saturating_sub(self.working_details.match_len as u16); } let border_width = if self.default_details.border.is_some() { @@ -1432,7 +1426,7 @@ mod tests { space_left: 50, space_right: 50, description_offset: 50, - shortest_base_string: String::new(), + match_len: 0, }; let mut editor = Editor::default(); // backtick at the end of the line @@ -1460,7 +1454,7 @@ mod tests { space_left: 50, space_right: 50, description_offset: 50, - shortest_base_string: String::new(), + match_len: 0, }; let mut editor = Editor::default(); diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index 84575f0c..0c42ae97 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,4 +1,6 @@ //! Collection of common functions that can be used to create menus +use unicode_segmentation::UnicodeSegmentation; + use crate::{Editor, Suggestion, UndoBehavior}; /// Index result obtained from parsing a string with an index marker @@ -354,6 +356,27 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo } } +/// Split suggestion based on number of graphemes in shortest matched string +/// +/// For highlighting prefix-matched suggestions +pub fn split_suggestion(sugg: &str, match_len: usize) -> (&str, &str) { + let mut graphemes = sugg.grapheme_indices(true).peekable(); + let Some((_, first)) = graphemes.peek() else { + return ("", ""); + }; + let skip = if match_len > 0 && (*first == "`" || *first == "'" || *first == "\"") { + 1 + match_len + } else { + match_len + }; + + if let Some((ind, _)) = graphemes.nth(skip) { + sugg.split_at(ind) + } else { + (sugg, "") + } +} + #[cfg(test)] mod tests { use super::*; @@ -697,4 +720,27 @@ mod tests { assert_eq!(orig_buffer, editor.get_buffer()); assert_eq!(orig_insertion_point, editor.insertion_point()); } + + #[rstest] + #[case("おはよう", "おは", "おは")] + #[case("`'おはよう)", "'お", "`'お")] + #[case("'a", "", "")] + #[case("おはよう", "a", "お")] + #[case("abcd", "お", "a")] + fn test_split_suggestions( + #[case] sugg: &str, + #[case] typed: &str, + #[case] exp_match_str: &str, + ) { + let (got_match_str, _) = split_suggestion(sugg, typed.graphemes(true).count()); + assert_eq!(exp_match_str, got_match_str) + } + + #[test] + fn test_split_suggestions_shorter_than_typed() { + let sugg = "a"; + let typed = "abcd"; + let (got_match_str, _) = split_suggestion(sugg, typed.graphemes(true).count()); + assert_eq!("a", got_match_str) + } }