From 7078547d9a6148dc6f74c4234c7e19a437ac253e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:00 -0400 Subject: [PATCH 01/10] Add Suggestions, CompletionResult, CompletionStatus, update trait --- src/completion/base.rs | 119 ++++++++++++++++++++++++++++++++--------- src/completion/mod.rs | 2 +- src/lib.rs | 4 +- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/src/completion/base.rs b/src/completion/base.rs index f50f3d559..7afe201ed 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -1,5 +1,6 @@ use nu_ansi_term::Style; use std::ops::Range; +use std::sync::Arc; /// A span of source code, with positions in bytes #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)] @@ -11,6 +12,12 @@ pub struct Span { pub end: usize, } +/// A shared, immutable list of completion suggestions. +/// +/// Held behind an [`Arc`] so a completer that caches results can hand the same +/// list to reedline on every keystroke, without massive penalty +pub type Suggestions = Arc<[Suggestion]>; + impl Span { /// Creates a new `Span` from start and end inputs. /// The end parameter must be greater than or equal to the start parameter. @@ -27,27 +34,98 @@ impl Span { } } +/// The outcome of a [`Completer::complete`] request. +/// +/// Synchro completor only EVER produces [`Fresh`](Self::Fresh). An +/// asynchronous completer that computes in the background reports its progress +/// through this type. +#[derive(Debug, Clone)] +pub enum CompletionResult { + /// Final, authoritative results. No further computation is in flight. + Fresh(Suggestions), + /// Best-effort results to show in the momemnt; a fresh computation is still running and + /// will replace these once it finishes. + Stale(Suggestions), + /// No results are available yet; a computation is spinning in the background. + Pending, +} + +impl CompletionResult { + /// Wrap authoritative results. + pub fn fresh(suggestions: impl Into) -> Self { + CompletionResult::Fresh(suggestions.into()) + } + + /// Best-effort fallback while an authoritative result is still computing + pub fn stale_or_pending(fallback: Vec) -> Self { + if fallback.is_empty() { + CompletionResult::Pending + } else { + CompletionResult::Stale(fallback.into()) + } + } + + /// Borrow the suggestions this result carries (empty for [`Pending`](Self::Pending)). + pub fn suggestions(&self) -> &[Suggestion] { + match self { + CompletionResult::Fresh(values) | CompletionResult::Stale(values) => values, + CompletionResult::Pending => &[], + } + } + + /// Consume the result into its suggestions (empty for [`Pending`](Self::Pending)). + /// + /// Prefer [`suggestions`](Self::suggestions) when a borrow suffices; this + /// copies the list out of the shared `Arc` into an owned `Vec`. + pub fn into_suggestions(self) -> Vec { + match self { + CompletionResult::Fresh(values) | CompletionResult::Stale(values) => values.to_vec(), + CompletionResult::Pending => Vec::new(), + } + } + + /// Whether there is nothing to show yet because a computation is in flight. + /// When `true`, callers should preserve any results already displayed. + pub fn is_pending(&self) -> bool { + matches!(self, CompletionResult::Pending) + } +} + +/// Vitality of a completer's background work, grabbed by the engine once per +/// event-loop iteration. It tells the engine whether to keep polling for +/// input (rather than blocking) and when a finished result is ready to display. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompletionStatus { + /// No background completion is in flight.... the engine may block on input. + Idle, + /// A background completion is still running... the engine should keep polling. + Pending, + /// The latest background completion just finished! Its results are now + /// available and any active menu should be refreshed. + Ready, +} + /// A trait that defines how to convert some text and a position to a list of potential completions in that position. /// The text could be a part of the whole line, and the position is the index of the end of the text in the original line. pub trait Completer { /// the action that will take the line and position and convert it to a vector of completions, which include the /// span to replace and the contents of that replacement - fn complete(&mut self, line: &str, pos: usize) -> Vec; + fn complete(&mut self, line: &str, pos: usize) -> CompletionResult; - /// same as [`Completer::complete`] but it will return a vector of ranges of the strings - /// the suggestions are based on + /// same as [`Completer::complete`] but it will also return a vector of ranges + /// of the strings the suggestions are based on fn complete_with_base_ranges( &mut self, line: &str, pos: usize, - ) -> (Vec, Vec>) { + ) -> (CompletionResult, Vec>) { + let result = self.complete(line, pos); let mut ranges = vec![]; - let suggestions = self.complete(line, pos); - for suggestion in &suggestions { + for suggestion in result.suggestions() { ranges.push(suggestion.span.start..suggestion.span.end); } ranges.dedup(); - (suggestions, ranges) + (result, ranges) } /// action that will return a partial section of available completions @@ -61,6 +139,7 @@ pub trait Completer { offset: usize, ) -> Vec { self.complete(line, pos) + .into_suggestions() .into_iter() .skip(start) .take(offset) @@ -69,28 +148,16 @@ pub trait Completer { /// number of available completions fn total_completions(&mut self, line: &str, pos: usize) -> usize { - self.complete(line, pos).len() - } - - /// Returns `true` while completions are being computed in the background. - /// - /// When this returns `true` the engine switches to polling mode so it can - /// call [`check_pending`](Self::check_pending) periodically without - /// stopping on keyboard input. The default implementation always returns - /// `false` (sync completer). - fn has_pending(&mut self) -> bool { - false + self.complete(line, pos).suggestions().len() } - /// Checks whether a background completion has finished. + /// Poll the completer's background work. /// - /// Returns `true` once the results have been stored in the completer's - /// internal cache so that the next call to [`complete`](Self::complete) - /// will return them immediately. The engine calls this while a menu is - /// active and [`has_pending`](Self::has_pending) has returned `true`. - /// The default implementation always returns `false`. - fn check_pending(&mut self) -> bool { - false + /// Called once per event-loop iteration by the engine. Synchronous + /// completers use the default, which always reports + /// [`CompletionStatus::Idle`]. + fn poll_completion(&mut self) -> CompletionStatus { + CompletionStatus::Idle } } diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 10f196ebc..7e1bd0808 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -2,5 +2,5 @@ mod base; mod default; pub(crate) mod history; -pub use base::{Completer, Span, Suggestion}; +pub use base::{Completer, CompletionResult, CompletionStatus, Span, Suggestion, Suggestions}; pub use default::DefaultCompleter; diff --git a/src/lib.rs b/src/lib.rs index 41601664b..997b56393 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -274,7 +274,9 @@ mod highlighter; pub use highlighter::{AbbrExpandContext, ExampleHighlighter, Highlighter, SimpleMatchHighlighter}; mod completion; -pub use completion::{Completer, DefaultCompleter, Span, Suggestion}; +pub use completion::{ + Completer, CompletionResult, CompletionStatus, DefaultCompleter, Span, Suggestion, Suggestions, +}; mod hinter; pub use hinter::CwdAwareHinter; From fc3d01405562f60b4a757dd6c83defe7a0d5ddd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:05 -0400 Subject: [PATCH 02/10] Migrate DefaultCompleter and HistoryCompleter to new API --- src/completion/default.rs | 20 ++++++++++---------- src/completion/history.rs | 14 ++++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/completion/default.rs b/src/completion/default.rs index a4f4d6073..cea0a4b8b 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -1,4 +1,4 @@ -use crate::{Completer, Span, Suggestion}; +use crate::{Completer, CompletionResult, Span, Suggestion}; use std::{ collections::{BTreeMap, BTreeSet}, str::Chars, @@ -53,7 +53,7 @@ impl Completer for DefaultCompleter { /// let mut completions = DefaultCompleter::default(); /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("bat",3), + /// completions.complete("bat",3).into_suggestions(), /// vec![ /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, @@ -61,14 +61,14 @@ impl Completer for DefaultCompleter { /// ]); /// /// assert_eq!( - /// completions.complete("to the\r\nbat",11), + /// completions.complete("to the\r\nbat",11).into_suggestions(), /// vec![ /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// ]); /// ``` - fn complete(&mut self, line: &str, pos: usize) -> Vec { + fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { let mut span_line_whitespaces = 0; let mut completions = vec![]; // Trimming in case someone passes in text containing stuff after the cursor, if @@ -121,7 +121,7 @@ impl Completer for DefaultCompleter { } } completions.dedup(); - completions + CompletionResult::fresh(completions) } } @@ -182,13 +182,13 @@ impl DefaultCompleter { /// let mut completions = DefaultCompleter::default(); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("te",2), + /// completions.complete("te",2).into_suggestions(), /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}]); /// /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("te",2), + /// completions.complete("te",2).into_suggestions(), /// vec![ /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, @@ -376,7 +376,7 @@ mod tests { ); assert_eq!( - completions.complete("n", 3), + completions.complete("n", 3).into_suggestions(), [ Suggestion { value: "null".into(), @@ -421,9 +421,9 @@ mod tests { let buffer = "this is t"; - let (suggestions, ranges) = completions.complete_with_base_ranges(buffer, 9); + let (result, ranges) = completions.complete_with_base_ranges(buffer, 9); assert_eq!( - suggestions, + result.into_suggestions(), [ Suggestion { value: "test".into(), diff --git a/src/completion/history.rs b/src/completion/history.rs index 99923c2dd..31dcdc1d3 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -1,8 +1,8 @@ use std::{collections::HashSet, ops::Deref}; use crate::{ - history::SearchQuery, menu_functions::parse_selection_char, Completer, History, HistoryItem, - Result, Span, Suggestion, + history::SearchQuery, menu_functions::parse_selection_char, Completer, CompletionResult, + History, HistoryItem, Result, Span, Suggestion, }; const SELECTION_CHAR: char = '!'; @@ -27,13 +27,14 @@ fn search_unique( } impl Completer for HistoryCompleter<'_> { - fn complete(&mut self, line: &str, pos: usize) -> Vec { - match search_unique(self, line) { + fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { + let suggestions = match search_unique(self, line) { Err(_) => vec![], Ok(search_results) => search_results .map(|value| self.create_suggestion(line, pos, value.command_line.deref())) .collect(), - } + }; + CompletionResult::fresh(suggestions) } // TODO: Implement `fn partial_complete()` @@ -110,7 +111,7 @@ mod tests { let input = "git s"; let mut sut = HistoryCompleter::new(&history); - let actual = sut.complete(input, input.len()); + let actual = sut.complete(input, input.len()).into_suggestions(); let num_completions = sut.total_completions(input, input.len()); assert_eq!(actual[0].value, "git status", "it was the last command"); @@ -149,6 +150,7 @@ mod tests { let mut sut = HistoryCompleter::new(&history); let actual: Vec = sut .complete(line, line.len()) + .into_suggestions() .into_iter() .map(|suggestion| suggestion.value) .collect(); From decbf454bce55d27254dbb57633d29998eaed257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:14 -0400 Subject: [PATCH 03/10] Replace has_pending/check_pending with poll_completion --- src/engine.rs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index e394ba047..aa52d3bfb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -17,7 +17,7 @@ use { }; use { crate::{ - completion::{Completer, DefaultCompleter}, + completion::{Completer, CompletionStatus, DefaultCompleter}, core_editor::Editor, edit_mode::{EditMode, Emacs}, enums::{EventStatus, ReedlineEvent}, @@ -972,19 +972,21 @@ impl Reedline { // Determine if we need to poll (non-blocking) or can block on input. // We need polling if external_printer or idle_callback is configured, // using the shared poll_interval for the timeout. - let completer_pending = self.completer.has_pending(); - - // When a background completion finishes, re-populate the active - // menu so results appear without waiting for another keypress. - if completer_pending && self.completer.check_pending() { - if let Some(menu) = self.menus.iter_mut().find(|m| m.is_active()) { - menu.update_values( - &mut self.editor, - self.completer.as_mut(), - self.history.as_ref(), - ); - self.repaint(prompt)?; - } + let status = self.completer.poll_completion(); + // Anything BUT idle means work is in flight. We need to keep polling. + let completer_pending = status != CompletionStatus::Idle; + + if let Some(menu) = (status == CompletionStatus::Ready) + .then(|| self.menus.iter_mut().find(|m| m.is_active())) + .flatten() + { + // latest request finished, so repopulate + menu.update_values( + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + ); + self.repaint(prompt)?; } // Helper function that returns true if the input is complete and From 16635d479d491796f39e8f995124ce7dd82c7fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:19 -0400 Subject: [PATCH 04/10] Adapt DescriptionMenu and ListMenu to CompletionResult --- src/menu/description_menu.rs | 2 +- src/menu/list_menu.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/menu/description_menu.rs b/src/menu/description_menu.rs index b41b5a432..0328cfa38 100644 --- a/src/menu/description_menu.rs +++ b/src/menu/description_menu.rs @@ -443,7 +443,7 @@ impl Menu for DescriptionMenu { fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); - self.values = completer.complete(&input, pos); + self.values = completer.complete(&input, pos).into_suggestions(); self.reset_position(); } diff --git a/src/menu/list_menu.rs b/src/menu/list_menu.rs index db33823e5..612e895db 100644 --- a/src/menu/list_menu.rs +++ b/src/menu/list_menu.rs @@ -404,7 +404,7 @@ impl Menu for ListMenu { completer.partial_complete(&input, pos, skip, take) } else { self.query_size = None; - completer.complete(&input, pos) + completer.complete(&input, pos).into_suggestions() } } From 5871ecc1d786812c562e225ffc611902b6851e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:24 -0400 Subject: [PATCH 05/10] Add CompletionDisplay struct for shared menu metrics --- src/menu/menu_functions.rs | 68 +++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index 340bf5419..9e1f5e66a 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,5 +1,6 @@ //! Collection of common functions that can be used to create menus use std::borrow::Cow; +use std::ops::Range; use unicase::UniCase; use itertools::{ @@ -13,7 +14,7 @@ use unicode_width::UnicodeWidthStr; use crate::{ menu::{InputMode, MenuSettings, OutputMode}, painting::Painter, - Editor, Suggestion, UndoBehavior, + CompletionResult, Editor, Suggestion, Suggestions, UndoBehavior, }; /// Index result obtained from parsing a string with an index marker @@ -368,6 +369,71 @@ pub(crate) fn scroll_offset(selected: u16, current: u16, window: u16) -> u16 { } } +/// The suggestions a completion menu is currently displaying, together with the +/// display metrics derived from them. Grouping these keeps the completion display +/// state cohesive and separate from a menu's column/layout details. Shared by the +/// columnar and IDE menus. +#[derive(Default)] +pub struct CompletionDisplay { + /// Cached suggestion values shown by the menu. + pub values: Suggestions, + /// Display width of each suggestion in `values`. + pub display_widths: Vec, + /// Shortest of the strings the suggestions are based on. + pub shortest_base_string: String, + /// Width of the longest suggestion in `values`. + pub longest_suggestion: usize, +} + +impl CompletionDisplay { + /// Build the display for a completion `result`, or `None` when there is + /// nothing to adopt yet. + pub fn from_result( + result: CompletionResult, + base_ranges: &[Range], + editor: &Editor, + ) -> Option { + match result { + CompletionResult::Pending => None, + CompletionResult::Fresh(values) | CompletionResult::Stale(values) => { + Some(Self::new(values, base_ranges, editor)) + } + } + } + + /// Adopt `values` as the menu's suggestions and measure their display metrics + /// against the buffer's replacement `base_ranges`. + pub fn new(values: Suggestions, base_ranges: &[Range], editor: &Editor) -> Self { + let display_widths: Vec = values + .iter() + .map(|suggestion| strip_ansi_escapes::strip_str(suggestion.display_value()).width()) + .collect(); + + // Find the maximum width + let longest_suggestion = display_widths.iter().copied().max().unwrap_or(0); + + // Find the shortest buffer slice + let buffer = editor.get_buffer(); + let shortest_base_string = base_ranges + .iter() + .map(|range| { + let end_index = floor_char_boundary(buffer, range.end); + let start_index = floor_char_boundary(buffer, range.start).min(end_index); + &buffer[start_index..end_index] + }) + .min_by_key(|buffer_slice| buffer_slice.width()) + .map(String::from) + .unwrap_or_default(); + + Self { + values, + display_widths, + shortest_base_string, + longest_suggestion, + } + } +} + /// Helper to accept a completion suggestion and edit the buffer pub fn replace_in_buffer( value: Option, From 2a82886491ae57d85ce923e651f2f65dbb764154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:34 -0400 Subject: [PATCH 06/10] Migrate ColumnarMenu to CompletionDisplay with awaiting --- src/menu/columnar_menu.rs | 457 ++++++++++++++++++++++---------------- 1 file changed, 260 insertions(+), 197 deletions(-) diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 64dae1e6e..9dcd315c7 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -2,9 +2,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ - available_lines, can_partially_complete, floor_char_boundary, get_match_indices, - replace_in_buffer, resolve_completer_input, scroll_offset, style_suggestion, - truncate_with_ansi, + available_lines, can_partially_complete, get_match_indices, replace_in_buffer, + resolve_completer_input, scroll_offset, style_suggestion, truncate_with_ansi, + CompletionDisplay, }, painting::Painter, Completer, Suggestion, @@ -54,8 +54,6 @@ struct ColumnDetails { pub columns: u16, /// Column width pub col_width: usize, - /// The shortest of the strings, which the suggestions are based on - pub shortest_base_string: String, } /// Menu to present suggestions in a columnar fashion @@ -73,10 +71,12 @@ pub struct ColumnarMenu { min_rows: u16, /// Working column details keep changing based on the collected values working_details: ColumnDetails, - /// Menu cached values - values: Vec, - /// Cached display width of each suggestion in `values` - display_widths: Vec, + /// Suggestions currently displayed and their derived display metrics + completions: CompletionDisplay, + /// Whether a background completion is still in flight with nothing to show + /// yet. While set, the menu draws nothing rather than a premature + /// "NO RECORDS FOUND" (which is reserved for a settled, genuinely empty result). + awaiting_results: bool, /// column position of the cursor. Starts from 0 col_pos: u16, /// row position in the menu. Starts from 0 @@ -86,8 +86,6 @@ pub struct ColumnarMenu { skip_rows: u16, /// Event sent to the menu event: Option, - /// Longest suggestion found in the values - longest_suggestion: usize, /// String collected after the menu is activated input: Option, } @@ -100,13 +98,12 @@ impl Default for ColumnarMenu { default_details: DefaultColumnDetails::default(), min_rows: 3, working_details: ColumnDetails::default(), - values: Vec::new(), - display_widths: Vec::new(), + completions: CompletionDisplay::default(), + awaiting_results: false, col_pos: 0, row_pos: 0, skip_rows: 0, event: None, - longest_suggestion: 0, input: None, } } @@ -169,7 +166,7 @@ impl ColumnarMenu { fn move_previous(&mut self) { let new_index = match self.index().checked_sub(1) { Some(index) => index, - None => self.values.len().saturating_sub(1), + None => self.completions.values.len().saturating_sub(1), }; (self.row_pos, self.col_pos) = self.position_from_index(new_index); @@ -310,18 +307,12 @@ impl ColumnarMenu { /// Calculates how many rows the menu will use fn get_rows(&self) -> u16 { - let values = self.get_values().len() as u16; - - if values == 0 { - // When the values are empty the "NO RECORDS FOUND" message is shown, taking 1 line - return 1; - } - - let rows = values / self.get_cols(); - if values % self.get_cols() != 0 { - rows + 1 - } else { - rows + match self.get_values().len() as u16 { + // No reason to save space if we're waiting for results. + 0 if self.awaiting_results => 0, + // Should be one row for actual empty results + 0 => 1, + total_values => (total_values + self.get_cols() - 1) / self.get_cols(), } } @@ -372,119 +363,139 @@ impl ColumnarMenu { } /// Creates default string that represents one suggestion from the menu - fn create_string( + pub fn create_string( &self, suggestion: &Suggestion, index: usize, use_ansi_coloring: bool, ) -> String { - let selected = index == self.index(); + match use_ansi_coloring { + true => self.format_ansi(suggestion, index), + false => self.format_plain(suggestion, index), + } + } + + /// Handles plain-text formatting. + fn format_plain(&self, suggestion: &Suggestion, index: usize) -> String { + let is_selected = index == self.index(); + let terminal_width = self.get_width(); let display_value = suggestion.display_value(); - let empty_space = self.get_width().saturating_sub(self.display_widths[index]); - if use_ansi_coloring { - // TODO(ysthakur): let the user strip quotes, rather than doing it here - let is_quote = |c: char| "`'\"".contains(c); - let shortest_base = &self.working_details.shortest_base_string; - let shortest_base = shortest_base - .strip_prefix(is_quote) - .unwrap_or(shortest_base); - - let match_indices = - get_match_indices(display_value, &suggestion.match_indices, shortest_base); - - let left_text_size = self - .get_width() - .min(self.longest_suggestion + self.default_details.col_padding); - let description_size = self.get_width().saturating_sub(left_text_size); - let padding = left_text_size.saturating_sub(self.display_widths[index]); - - let text_style = &suggestion.style.unwrap_or(self.settings.color.text_style); - let match_style = if selected { - &self.settings.color.selected_match_style - } else { - &self.settings.color.match_style - }; - let value_trunc = truncate_with_ansi(display_value, left_text_size); - let styled_value = style_suggestion( - &value_trunc, - &match_indices, - text_style, - match_style, - selected.then_some(&self.settings.color.selected_text_style), - ); - - match &suggestion.description { - Some(desc) if description_size > 3 => { - let desc = desc.replace('\n', ""); - let desc_trunc = truncate_with_ansi(desc.as_str(), description_size); - if selected { - format!( - "{}{}{}{}{}{}{}", - styled_value, - RESET, - text_style.prefix(), - self.settings.color.selected_text_style.prefix(), - " ".repeat(padding), - self.settings.color.description_style.paint(desc_trunc), - RESET, - ) - } else { - format!( - "{}{}{}{}{}", - styled_value, - " ".repeat(padding), - RESET, - self.settings.color.description_style.paint(desc_trunc), - RESET, - ) - } - } - _ => { - format!( - "{}{}{:>empty$}", - styled_value, - RESET, - "", - empty = empty_space - ) + // Calculate the remaining space after the suggestion text + let empty_space = terminal_width.saturating_sub(self.completions.display_widths[index]); + let marker = if is_selected { ">" } else { "" }; + let description = suggestion.description.as_deref().unwrap_or(""); + + let mut formatted_line = if description.is_empty() { + // If there is no description, pad with empty space to fill the terminal width. + let padding_width = empty_space.saturating_sub(marker.len()); + format!("{marker}{display_value}{:padding_width$}", "") + } else { + // Calculate padding taking the marker into account + let padding_width = self.completions.longest_suggestion + + self.default_details.col_padding + - marker.len(); + let mut base_line = format!("{marker}{display_value:padding_width$}"); + + base_line.extend(description.chars().take(empty_space).map(|character| { + if character == '\n' { + ' ' + } else { + character } - } + })); + base_line + }; + + if is_selected { + // Modifies in-place. Prevents unicode edge cases as well. + formatted_line.make_ascii_uppercase(); + } + + formatted_line + } + + /// Handles ANSI-colored formatting. + fn format_ansi(&self, suggestion: &Suggestion, index: usize) -> String { + let is_selected = index == self.index(); + let terminal_width = self.get_width(); + let display_value = suggestion.display_value(); + let display_width = self.completions.display_widths[index]; + let color_settings = &self.settings.color; + + // TODO(ysthakur): let the user strip quotes, rather than doing it here + // Natively trim standard quote characters using a character array match. + let base_string = self + .completions + .shortest_base_string + .trim_start_matches(['`', '\'', '"']); + let match_indices = + get_match_indices(display_value, &suggestion.match_indices, base_string); + + // Calculate spatial boundaries for the suggestion text and its description. + let maximum_left_size = + self.completions.longest_suggestion + self.default_details.col_padding; + let left_text_size = terminal_width.min(maximum_left_size); + let description_size = terminal_width.saturating_sub(left_text_size); + + // Resolve ANSI styles based on selection state. + let text_style = suggestion + .style + .as_ref() + .unwrap_or(&color_settings.text_style); + let match_style = if is_selected { + &color_settings.selected_match_style } else { - // If no ansi coloring is found, then the selection word is the line in uppercase - let marker = if index == self.index() { ">" } else { "" }; - - let line = if let Some(description) = &suggestion.description { - format!( - "{}{:max$}{}", - marker, - display_value, - description - .chars() - .take(empty_space) - .collect::() - .replace('\n', " "), - max = self.longest_suggestion - + self - .default_details - .col_padding - .saturating_sub(marker.width()), - ) - } else { - format!( - "{}{}{:>empty$}", - marker, - display_value, - "", - empty = empty_space.saturating_sub(marker.width()), - ) - }; - - if selected { - line.to_uppercase() - } else { - line - } + &color_settings.match_style + }; + + let selected_style = is_selected.then_some(&color_settings.selected_text_style); + + // Truncate and apply ANSI highlighting to the primary suggestion text. + let truncated_value = truncate_with_ansi(display_value, left_text_size); + let styled_value = style_suggestion( + &truncated_value, + &match_indices, + text_style, + match_style, + selected_style, + ); + + // Extract the description, but only if it's long enough + // to be visible (> 3). + let description = suggestion + .description + .as_deref() + .filter(|_| description_size > 3) + .unwrap_or(""); + + if description.is_empty() { + let padding_width = terminal_width.saturating_sub(display_width); + return format!("{styled_value}{RESET}{:padding_width$}", ""); + } + + // Prepare the description by filtering out newlines + // and applying truncation and paint. + let padding_size = left_text_size.saturating_sub(display_width); + let cleaned_description: String = description + .chars() + .filter(|&character| character != '\n') + .collect(); + let painted_description = color_settings + .description_style + .paint(truncate_with_ansi(&cleaned_description, description_size)); + + match is_selected { + true => format!( + "{styled_value}{RESET}{}{}{:padding_size$}{painted_description}{RESET}", + text_style.prefix(), + color_settings.selected_text_style.prefix(), + "" + ), + false => format!( + "{styled_value}{:padding_size$}{RESET}{painted_description}{RESET}", + "" + ), } } @@ -537,7 +548,8 @@ impl ColumnarMenu { // Adjusting the working width of the column based the max line width found // in the menu values - let required_width = self.longest_suggestion + self.default_details.col_padding; + let required_width = + self.completions.longest_suggestion + self.default_details.col_padding; self.working_details.col_width = required_width.max(default_width).min(screen_width); @@ -617,26 +629,12 @@ impl Menu for ColumnarMenu { fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { 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; - self.display_widths = self - .values - .iter() - .map(|sugg| strip_ansi_escapes::strip_str(sugg.display_value()).width()) - .collect(); - self.working_details.shortest_base_string = base_ranges - .iter() - .map(|range| { - let end = floor_char_boundary(editor.get_buffer(), range.end); - let start = floor_char_boundary(editor.get_buffer(), range.start).min(end); - editor.get_buffer()[start..end].to_string() - }) - .min_by_key(|s| s.width()) - .unwrap_or_default(); - self.longest_suggestion = *self.display_widths.iter().max().unwrap_or(&0); - - self.reset_position(); + let (result, base_ranges) = completer.complete_with_base_ranges(&input, pos); + self.awaiting_results = result.is_pending(); + if let Some(completions) = CompletionDisplay::from_result(result, &base_ranges, editor) { + self.completions = completions; + self.reset_position(); + } } /// The working details for the menu changes based on the size of the lines @@ -666,7 +664,7 @@ impl Menu for ColumnarMenu { /// Gets values from filler that will be displayed in the menu fn get_values(&self) -> &[Suggestion] { - &self.values + &self.completions.values } fn menu_required_lines(&self, _terminal_columns: u16) -> u16 { @@ -675,7 +673,13 @@ impl Menu for ColumnarMenu { fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { if self.get_values().is_empty() { - self.no_records_msg(use_ansi_coloring) + if self.awaiting_results { + // A background completion is still running; draw nothing rather + // than flashing "NO RECORDS FOUND" before the results land. + String::new() + } else { + self.no_records_msg(use_ansi_coloring) + } } else { // It seems that crossterm prefers to have a complete string ready to be printed // rather than looping through the values and printing multiple things @@ -816,33 +820,24 @@ mod tests { struct FakeCompleter { completions: Vec, - /// When set, every suggestion carries a description - describe: bool, } impl FakeCompleter { fn new(completions: &[&str]) -> Self { Self { completions: completions.iter().map(|c| c.to_string()).collect(), - describe: false, } } - - fn with_descriptions(mut self) -> Self { - self.describe = true; - self - } } impl Completer for FakeCompleter { - fn complete(&mut self, _line: &str, pos: usize) -> Vec { - self.completions - .iter() - .map(|c| Suggestion { - description: self.describe.then(|| format!("desc for {c}")), - ..fake_suggestion(c, pos) - }) - .collect() + fn complete(&mut self, _line: &str, pos: usize) -> crate::CompletionResult { + crate::CompletionResult::fresh( + self.completions + .iter() + .map(|c| fake_suggestion(c, pos)) + .collect::>(), + ) } } @@ -858,6 +853,35 @@ mod tests { } } + /// Like [`FakeCompleter`] but every suggestion carries a description, which + /// drives the columnar menu into its single-column layout. + struct DescribedCompleter { + completions: Vec, + } + + impl DescribedCompleter { + fn new(completions: &[&str]) -> Self { + Self { + completions: completions.iter().map(|c| c.to_string()).collect(), + } + } + } + + impl Completer for DescribedCompleter { + fn complete(&mut self, _line: &str, pos: usize) -> crate::CompletionResult { + crate::CompletionResult::fresh( + self.completions + .iter() + .map(|c| { + let mut suggestion = fake_suggestion(c, pos); + suggestion.description = Some(format!("desc for {c}")); + suggestion + }) + .collect::>(), + ) + } + } + fn setup_menu( menu: &mut ColumnarMenu, editor: &mut Editor, @@ -871,6 +895,42 @@ mod tests { menu.update_working_details(editor, completer, &painter); } + /// The menu layout must recompute whenever `update_working_details` runs, + /// not only when a menu event is queued. Here the suggestions are refreshed + /// through `update_values` (as a repaint would after new results arrive) + /// with no pending event; the appearance of descriptions must still collapse + /// the layout to a single column instead of waiting for the next keypress. + #[test] + fn layout_recomputes_without_a_menu_event() { + let mut menu = ColumnarMenu::default().with_name("testmenu"); + let mut editor = Editor::default(); + let mut painter = Painter::new(W::sink()); + painter.handle_resize(120, 10); + + // Initial results have no descriptions -> normal multi-column layout. + let mut plain = FakeCompleter::new(&["alpha", "beta", "gamma"]); + menu.menu_event(MenuEvent::Activate(false)); + menu.update_working_details(&mut editor, &mut plain, &painter); + assert!( + menu.get_cols() > 1, + "expected a multi-column layout without descriptions" + ); + + // Refresh the values without queuing a menu event, then repaint. + let mut described = DescribedCompleter::new(&["alpha", "beta", "gamma"]); + menu.update_values(&mut editor, &mut described); + assert!(menu.event.is_none(), "no menu event should be queued"); + menu.update_working_details(&mut editor, &mut described, &painter); + + // Descriptions must collapse the menu to one column now; before the fix + // this only took effect after the next menu event (e.g. an arrow key). + assert_eq!( + menu.get_cols(), + 1, + "descriptions must relayout to one column without a menu event" + ); + } + #[test] fn test_menu_replace_backtick() { // https://github.com/nushell/nushell/issues/7885 @@ -928,6 +988,36 @@ mod tests { assert!(menu.menu_string(10, true).contains("验")); } + /// A completer whose result is always `Pending` (a background compute that + /// never lands within the test), to exercise the awaiting-results path. + struct PendingCompleter; + + impl Completer for PendingCompleter { + fn complete(&mut self, _line: &str, _pos: usize) -> crate::CompletionResult { + crate::CompletionResult::Pending + } + } + + #[test] + fn pending_completion_draws_nothing_instead_of_no_records() { + let mut menu = ColumnarMenu::default().with_name("testmenu"); + let mut editor = Editor::default(); + + // A settled, genuinely empty result shows "NO RECORDS FOUND" on 1 line. + let mut empty = FakeCompleter::new(&[]); + setup_menu(&mut menu, &mut editor, &mut empty, (30, 10)); + assert_eq!(menu.get_rows(), 1); + assert!(menu.menu_string(10, false).contains("NO RECORDS FOUND")); + + // A pending result (background work still in flight) reserves no space and + // draws nothing, so the menu never flashes "NO RECORDS FOUND". + let mut pending = PendingCompleter; + setup_menu(&mut menu, &mut editor, &mut pending, (30, 10)); + assert_eq!(menu.get_rows(), 0); + assert_eq!(menu.menu_required_lines(30), 0); + assert!(menu.menu_string(10, false).is_empty()); + } + #[test] fn test_horizontal_menu_selection_position() { // Test selection position update @@ -1061,31 +1151,4 @@ mod tests { assert!(menu.row_pos == 0 && menu.col_pos == 1); } } - - #[test] - fn layout_recomputes_without_a_menu_event() { - let mut menu = ColumnarMenu::default().with_name("testmenu"); - let mut editor = Editor::default(); - let mut painter = Painter::new(W::sink()); - painter.handle_resize(120, 10); - - let mut plain = FakeCompleter::new(&["alpha", "beta", "gamma"]); - menu.menu_event(MenuEvent::Activate(false)); - menu.update_working_details(&mut editor, &mut plain, &painter); - assert!( - menu.get_cols() > 1, - "expected a multi-column layout without descriptions" - ); - - let mut described = FakeCompleter::new(&["alpha", "beta", "gamma"]).with_descriptions(); - menu.update_values(&mut editor, &mut described); - assert!(menu.event.is_none(), "no menu event should be queued"); - menu.update_working_details(&mut editor, &mut described, &painter); - - assert_eq!( - menu.get_cols(), - 1, - "descriptions must relayout to one column without a menu event" - ); - } } From bc8d75b85e75c297e46b34e1fd084a44b9b5752a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:06:39 -0400 Subject: [PATCH 07/10] Migrate IdeMenu to CompletionDisplay with awaiting --- src/menu/ide_menu.rs | 152 ++++++++++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 53 deletions(-) diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index f676eb184..44deba5f4 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -2,9 +2,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ - available_lines, can_partially_complete, floor_char_boundary, get_match_indices, - replace_in_buffer, resolve_completer_input, scroll_offset, style_suggestion, - truncate_with_ansi, + available_lines, can_partially_complete, get_match_indices, replace_in_buffer, + resolve_completer_input, scroll_offset, style_suggestion, truncate_with_ansi, + CompletionDisplay, }, painting::Painter, Completer, Suggestion, @@ -129,8 +129,6 @@ struct IdeMenuDetails { pub space_right: u16, /// Corrected description offset, based on the available space pub description_offset: u16, - /// The shortest of the strings, which the suggestions are based on - pub shortest_base_string: String, } /// Menu to present suggestions like similar to Ide completion menus @@ -144,10 +142,12 @@ pub struct IdeMenu { default_details: DefaultIdeMenuDetails, /// Working ide menu details keep changing based on the collected values working_details: IdeMenuDetails, - /// Menu cached values - values: Vec, - /// Cached display width of each suggestion in `values` - display_widths: Vec, + /// Suggestions currently displayed and their derived display metrics + completions: CompletionDisplay, + /// Whether a background completion is still in flight with nothing to show + /// yet. While set, the menu draws nothing rather than a premature + /// "NO RECORDS FOUND" (which is reserved for a settled, genuinely empty result). + awaiting_results: bool, /// Selected value. Starts at 0 selected: u16, /// Number of values that are skipped when printing, @@ -155,8 +155,6 @@ pub struct IdeMenu { skip_values: u16, /// Event sent to the menu event: Option, - /// Longest suggestion found in the values - longest_suggestion: usize, /// String collected after the menu is activated input: Option, } @@ -168,12 +166,11 @@ impl Default for IdeMenu { active: false, default_details: DefaultIdeMenuDetails::default(), working_details: IdeMenuDetails::default(), - values: Vec::new(), - display_widths: Vec::new(), + completions: CompletionDisplay::default(), + awaiting_results: false, selected: 0, skip_values: 0, event: None, - longest_suggestion: 0, input: None, } } @@ -298,7 +295,7 @@ impl IdeMenu { // Menu functionality impl IdeMenu { fn move_next(&mut self) { - if self.selected < (self.values.len() as u16).saturating_sub(1) { + if self.selected < (self.completions.values.len() as u16).saturating_sub(1) { self.selected += 1; } else { self.selected = 0; @@ -309,7 +306,7 @@ impl IdeMenu { if self.selected > 0 { self.selected -= 1; } else { - self.selected = self.values.len().saturating_sub(1) as u16; + self.selected = self.completions.values.len().saturating_sub(1) as u16; } } @@ -318,7 +315,7 @@ impl IdeMenu { } fn get_value(&self) -> Option { - self.values.get(self.index()).cloned() + self.completions.values.get(self.index()).cloned() } /// Calculates how many rows the Menu will try to use (if available) @@ -326,8 +323,10 @@ impl IdeMenu { let mut values = self.get_values().len() as u16; if values == 0 { - // When the values are empty the no_records_msg is shown, taking 1 line - return 1; + // While a background completion is still in flight there is nothing to + // show yet, so reserve no space; otherwise the empty menu is a settled + // result and reserves 1 line for the no_records_msg. + return if self.awaiting_results { 0 } else { 1 }; } if self.default_details.border.is_some() { @@ -498,7 +497,7 @@ impl IdeMenu { let display_value = suggestion.display_value(); let padding_right = (self.working_details.completion_width as usize) - .saturating_sub(self.display_widths[index] + border_width + padding); + .saturating_sub(self.completions.display_widths[index] + border_width + padding); let max_string_width = (self.working_details.completion_width as usize).saturating_sub(border_width + padding); @@ -508,7 +507,7 @@ impl IdeMenu { if use_ansi_coloring { // TODO(ysthakur): let the user strip quotes, rather than doing it here let is_quote = |c: char| "`'\"".contains(c); - let shortest_base = &self.working_details.shortest_base_string; + let shortest_base = &self.completions.shortest_base_string; let shortest_base = shortest_base .strip_prefix(is_quote) .unwrap_or(shortest_base); @@ -627,7 +626,7 @@ impl IdeMenu { const PADDING_SIDES: u16 = 2; const ELLIPSIS_WIDTH: u16 = 3; - let desired_width = (self.longest_suggestion.min(u16::MAX as usize) as u16) + let desired_width = (self.completions.longest_suggestion.min(u16::MAX as usize) as u16) + (PADDING_SIDES * self.default_details.padding) + border_width; @@ -659,7 +658,7 @@ impl IdeMenu { let mut start_pos = (base as i16 + self.default_details.cursor_offset).max(0) as u16; if self.default_details.correct_cursor_pos { - let base_string_width = self.working_details.shortest_base_string.width(); + let base_string_width = self.completions.shortest_base_string.width(); start_pos = start_pos.saturating_sub(base_string_width as u16); } @@ -805,26 +804,12 @@ impl Menu for IdeMenu { fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { 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; - self.display_widths = self - .values - .iter() - .map(|sugg| strip_ansi_escapes::strip_str(sugg.display_value()).width()) - .collect(); - self.working_details.shortest_base_string = base_ranges - .iter() - .map(|range| { - let end = floor_char_boundary(editor.get_buffer(), range.end); - let start = floor_char_boundary(editor.get_buffer(), range.start).min(end); - editor.get_buffer()[start..end].to_string() - }) - .min_by_key(|s| s.width()) - .unwrap_or_default(); - self.longest_suggestion = *self.display_widths.iter().max().unwrap_or(&0); - - self.reset_position(); + let (result, base_ranges) = completer.complete_with_base_ranges(&input, pos); + self.awaiting_results = result.is_pending(); + if let Some(completions) = CompletionDisplay::from_result(result, &base_ranges, editor) { + self.completions = completions; + self.reset_position(); + } } /// The working details for the menu changes based on the size of the lines @@ -853,7 +838,7 @@ impl Menu for IdeMenu { } fn get_values(&self) -> &[Suggestion] { - &self.values + &self.completions.values } fn menu_required_lines(&self, _terminal_columns: u16) -> u16 { @@ -863,7 +848,13 @@ impl Menu for IdeMenu { fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { if self.get_values().is_empty() { - self.no_records_msg(use_ansi_coloring) + if self.awaiting_results { + // A background completion is still running; draw nothing rather + // than flashing "NO RECORDS FOUND" before the results land. + String::new() + } else { + self.no_records_msg(use_ansi_coloring) + } } else { let border_width = if self.default_details.border.is_some() { 2 @@ -877,7 +868,7 @@ impl Menu for IdeMenu { let available_values = available_lines.saturating_sub(border_width) as usize; let max_padding = self.working_details.completion_width.saturating_sub( - self.longest_suggestion.min(u16::MAX as usize) as u16 + border_width, + self.completions.longest_suggestion.min(u16::MAX as usize) as u16 + border_width, ) / 2; let corrected_padding = self.default_details.padding.min(max_padding) as usize; @@ -1392,11 +1383,13 @@ mod tests { } impl Completer for FakeCompleter { - fn complete(&mut self, _line: &str, pos: usize) -> Vec { - self.completions - .iter() - .map(|c| fake_suggestion(c, pos)) - .collect() + fn complete(&mut self, _line: &str, pos: usize) -> crate::CompletionResult { + crate::CompletionResult::fresh( + self.completions + .iter() + .map(|c| fake_suggestion(c, pos)) + .collect::>(), + ) } } @@ -1455,7 +1448,6 @@ mod tests { space_left: 50, space_right: 50, description_offset: 50, - shortest_base_string: String::new(), }; let mut editor = Editor::default(); // backtick at the end of the line @@ -1483,7 +1475,6 @@ mod tests { space_left: 50, space_right: 50, description_offset: 50, - shortest_base_string: String::new(), }; let mut editor = Editor::default(); @@ -1517,4 +1508,59 @@ mod tests { menu.update_values(&mut editor, &mut completer); assert!(menu.menu_string(10, true).contains("验")); } + + /// A completer whose result is always `Pending` (a background compute that + /// has produced nothing to show yet). + struct PendingCompleter; + + impl Completer for PendingCompleter { + fn complete(&mut self, _line: &str, _pos: usize) -> crate::CompletionResult { + crate::CompletionResult::Pending + } + } + + #[test] + fn pending_update_does_not_move_the_menu() { + // once the menu is + // anchored under the cursor, a background completion reporting `Pending` + // (no results yet) must keep the current suggestions and position + // Rather than hopping and jumping around + use crate::painting::W; + + let mut painter = Painter::new(W::sink()); + painter.handle_resize(100, 40); + let mut editor = Editor::default(); + + // Full-width cap, like nushell's `max_completion_width: (term size).columns`. + let mut menu = IdeMenu::default() + .with_name("testmenu") + .with_max_completion_width(100); + + // Menu opens with real results, anchored under the cursor. + let mut fresh = FakeCompleter::new(&["hello", "help"]); + menu.menu_event(MenuEvent::Activate(false)); + menu.set_cursor_pos((20, 3)); + menu.update_working_details(&mut editor, &mut fresh, &painter); + let anchored = menu.working_details.space_left; + let shown = menu.get_values().len(); + assert_eq!(anchored, 20, "menu should open under the cursor"); + assert_eq!(shown, 2, "menu should show the fresh results"); + + // A background recompute reports `Pending` — no menu event. + let mut pending = PendingCompleter; + menu.update_values(&mut editor, &mut pending); + assert!(menu.event.is_none(), "no menu event should be queued"); + menu.set_cursor_pos((20, 3)); + menu.update_working_details(&mut editor, &mut pending, &painter); + + assert_eq!( + menu.get_values().len(), + shown, + "pending results must not clear the suggestions already on screen" + ); + assert_eq!( + menu.working_details.space_left, anchored, + "menu jumped horizontally when a pending update arrived" + ); + } } From c7b1469d5ff9feae22b16eab734d4eec63455876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:07:21 -0400 Subject: [PATCH 08/10] Fix persistent_menus example for new Completer API --- examples/persistent_menus.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/examples/persistent_menus.rs b/examples/persistent_menus.rs index e91ca754a..847621d51 100644 --- a/examples/persistent_menus.rs +++ b/examples/persistent_menus.rs @@ -10,9 +10,9 @@ // menu; without quick completions, the menu closes once the line is empty. use reedline::{ - default_emacs_keybindings, ColumnarMenu, Completer, DefaultPrompt, Emacs, KeyCode, - KeyModifiers, Keybindings, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, - Suggestion, + default_emacs_keybindings, ColumnarMenu, Completer, CompletionResult, DefaultPrompt, Emacs, + KeyCode, KeyModifiers, Keybindings, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, + Span, Suggestion, }; use std::io; @@ -24,17 +24,19 @@ struct PrefixCompleter { } impl Completer for PrefixCompleter { - fn complete(&mut self, line: &str, pos: usize) -> Vec { + fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { let filter = &line[..pos]; - self.commands - .iter() - .filter(|command| command.starts_with(filter)) - .map(|command| Suggestion { - value: command.clone(), - span: Span::new(0, pos), - ..Suggestion::default() - }) - .collect() + CompletionResult::fresh( + self.commands + .iter() + .filter(|command| command.starts_with(filter)) + .map(|command| Suggestion { + value: command.clone(), + span: Span::new(0, pos), + ..Suggestion::default() + }) + .collect::>(), + ) } } From 9183f162754dc405cc5dee54929db358591fd49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 10:07:42 -0400 Subject: [PATCH 09/10] Remove unused UnicodeWidthStr import in columnar_menu --- src/menu/columnar_menu.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 9dcd315c7..8dd169d4f 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -10,7 +10,6 @@ use crate::{ Completer, Suggestion, }; use nu_ansi_term::ansi::RESET; -use unicode_width::UnicodeWidthStr; /// The traversal direction of the menu #[derive(Debug, PartialEq, Eq)] From c450686f592e5ff4cb78f8b8233c55fc17da21cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 19 Jul 2026 16:07:04 -0400 Subject: [PATCH 10/10] fixes to review comments --- src/completion/base.rs | 31 +++++++++++++++++-------------- src/completion/default.rs | 12 ++++++------ src/completion/history.rs | 9 +++++---- src/menu/columnar_menu.rs | 26 +++++++++++++++++--------- src/menu/description_menu.rs | 18 ++++++++++++------ src/menu/list_menu.rs | 34 ++++++++++++++++++++++------------ 6 files changed, 79 insertions(+), 51 deletions(-) diff --git a/src/completion/base.rs b/src/completion/base.rs index 7afe201ed..7c98a7cbd 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -36,14 +36,14 @@ impl Span { /// The outcome of a [`Completer::complete`] request. /// -/// Synchro completor only EVER produces [`Fresh`](Self::Fresh). An +/// A synchronous completer only ever produces [`Fresh`](Self::Fresh). An /// asynchronous completer that computes in the background reports its progress /// through this type. #[derive(Debug, Clone)] pub enum CompletionResult { /// Final, authoritative results. No further computation is in flight. Fresh(Suggestions), - /// Best-effort results to show in the momemnt; a fresh computation is still running and + /// Best-effort results to show in the moment; a fresh computation is still running and /// will replace these once it finishes. Stale(Suggestions), /// No results are available yet; a computation is spinning in the background. @@ -56,12 +56,14 @@ impl CompletionResult { CompletionResult::Fresh(suggestions.into()) } - /// Best-effort fallback while an authoritative result is still computing - pub fn stale_or_pending(fallback: Vec) -> Self { + /// Best-effort fallback while an authoritative result is still computing: + /// [`Stale`](Self::Stale) when there is something to show now, else + /// [`Pending`](Self::Pending). + pub fn stale_or_pending(fallback: Suggestions) -> Self { if fallback.is_empty() { CompletionResult::Pending } else { - CompletionResult::Stale(fallback.into()) + CompletionResult::Stale(fallback) } } @@ -73,14 +75,14 @@ impl CompletionResult { } } - /// Consume the result into its suggestions (empty for [`Pending`](Self::Pending)). + /// Move the shared suggestion list out of the result without copying. /// - /// Prefer [`suggestions`](Self::suggestions) when a borrow suffices; this - /// copies the list out of the shared `Arc` into an owned `Vec`. - pub fn into_suggestions(self) -> Vec { + /// Returns `None` for [`Pending`](Self::Pending) nothing is settled yet, so + /// callers should keep whatever they are already displaying. + pub fn into_shared(self) -> Option { match self { - CompletionResult::Fresh(values) | CompletionResult::Stale(values) => values.to_vec(), - CompletionResult::Pending => Vec::new(), + CompletionResult::Fresh(values) | CompletionResult::Stale(values) => Some(values), + CompletionResult::Pending => None, } } @@ -137,12 +139,13 @@ pub trait Completer { pos: usize, start: usize, offset: usize, - ) -> Vec { + ) -> Suggestions { self.complete(line, pos) - .into_suggestions() - .into_iter() + .suggestions() + .iter() .skip(start) .take(offset) + .cloned() .collect() } diff --git a/src/completion/default.rs b/src/completion/default.rs index cea0a4b8b..05fc43b9c 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -53,7 +53,7 @@ impl Completer for DefaultCompleter { /// let mut completions = DefaultCompleter::default(); /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("bat",3).into_suggestions(), + /// completions.complete("bat",3).suggestions(), /// vec![ /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, @@ -61,7 +61,7 @@ impl Completer for DefaultCompleter { /// ]); /// /// assert_eq!( - /// completions.complete("to the\r\nbat",11).into_suggestions(), + /// completions.complete("to the\r\nbat",11).suggestions(), /// vec![ /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, @@ -182,13 +182,13 @@ impl DefaultCompleter { /// let mut completions = DefaultCompleter::default(); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("te",2).into_suggestions(), + /// completions.complete("te",2).suggestions(), /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}]); /// /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( - /// completions.complete("te",2).into_suggestions(), + /// completions.complete("te",2).suggestions(), /// vec![ /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, @@ -376,7 +376,7 @@ mod tests { ); assert_eq!( - completions.complete("n", 3).into_suggestions(), + completions.complete("n", 3).suggestions(), [ Suggestion { value: "null".into(), @@ -423,7 +423,7 @@ mod tests { let (result, ranges) = completions.complete_with_base_ranges(buffer, 9); assert_eq!( - result.into_suggestions(), + result.suggestions(), [ Suggestion { value: "test".into(), diff --git a/src/completion/history.rs b/src/completion/history.rs index 31dcdc1d3..dee34e719 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -111,7 +111,8 @@ mod tests { let input = "git s"; let mut sut = HistoryCompleter::new(&history); - let actual = sut.complete(input, input.len()).into_suggestions(); + let completion = sut.complete(input, input.len()); + let actual = completion.suggestions(); let num_completions = sut.total_completions(input, input.len()); assert_eq!(actual[0].value, "git status", "it was the last command"); @@ -150,9 +151,9 @@ mod tests { let mut sut = HistoryCompleter::new(&history); let actual: Vec = sut .complete(line, line.len()) - .into_suggestions() - .into_iter() - .map(|suggestion| suggestion.value) + .suggestions() + .iter() + .map(|suggestion| suggestion.value.clone()) .collect(); assert_eq!(actual, expected); Ok(()) diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 8dd169d4f..6f62bd773 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -374,6 +374,17 @@ impl ColumnarMenu { } } + /// Horizontal split of a menu row into the suggestion column and the + /// description that follows it. + fn column_split(&self, terminal_width: usize) -> (usize, usize) { + let maximum_left_size = + self.completions.longest_suggestion + self.default_details.col_padding; + let left_text_size = terminal_width.min(maximum_left_size); + let description_size = terminal_width.saturating_sub(left_text_size); + + (left_text_size, description_size) + } + /// Handles plain-text formatting. fn format_plain(&self, suggestion: &Suggestion, index: usize) -> String { let is_selected = index == self.index(); @@ -390,13 +401,13 @@ impl ColumnarMenu { let padding_width = empty_space.saturating_sub(marker.len()); format!("{marker}{display_value}{:padding_width$}", "") } else { - // Calculate padding taking the marker into account - let padding_width = self.completions.longest_suggestion - + self.default_details.col_padding - - marker.len(); + // The left column is capped at `left_text_size` and the description + // takes only what's left, so the row can't exceed the terminal + let (left_text_size, description_size) = self.column_split(terminal_width); + let padding_width = left_text_size.saturating_sub(marker.len()); let mut base_line = format!("{marker}{display_value:padding_width$}"); - base_line.extend(description.chars().take(empty_space).map(|character| { + base_line.extend(description.chars().take(description_size).map(|character| { if character == '\n' { ' ' } else { @@ -432,10 +443,7 @@ impl ColumnarMenu { get_match_indices(display_value, &suggestion.match_indices, base_string); // Calculate spatial boundaries for the suggestion text and its description. - let maximum_left_size = - self.completions.longest_suggestion + self.default_details.col_padding; - let left_text_size = terminal_width.min(maximum_left_size); - let description_size = terminal_width.saturating_sub(left_text_size); + let (left_text_size, description_size) = self.column_split(terminal_width); // Resolve ANSI styles based on selection state. let text_style = suggestion diff --git a/src/menu/description_menu.rs b/src/menu/description_menu.rs index 0328cfa38..47f5ef0b5 100644 --- a/src/menu/description_menu.rs +++ b/src/menu/description_menu.rs @@ -2,7 +2,7 @@ use { super::MenuSettings, crate::{ menu_functions::{replace_in_buffer, resolve_completer_input}, - Completer, Editor, Menu, MenuBuilder, MenuEvent, Painter, Suggestion, + Completer, Editor, Menu, MenuBuilder, MenuEvent, Painter, Suggestion, Suggestions, }, nu_ansi_term::ansi::RESET, }; @@ -62,7 +62,7 @@ pub struct DescriptionMenu { /// Working column details keep changing based on the collected values working_details: WorkingDetails, /// Menu cached values - values: Vec, + values: Suggestions, /// column position of the cursor. Starts from 0 col_pos: u16, /// row position in the menu. Starts from 0 @@ -92,7 +92,7 @@ impl Default for DescriptionMenu { default_details: DefaultMenuDetails::default(), min_rows: 3, working_details: WorkingDetails::default(), - values: Vec::new(), + values: Suggestions::default(), col_pos: 0, row_pos: 0, event: None, @@ -426,7 +426,7 @@ impl Menu for DescriptionMenu { MenuEvent::Deactivate => { self.active = false; self.input = None; - self.values = Vec::new(); + self.values = Suggestions::default(); } _ => {} }; @@ -443,9 +443,15 @@ impl Menu for DescriptionMenu { fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { let (input, pos) = resolve_completer_input(editor, &mut self.input, &self.settings); - self.values = completer.complete(&input, pos).into_suggestions(); - self.reset_position(); + // `into_shared` yields `None` for a `Pending` result (a background + // completion is still in flight with nothing to show yet), so we keep the + // current suggestions and selection rather than blanking the menu. A + // settled result hands over its shared `Arc` without copying. + if let Some(values) = completer.complete(&input, pos).into_shared() { + self.values = values; + self.reset_position(); + } } /// The working details for the menu changes based on the size of the lines diff --git a/src/menu/list_menu.rs b/src/menu/list_menu.rs index 612e895db..0d69fde66 100644 --- a/src/menu/list_menu.rs +++ b/src/menu/list_menu.rs @@ -4,7 +4,7 @@ use { core_editor::Editor, menu_functions::{replace_in_buffer, resolve_completer_input}, painting::{estimate_single_line_wraps, Painter}, - Completer, Suggestion, + Completer, Suggestion, Suggestions, }, nu_ansi_term::ansi::RESET, std::{fmt::Write, iter::Sum}, @@ -62,8 +62,8 @@ pub struct ListMenu { /// When collecting chronological values, the menu only caches at least /// page_size records. /// When performing a query to the completer, the cached values will - /// be the result from such query - values: Vec, + /// be the result from such query. + values: Suggestions, /// row position in the menu. Starts from 0 row_position: u16, /// Max size of the suggestions when querying without a search buffer @@ -93,7 +93,7 @@ impl Default for ListMenu { .with_only_buffer_difference(true), page_size: 10, active: false, - values: Vec::new(), + values: Suggestions::default(), row_position: 0, page: 0, query_size: None, @@ -381,30 +381,40 @@ impl Menu for ListMenu { fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { 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); // If there are no row selector and the menu has an Edit event, this clears // the position together with the pages vector - if matches!(self.event, Some(MenuEvent::Edit(_))) && parsed.index.is_none() { + if parsed.index.is_none() && matches!(self.event, Some(MenuEvent::Edit(_))) { self.reset_position(); } - self.values = if parsed.remainder.is_empty() { + if parsed.remainder.is_empty() { self.query_size = Some(completer.total_completions(parsed.remainder, pos)); - let skip = self.pages.iter().take(self.page).sum::().size; + let skip = self + .pages + .iter() + .take(self.page) + .map(|page| page.size) + .sum(); + let take = self .pages .get(self.page) - .map(|page| page.size) - .unwrap_or(self.page_size); + .map_or(self.page_size, |page| page.size); - completer.partial_complete(&input, pos, skip, take) + self.values = completer.partial_complete(&input, pos, skip, take); } else { self.query_size = None; - completer.complete(&input, pos).into_suggestions() + + // `into_shared` yields `None` for a `Pending` result (a background + // completion is still in flight with nothing to show yet), so we keep + // the current suggestions rather than blanking the menu. + if let Some(values) = completer.complete(&input, pos).into_shared() { + self.values = values; + } } }