Autosuggestions in multi-line command lines
Some checks are pending
make test / ubuntu (push) Waiting to run
make test / ubuntu-32bit-static-pcre2 (push) Waiting to run
make test / ubuntu-asan (push) Waiting to run
make test / macos (push) Waiting to run
Rust checks / rustfmt (push) Waiting to run
Rust checks / clippy (push) Waiting to run

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.
This commit is contained in:
Johannes Altmanninger 2024-12-15 17:27:00 +01:00
parent 532abaddae
commit 1c4e5cadf2
6 changed files with 460 additions and 173 deletions

View File

@ -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
^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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()
);
}

View File

@ -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);
}