From 1c4e5cadf23d38350fd281bac85a6908c2414ce2 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger <aclopte@gmail.com> Date: Sun, 15 Dec 2024 17:27:00 +0100 Subject: [PATCH] Autosuggestions in multi-line command lines If I run $ command A $ command B $ command C and find myself wanting to re-run the same sequence of commands multiple times, I like to join them into a single command: $ command A && command B && command C When composing this mega-commandline, history search can recall the first one; the others I usually inserted with a combination of ctrl-k, ctrl-x or the ctrl-r (since 232483d89a (History pager to only operate on the line at cursor, 2024-03-22), which is motivated by exactly this use case). It's irritating that autosuggestions are missing, so try adding them. Today, only single-line commands from history are suggested. In future, we should perhaps also suggest any line from a multi-line command from history. --- CHANGELOG.rst | 1 + src/editable_line.rs | 2 +- src/reader.rs | 383 +++++++++++++++++++++++++++++-------------- src/screen.rs | 109 +++++++----- src/tests/reader.rs | 25 ++- src/tests/screen.rs | 113 ++++++++++++- 6 files changed, 460 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index baef63c5d..7323361db 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,7 @@ Scripting improvements Interactive improvements ------------------------ +- Autosuggestions are now also provided in multi-line command lines. Like `ctrl-r`, autosuggestions operate only on the current line. New or improved bindings ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/editable_line.rs b/src/editable_line.rs index 725a26fd7..b12bc1c81 100644 --- a/src/editable_line.rs +++ b/src/editable_line.rs @@ -342,7 +342,7 @@ fn cursor_position_after_edit(edit: &Edit) -> usize { cursor.saturating_sub(removed) } -fn range_of_line_at_cursor(buffer: &wstr, cursor: usize) -> Range<usize> { +pub fn range_of_line_at_cursor(buffer: &wstr, cursor: usize) -> Range<usize> { let start = buffer[0..cursor] .as_char_slice() .iter() diff --git a/src/reader.rs b/src/reader.rs index 6355ce912..3ac74c970 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -58,8 +58,7 @@ use crate::complete::{ complete, complete_load, sort_and_prioritize, CompleteFlags, Completion, CompletionList, CompletionRequestOptions, }; -use crate::editable_line::line_at_cursor; -use crate::editable_line::{Edit, EditableLine}; +use crate::editable_line::{line_at_cursor, range_of_line_at_cursor, Edit, EditableLine}; use crate::env::{EnvMode, Environment, Statuses}; use crate::exec::exec_subshell; use crate::expand::{expand_string, expand_tilde, ExpandFlags, ExpandResultCode}; @@ -100,6 +99,7 @@ use crate::panic::AT_EXIT; use crate::parse_constants::SourceRange; use crate::parse_constants::{ParseTreeFlags, ParserTestErrorBits}; use crate::parse_tree::ParsedSource; +use crate::parse_util::parse_util_process_extent; use crate::parse_util::MaybeParentheses; use crate::parse_util::SPACES_PER_INDENT; use crate::parse_util::{ @@ -133,7 +133,8 @@ use crate::tokenizer::{ use crate::wchar::prelude::*; use crate::wcstringutil::string_prefixes_string_maybe_case_insensitive; use crate::wcstringutil::{ - count_preceding_backslashes, join_strings, string_prefixes_string, StringFuzzyMatch, + count_preceding_backslashes, join_strings, string_prefixes_string, + string_prefixes_string_case_insensitive, StringFuzzyMatch, }; use crate::wildcard::wildcard_has; use crate::wutil::{fstat, perror}; @@ -983,7 +984,8 @@ pub fn reader_showing_suggestion(parser: &Parser) -> bool { let reader = Reader { parser, data }; let suggestion = &reader.autosuggestion.text; let is_single_space = suggestion.ends_with(L!(" ")) - && reader.command_line.text() == suggestion[..suggestion.len() - 1]; + && line_at_cursor(reader.command_line.text(), reader.command_line.position()) + == suggestion[..suggestion.len() - 1]; !suggestion.is_empty() && !is_single_space } else { false @@ -1322,8 +1324,8 @@ impl ReaderData { /// Update the cursor position. fn update_buff_pos(&mut self, elt: EditableLineTag, mut new_pos: Option<usize>) -> bool { + let el = self.edit_line(elt); if self.cursor_end_mode == CursorEndMode::Inclusive { - let el = self.edit_line(elt); let mut pos = new_pos.unwrap_or(el.position()); if !el.is_empty() && pos == el.len() { pos = el.len() - 1; @@ -1333,6 +1335,7 @@ impl ReaderData { new_pos = Some(pos); } } + let old_pos = el.position(); if let Some(pos) = new_pos { self.edit_line_mut(elt).set_position(pos); } @@ -1340,6 +1343,17 @@ impl ReaderData { if elt != EditableLineTag::Commandline { return true; } + // When moving across lines, hold off on autosuggestions until the next insertion. + if let Some(new_pos) = new_pos { + let range = if new_pos <= old_pos { + new_pos..old_pos + } else { + old_pos..new_pos + }; + if self.command_line.text()[range].contains('\n') { + self.suppress_autosuggestion = true; + } + } let buff_pos = self.command_line.position(); let target_char = if self.cursor_selection_mode == CursorSelectionMode::Inclusive { 1 @@ -1386,36 +1400,50 @@ impl ReaderData { /// Given a command line and an autosuggestion, return the string that gets shown to the user. /// Exposed for testing purposes only. -pub fn combine_command_and_autosuggestion(cmdline: &wstr, autosuggestion: &wstr) -> WString { +pub fn combine_command_and_autosuggestion( + cmdline: &wstr, + line_range: Range<usize>, + autosuggestion: &wstr, +) -> WString { // We want to compute the full line, containing the command line and the autosuggestion They may - // disagree on whether characters are uppercase or lowercase Here we do something funny: if the - // last token of the command line contains any uppercase characters, we use its case. Otherwise - // we use the case of the autosuggestion. This is an idea from issue #335. - let mut full_line; - if autosuggestion.len() <= cmdline.len() || cmdline.is_empty() { - // No or useless autosuggestion, or no command line. - full_line = cmdline.to_owned(); - } else if string_prefixes_string(cmdline, autosuggestion) { - // No case disagreements, or no extra characters in the autosuggestion. - full_line = autosuggestion.to_owned(); - } else { + // disagree on whether characters are uppercase or lowercase. + let pos = line_range.end; + let full_line; + assert!(!autosuggestion.is_empty()); + assert!(autosuggestion.len() >= line_range.len()); + let available = autosuggestion.len() - line_range.len(); + let line = &cmdline[line_range.clone()]; + + if !string_prefixes_string(line, autosuggestion) { // We have an autosuggestion which is not a prefix of the command line, i.e. a case // disagreement. Decide whose case we want to use. + assert!(string_prefixes_string_case_insensitive( + line, + autosuggestion + )); + // Here we do something funny: if the last token of the command line contains any uppercase + // characters, we use its case. Otherwise we use the case of the autosuggestion. This + // is an idea from issue #335. let mut tok = 0..0; parse_util_token_extent(cmdline, cmdline.len() - 1, &mut tok, None); let last_token_contains_uppercase = cmdline[tok].chars().any(|c| c.is_uppercase()); if !last_token_contains_uppercase { // Use the autosuggestion's case. - full_line = autosuggestion.to_owned(); - } else { - // Use the command line case for its characters, then append the remaining characters in - // the autosuggestion. Note that we know that autosuggestion.size() > cmdline.size() due - // to the first test above. - full_line = cmdline.to_owned(); - full_line.push_utfstr(&autosuggestion[cmdline.len()..]); + let start: usize = unsafe { + (line.as_char_slice().first().unwrap() as *const char) + .offset_from(&cmdline.as_char_slice()[0]) + } + .try_into() + .unwrap(); + full_line = cmdline[..start].to_owned() + autosuggestion + &cmdline[pos..]; + return full_line; } } - full_line + // Use the command line case for its characters, then append the remaining characters in + // the autosuggestion. + cmdline[..pos].to_owned() + + &autosuggestion[autosuggestion.len() - available..] + + &cmdline[pos..] } impl<'a> Reader<'a> { @@ -1510,17 +1538,35 @@ impl<'a> Reader<'a> { /// `reason` is used in FLOG to explain why. fn paint_layout(&mut self, reason: &wstr, is_final_rendering: bool) { FLOGF!(reader_render, "Repainting from %ls", reason); - let data = &self.data.rendered_layout; let cmd_line = &self.data.command_line; - let full_line = if self.conf.in_silent_mode { - wstr::from_char_slice(&[get_obfuscation_read_char()]).repeat(cmd_line.len()) - } else { + let (full_line, autosuggested_range) = if self.conf.in_silent_mode { + ( + Cow::Owned( + wstr::from_char_slice(&[get_obfuscation_read_char()]).repeat(cmd_line.len()), + ), + 0..0, + ) + } else if self.is_at_line_with_autosuggestion() { // Combine the command and autosuggestion into one string. - combine_command_and_autosuggestion(cmd_line.text(), &self.autosuggestion.text) + let autosuggestion = &self.autosuggestion; + let search_string_range = &autosuggestion.search_string_range; + let autosuggested_start = search_string_range.end; + let autosuggested_end = search_string_range.start + autosuggestion.text.len(); + ( + Cow::Owned(combine_command_and_autosuggestion( + cmd_line.text(), + autosuggestion.search_string_range.clone(), + &autosuggestion.text, + )), + autosuggested_start..autosuggested_end, + ) + } else { + (Cow::Borrowed(cmd_line.text()), 0..0) }; - // Copy the colors and extend them with autosuggestion color. + // Copy the colors and insert the autosuggestion color. + let data = &self.data.rendered_layout; let mut colors = data.colors.clone(); // Highlight any history search. @@ -1546,16 +1592,23 @@ impl<'a> Reader<'a> { } } - // Extend our colors with the autosuggestion. - colors.resize( - full_line.len(), - HighlightSpec::with_fg(HighlightRole::autosuggestion), - ); + let mut indents; + { + // Extend our colors with the autosuggestion. + let pos = autosuggested_range.start; + colors.splice( + pos..pos, + vec![ + HighlightSpec::with_fg(HighlightRole::autosuggestion); + autosuggested_range.len() + ], + ); - // Compute the indentation, then extend it with 0s for the autosuggestion. The autosuggestion - // always conceptually has an indent of 0. - let mut indents = parse_util_compute_indents(cmd_line.text()); - indents.resize(full_line.len(), 0); + // Compute the indentation, then extend it with 0s for the autosuggestion. The autosuggestion + // always conceptually has an indent of 0. + indents = parse_util_compute_indents(cmd_line.text()); + indents.splice(pos..pos, vec![0; autosuggested_range.len()]); + } let screen = &mut self.data.screen; let pager = &mut self.data.pager; @@ -1565,9 +1618,9 @@ impl<'a> Reader<'a> { &(self.data.mode_prompt_buff.clone() + &self.data.left_prompt_buff[..]), &self.data.right_prompt_buff, &full_line, - cmd_line.len(), - &colors, - &indents, + autosuggested_range, + colors, + indents, data.position, data.pager_search_field_position, self.parser.vars(), @@ -1682,15 +1735,19 @@ impl ReaderData { // text avoid recomputing the autosuggestion. assert!(string_prefixes_string_maybe_case_insensitive( autosuggestion.icase, - &self.command_line.text(), + &self.command_line.text()[autosuggestion.search_string_range.clone()], &autosuggestion.text )); + let search_string_range = autosuggestion.search_string_range.clone(); // This is a heuristic with false negatives but that seems fine. - let Some(remaining) = autosuggestion.text.get(edit.range.start..) else { + let Some(offset) = edit.range.start.checked_sub(search_string_range.start) else { return false; }; - if edit.range.end != self.command_line.len() + let Some(remaining) = autosuggestion.text.get(offset..) else { + return false; + }; + if edit.range.end != search_string_range.end || !string_prefixes_string_maybe_case_insensitive( autosuggestion.icase, &edit.replacement, @@ -1700,6 +1757,9 @@ impl ReaderData { { return false; } + self.autosuggestion.search_string_range.end = search_string_range.end + - edit.range.len().min(search_string_range.end) + + edit.replacement.len(); true } @@ -2379,10 +2439,9 @@ impl<'a> Reader<'a> { } } rl::EndOfLine => { - let (_elt, el) = self.active_edit_line(); - if self.is_at_end(el) { + if self.is_at_autosuggestion() { self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX)); - } else { + } else if !self.is_at_end() { loop { let position = { let (_elt, el) = self.active_edit_line(); @@ -2860,7 +2919,7 @@ impl<'a> Reader<'a> { rl::HistoryPagerDelete => { // Also applies to ordinary history search. let is_history_search = !self.history_search.is_at_end(); - if is_history_search || !self.autosuggestion.is_empty() { + if is_history_search || self.is_at_line_with_autosuggestion() { self.history.remove(if is_history_search { self.history_search.current_result() } else { @@ -2909,10 +2968,9 @@ impl<'a> Reader<'a> { } } rl::ForwardChar | rl::ForwardSingleChar => { - let (elt, el) = self.active_edit_line(); if self.is_navigating_pager_contents() { self.select_completion_in_direction(SelectionMotion::East, false); - } else if self.is_at_end(el) { + } else if self.is_at_autosuggestion() { self.accept_autosuggestion(AutosuggestionPortion::Count( if c == rl::ForwardSingleChar { 1 @@ -2920,13 +2978,14 @@ impl<'a> Reader<'a> { usize::MAX }, )); - } else { + } else if !self.is_at_end() { + let (elt, el) = self.active_edit_line(); self.update_buff_pos(elt, Some(el.position() + 1)); } } rl::ForwardCharPassive => { - let (elt, el) = self.active_edit_line(); - if !self.is_at_end(el) { + if !self.is_at_end() { + let (elt, el) = self.active_edit_line(); if elt == EditableLineTag::SearchField || !self.is_navigating_pager_contents() { self.update_buff_pos(elt, Some(el.position() + 1)); } @@ -3016,15 +3075,16 @@ impl<'a> Reader<'a> { ); } rl::ForwardToken => { - let (_elt, el) = self.active_edit_line(); - if self.is_at_end(el) { + if self.is_at_autosuggestion() { let Some(new_position) = self.forward_token(true) else { return; }; + let (_elt, el) = self.active_edit_line(); + let search_string_range = range_of_line_at_cursor(el.text(), el.position()); self.accept_autosuggestion(AutosuggestionPortion::Count( - new_position - el.len(), + new_position - search_string_range.end, )); - } else { + } else if !self.is_at_end() { let Some(new_position) = self.forward_token(false) else { return; }; @@ -3068,10 +3128,10 @@ impl<'a> Reader<'a> { } else { MoveWordStyle::Whitespace }; - let (elt, el) = self.active_edit_line(); - if self.is_at_end(el) { + if self.is_at_autosuggestion() { self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(style)); - } else { + } else if !self.is_at_end() { + let (elt, _el) = self.active_edit_line(); self.move_word(elt, MoveWordDir::Right, /*erase=*/ false, style, false); } } @@ -3165,14 +3225,16 @@ impl<'a> Reader<'a> { } rl::SuppressAutosuggestion => { self.suppress_autosuggestion = true; - let success = !self.autosuggestion.is_empty(); + let success = self.is_at_line_with_autosuggestion(); self.autosuggestion.clear(); // Return true if we had a suggestion to clear. self.input_data.function_set_status(success); } rl::AcceptAutosuggestion => { - let success = !self.autosuggestion.is_empty(); - self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX)); + let success = self.is_at_line_with_autosuggestion(); + if success { + self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX)); + } self.input_data.function_set_status(success); } rl::TransposeChars => { @@ -3312,7 +3374,7 @@ impl<'a> Reader<'a> { let mut replacement = WString::new(); while pos < if self.cursor_selection_mode == CursorSelectionMode::Inclusive - && self.is_at_end(el) + && self.is_at_end() { el.len() } else { @@ -3606,15 +3668,19 @@ impl<'a> Reader<'a> { } fn forward_token(&self, autosuggest: bool) -> Option<usize> { - let (_elt, el) = self.active_edit_line(); + let (elt, el) = self.active_edit_line(); let pos = el.position(); let buffer = if autosuggest { - if pos > self.autosuggestion.text.len() { - return None; - } - &self.autosuggestion.text + assert!(elt == EditableLineTag::Commandline); + assert!(self.is_at_line_with_autosuggestion()); + let autosuggestion = &self.autosuggestion; + Cow::Owned(combine_command_and_autosuggestion( + el.text(), + autosuggestion.search_string_range.clone(), + &autosuggestion.text, + )) } else { - el.text() + Cow::Borrowed(el.text()) }; if pos == buffer.len() { return None; @@ -3628,7 +3694,7 @@ impl<'a> Reader<'a> { .count(); let mut tok = 0..0; - parse_util_token_extent(buffer, buff_pos, &mut tok, None); + parse_util_token_extent(&buffer, buff_pos, &mut tok, None); let new_position = if tok.end == pos { pos + 1 } else { tok.end }; @@ -4364,9 +4430,12 @@ impl<'a> Reader<'a> { #[derive(Default)] struct Autosuggestion { - // The text to use, as an extension/replacement of the command line. + // The text to use, as an extension/replacement of the current line. text: WString, + // The range within the commandline that was searched. Always a whole line. + search_string_range: Range<usize>, + // Whether the autosuggestion should be case insensitive. // This is true for file-generated autosuggestions, but not for history. icase: bool, @@ -4387,10 +4456,11 @@ impl Autosuggestion { /// The result of an autosuggestion computation. #[derive(Default)] struct AutosuggestionResult { + // The autosuggestion. autosuggestion: Autosuggestion, - // The string which was searched for. - search_string: WString, + // The commandline this result is based off. + command_line: WString, // The list of completions which may need loading. needs_load: Vec<WString>, @@ -4404,20 +4474,34 @@ impl std::ops::Deref for AutosuggestionResult { } impl AutosuggestionResult { - fn new(text: WString, search_string: WString, icase: bool) -> Self { + fn new( + command_line: WString, + search_string_range: Range<usize>, + text: WString, + icase: bool, + ) -> Self { Self { - autosuggestion: Autosuggestion { text, icase }, - search_string, + autosuggestion: Autosuggestion { + text, + search_string_range, + icase, + }, + command_line, needs_load: vec![], } } + + /// The line which was searched for. + fn search_string(&self) -> &wstr { + &self.command_line[self.search_string_range.clone()] + } } // Returns a function that can be invoked (potentially // on a background thread) to determine the autosuggestion fn get_autosuggestion_performer( parser: &Parser, - search_string: WString, + command_line: WString, cursor_pos: usize, history: Arc<History>, ) -> impl FnOnce() -> AutosuggestionResult { @@ -4433,29 +4517,38 @@ fn get_autosuggestion_performer( } // Let's make sure we aren't using the empty string. - if search_string.is_empty() { + let search_string_range = range_of_line_at_cursor(&command_line, cursor_pos); + let search_string = &command_line[search_string_range.clone()]; + let Some(last_char) = search_string.chars().next_back() else { return nothing; - } + }; - // Search history for a matching item. - let mut searcher = - HistorySearch::new_with_type(history, search_string.to_owned(), SearchType::Prefix); - while !ctx.check_cancel() && searcher.go_to_next_match(SearchDirection::Backward) { - let item = searcher.current_item(); + // Search history for a matching item unless this line is not a continuation line or quoted. + if range_of_line_at_cursor( + &command_line, + parse_util_process_extent(&command_line, cursor_pos, None).start, + ) == search_string_range + { + let mut searcher = + HistorySearch::new_with_type(history, search_string.to_owned(), SearchType::Prefix); + while !ctx.check_cancel() && searcher.go_to_next_match(SearchDirection::Backward) { + let item = searcher.current_item(); - // Skip items with newlines because they make terrible autosuggestions. - if item.str().contains('\n') { - continue; - } + // Skip items with newlines because they make terrible autosuggestions. + if item.str().contains('\n') { + continue; + } - if autosuggest_validate_from_history(item, &working_directory, &ctx) { - // The command autosuggestion was handled specially, so we're done. - // History items are case-sensitive, see #3978. - return AutosuggestionResult::new( - searcher.current_string().to_owned(), - search_string.to_owned(), - /*icase=*/ false, - ); + if autosuggest_validate_from_history(item, &working_directory, &ctx) { + // The command autosuggestion was handled specially, so we're done. + // History items are case-sensitive, see #3978. + return AutosuggestionResult::new( + command_line, + search_string_range, + searcher.current_string().to_owned(), + /*icase=*/ false, + ); + } } } @@ -4467,8 +4560,8 @@ fn get_autosuggestion_performer( // Here we do something a little funny. If the line ends with a space, and the cursor is not // at the end, don't use completion autosuggestions. It ends up being pretty weird seeing // stuff get spammed on the right while you go back to edit a line - let last_char = search_string.chars().next_back().unwrap(); - let cursor_at_end = cursor_pos == search_string.len(); + let cursor_at_end = + cursor_pos == command_line.len() || command_line.as_char_slice()[cursor_pos] == '\n'; if !cursor_at_end && last_char.is_whitespace() { return nothing; } @@ -4480,25 +4573,28 @@ fn get_autosuggestion_performer( // Try normal completions. let complete_flags = CompletionRequestOptions::autosuggest(); - let (mut completions, needs_load) = complete(&search_string, complete_flags, &ctx); + let (mut completions, needs_load) = + complete(&command_line[..cursor_pos], complete_flags, &ctx); - let full_line = if completions.is_empty() { + let suggestion = if completions.is_empty() { WString::new() } else { sort_and_prioritize(&mut completions, complete_flags); let comp = &completions[0]; let mut cursor = cursor_pos; - completion_apply_to_command_line( + let full_line = completion_apply_to_command_line( &comp.completion, comp.flags, - &search_string, + &command_line, &mut cursor, /*append_only=*/ true, - ) + ); + line_at_cursor(&full_line, search_string_range.end).to_owned() }; let mut result = AutosuggestionResult::new( - full_line, - search_string.to_owned(), + command_line, + search_string_range.clone(), + suggestion, true, // normal completions are case-insensitive ); result.needs_load = needs_load; @@ -4529,10 +4625,10 @@ impl<'a> Reader<'a> { // Called after an autosuggestion has been computed on a background thread. fn autosuggest_completed(&mut self, result: AutosuggestionResult) { assert_is_main_thread(); - if result.search_string == self.data.in_flight_autosuggest_request { + if result.command_line == self.data.in_flight_autosuggest_request { self.data.in_flight_autosuggest_request.clear(); } - if result.search_string != self.command_line.text() { + if result.command_line != self.command_line.text() { // This autosuggestion is stale. return; } @@ -4556,7 +4652,7 @@ impl<'a> Reader<'a> { && self.can_autosuggest() && string_prefixes_string_maybe_case_insensitive( result.icase, - &result.search_string, + result.search_string(), &result.text, ) { @@ -4578,10 +4674,10 @@ impl<'a> Reader<'a> { let el = &self.data.command_line; let autosuggestion = &self.autosuggestion; - if !self.autosuggestion.is_empty() { + if self.is_at_line_with_autosuggestion() { assert!(string_prefixes_string_maybe_case_insensitive( autosuggestion.icase, - &el.text(), + &el.text()[autosuggestion.search_string_range.clone()], &autosuggestion.text )); return; @@ -4612,52 +4708,87 @@ impl<'a> Reader<'a> { debounce_autosuggestions().perform_with_completion(performer, completion); } - fn is_at_end(&self, el: &EditableLine) -> bool { + fn is_at_end(&self) -> bool { + let (_elt, el) = self.active_edit_line(); match self.cursor_end_mode { CursorEndMode::Exclusive => el.position() == el.len(), CursorEndMode::Inclusive => el.position() + 1 >= el.len(), } } + fn is_at_autosuggestion(&self) -> bool { + if self.active_edit_line_tag() != EditableLineTag::Commandline { + return false; + } + let autosuggestion = &self.autosuggestion; + if autosuggestion.is_empty() { + return false; + } + let el = &self.command_line; + (match self.cursor_end_mode { + CursorEndMode::Exclusive => el.position(), + CursorEndMode::Inclusive => el.position() + 1, + }) == autosuggestion.search_string_range.end + } + + fn is_at_line_with_autosuggestion(&self) -> bool { + if self.active_edit_line_tag() != EditableLineTag::Commandline { + return false; + } + let autosuggestion = &self.autosuggestion; + if autosuggestion.is_empty() { + return false; + } + let el = &self.command_line; + range_of_line_at_cursor(el.text(), el.position()) == autosuggestion.search_string_range + } + // Accept any autosuggestion by replacing the command line with it. If full is true, take the whole // thing; if it's false, then respect the passed in style. fn accept_autosuggestion(&mut self, amount: AutosuggestionPortion) { - if self.autosuggestion.is_empty() { - return; - } + assert!(self.is_at_line_with_autosuggestion()); + // Accepting an autosuggestion clears the pager. self.clear_pager(); + let autosuggestion = &self.autosuggestion; + let autosuggestion_text = &autosuggestion.text; + let search_string_range = autosuggestion.search_string_range.clone(); // Accept the autosuggestion. let (range, replacement) = match amount { AutosuggestionPortion::Count(count) => { - let pos = self.command_line.len(); if count == usize::MAX { - (0..self.command_line.len(), self.autosuggestion.text.clone()) + (search_string_range, autosuggestion_text.clone()) } else { - let count = count.min(self.autosuggestion.text.len() - pos); + let pos = search_string_range.end; + let available = autosuggestion_text.len() - search_string_range.len(); + let count = count.min(available); if count == 0 { return; } + let start = autosuggestion_text.len() - available; ( pos..pos, - self.autosuggestion.text[pos..pos + count].to_owned(), + autosuggestion_text[start..start + count].to_owned(), ) } } AutosuggestionPortion::PerMoveWordStyle(style) => { // Accept characters according to the specified style. let mut state = MoveWordStateMachine::new(style); - let mut want = self.command_line.len(); - while want < self.autosuggestion.text.len() { - let wc = self.autosuggestion.text.as_char_slice()[want]; + let have = search_string_range.len(); + let mut want = have; + while want < autosuggestion_text.len() { + let wc = autosuggestion_text.as_char_slice()[want]; if !state.consume_char(wc) { break; } want += 1; } - let have = self.command_line.len(); - (have..have, self.autosuggestion.text[have..want].to_owned()) + ( + search_string_range.end..search_string_range.end, + autosuggestion_text[have..want].to_owned(), + ) } }; self.data diff --git a/src/screen.rs b/src/screen.rs index 17301e37b..638b2949f 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -15,6 +15,7 @@ use std::cell::RefCell; use std::collections::LinkedList; use std::ffi::{CStr, CString}; use std::io::Write; +use std::ops::Range; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; use std::time::SystemTime; @@ -255,9 +256,9 @@ impl Screen { left_prompt: &wstr, right_prompt: &wstr, commandline: &wstr, - explicit_len: usize, - colors: &[HighlightSpec], - indent: &[i32], + autosuggested_range: Range<usize>, + mut colors: Vec<HighlightSpec>, + mut indent: Vec<i32>, cursor_pos: usize, pager_search_field_position: Option<usize>, vars: &dyn Environment, @@ -282,17 +283,19 @@ impl Screen { let mut scrolled_cursor: Option<ScrolledCursor> = None; // Turn the command line into the explicit portion and the autosuggestion. - let (explicit_command_line, autosuggestion) = commandline.split_at(explicit_len); + let explicit_before_suggestion = &commandline[..autosuggested_range.start]; + let autosuggestion = &commandline[autosuggested_range.clone()]; + let explicit_after_suggestion = &commandline[autosuggested_range.end..]; // If we are using a dumb terminal, don't try any fancy stuff, just print out the text. // right_prompt not supported. if is_dumb() { let prompt_narrow = wcs2string(left_prompt); - let command_line_narrow = wcs2string(explicit_command_line); let _ = write_loop(&STDOUT_FILENO, b"\r"); let _ = write_loop(&STDOUT_FILENO, &prompt_narrow); - let _ = write_loop(&STDOUT_FILENO, &command_line_narrow); + let _ = write_loop(&STDOUT_FILENO, &wcs2string(explicit_before_suggestion)); + let _ = write_loop(&STDOUT_FILENO, &wcs2string(explicit_after_suggestion)); return; } @@ -311,10 +314,13 @@ impl Screen { // Compute a layout. let layout = compute_layout( + get_ellipsis_char(), screen_width, left_prompt, right_prompt, - explicit_command_line, + explicit_before_suggestion, + &mut colors, + &mut indent, autosuggestion, ); @@ -345,7 +351,9 @@ impl Screen { let first_line_prompt_space = layout.left_prompt_space; // Reconstruct the command line. - let effective_commandline = explicit_command_line.to_owned() + &layout.autosuggestion[..]; + let effective_commandline = explicit_before_suggestion.to_owned() + + &layout.autosuggestion[..] + + explicit_after_suggestion; // Output the command line. let mut i = 0; @@ -375,7 +383,12 @@ impl Screen { break scrolled_cursor.unwrap(); } if !self.desired_append_char( - /*offset_in_cmdline=*/ i, + /*offset_in_cmdline=*/ + if i <= explicit_before_suggestion.len() + layout.autosuggestion.len() { + i.min(explicit_before_suggestion.len()) + } else { + i - layout.autosuggestion.len() + }, if is_final_rendering { usize::MAX } else { @@ -1835,16 +1848,17 @@ fn is_dumb() -> bool { }) } -#[derive(Default)] -struct ScreenLayout { +// Exposed for testing. +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) struct ScreenLayout { // The left prompt that we're going to use. - left_prompt: WString, + pub(crate) left_prompt: WString, // How much space to leave for it. - left_prompt_space: usize, + pub(crate) left_prompt_space: usize, // The right prompt. - right_prompt: WString, + pub(crate) right_prompt: WString, // The autosuggestion. - autosuggestion: WString, + pub(crate) autosuggestion: WString, } // Given a vector whose indexes are offsets and whose values are the widths of the string if @@ -1864,11 +1878,15 @@ fn truncation_offset_for_width(width_by_offset: &[usize], max_width: usize) -> u i - 1 } -fn compute_layout( +// Exposed for testing. +pub(crate) fn compute_layout( + ellipsis_char: char, screen_width: usize, left_untrunc_prompt: &wstr, right_untrunc_prompt: &wstr, - commandline: &wstr, + commandline_before_suggestion: &wstr, + colors: &mut Vec<HighlightSpec>, + indent: &mut Vec<i32>, autosuggestion_str: &wstr, ) -> ScreenLayout { let mut result = ScreenLayout::default(); @@ -1901,24 +1919,23 @@ fn compute_layout( assert!(left_prompt_width + right_prompt_width <= screen_width); // Get the width of the first line, and if there is more than one line. - let multiline = commandline.contains('\n'); - let first_command_line_width: usize = line_at_cursor(commandline, 0) + let first_command_line_width: usize = line_at_cursor(commandline_before_suggestion, 0) .chars() .map(wcwidth_rendered_min_0) .sum(); + let autosuggestion_line_explicit_width: usize = line_at_cursor( + commandline_before_suggestion, + commandline_before_suggestion.len(), + ) + .chars() + .map(wcwidth_rendered_min_0) + .sum(); - // If we have more than one line, ensure we have no autosuggestion. - let mut autosuggestion = autosuggestion_str; let mut autosuggest_total_width = 0; - let mut autosuggest_truncated_widths = vec![]; - if multiline { - autosuggestion = L!(""); - } else { - autosuggest_truncated_widths.reserve(1 + autosuggestion_str.len()); - for c in autosuggestion_str.chars() { - autosuggest_truncated_widths.push(autosuggest_total_width); - autosuggest_total_width += wcwidth_rendered_min_0(c); - } + let mut autosuggest_truncated_widths = Vec::with_capacity(autosuggestion_str.len()); + for c in autosuggestion_str.chars() { + autosuggest_truncated_widths.push(autosuggest_total_width); + autosuggest_total_width += wcwidth_rendered_min_0(c); } // Here are the layouts we try in turn: @@ -1940,21 +1957,35 @@ fn compute_layout( // prompt will wrap to the next line. This means that we can't go back to the line that we were // on, and things turn to chaos very quickly. - let truncated_autosuggestion = |right_prompt_width: usize| { - let width = left_prompt_width + right_prompt_width + first_command_line_width; + let mut truncated_autosuggestion = |indent: &mut Vec<i32>, right_prompt_width: usize| { + let width = if let Some(pos) = commandline_before_suggestion + .chars() + .rposition(|c| c == '\n') + { + left_prompt_width + + usize::try_from(indent[pos]).unwrap() * INDENT_STEP + + autosuggestion_line_explicit_width + } else { + left_prompt_width + right_prompt_width + first_command_line_width + }; // Need at least two characters to show an autosuggestion. - let available_autosuggest_space = screen_width - width; + let available_autosuggest_space = screen_width.saturating_sub(width); let mut result = WString::new(); if available_autosuggest_space > autosuggest_total_width { - result = autosuggestion.to_owned(); + result = autosuggestion_str.to_owned(); } else if autosuggest_total_width > 0 && available_autosuggest_space > 2 { let truncation_offset = truncation_offset_for_width( &autosuggest_truncated_widths, available_autosuggest_space - 2, ); - result = autosuggestion[..truncation_offset].to_owned(); - result.push(get_ellipsis_char()); + result = autosuggestion_str[..truncation_offset].to_owned(); + result.push(ellipsis_char); } + let suggestion_start = commandline_before_suggestion.len(); + let truncation_range = + suggestion_start + result.len()..suggestion_start + autosuggestion_str.len(); + colors.drain(truncation_range.clone()); + indent.drain(truncation_range); result }; @@ -1965,7 +1996,7 @@ fn compute_layout( result.left_prompt = left_prompt; result.left_prompt_space = left_prompt_width; result.right_prompt = right_prompt; - result.autosuggestion = truncated_autosuggestion(right_prompt_width); + result.autosuggestion = truncated_autosuggestion(indent, right_prompt_width); return result; } @@ -1974,14 +2005,14 @@ fn compute_layout( if calculated_width <= screen_width { result.left_prompt = left_prompt; result.left_prompt_space = left_prompt_width; - result.autosuggestion = truncated_autosuggestion(0); + result.autosuggestion = truncated_autosuggestion(indent, 0); return result; } // Case 5 result.left_prompt = left_prompt; result.left_prompt_space = left_prompt_width; - result.autosuggestion = autosuggestion.to_owned(); + result.autosuggestion = autosuggestion_str.to_owned(); result } diff --git a/src/tests/reader.rs b/src/tests/reader.rs index 2b0450a91..ffd3a7a4c 100644 --- a/src/tests/reader.rs +++ b/src/tests/reader.rs @@ -5,30 +5,43 @@ use crate::wchar::prelude::*; #[test] fn test_autosuggestion_combining() { assert_eq!( - combine_command_and_autosuggestion(L!("alpha"), L!("alphabeta")), + combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("alphabeta")), L!("alphabeta") ); // When the last token contains no capital letters, we use the case of the autosuggestion. assert_eq!( - combine_command_and_autosuggestion(L!("alpha"), L!("ALPHABETA")), + combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHABETA")), L!("ALPHABETA") ); // When the last token contains capital letters, we use its case. assert_eq!( - combine_command_and_autosuggestion(L!("alPha"), L!("alphabeTa")), + combine_command_and_autosuggestion(L!("alPha"), 0..5, L!("alphabeTa")), L!("alPhabeTa") ); // If autosuggestion is not longer than input, use the input's case. assert_eq!( - combine_command_and_autosuggestion(L!("alpha"), L!("ALPHAA")), + combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHAA")), L!("ALPHAA") ); assert_eq!( - combine_command_and_autosuggestion(L!("alpha"), L!("ALPHA")), - L!("alpha") + combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHA")), + L!("ALPHA") + ); + + assert_eq!( + combine_command_and_autosuggestion(L!("al\nbeta"), 0..2, L!("alpha")), + L!("alpha\nbeta").to_owned() + ); + assert_eq!( + combine_command_and_autosuggestion(L!("alpha\nbe"), 6..8, L!("beta")), + L!("alpha\nbeta").to_owned() + ); + assert_eq!( + combine_command_and_autosuggestion(L!("alpha\nbe\ngamma"), 6..8, L!("beta")), + L!("alpha\nbeta\ngamma").to_owned() ); } diff --git a/src/tests/screen.rs b/src/tests/screen.rs index 5866180d8..5ed37e3bd 100644 --- a/src/tests/screen.rs +++ b/src/tests/screen.rs @@ -1,5 +1,7 @@ use crate::common::get_ellipsis_char; -use crate::screen::{LayoutCache, PromptCacheEntry, PromptLayout}; +use crate::highlight::HighlightSpec; +use crate::parse_util::parse_util_compute_indents; +use crate::screen::{compute_layout, LayoutCache, PromptCacheEntry, PromptLayout, ScreenLayout}; use crate::tests::prelude::*; use crate::wchar::prelude::*; use crate::wcstringutil::join_strings; @@ -245,3 +247,112 @@ fn test_prompt_truncation() { ); assert_eq!(trunc, ellipsis()); } + +#[test] +fn test_compute_layout() { + macro_rules! validate { + ( + ( + $screen_width:expr, + $left_untrunc_prompt:literal, + $right_untrunc_prompt:literal, + $commandline_before_suggestion:literal, + $autosuggestion_str:literal, + $commandline_after_suggestion:literal + ) + -> ( + $left_prompt:literal, + $left_prompt_space:expr, + $right_prompt:literal, + $autosuggestion:literal $(,)? + ) + ) => {{ + let full_commandline = L!($commandline_before_suggestion).to_owned() + + L!($autosuggestion_str) + + L!($commandline_after_suggestion); + let mut colors = vec![HighlightSpec::default(); full_commandline.len()]; + let mut indent = parse_util_compute_indents(&full_commandline); + assert_eq!( + compute_layout( + '…', + $screen_width, + L!($left_untrunc_prompt), + L!($right_untrunc_prompt), + L!($commandline_before_suggestion), + &mut colors, + &mut indent, + L!($autosuggestion_str), + ), + ScreenLayout { + left_prompt: L!($left_prompt).to_owned(), + left_prompt_space: $left_prompt_space, + right_prompt: L!($right_prompt).to_owned(), + autosuggestion: L!($autosuggestion).to_owned(), + } + ); + indent + }}; + } + + validate!( + ( + 80, "left>", "<right", "command", " autosuggestion", "" + ) -> ( + "left>", + 5, + "<right", + " autosuggestion", + ) + ); + validate!( + ( + 30, "left>", "<right", "command", " autosuggestion", "" + ) -> ( + "left>", + 5, + "<right", + " autosugge…", + ) + ); + validate!( + ( + 30, "left>", "<right", "foo\ncommand", " autosuggestion", "" + ) -> ( + "left>", + 5, + "<right", + " autosuggestion", + ) + ); + validate!( + ( + 30, "left>", "<right", "foo\ncommand", " autosuggestion TRUNCATED", "" + ) -> ( + "left>", + 5, + "<right", + " autosuggestion …", + ) + ); + validate!( + ( + 30, "left>", "<right", "if :\ncommand", " autosuggestion TRUNCATED", "" + ) -> ( + "left>", + 5, + "<right", + " autosuggest…", + ) + ); + let indent = validate!( + ( + 30, "left>", "<right", "if :\ncommand", " autosuggestion TRUNCATED", "\nfoo" + ) -> ( + "left>", + 5, + "<right", + " autosuggest…", + ) + ); + assert_eq!(indent["if :\ncommand autosuggest…\n".len()], 1); +}