reedline/menu/
columnar_menu.rs

1use super::{menu_functions::find_common_string, Menu, MenuEvent, MenuTextStyle};
2use crate::{
3    core_editor::Editor, menu_functions::string_difference, painting::Painter, Completer,
4    Suggestion, UndoBehavior,
5};
6use nu_ansi_term::{ansi::RESET, Style};
7
8/// Default values used as reference for the menu. These values are set during
9/// the initial declaration of the menu and are always kept as reference for the
10/// changeable [`ColumnDetails`]
11struct DefaultColumnDetails {
12    /// Number of columns that the menu will have
13    pub columns: u16,
14    /// Column width
15    pub col_width: Option<usize>,
16    /// Column padding
17    pub col_padding: usize,
18}
19
20impl Default for DefaultColumnDetails {
21    fn default() -> Self {
22        Self {
23            columns: 4,
24            col_width: None,
25            col_padding: 2,
26        }
27    }
28}
29
30/// Represents the actual column conditions of the menu. These conditions change
31/// since they need to accommodate possible different line sizes for the column values
32#[derive(Default)]
33struct ColumnDetails {
34    /// Number of columns that the menu will have
35    pub columns: u16,
36    /// Column width
37    pub col_width: usize,
38}
39
40/// Menu to present suggestions in a columnar fashion
41/// It presents a description of the suggestion if available
42pub struct ColumnarMenu {
43    /// Menu name
44    name: String,
45    /// Columnar menu active status
46    active: bool,
47    /// Menu coloring
48    color: MenuTextStyle,
49    /// Default column details that are set when creating the menu
50    /// These values are the reference for the working details
51    default_details: DefaultColumnDetails,
52    /// Number of minimum rows that are displayed when
53    /// the required lines is larger than the available lines
54    min_rows: u16,
55    /// Working column details keep changing based on the collected values
56    working_details: ColumnDetails,
57    /// Menu cached values
58    values: Vec<Suggestion>,
59    /// column position of the cursor. Starts from 0
60    col_pos: u16,
61    /// row position in the menu. Starts from 0
62    row_pos: u16,
63    /// Menu marker when active
64    marker: String,
65    /// Event sent to the menu
66    event: Option<MenuEvent>,
67    /// Longest suggestion found in the values
68    longest_suggestion: usize,
69    /// String collected after the menu is activated
70    input: Option<String>,
71    /// Calls the completer using only the line buffer difference difference
72    /// after the menu was activated
73    only_buffer_difference: bool,
74}
75
76impl Default for ColumnarMenu {
77    fn default() -> Self {
78        Self {
79            name: "columnar_menu".to_string(),
80            active: false,
81            color: MenuTextStyle::default(),
82            default_details: DefaultColumnDetails::default(),
83            min_rows: 3,
84            working_details: ColumnDetails::default(),
85            values: Vec::new(),
86            col_pos: 0,
87            row_pos: 0,
88            marker: "| ".to_string(),
89            event: None,
90            longest_suggestion: 0,
91            input: None,
92            only_buffer_difference: false,
93        }
94    }
95}
96
97// Menu configuration functions
98impl ColumnarMenu {
99    /// Menu builder with new name
100    #[must_use]
101    pub fn with_name(mut self, name: &str) -> Self {
102        self.name = name.into();
103        self
104    }
105
106    /// Menu builder with new value for text style
107    #[must_use]
108    pub fn with_text_style(mut self, text_style: Style) -> Self {
109        self.color.text_style = text_style;
110        self
111    }
112
113    /// Menu builder with new value for text style
114    #[must_use]
115    pub fn with_selected_text_style(mut self, selected_text_style: Style) -> Self {
116        self.color.selected_text_style = selected_text_style;
117        self
118    }
119
120    /// Menu builder with new value for text style
121    #[must_use]
122    pub fn with_description_text_style(mut self, description_text_style: Style) -> Self {
123        self.color.description_style = description_text_style;
124        self
125    }
126
127    /// Menu builder with new columns value
128    #[must_use]
129    pub fn with_columns(mut self, columns: u16) -> Self {
130        self.default_details.columns = columns;
131        self
132    }
133
134    /// Menu builder with new column width value
135    #[must_use]
136    pub fn with_column_width(mut self, col_width: Option<usize>) -> Self {
137        self.default_details.col_width = col_width;
138        self
139    }
140
141    /// Menu builder with new column width value
142    #[must_use]
143    pub fn with_column_padding(mut self, col_padding: usize) -> Self {
144        self.default_details.col_padding = col_padding;
145        self
146    }
147
148    /// Menu builder with marker
149    #[must_use]
150    pub fn with_marker(mut self, marker: String) -> Self {
151        self.marker = marker;
152        self
153    }
154
155    /// Menu builder with new only buffer difference
156    #[must_use]
157    pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self {
158        self.only_buffer_difference = only_buffer_difference;
159        self
160    }
161}
162
163// Menu functionality
164impl ColumnarMenu {
165    /// Move menu cursor to the next element
166    fn move_next(&mut self) {
167        let mut new_col = self.col_pos + 1;
168        let mut new_row = self.row_pos;
169
170        if new_col >= self.get_cols() {
171            new_row += 1;
172            new_col = 0;
173        }
174
175        if new_row >= self.get_rows() {
176            new_row = 0;
177            new_col = 0;
178        }
179
180        let position = new_row * self.get_cols() + new_col;
181        if position >= self.get_values().len() as u16 {
182            self.reset_position();
183        } else {
184            self.col_pos = new_col;
185            self.row_pos = new_row;
186        }
187    }
188
189    /// Move menu cursor to the previous element
190    fn move_previous(&mut self) {
191        let new_col = self.col_pos.checked_sub(1);
192
193        let (new_col, new_row) = match new_col {
194            Some(col) => (col, self.row_pos),
195            None => match self.row_pos.checked_sub(1) {
196                Some(row) => (self.get_cols().saturating_sub(1), row),
197                None => (
198                    self.get_cols().saturating_sub(1),
199                    self.get_rows().saturating_sub(1),
200                ),
201            },
202        };
203
204        let position = new_row * self.get_cols() + new_col;
205        if position >= self.get_values().len() as u16 {
206            self.col_pos = (self.get_values().len() as u16 % self.get_cols()).saturating_sub(1);
207            self.row_pos = self.get_rows().saturating_sub(1);
208        } else {
209            self.col_pos = new_col;
210            self.row_pos = new_row;
211        }
212    }
213
214    /// Move menu cursor up
215    fn move_up(&mut self) {
216        self.row_pos = if let Some(new_row) = self.row_pos.checked_sub(1) {
217            new_row
218        } else {
219            let new_row = self.get_rows().saturating_sub(1);
220            let index = new_row * self.get_cols() + self.col_pos;
221            if index >= self.values.len() as u16 {
222                new_row.saturating_sub(1)
223            } else {
224                new_row
225            }
226        }
227    }
228
229    /// Move menu cursor left
230    fn move_down(&mut self) {
231        let new_row = self.row_pos + 1;
232        self.row_pos = if new_row >= self.get_rows() {
233            0
234        } else {
235            let index = new_row * self.get_cols() + self.col_pos;
236            if index >= self.values.len() as u16 {
237                0
238            } else {
239                new_row
240            }
241        }
242    }
243
244    /// Move menu cursor left
245    fn move_left(&mut self) {
246        self.col_pos = if let Some(row) = self.col_pos.checked_sub(1) {
247            row
248        } else if self.index() + 1 == self.values.len() {
249            0
250        } else {
251            self.get_cols().saturating_sub(1)
252        }
253    }
254
255    /// Move menu cursor element
256    fn move_right(&mut self) {
257        let new_col = self.col_pos + 1;
258        self.col_pos = if new_col >= self.get_cols() || self.index() + 2 > self.values.len() {
259            0
260        } else {
261            new_col
262        }
263    }
264
265    /// Menu index based on column and row position
266    fn index(&self) -> usize {
267        let index = self.row_pos * self.get_cols() + self.col_pos;
268        index as usize
269    }
270
271    /// Get selected value from the menu
272    fn get_value(&self) -> Option<Suggestion> {
273        self.get_values().get(self.index()).cloned()
274    }
275
276    /// Calculates how many rows the Menu will use
277    fn get_rows(&self) -> u16 {
278        let values = self.get_values().len() as u16;
279
280        if values == 0 {
281            // When the values are empty the no_records_msg is shown, taking 1 line
282            return 1;
283        }
284
285        let rows = values / self.get_cols();
286        if values % self.get_cols() != 0 {
287            rows + 1
288        } else {
289            rows
290        }
291    }
292
293    /// Returns working details col width
294    fn get_width(&self) -> usize {
295        self.working_details.col_width
296    }
297
298    /// Reset menu position
299    fn reset_position(&mut self) {
300        self.col_pos = 0;
301        self.row_pos = 0;
302    }
303
304    fn no_records_msg(&self, use_ansi_coloring: bool) -> String {
305        let msg = "NO RECORDS FOUND";
306        if use_ansi_coloring {
307            format!(
308                "{}{}{}",
309                self.color.selected_text_style.prefix(),
310                msg,
311                RESET
312            )
313        } else {
314            msg.to_string()
315        }
316    }
317
318    /// Returns working details columns
319    fn get_cols(&self) -> u16 {
320        self.working_details.columns.max(1)
321    }
322
323    /// End of line for menu
324    fn end_of_line(&self, column: u16) -> &str {
325        if column == self.get_cols().saturating_sub(1) {
326            "\r\n"
327        } else {
328            ""
329        }
330    }
331
332    /// Creates default string that represents one suggestion from the menu
333    fn create_string(
334        &self,
335        suggestion: &Suggestion,
336        index: usize,
337        column: u16,
338        empty_space: usize,
339        use_ansi_coloring: bool,
340    ) -> String {
341        if use_ansi_coloring {
342            if index == self.index() {
343                if let Some(description) = &suggestion.description {
344                    let left_text_size = self.longest_suggestion + self.default_details.col_padding;
345                    let right_text_size = self.get_width().saturating_sub(left_text_size);
346                    format!(
347                        "{}{:max$}{}{}{}",
348                        self.color.selected_text_style.prefix(),
349                        &suggestion.value,
350                        description
351                            .chars()
352                            .take(right_text_size)
353                            .collect::<String>()
354                            .replace('\n', " "),
355                        RESET,
356                        self.end_of_line(column),
357                        max = left_text_size,
358                    )
359                } else {
360                    format!(
361                        "{}{}{}{:>empty$}{}",
362                        self.color.selected_text_style.prefix(),
363                        &suggestion.value,
364                        RESET,
365                        "",
366                        self.end_of_line(column),
367                        empty = empty_space,
368                    )
369                }
370            } else if let Some(description) = &suggestion.description {
371                let left_text_size = self.longest_suggestion + self.default_details.col_padding;
372                let right_text_size = self.get_width().saturating_sub(left_text_size);
373                format!(
374                    "{}{:max$}{}{}{}{}{}",
375                    self.color.text_style.prefix(),
376                    &suggestion.value,
377                    RESET,
378                    self.color.description_style.prefix(),
379                    description
380                        .chars()
381                        .take(right_text_size)
382                        .collect::<String>()
383                        .replace('\n', " "),
384                    RESET,
385                    self.end_of_line(column),
386                    max = left_text_size,
387                )
388            } else {
389                format!(
390                    "{}{}{}{}{:>empty$}{}{}",
391                    self.color.text_style.prefix(),
392                    &suggestion.value,
393                    RESET,
394                    self.color.description_style.prefix(),
395                    "",
396                    RESET,
397                    self.end_of_line(column),
398                    empty = empty_space,
399                )
400            }
401        } else {
402            // If no ansi coloring is found, then the selection word is the line in uppercase
403            let marker = if index == self.index() { ">" } else { "" };
404
405            let line = if let Some(description) = &suggestion.description {
406                format!(
407                    "{}{:max$}{}{}",
408                    marker,
409                    &suggestion.value,
410                    description
411                        .chars()
412                        .take(empty_space)
413                        .collect::<String>()
414                        .replace('\n', " "),
415                    self.end_of_line(column),
416                    max = self.longest_suggestion
417                        + self
418                            .default_details
419                            .col_padding
420                            .saturating_sub(marker.len()),
421                )
422            } else {
423                format!(
424                    "{}{}{:>empty$}{}",
425                    marker,
426                    &suggestion.value,
427                    "",
428                    self.end_of_line(column),
429                    empty = empty_space.saturating_sub(marker.len()),
430                )
431            };
432
433            if index == self.index() {
434                line.to_uppercase()
435            } else {
436                line
437            }
438        }
439    }
440}
441
442impl Menu for ColumnarMenu {
443    /// Menu name
444    fn name(&self) -> &str {
445        self.name.as_str()
446    }
447
448    /// Menu indicator
449    fn indicator(&self) -> &str {
450        self.marker.as_str()
451    }
452
453    /// Deactivates context menu
454    fn is_active(&self) -> bool {
455        self.active
456    }
457
458    /// The columnar menu can to quick complete if there is only one element
459    fn can_quick_complete(&self) -> bool {
460        true
461    }
462
463    /// The columnar menu can try to find the common string and replace it
464    /// in the given line buffer
465    fn can_partially_complete(
466        &mut self,
467        values_updated: bool,
468        editor: &mut Editor,
469        completer: &mut dyn Completer,
470    ) -> bool {
471        // If the values were already updated (e.g. quick completions are true)
472        // there is no need to update the values from the menu
473        if !values_updated {
474            self.update_values(editor, completer);
475        }
476
477        let values = self.get_values();
478        if let (Some(Suggestion { value, span, .. }), Some(index)) = find_common_string(values) {
479            let index = index.min(value.len());
480            let matching = &value[0..index];
481
482            // make sure that the partial completion does not overwrite user entered input
483            let extends_input = matching.starts_with(&editor.get_buffer()[span.start..span.end]);
484
485            if !matching.is_empty() && extends_input {
486                let mut line_buffer = editor.line_buffer().clone();
487                line_buffer.replace_range(span.start..span.end, matching);
488
489                let offset = if matching.len() < (span.end - span.start) {
490                    line_buffer
491                        .insertion_point()
492                        .saturating_sub((span.end - span.start) - matching.len())
493                } else {
494                    line_buffer.insertion_point() + matching.len() - (span.end - span.start)
495                };
496
497                line_buffer.set_insertion_point(offset);
498                editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint);
499
500                // The values need to be updated because the spans need to be
501                // recalculated for accurate replacement in the string
502                self.update_values(editor, completer);
503
504                true
505            } else {
506                false
507            }
508        } else {
509            false
510        }
511    }
512
513    /// Selects what type of event happened with the menu
514    fn menu_event(&mut self, event: MenuEvent) {
515        match &event {
516            MenuEvent::Activate(_) => self.active = true,
517            MenuEvent::Deactivate => {
518                self.active = false;
519                self.input = None;
520            }
521            _ => {}
522        }
523
524        self.event = Some(event);
525    }
526
527    /// Updates menu values
528    fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) {
529        if self.only_buffer_difference {
530            if let Some(old_string) = &self.input {
531                let (start, input) = string_difference(editor.get_buffer(), old_string);
532                if !input.is_empty() {
533                    self.values = completer.complete(input, start);
534                    self.reset_position();
535                }
536            }
537        } else {
538            // If there is a new line character in the line buffer, the completer
539            // doesn't calculate the suggested values correctly. This happens when
540            // editing a multiline buffer.
541            // Also, by replacing the new line character with a space, the insert
542            // position is maintain in the line buffer.
543            let trimmed_buffer = editor.get_buffer().replace('\n', " ");
544            self.values = completer.complete(trimmed_buffer.as_str(), editor.insertion_point());
545            self.reset_position();
546        }
547    }
548
549    /// The working details for the menu changes based on the size of the lines
550    /// collected from the completer
551    fn update_working_details(
552        &mut self,
553        editor: &mut Editor,
554        completer: &mut dyn Completer,
555        painter: &Painter,
556    ) {
557        if let Some(event) = self.event.take() {
558            // The working value for the menu are updated first before executing any of the
559            // menu events
560            //
561            // If there is at least one suggestion that contains a description, then the layout
562            // is changed to one column to fit the description
563            let exist_description = self
564                .get_values()
565                .iter()
566                .any(|suggestion| suggestion.description.is_some());
567
568            if exist_description {
569                self.working_details.columns = 1;
570                self.working_details.col_width = painter.screen_width() as usize;
571
572                self.longest_suggestion = self.get_values().iter().fold(0, |prev, suggestion| {
573                    if prev >= suggestion.value.len() {
574                        prev
575                    } else {
576                        suggestion.value.len()
577                    }
578                });
579            } else {
580                let max_width = self.get_values().iter().fold(0, |acc, suggestion| {
581                    let str_len = suggestion.value.len() + self.default_details.col_padding;
582                    if str_len > acc {
583                        str_len
584                    } else {
585                        acc
586                    }
587                });
588
589                // If no default width is found, then the total screen width is used to estimate
590                // the column width based on the default number of columns
591                let default_width = if let Some(col_width) = self.default_details.col_width {
592                    col_width
593                } else {
594                    let col_width = painter.screen_width() / self.default_details.columns;
595                    col_width as usize
596                };
597
598                // Adjusting the working width of the column based the max line width found
599                // in the menu values
600                if max_width > default_width {
601                    self.working_details.col_width = max_width;
602                } else {
603                    self.working_details.col_width = default_width;
604                };
605
606                // The working columns is adjusted based on possible number of columns
607                // that could be fitted in the screen with the calculated column width
608                let possible_cols = painter.screen_width() / self.working_details.col_width as u16;
609                if possible_cols > self.default_details.columns {
610                    self.working_details.columns = self.default_details.columns.max(1);
611                } else {
612                    self.working_details.columns = possible_cols;
613                }
614            }
615
616            match event {
617                MenuEvent::Activate(updated) => {
618                    self.active = true;
619                    self.reset_position();
620
621                    self.input = if self.only_buffer_difference {
622                        Some(editor.get_buffer().to_string())
623                    } else {
624                        None
625                    };
626
627                    if !updated {
628                        self.update_values(editor, completer);
629                    }
630                }
631                MenuEvent::Deactivate => self.active = false,
632                MenuEvent::Edit(updated) => {
633                    self.reset_position();
634
635                    if !updated {
636                        self.update_values(editor, completer);
637                    }
638                }
639                MenuEvent::NextElement => self.move_next(),
640                MenuEvent::PreviousElement => self.move_previous(),
641                MenuEvent::MoveUp => self.move_up(),
642                MenuEvent::MoveDown => self.move_down(),
643                MenuEvent::MoveLeft => self.move_left(),
644                MenuEvent::MoveRight => self.move_right(),
645                MenuEvent::PreviousPage | MenuEvent::NextPage => {
646                    // The columnar menu doest have the concept of pages, yet
647                }
648            }
649        }
650    }
651
652    /// The buffer gets replaced in the Span location
653    fn replace_in_buffer(&self, editor: &mut Editor) {
654        if let Some(Suggestion {
655            mut value,
656            span,
657            append_whitespace,
658            ..
659        }) = self.get_value()
660        {
661            let start = span.start.min(editor.line_buffer().len());
662            let end = span.end.min(editor.line_buffer().len());
663            if append_whitespace {
664                value.push(' ');
665            }
666            let mut line_buffer = editor.line_buffer().clone();
667            line_buffer.replace_range(start..end, &value);
668
669            let mut offset = line_buffer.insertion_point();
670            offset = offset.saturating_add(value.len());
671            offset = offset.saturating_sub(end.saturating_sub(start));
672            line_buffer.set_insertion_point(offset);
673            editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint);
674        }
675    }
676
677    /// Minimum rows that should be displayed by the menu
678    fn min_rows(&self) -> u16 {
679        self.get_rows().min(self.min_rows)
680    }
681
682    /// Gets values from filler that will be displayed in the menu
683    fn get_values(&self) -> &[Suggestion] {
684        &self.values
685    }
686
687    fn menu_required_lines(&self, _terminal_columns: u16) -> u16 {
688        self.get_rows()
689    }
690
691    fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String {
692        if self.get_values().is_empty() {
693            self.no_records_msg(use_ansi_coloring)
694        } else {
695            // The skip values represent the number of lines that should be skipped
696            // while printing the menu
697            let skip_values = if self.row_pos >= available_lines {
698                let skip_lines = self.row_pos.saturating_sub(available_lines) + 1;
699                (skip_lines * self.get_cols()) as usize
700            } else {
701                0
702            };
703
704            // It seems that crossterm prefers to have a complete string ready to be printed
705            // rather than looping through the values and printing multiple things
706            // This reduces the flickering when printing the menu
707            let available_values = (available_lines * self.get_cols()) as usize;
708            self.get_values()
709                .iter()
710                .skip(skip_values)
711                .take(available_values)
712                .enumerate()
713                .map(|(index, suggestion)| {
714                    // Correcting the enumerate index based on the number of skipped values
715                    let index = index + skip_values;
716                    let column = index as u16 % self.get_cols();
717                    let empty_space = self.get_width().saturating_sub(suggestion.value.len());
718
719                    self.create_string(suggestion, index, column, empty_space, use_ansi_coloring)
720                })
721                .collect()
722        }
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use crate::Span;
729
730    use super::*;
731
732    macro_rules! partial_completion_tests {
733        (name: $test_group_name:ident, completions: $completions:expr, test_cases: $($name:ident: $value:expr,)*) => {
734            mod $test_group_name {
735                use crate::{menu::Menu, ColumnarMenu, core_editor::Editor, enums::UndoBehavior};
736                use super::FakeCompleter;
737
738                $(
739                    #[test]
740                    fn $name() {
741                        let (input, expected) = $value;
742                        let mut menu = ColumnarMenu::default();
743                        let mut editor = Editor::default();
744                        editor.set_buffer(input.to_string(), UndoBehavior::CreateUndoPoint);
745                        let mut completer = FakeCompleter::new(&$completions);
746
747                        menu.can_partially_complete(false, &mut editor, &mut completer);
748
749                        assert_eq!(editor.get_buffer(), expected);
750                    }
751                )*
752            }
753        }
754    }
755
756    partial_completion_tests! {
757        name: partial_completion_prefix_matches,
758        completions: ["build.rs", "build-all.sh"],
759
760        test_cases:
761            empty_completes_prefix: ("", "build"),
762            partial_completes_shared_prefix: ("bui", "build"),
763            full_prefix_completes_nothing: ("build", "build"),
764    }
765
766    partial_completion_tests! {
767        name: partial_completion_fuzzy_matches,
768        completions: ["build.rs", "build-all.sh", "prepare-build.sh"],
769
770        test_cases:
771            no_shared_prefix_completes_nothing: ("", ""),
772            shared_prefix_completes_nothing: ("bui", "bui"),
773    }
774
775    partial_completion_tests! {
776        name: partial_completion_fuzzy_same_prefix_matches,
777        completions: ["build.rs", "build-all.sh", "build-all-tests.sh"],
778
779        test_cases:
780            // assure "all" does not get replaced with shared prefix "build"
781            completes_no_shared_prefix: ("all", "all"),
782    }
783
784    struct FakeCompleter {
785        completions: Vec<String>,
786    }
787
788    impl FakeCompleter {
789        fn new(completions: &[&str]) -> Self {
790            Self {
791                completions: completions.iter().map(|c| c.to_string()).collect(),
792            }
793        }
794    }
795
796    impl Completer for FakeCompleter {
797        fn complete(&mut self, _line: &str, pos: usize) -> Vec<Suggestion> {
798            self.completions
799                .iter()
800                .map(|c| fake_suggestion(c, pos))
801                .collect()
802        }
803    }
804
805    fn fake_suggestion(name: &str, pos: usize) -> Suggestion {
806        Suggestion {
807            value: name.to_string(),
808            description: None,
809            extra: None,
810            span: Span { start: 0, end: pos },
811            append_whitespace: false,
812        }
813    }
814
815    #[test]
816    fn test_menu_replace_backtick() {
817        // https://github.com/nushell/nushell/issues/7885
818        let mut completer = FakeCompleter::new(&["file1.txt", "file2.txt"]);
819        let mut menu = ColumnarMenu::default().with_name("testmenu");
820        let mut editor = Editor::default();
821
822        // backtick at the end of the line
823        editor.set_buffer("file1.txt`".to_string(), UndoBehavior::CreateUndoPoint);
824
825        menu.update_values(&mut editor, &mut completer);
826
827        menu.replace_in_buffer(&mut editor);
828
829        // After replacing the editor, make sure insertion_point is at the right spot
830        assert!(
831            editor.is_cursor_at_buffer_end(),
832            "cursor should be at the end after completion"
833        );
834    }
835}