diff --git a/examples/demo.rs b/examples/demo.rs index 83c6883ce..7b91d1b10 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -16,7 +16,7 @@ use { #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] use reedline::FileBackedHistory; -use reedline::{CursorConfig, MenuBuilder}; +use reedline::{CursorConfig, MenuBuilder, OutputMode}; fn main() -> reedline::Result<()> { println!("Ctrl-D to quit"); @@ -102,7 +102,9 @@ fn main() -> reedline::Result<()> { ColumnarMenu::default().with_name("completion_menu"), ))) .with_menu(ReedlineMenu::HistoryMenu(Box::new( - ListMenu::default().with_name("history_menu"), + ListMenu::default() + .with_name("history_menu") + .with_output_mode(OutputMode::FullBuffer), ))); let edit_mode: Box = if vi_mode { diff --git a/src/completion/history.rs b/src/completion/history.rs index a48cffca0..eb11d2906 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -52,6 +52,9 @@ impl<'menu> HistoryCompleter<'menu> { Self(history) } + /// Assumes `line.len() <= pos` (i.e. `line` is the cursor-prefix slice). + /// Update this span calculation before HistoryMenu opts into `InputMode::FullBuffer`, + /// where `line` would be the entire buffer and `pos - line.len()` would underflow. fn create_suggestion(&self, line: &str, pos: usize, value: &str) -> Suggestion { let span = Span { start: pos - line.len(), diff --git a/src/lib.rs b/src/lib.rs index 69bf257c2..508233697 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -291,8 +291,9 @@ pub use validator::{DefaultValidator, ValidationResult, Validator}; mod menu; pub use menu::{ - menu_functions, ColumnarMenu, DescriptionMenu, DescriptionMode, IdeMenu, ListMenu, Menu, - MenuBuilder, MenuEvent, MenuSettings, MenuTextStyle, ReedlineMenu, TraversalDirection, + menu_functions, ColumnarMenu, DescriptionMenu, DescriptionMode, IdeMenu, InputMode, ListMenu, + Menu, MenuBuilder, MenuEvent, MenuSettings, MenuTextStyle, OutputMode, ReedlineMenu, + TraversalDirection, }; mod terminal_extensions; diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index f12bcf3bd..39aee98f7 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -2,8 +2,8 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ - can_partially_complete, completer_input, floor_char_boundary, get_match_indices, - replace_in_buffer, style_suggestion, truncate_with_ansi, + can_partially_complete, floor_char_boundary, get_match_indices, replace_in_buffer, + resolve_completer_input, style_suggestion, truncate_with_ansi, }, painting::Painter, Completer, Suggestion, @@ -551,16 +551,7 @@ impl Menu for ColumnarMenu { /// Updates menu values fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { - if self.settings.only_buffer_difference && self.input.is_none() { - self.input = Some(editor.get_buffer().to_string()); - } - - let (input, pos) = completer_input( - editor.get_buffer(), - editor.insertion_point(), - self.input.as_deref(), - self.settings.only_buffer_difference, - ); + let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); let (values, base_ranges) = completer.complete_with_base_ranges(&input, pos); @@ -681,7 +672,7 @@ impl Menu for ColumnarMenu { /// The buffer gets replaced in the Span location fn replace_in_buffer(&self, editor: &mut Editor) { - replace_in_buffer(self.get_value(), editor); + replace_in_buffer(self.get_value(), editor, self.settings.output_mode); } /// Minimum rows that should be displayed by the menu diff --git a/src/menu/description_menu.rs b/src/menu/description_menu.rs index a40552f21..b55eec9f7 100644 --- a/src/menu/description_menu.rs +++ b/src/menu/description_menu.rs @@ -1,7 +1,7 @@ use { super::MenuSettings, crate::{ - menu_functions::{completer_input, replace_in_buffer}, + menu_functions::{replace_in_buffer, resolve_completer_input}, Completer, Editor, Menu, MenuBuilder, MenuEvent, Painter, Suggestion, }, nu_ansi_term::ansi::RESET, @@ -443,16 +443,7 @@ impl Menu for DescriptionMenu { /// Updates menu values fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { - if self.settings.only_buffer_difference && self.input.is_none() { - self.input = Some(editor.get_buffer().to_string()); - } - - let (input, pos) = completer_input( - editor.get_buffer(), - editor.insertion_point(), - self.input.as_deref(), - self.settings.only_buffer_difference, - ); + let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); self.values = completer.complete(&input, pos); self.reset_position(); @@ -593,7 +584,7 @@ impl Menu for DescriptionMenu { .expect("the example index is always checked"); suggestion.value.clone_from(example); } - replace_in_buffer(Some(suggestion), editor); + replace_in_buffer(Some(suggestion), editor, self.settings.output_mode); } } diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index ba49ac68b..6919aeded 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -2,8 +2,8 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ - can_partially_complete, completer_input, floor_char_boundary, get_match_indices, - replace_in_buffer, style_suggestion, truncate_with_ansi, + can_partially_complete, floor_char_boundary, get_match_indices, replace_in_buffer, + resolve_completer_input, style_suggestion, truncate_with_ansi, }, painting::Painter, Completer, Suggestion, @@ -620,16 +620,7 @@ impl Menu for IdeMenu { /// Update menu values fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { - if self.settings.only_buffer_difference && self.input.is_none() { - self.input = Some(editor.get_buffer().to_string()); - } - - let (input, pos) = completer_input( - editor.get_buffer(), - editor.insertion_point(), - self.input.as_deref(), - self.settings.only_buffer_difference, - ); + let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); let (values, base_ranges) = completer.complete_with_base_ranges(&input, pos); self.values = values; @@ -817,7 +808,7 @@ impl Menu for IdeMenu { /// The buffer gets replaced in the Span location fn replace_in_buffer(&self, editor: &mut Editor) { - replace_in_buffer(self.get_value(), editor); + replace_in_buffer(self.get_value(), editor, self.settings.output_mode); } /// Minimum rows that should be displayed by the menu diff --git a/src/menu/list_menu.rs b/src/menu/list_menu.rs index 071d5ff5a..29016c489 100644 --- a/src/menu/list_menu.rs +++ b/src/menu/list_menu.rs @@ -2,7 +2,7 @@ use { super::{menu_functions::parse_selection_char, Menu, MenuBuilder, MenuEvent, MenuSettings}, crate::{ core_editor::Editor, - menu_functions::{completer_input, replace_in_buffer}, + menu_functions::{replace_in_buffer, resolve_completer_input}, painting::{estimate_single_line_wraps, Painter}, Completer, Suggestion, }, @@ -346,16 +346,7 @@ impl Menu for ListMenu { /// Collecting the value from the completer to be shown in the menu fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { - if self.settings.only_buffer_difference && self.input.is_none() { - self.input = Some(editor.get_buffer().to_string()); - } - - let (input, pos) = completer_input( - editor.get_buffer(), - editor.insertion_point(), - self.input.as_deref(), - self.settings.only_buffer_difference, - ); + let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); let parsed = parse_selection_char(&input, SELECTION_CHAR); self.update_row_pos(parsed.index); @@ -412,7 +403,7 @@ impl Menu for ListMenu { /// The buffer gets cleared with the actual value fn replace_in_buffer(&self, editor: &mut Editor) { - replace_in_buffer(self.get_value(), editor); + replace_in_buffer(self.get_value(), editor, self.settings.output_mode); } fn update_working_details( diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index d883fc54c..8f3342d07 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -10,7 +10,10 @@ use nu_ansi_term::{ansi::RESET, Style}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::{Editor, Suggestion, UndoBehavior}; +use crate::{ + menu::{InputMode, MenuSettings, OutputMode}, + Editor, Suggestion, UndoBehavior, +}; /// Index result obtained from parsing a string with an index marker /// For example, the next string: @@ -272,31 +275,56 @@ pub fn string_difference<'a>(new_string: &'a str, old_string: &str) -> (usize, & /// Get the part of the line that should be given as input to the completer, as well /// as the index of the end of that piece of text /// -/// `prev_input` is the text in the buffer when the menu was activated. Needed if only_buffer_difference is true +/// `prev_input` is the text in the buffer when the menu was activated. Needed for `InputMode::Diff`. pub fn completer_input( buffer: &str, insertion_point: usize, prev_input: Option<&str>, - only_buffer_difference: bool, + input_mode: InputMode, ) -> (String, usize) { - if only_buffer_difference { - if let Some(old_string) = prev_input { - let (start, input) = string_difference(buffer, old_string); - if !input.is_empty() { - (input.to_owned(), start + input.len()) + match input_mode { + InputMode::FullBuffer => (buffer.to_owned(), insertion_point), + InputMode::CursorPrefix => { + // TODO previously, all but the list menu replaced newlines with spaces here + // The completers should be adapted to account for this, and tests need to be added + (buffer[..insertion_point].to_owned(), insertion_point) + } + InputMode::Diff => { + if let Some(old_string) = prev_input { + let (start, input) = string_difference(buffer, old_string); + if !input.is_empty() { + (input.to_owned(), start + input.len()) + } else { + (String::new(), insertion_point) + } } else { (String::new(), insertion_point) } - } else { - (String::new(), insertion_point) } - } else { - // TODO previously, all but the list menu replaced newlines with spaces here - // The completers should be adapted to account for this, and tests need to be added - (buffer[..insertion_point].to_owned(), insertion_point) } } +/// Stashes the buffer on first call when in `InputMode::Diff` (so later calls can diff +/// against the original), then resolves the completer input via [`completer_input`]. +/// +/// Centralises the input-resolution boilerplate shared by all menu `update_values` impls. +pub fn resolve_completer_input( + editor: &Editor, + saved_input: &mut Option, + settings: &MenuSettings, +) -> (String, usize) { + let mode = settings.effective_input_mode(); + if mode == InputMode::Diff && saved_input.is_none() { + *saved_input = Some(editor.get_buffer().to_string()); + } + completer_input( + editor.get_buffer(), + editor.insertion_point(), + saved_input.as_deref(), + mode, + ) +} + /// Find the closest index less than or equal to the current index that's a /// character boundary /// @@ -314,7 +342,11 @@ pub fn floor_char_boundary(s: &str, index: usize) -> usize { } /// Helper to accept a completion suggestion and edit the buffer -pub fn replace_in_buffer(value: Option, editor: &mut Editor) { +pub fn replace_in_buffer( + value: Option, + editor: &mut Editor, + output_mode: Option, +) { if let Some(Suggestion { mut value, span, @@ -322,8 +354,14 @@ pub fn replace_in_buffer(value: Option, editor: &mut Editor) { .. }) = value { - let end = floor_char_boundary(editor.get_buffer(), span.end); - let start = floor_char_boundary(editor.get_buffer(), span.start).min(end); + let buffer_len = editor.get_buffer().len(); + let (raw_start, raw_end) = match output_mode { + Some(OutputMode::FullBuffer) => (0, buffer_len), + Some(OutputMode::ExtendToEnd) => (span.start, buffer_len), + Some(OutputMode::SuggestedSpan) | None => (span.start, span.end), + }; + let end = floor_char_boundary(editor.get_buffer(), raw_end); + let start = floor_char_boundary(editor.get_buffer(), raw_start).min(end); if append_whitespace { value.push(' '); } @@ -948,24 +986,25 @@ mod tests { } #[rstest] - #[case("foobar", 6, None, false, "foobar", 6)] - #[case("foo\r\nbar", 5, None, false, "foo\r\n", 5)] - #[case("foo\nbar", 4, None, false, "foo\n", 4)] - #[case("foobar", 6, None, true, "", 6)] - #[case("foobar", 3, Some("foobar"), true, "", 3)] - #[case("foobar", 6, Some("foo"), true, "bar", 6)] - #[case("foobar", 6, Some("for"), true, "oba", 5)] + #[case("foobar", 6, None, InputMode::CursorPrefix, "foobar", 6)] + #[case("foo\r\nbar", 5, None, InputMode::CursorPrefix, "foo\r\n", 5)] + #[case("foo\nbar", 4, None, InputMode::CursorPrefix, "foo\n", 4)] + #[case("foobar", 6, None, InputMode::Diff, "", 6)] + #[case("foobar", 3, Some("foobar"), InputMode::Diff, "", 3)] + #[case("foobar", 6, Some("foo"), InputMode::Diff, "bar", 6)] + #[case("foobar", 6, Some("for"), InputMode::Diff, "oba", 5)] + #[case("foobar baz", 3, None, InputMode::FullBuffer, "foobar baz", 3)] fn test_completer_input( #[case] buffer: String, #[case] insertion_point: usize, #[case] prev_input: Option<&str>, - #[case] only_buffer_difference: bool, + #[case] input_mode: InputMode, #[case] output: String, #[case] pos: usize, ) { assert_eq!( (output, pos), - completer_input(&buffer, insertion_point, prev_input, only_buffer_difference) + completer_input(&buffer, insertion_point, prev_input, input_mode) ) } @@ -994,6 +1033,57 @@ mod tests { ..Default::default() }), &mut editor, + None, + ); + assert_eq!(new_buffer, editor.get_buffer()); + assert_eq!(new_insertion_point, editor.insertion_point()); + + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(orig_buffer, editor.get_buffer()); + assert_eq!(orig_insertion_point, editor.insertion_point()); + } + + #[rstest] + #[case::full_buffer( + "old content", + 11, + "new", + 3, + "new", + Span::new(0, 0), + OutputMode::FullBuffer + )] + #[case::extend_to_end( + "hello world", + 11, + "hello rust", + 10, + "rust", + Span::new(6, 8), + OutputMode::ExtendToEnd + )] + fn test_replace_in_buffer_with_output_mode( + #[case] orig_buffer: &str, + #[case] orig_insertion_point: usize, + #[case] new_buffer: &str, + #[case] new_insertion_point: usize, + #[case] value: String, + #[case] span: Span, + #[case] output_mode: OutputMode, + ) { + let mut editor = Editor::default(); + let mut line_buffer = LineBuffer::new(); + line_buffer.set_buffer(orig_buffer.to_owned()); + line_buffer.set_insertion_point(orig_insertion_point); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + replace_in_buffer( + Some(Suggestion { + value, + span, + ..Default::default() + }), + &mut editor, + Some(output_mode), ); assert_eq!(new_buffer, editor.get_buffer()); assert_eq!(new_insertion_point, editor.insertion_point()); diff --git a/src/menu/mod.rs b/src/menu/mod.rs index 8d1fc1652..5fee5999e 100644 --- a/src/menu/mod.rs +++ b/src/menu/mod.rs @@ -159,9 +159,15 @@ pub struct MenuSettings { color: MenuTextStyle, /// Menu marker when active marker: String, - /// Calls the completer using only the line buffer difference difference - /// after the menu was activated + /// Calls the completer using only the line buffer difference + /// after the menu was activated. Ignored if `input_mode` is set. only_buffer_difference: bool, + /// Optional override for completer input handling. + /// If `Some`, takes precedence over `only_buffer_difference`. + input_mode: Option, + /// Optional override for the buffer range replaced on selection. + /// If `None`, the menu uses `Suggestion::span` as-is. + output_mode: Option, } impl Default for MenuSettings { @@ -171,6 +177,8 @@ impl Default for MenuSettings { color: MenuTextStyle::default(), marker: "| ".to_string(), only_buffer_difference: false, + input_mode: None, + output_mode: None, } } } @@ -197,12 +205,66 @@ impl MenuSettings { self } - /// MenuSettings builder with only_buffer_difference + /// MenuSettings builder with only_buffer_difference. + /// Consider `with_input_mode` for finer control; the bool is ignored when + /// `input_mode` is set. #[must_use] pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self { self.only_buffer_difference = only_buffer_difference; self } + + /// Set the input mode. If set, this overrides `only_buffer_difference`. + #[must_use] + pub fn with_input_mode(mut self, mode: InputMode) -> Self { + self.input_mode = Some(mode); + self + } + + /// Set the output mode. If unset, the menu uses `Suggestion::span` as-is. + #[must_use] + pub fn with_output_mode(mut self, mode: OutputMode) -> Self { + self.output_mode = Some(mode); + self + } + + /// Resolves input_mode and only_buffer_difference into concrete InputMode. + /// `input_mode` wins if set; otherwise falls back to the bool. + pub fn effective_input_mode(&self) -> InputMode { + self.input_mode.unwrap_or(if self.only_buffer_difference { + InputMode::Diff + } else { + InputMode::CursorPrefix + }) + } +} + +/// Controls what the menu hands to its completer. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMode { + /// Completer receives only the text typed after menu activation. + /// Equivalent to `only_buffer_difference: true`. + Diff, + /// Completer receives the buffer up to the cursor (`buffer[..cursor]`). + /// Equivalent to `only_buffer_difference: false`. + CursorPrefix, + /// Completer receives the entire buffer including text after the cursor. + /// No bool equivalent. + FullBuffer, +} + +/// Controls what range of the buffer the menu replaces when a suggestion is selected. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + /// Replace the range specified by `Suggestion::span`. + /// Equivalent to leaving `output_mode` unset. + SuggestedSpan, + /// Replace the entire buffer (`0..buffer.len()`), ignoring `Suggestion::span`. + FullBuffer, + /// Keep `Suggestion::span.start`, force `end = buffer.len()`. + ExtendToEnd, } /// Common builder for all menus @@ -264,12 +326,27 @@ pub trait MenuBuilder: Menu + Sized { self } - /// Menu builder with new value for only_buffer_difference + /// Menu builder with new value for only_buffer_difference. + /// Ignored when `input_mode` is set; consider `with_input_mode` for finer control. #[must_use] fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self { self.settings_mut().only_buffer_difference = only_buffer_difference; self } + + /// Menu builder with new value for input_mode. Overrides `only_buffer_difference` when set. + #[must_use] + fn with_input_mode(mut self, mode: InputMode) -> Self { + self.settings_mut().input_mode = Some(mode); + self + } + + /// Menu builder with new value for output_mode. Defaults to `OutputMode::SuggestedSpan` when unset. + #[must_use] + fn with_output_mode(mut self, mode: OutputMode) -> Self { + self.settings_mut().output_mode = Some(mode); + self + } } /// Allowed menus in Reedline @@ -472,3 +549,28 @@ impl Menu for ReedlineMenu { self.as_mut().set_cursor_pos(pos); } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::bool_only_false(false, None, InputMode::CursorPrefix)] + #[case::bool_only_true(true, None, InputMode::Diff)] + #[case::enum_overrides_false_bool(false, Some(InputMode::Diff), InputMode::Diff)] + #[case::enum_overrides_true_bool(true, Some(InputMode::CursorPrefix), InputMode::CursorPrefix)] + #[case::full_buffer(true, Some(InputMode::FullBuffer), InputMode::FullBuffer)] + fn test_effective_input_mode( + #[case] only_buffer_difference: bool, + #[case] input_mode: Option, + #[case] expected: InputMode, + ) { + let mut settings = + MenuSettings::default().with_only_buffer_difference(only_buffer_difference); + if let Some(mode) = input_mode { + settings = settings.with_input_mode(mode); + } + assert_eq!(settings.effective_input_mode(), expected); + } +}