diff --git a/examples/fuzzy_completions.rs b/examples/fuzzy_completions.rs new file mode 100644 index 00000000..65c83e41 --- /dev/null +++ b/examples/fuzzy_completions.rs @@ -0,0 +1,139 @@ +// Modifies the completions example to demonstrate highlighting of fuzzy completions +// cargo run --example fuzzy_completions +// +// One of the suggestions is "multiple 汉 by̆tes字👩🏾". Try typing in "y" or "👩" and note how +// the entire grapheme "y̆" or "👩🏾" is highlighted (might not look right in your terminal). + +use reedline::{ + default_emacs_keybindings, ColumnarMenu, Completer, DefaultPrompt, EditCommand, Emacs, KeyCode, + KeyModifiers, Keybindings, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, + Suggestion, +}; +use std::io; + +struct HomegrownFuzzyCompleter(Vec); + +impl Completer for HomegrownFuzzyCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + // Grandma's fuzzy matching recipe. She swears it's better than that crates.io-bought stuff + self.0 + .iter() + .filter_map(|command_str| { + let command = command_str.as_bytes(); + let mut start = 0; + let mut match_indices = Vec::new(); + for l in line.as_bytes() { + if start == command.len() { + break; + } + let mut i = start; + while i < command.len() && *l != command[i] { + i += 1; + } + if i < command.len() { + match_indices.push(i); + start = i + 1; + } + } + if match_indices.is_empty() || match_indices.len() * 2 < pos { + None + } else { + Some(Suggestion { + value: command_str.to_string(), + description: None, + style: None, + extra: None, + span: Span::new(pos - line.len(), pos), + append_whitespace: false, + match_indices: Some(match_indices), + }) + } + }) + .collect() + } +} + +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, + ]), + ); + keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Enter, + ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), + ); +} + +fn main() -> io::Result<()> { + // Number of columns + let columns: u16 = 4; + // Column width + let col_width: Option = None; + // Column padding + let col_padding: usize = 2; + + let commands = vec![ + "test".into(), + "clear".into(), + "exit".into(), + "history 1".into(), + "history 2".into(), + "logout".into(), + "login".into(), + "hello world".into(), + "hello world reedline".into(), + "hello world something".into(), + "hello world another".into(), + "hello world 1".into(), + "hello world 2".into(), + "hello another very large option for hello word that will force one column".into(), + "this is the reedline crate".into(), + "abaaabas".into(), + "abaaacas".into(), + "ababac".into(), + "abacaxyc".into(), + "abadarabc".into(), + "multiple 汉 by̆tes字👩🏾".into(), + ]; + + let completer = Box::new(HomegrownFuzzyCompleter(commands)); + + // Use the interactive menu to select options from the completer + let columnar_menu = ColumnarMenu::default() + .with_name("completion_menu") + .with_columns(columns) + .with_column_width(col_width) + .with_column_padding(col_padding); + + let completion_menu = Box::new(columnar_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/completion/base.rs b/src/completion/base.rs index 909c467b..77f207e0 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -90,4 +90,7 @@ pub struct Suggestion { /// Whether to append a space after selecting this suggestion. /// This helps to avoid that a completer repeats the complete suggestion. pub append_whitespace: bool, + /// Indices of the characters in the suggestion that matched the typed text. + /// Useful if using fuzzy matching. + pub match_indices: Option>, } diff --git a/src/completion/default.rs b/src/completion/default.rs index 882debb6..bfcb815e 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -55,17 +55,17 @@ impl Completer for DefaultCompleter { /// assert_eq!( /// completions.complete("bat",3), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, match_indices: None}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, match_indices: None}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, match_indices: None}, /// ]); /// /// assert_eq!( /// completions.complete("to the\r\nbat",11), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, match_indices: None}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, match_indices: None}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, match_indices: None}, /// ]); /// ``` fn complete(&mut self, line: &str, pos: usize) -> Vec { @@ -110,6 +110,7 @@ impl Completer for DefaultCompleter { extra: None, span, append_whitespace: false, + match_indices: None, } }) .filter(|t| t.value.len() > (t.span.end - t.span.start)) @@ -182,15 +183,15 @@ impl DefaultCompleter { /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), - /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}]); + /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, match_indices: None}]); /// /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), /// vec![ - /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, - /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, + /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, match_indices: None}, + /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, match_indices: None}, /// ]); /// ``` pub fn with_inclusions(incl: &[char]) -> Self { @@ -384,6 +385,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + match_indices: None, }, Suggestion { value: "number".into(), @@ -392,6 +394,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + match_indices: None, }, Suggestion { value: "nushell".into(), @@ -400,6 +403,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + match_indices: None, }, ] ); @@ -428,6 +432,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + match_indices: None, }, Suggestion { value: "this is the reedline crate".into(), @@ -436,6 +441,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + match_indices: None, }, Suggestion { value: "this is the reedline crate".into(), @@ -444,6 +450,7 @@ mod tests { extra: None, span: Span { start: 0, end: 9 }, append_whitespace: false, + match_indices: None, }, ] ); diff --git a/src/completion/history.rs b/src/completion/history.rs index fda3384f..b21fe947 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -65,6 +65,7 @@ impl<'menu> HistoryCompleter<'menu> { extra: None, span, append_whitespace: false, + match_indices: None, } } } diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 6eac27ab..d182c4a4 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1,7 +1,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; @@ -301,32 +303,27 @@ impl ColumnarMenu { if use_ansi_coloring { let match_len = self.working_details.shortest_base_string.len(); - // Split string so the match text can be styled - let (match_str, remaining_str) = suggestion.value.split_at(match_len); - - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let suggestion_style = suggestion.style.unwrap_or(self.settings.color.text_style); let left_text_size = self.longest_suggestion + self.default_details.col_padding; let right_text_size = self.get_width().saturating_sub(left_text_size); - let max_remaining = left_text_size.saturating_sub(match_str.width()); - let max_match = max_remaining.saturating_sub(remaining_str.width()); + let default_indices = (0..match_len).collect(); + let match_indices = suggestion + .match_indices + .as_ref() + .unwrap_or(&default_indices); if index == self.index() { if let Some(description) = &suggestion.description { format!( - "{}{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}{}", - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - &remaining_str, - RESET, + "{:left_text_size$}{}{}{}{}{}", + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.selected_match_style, + &self.settings.color.selected_text_style + ), self.settings.color.description_style.prefix(), self.settings.color.selected_text_style.prefix(), description @@ -339,15 +336,13 @@ impl ColumnarMenu { ) } else { format!( - "{}{}{}{}{}{}{}{}{:>empty$}{}", - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, - RESET, + "{}{:>empty$}{}", + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.selected_match_style, + &self.settings.color.selected_text_style + ), "", self.end_of_line(column), empty = empty_space, @@ -355,14 +350,13 @@ impl ColumnarMenu { } } else if let Some(description) = &suggestion.description { format!( - "{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}", - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, - RESET, + "{:left_text_size$}{}{}{}{}", + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.match_style, + &suggestion_style + ), self.settings.color.description_style.prefix(), description .chars() @@ -374,14 +368,13 @@ impl ColumnarMenu { ) } else { format!( - "{}{}{}{}{}{}{}{}{:>empty$}{}{}", - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, - RESET, + "{}{}{:>empty$}{}{}", + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.match_style, + &suggestion_style + ), self.settings.color.description_style.prefix(), "", RESET, @@ -750,6 +743,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + match_indices: None, } } diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index 5dc325f7..0785a55a 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -1,7 +1,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; @@ -514,41 +516,42 @@ impl IdeMenu { if use_ansi_coloring { let match_len = self.working_details.shortest_base_string.len(); - // Split string so the match text can be styled - let (match_str, remaining_str) = string.split_at(match_len); + let default_indices = (0..match_len).collect(); + let match_indices = suggestion + .match_indices + .as_ref() + .unwrap_or(&default_indices); - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let suggestion_style = suggestion.style.unwrap_or(self.settings.color.text_style); if index == self.index() { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.selected_match_style, + &self.settings.color.selected_text_style + ), " ".repeat(padding_right), RESET, vertical_border, ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, + style_suggestion( + &suggestion.value, + match_indices, + &self.settings.color.match_style, + &suggestion_style + ), " ".repeat(padding_right), RESET, vertical_border, @@ -1382,6 +1385,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + match_indices: None, } } diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index bf9ded7b..1ab3d4a2 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,4 +1,7 @@ //! Collection of common functions that can be used to create menus +use nu_ansi_term::{AnsiStrings, Style}; +use unicode_segmentation::UnicodeSegmentation; + use crate::{Editor, Suggestion, UndoBehavior}; /// Index result obtained from parsing a string with an index marker @@ -353,6 +356,42 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo } } +/// Style a suggestion to be shown in a completer menu +/// +/// * `match_indices` - Indices of bytes that matched the typed text +pub fn style_suggestion( + suggestion: &str, + match_indices: &[usize], + match_style: &Style, + text_style: &Style, +) -> String { + let mut parts = Vec::new(); + let mut prev_styled = false; + let mut part_start = 0; + for (grapheme_start, grapheme) in suggestion.grapheme_indices(true) { + let is_match = + (grapheme_start..(grapheme_start + grapheme.len())).any(|i| match_indices.contains(&i)); + if is_match && !prev_styled { + if part_start < grapheme_start { + parts.push(text_style.paint(&suggestion[part_start..grapheme_start])); + } + part_start = grapheme_start; + prev_styled = true; + } else if !is_match && prev_styled { + if part_start < grapheme_start { + parts.push(match_style.paint(&suggestion[part_start..grapheme_start])); + } + part_start = grapheme_start; + prev_styled = false; + } + } + + let last_style = if prev_styled { match_style } else { text_style }; + parts.push(last_style.paint(&suggestion[part_start..])); + + AnsiStrings(&parts).to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -611,6 +650,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + match_indices: None, }) .collect(); let res = find_common_string(&input); @@ -631,6 +671,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + match_indices: None, }) .collect(); let res = find_common_string(&input); @@ -686,6 +727,7 @@ mod tests { extra: None, span: Span::new(start, end), append_whitespace: false, + match_indices: None, }), &mut editor, ); @@ -696,4 +738,29 @@ mod tests { assert_eq!(orig_buffer, editor.get_buffer()); assert_eq!(orig_insertion_point, editor.insertion_point()); } + + #[test] + fn style_fuzzy_suggestion() { + let match_style = Style::new().italic(); + let text_style = Style::new().dimmed(); + + let expected = AnsiStrings(&[ + match_style.paint("ab"), + text_style.paint("c"), + match_style.paint("汉"), + text_style.paint("d"), + match_style.paint("y̆"), + text_style.paint("e"), + ]) + .to_string(); + let match_indices = &[ + 0, 1, // ab + 5, // the last (third) byte of 汉 + 7, // the first byte of y̆ + ]; + assert_eq!( + expected, + style_suggestion("abc汉dy̆e", match_indices, &match_style, &text_style) + ); + } }