reedline/menu/
list_menu.rs

1use {
2    super::{
3        menu_functions::{parse_selection_char, string_difference},
4        Menu, MenuEvent, MenuTextStyle,
5    },
6    crate::{
7        core_editor::Editor,
8        painting::{estimate_single_line_wraps, Painter},
9        Completer, Suggestion, UndoBehavior,
10    },
11    nu_ansi_term::{ansi::RESET, Style},
12    std::{fmt::Write, iter::Sum},
13    unicode_width::UnicodeWidthStr,
14};
15
16const SELECTION_CHAR: char = '!';
17
18struct Page {
19    size: usize,
20    full: bool,
21}
22
23impl<'a> Sum<&'a Page> for Page {
24    fn sum<I>(iter: I) -> Page
25    where
26        I: Iterator<Item = &'a Page>,
27    {
28        iter.fold(
29            Page {
30                size: 0,
31                full: false,
32            },
33            |acc, menu| Page {
34                size: acc.size + menu.size,
35                full: acc.full || menu.full,
36            },
37        )
38    }
39}
40
41/// Struct to store the menu style
42/// Context menu definition
43pub struct ListMenu {
44    /// Menu name
45    name: String,
46    /// Menu coloring
47    color: MenuTextStyle,
48    /// Number of records pulled until page is full
49    page_size: usize,
50    /// Menu marker displayed when the menu is active
51    marker: String,
52    /// Menu active status
53    active: bool,
54    /// Cached values collected when querying the completer.
55    /// When collecting chronological values, the menu only caches at least
56    /// page_size records.
57    /// When performing a query to the completer, the cached values will
58    /// be the result from such query
59    values: Vec<Suggestion>,
60    /// row position in the menu. Starts from 0
61    row_position: u16,
62    /// Max size of the suggestions when querying without a search buffer
63    query_size: Option<usize>,
64    /// Max number of lines that are shown with large suggestions entries
65    max_lines: u16,
66    /// Multiline marker
67    multiline_marker: String,
68    /// Registry of the number of entries per page that have been displayed
69    pages: Vec<Page>,
70    /// Page index
71    page: usize,
72    /// Event sent to the menu
73    event: Option<MenuEvent>,
74    /// String collected after the menu is activated
75    input: Option<String>,
76    /// Calls the completer using only the line buffer difference difference
77    /// after the menu was activated
78    only_buffer_difference: bool,
79}
80
81impl Default for ListMenu {
82    fn default() -> Self {
83        Self {
84            name: "search_menu".to_string(),
85            color: MenuTextStyle::default(),
86            page_size: 10,
87            active: false,
88            values: Vec::new(),
89            row_position: 0,
90            page: 0,
91            query_size: None,
92            marker: "? ".to_string(),
93            max_lines: 5,
94            multiline_marker: ":::".to_string(),
95            pages: Vec::new(),
96            event: None,
97            input: None,
98            only_buffer_difference: true,
99        }
100    }
101}
102
103// Menu configuration functions
104impl ListMenu {
105    /// Menu builder with new name
106    #[must_use]
107    pub fn with_name(mut self, name: &str) -> Self {
108        self.name = name.into();
109        self
110    }
111
112    /// Menu builder with new value for text style
113    #[must_use]
114    pub fn with_text_style(mut self, text_style: Style) -> Self {
115        self.color.text_style = text_style;
116        self
117    }
118
119    /// Menu builder with new value for text style
120    #[must_use]
121    pub fn with_selected_text_style(mut self, selected_text_style: Style) -> Self {
122        self.color.selected_text_style = selected_text_style;
123        self
124    }
125
126    /// Menu builder with new value for description style
127    #[must_use]
128    pub fn with_description_text_style(mut self, description_text_style: Style) -> Self {
129        self.color.description_style = description_text_style;
130        self
131    }
132
133    /// Menu builder with new page size
134    #[must_use]
135    pub fn with_page_size(mut self, page_size: usize) -> Self {
136        self.page_size = page_size;
137        self
138    }
139
140    /// Menu builder with new only buffer difference
141    #[must_use]
142    pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self {
143        self.only_buffer_difference = only_buffer_difference;
144        self
145    }
146}
147
148// Menu functionality
149impl ListMenu {
150    /// Menu builder with menu marker
151    #[must_use]
152    pub fn with_marker(mut self, marker: String) -> Self {
153        self.marker = marker;
154        self
155    }
156
157    /// Menu builder with max entry lines
158    #[must_use]
159    pub fn with_max_entry_lines(mut self, max_lines: u16) -> Self {
160        self.max_lines = max_lines;
161        self
162    }
163
164    fn update_row_pos(&mut self, new_pos: Option<usize>) {
165        if let (Some(row), Some(page)) = (new_pos, self.pages.get(self.page)) {
166            let values_before_page = self.pages.iter().take(self.page).sum::<Page>().size;
167            let row = row.saturating_sub(values_before_page);
168            if row < page.size {
169                self.row_position = row as u16;
170            }
171        }
172    }
173
174    /// The number of rows an entry from the menu can take considering wrapping
175    fn number_of_lines(&self, entry: &str, terminal_columns: u16) -> u16 {
176        number_of_lines(entry, self.max_lines as usize, terminal_columns)
177    }
178
179    fn total_values(&self) -> usize {
180        self.query_size.unwrap_or(self.values.len())
181    }
182
183    fn values_until_current_page(&self) -> usize {
184        self.pages.iter().take(self.page + 1).sum::<Page>().size
185    }
186
187    fn set_actual_page_size(&mut self, printable_entries: usize) {
188        if let Some(page) = self.pages.get_mut(self.page) {
189            page.full = page.size > printable_entries || page.full;
190            page.size = printable_entries;
191        }
192    }
193
194    /// Menu index based on column and row position
195    fn index(&self) -> usize {
196        self.row_position as usize
197    }
198
199    /// Get selected value from the menu
200    fn get_value(&self) -> Option<Suggestion> {
201        self.get_values().get(self.index()).cloned()
202    }
203
204    /// Reset menu position
205    fn reset_position(&mut self) {
206        self.page = 0;
207        self.row_position = 0;
208        self.pages = Vec::new();
209    }
210
211    fn printable_entries(&self, painter: &Painter) -> usize {
212        // The number 2 comes from the prompt line and the banner printed at the bottom
213        // of the menu
214        let available_lines = painter.screen_height().saturating_sub(2);
215        let (printable_entries, _) =
216            self.get_values()
217                .iter()
218                .fold(
219                    (0, Some(0)),
220                    |(lines, total_lines), suggestion| match total_lines {
221                        None => (lines, None),
222                        Some(total_lines) => {
223                            let new_total_lines = total_lines
224                                + self.number_of_lines(
225                                    &suggestion.value,
226                                    //  to account for the index and the indicator e.g. 0: XXXX
227                                    painter.screen_width().saturating_sub(
228                                        self.indicator().width() as u16 + count_digits(lines),
229                                    ),
230                                );
231
232                            if new_total_lines < available_lines {
233                                (lines + 1, Some(new_total_lines))
234                            } else {
235                                (lines, None)
236                            }
237                        }
238                    },
239                );
240
241        printable_entries
242    }
243
244    fn no_page_msg(&self, use_ansi_coloring: bool) -> String {
245        let msg = "PAGE NOT FOUND";
246        if use_ansi_coloring {
247            format!(
248                "{}{}{}",
249                self.color.selected_text_style.prefix(),
250                msg,
251                RESET
252            )
253        } else {
254            msg.to_string()
255        }
256    }
257
258    fn banner_message(&self, page: &Page, use_ansi_coloring: bool) -> String {
259        let values_until = self.values_until_current_page().saturating_sub(1);
260        let value_before = if self.values.is_empty() || self.page == 0 {
261            0
262        } else {
263            let page_size = self.pages.get(self.page).map(|page| page.size).unwrap_or(0);
264            values_until.saturating_sub(page_size) + 1
265        };
266
267        let full_page = if page.full { "[FULL]" } else { "" };
268        let status_bar = format!(
269            "Page {}: records {} - {}  total: {}  {}",
270            self.page + 1,
271            value_before,
272            values_until,
273            self.total_values(),
274            full_page,
275        );
276
277        if use_ansi_coloring {
278            format!(
279                "{}{}{}",
280                self.color.selected_text_style.prefix(),
281                status_bar,
282                RESET,
283            )
284        } else {
285            status_bar
286        }
287    }
288
289    /// End of line for menu
290    fn end_of_line() -> &'static str {
291        "\r\n"
292    }
293
294    /// Text style for menu
295    fn text_style(&self, index: usize) -> String {
296        if index == self.index() {
297            self.color.selected_text_style.prefix().to_string()
298        } else {
299            self.color.text_style.prefix().to_string()
300        }
301    }
302
303    /// Creates default string that represents one line from a menu
304    fn create_string(
305        &self,
306        line: &str,
307        description: Option<&str>,
308        index: usize,
309        row_number: &str,
310        use_ansi_coloring: bool,
311    ) -> String {
312        let description = description.map_or("".to_string(), |desc| {
313            if use_ansi_coloring {
314                format!(
315                    "{}({}) {}",
316                    self.color.description_style.prefix(),
317                    desc,
318                    RESET
319                )
320            } else {
321                format!("({desc}) ")
322            }
323        });
324
325        if use_ansi_coloring {
326            format!(
327                "{}{}{}{}{}{}",
328                row_number,
329                description,
330                self.text_style(index),
331                &line,
332                RESET,
333                Self::end_of_line(),
334            )
335        } else {
336            // If no ansi coloring is found, then the selection word is
337            // the line in uppercase
338            let line_str = if index == self.index() {
339                format!("{}{}>{}", row_number, description, line.to_uppercase())
340            } else {
341                format!("{row_number}{description}{line}")
342            };
343
344            // Final string with formatting
345            format!("{}{}", line_str, Self::end_of_line())
346        }
347    }
348}
349
350impl Menu for ListMenu {
351    fn name(&self) -> &str {
352        self.name.as_str()
353    }
354
355    /// Menu indicator
356    fn indicator(&self) -> &str {
357        self.marker.as_str()
358    }
359
360    /// Deactivates context menu
361    fn is_active(&self) -> bool {
362        self.active
363    }
364
365    /// There is no use for quick complete for the menu
366    fn can_quick_complete(&self) -> bool {
367        false
368    }
369
370    /// The menu should not try to auto complete to avoid comparing
371    /// all registered values
372    fn can_partially_complete(
373        &mut self,
374        _values_updated: bool,
375        _editor: &mut Editor,
376        _completer: &mut dyn Completer,
377    ) -> bool {
378        false
379    }
380
381    /// Selects what type of event happened with the menu
382    fn menu_event(&mut self, event: MenuEvent) {
383        match &event {
384            MenuEvent::Activate(_) => self.active = true,
385            MenuEvent::Deactivate => {
386                self.active = false;
387                self.input = None;
388            }
389            _ => {}
390        }
391
392        self.event = Some(event);
393    }
394
395    /// Collecting the value from the completer to be shown in the menu
396    fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) {
397        let line_buffer = editor.line_buffer();
398        let (start, input) = if self.only_buffer_difference {
399            match &self.input {
400                Some(old_string) => {
401                    let (start, input) = string_difference(line_buffer.get_buffer(), old_string);
402                    if input.is_empty() {
403                        (line_buffer.insertion_point(), "")
404                    } else {
405                        (start, input)
406                    }
407                }
408                None => (line_buffer.insertion_point(), ""),
409            }
410        } else {
411            (line_buffer.insertion_point(), line_buffer.get_buffer())
412        };
413
414        let parsed = parse_selection_char(input, SELECTION_CHAR);
415        self.update_row_pos(parsed.index);
416
417        // If there are no row selector and the menu has an Edit event, this clears
418        // the position together with the pages vector
419        if matches!(self.event, Some(MenuEvent::Edit(_))) && parsed.index.is_none() {
420            self.reset_position();
421        }
422
423        self.values = if parsed.remainder.is_empty() {
424            self.query_size = Some(completer.total_completions(parsed.remainder, start));
425
426            let skip = self.pages.iter().take(self.page).sum::<Page>().size;
427            let take = self
428                .pages
429                .get(self.page)
430                .map(|page| page.size)
431                .unwrap_or(self.page_size);
432
433            completer.partial_complete(input, start, skip, take)
434        } else {
435            self.query_size = None;
436            completer.complete(input, start)
437        }
438    }
439
440    /// Gets values from cached values that will be displayed in the menu
441    fn get_values(&self) -> &[Suggestion] {
442        if self.query_size.is_some() {
443            // When there is a size value it means that only a chunk of the
444            // chronological data from the database was collected
445            &self.values
446        } else {
447            // If no record then it means that the values hold the result
448            // from the query to the database. This slice can be used to get the
449            // data that will be shown in the menu
450            if self.values.is_empty() {
451                return &self.values;
452            }
453
454            let start = self.pages.iter().take(self.page).sum::<Page>().size;
455
456            let end: usize = if self.page >= self.pages.len() {
457                self.page_size + start
458            } else {
459                self.pages.iter().take(self.page + 1).sum::<Page>().size
460            };
461
462            let end = end.min(self.total_values());
463            &self.values[start..end]
464        }
465    }
466
467    /// The buffer gets cleared with the actual value
468    fn replace_in_buffer(&self, editor: &mut Editor) {
469        if let Some(Suggestion {
470            mut value,
471            span,
472            append_whitespace,
473            ..
474        }) = self.get_value()
475        {
476            let buffer_len = editor.line_buffer().len();
477            let start = span.start.min(buffer_len);
478            let end = span.end.min(buffer_len);
479            if append_whitespace {
480                value.push(' ');
481            }
482            let mut line_buffer = editor.line_buffer().clone();
483            line_buffer.replace_range(start..end, &value);
484
485            let mut offset = line_buffer.insertion_point();
486            offset += value.len().saturating_sub(end.saturating_sub(start));
487            line_buffer.set_insertion_point(offset);
488            editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint);
489        }
490    }
491
492    fn update_working_details(
493        &mut self,
494        editor: &mut Editor,
495        completer: &mut dyn Completer,
496        painter: &Painter,
497    ) {
498        if let Some(event) = self.event.clone() {
499            match event {
500                MenuEvent::Activate(_) => {
501                    self.reset_position();
502
503                    self.input = if self.only_buffer_difference {
504                        Some(editor.get_buffer().to_string())
505                    } else {
506                        None
507                    };
508
509                    self.update_values(editor, completer);
510
511                    self.pages.push(Page {
512                        size: self.printable_entries(painter),
513                        full: false,
514                    });
515                }
516                MenuEvent::Deactivate => {
517                    self.active = false;
518                    self.input = None;
519                }
520                MenuEvent::Edit(_) => {
521                    self.update_values(editor, completer);
522                    self.pages.push(Page {
523                        size: self.printable_entries(painter),
524                        full: false,
525                    });
526                }
527                MenuEvent::NextElement | MenuEvent::MoveDown | MenuEvent::MoveRight => {
528                    let new_pos = self.row_position + 1;
529
530                    if let Some(page) = self.pages.get(self.page) {
531                        if new_pos >= page.size as u16 {
532                            self.event = Some(MenuEvent::NextPage);
533                            self.update_working_details(editor, completer, painter);
534                        } else {
535                            self.row_position = new_pos;
536                        }
537                    }
538                }
539                MenuEvent::PreviousElement | MenuEvent::MoveUp | MenuEvent::MoveLeft => {
540                    if let Some(new_pos) = self.row_position.checked_sub(1) {
541                        self.row_position = new_pos;
542                    } else {
543                        let page = if let Some(page) = self.page.checked_sub(1) {
544                            self.pages.get(page)
545                        } else {
546                            self.pages.get(self.pages.len().saturating_sub(1))
547                        };
548
549                        if let Some(page) = page {
550                            self.row_position = page.size.saturating_sub(1) as u16;
551                        }
552
553                        self.event = Some(MenuEvent::PreviousPage);
554                        self.update_working_details(editor, completer, painter);
555                    }
556                }
557                MenuEvent::NextPage => {
558                    if self.values_until_current_page() <= self.total_values().saturating_sub(1) {
559                        if let Some(page) = self.pages.get_mut(self.page) {
560                            if page.full {
561                                self.row_position = 0;
562                                self.page += 1;
563                                if self.page >= self.pages.len() {
564                                    self.pages.push(Page {
565                                        size: self.page_size,
566                                        full: false,
567                                    });
568                                }
569                            } else {
570                                page.size += self.page_size;
571                            }
572                        }
573
574                        self.update_values(editor, completer);
575                        self.set_actual_page_size(self.printable_entries(painter));
576                    } else {
577                        self.row_position = 0;
578                        self.page = 0;
579                        self.update_values(editor, completer);
580                    }
581                }
582                MenuEvent::PreviousPage => {
583                    match self.page.checked_sub(1) {
584                        Some(page_num) => self.page = page_num,
585                        None => self.page = self.pages.len().saturating_sub(1),
586                    }
587                    self.update_values(editor, completer);
588                }
589            }
590
591            self.event = None;
592        }
593    }
594
595    /// Calculates the real required lines for the menu considering how many lines
596    /// wrap the terminal and if an entry is larger than the remaining lines
597    fn menu_required_lines(&self, terminal_columns: u16) -> u16 {
598        let mut entry_index = 0;
599        self.get_values().iter().fold(0, |total_lines, suggestion| {
600            //  to account for the the index and the indicator e.g. 0: XXXX
601            let ret = total_lines
602                + self.number_of_lines(
603                    &suggestion.value,
604                    terminal_columns.saturating_sub(
605                        self.indicator().width() as u16 + count_digits(entry_index),
606                    ),
607                );
608            entry_index += 1;
609            ret
610        }) + 1
611    }
612
613    /// Creates the menu representation as a string which will be painted by the painter
614    fn menu_string(&self, _available_lines: u16, use_ansi_coloring: bool) -> String {
615        let values_before_page = self.pages.iter().take(self.page).sum::<Page>().size;
616        match self.pages.get(self.page) {
617            Some(page) => {
618                let lines_string = self
619                    .get_values()
620                    .iter()
621                    .take(page.size)
622                    .enumerate()
623                    .map(|(index, suggestion)| {
624                        // Final string with colors
625                        let line = &suggestion.value;
626                        let line = if line.lines().count() > self.max_lines as usize {
627                            let lines = line.lines().take(self.max_lines as usize).fold(
628                                String::new(),
629                                |mut out_string, string| {
630                                    let _ = write!(
631                                        out_string,
632                                        "{}\r\n{}",
633                                        string, self.multiline_marker
634                                    );
635                                    out_string
636                                },
637                            );
638
639                            lines + "..."
640                        } else {
641                            line.replace('\n', &format!("\r\n{}", self.multiline_marker))
642                        };
643
644                        let row_number = format!("{}: ", index + values_before_page);
645
646                        self.create_string(
647                            &line,
648                            suggestion.description.as_deref(),
649                            index,
650                            &row_number,
651                            use_ansi_coloring,
652                        )
653                    })
654                    .collect::<String>();
655
656                format!(
657                    "{}{}",
658                    lines_string,
659                    self.banner_message(page, use_ansi_coloring)
660                )
661            }
662            None => self.no_page_msg(use_ansi_coloring),
663        }
664    }
665
666    /// Minimum rows that should be displayed by the menu
667    fn min_rows(&self) -> u16 {
668        self.max_lines + 1
669    }
670}
671
672fn number_of_lines(entry: &str, max_lines: usize, terminal_columns: u16) -> u16 {
673    let lines = if entry.contains('\n') {
674        let total_lines = entry.lines().count();
675        let printable_lines = if total_lines > max_lines {
676            // The extra one is there because when printing a large entry and extra line
677            // is added with ...
678            max_lines + 1
679        } else {
680            total_lines
681        };
682
683        let wrap_lines = entry.lines().take(max_lines).fold(0, |acc, line| {
684            acc + estimate_single_line_wraps(line, terminal_columns)
685        });
686
687        (printable_lines + wrap_lines) as u16
688    } else {
689        1 + estimate_single_line_wraps(entry, terminal_columns) as u16
690    };
691
692    lines
693}
694
695fn count_digits(mut n: usize) -> u16 {
696    // count the digits in the number
697    if n == 0 {
698        return 1;
699    }
700    let mut count = 0;
701    while n > 0 {
702        n /= 10;
703        count += 1;
704    }
705    count
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn number_of_lines_test() {
714        let input = "let a: another:\nsomething\nanother";
715        let res = number_of_lines(input, 5, 30);
716
717        // There is an extra line showing ...
718        assert_eq!(res, 3);
719    }
720
721    #[test]
722    fn number_one_line_test() {
723        let input = "let a: another";
724        let res = number_of_lines(input, 5, 30);
725
726        assert_eq!(res, 1);
727    }
728
729    #[test]
730    fn lines_with_wrap_test() {
731        let input = "let a= an1other ver2y large l3ine what 4should wr5ap";
732        let res = number_of_lines(input, 5, 10);
733
734        assert_eq!(res, 6);
735    }
736
737    #[test]
738    fn number_of_max_lines_test() {
739        let input = "let a\n: ano\nther:\nsomething\nanother\nmore\nanother\nasdf\nasdfa\n3123";
740        let res = number_of_lines(input, 3, 30);
741
742        // There is an extra line showing ...
743        assert_eq!(res, 4);
744    }
745}