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
8struct DefaultColumnDetails {
12 pub columns: u16,
14 pub col_width: Option<usize>,
16 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#[derive(Default)]
33struct ColumnDetails {
34 pub columns: u16,
36 pub col_width: usize,
38}
39
40pub struct ColumnarMenu {
43 name: String,
45 active: bool,
47 color: MenuTextStyle,
49 default_details: DefaultColumnDetails,
52 min_rows: u16,
55 working_details: ColumnDetails,
57 values: Vec<Suggestion>,
59 col_pos: u16,
61 row_pos: u16,
63 marker: String,
65 event: Option<MenuEvent>,
67 longest_suggestion: usize,
69 input: Option<String>,
71 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
97impl ColumnarMenu {
99 #[must_use]
101 pub fn with_name(mut self, name: &str) -> Self {
102 self.name = name.into();
103 self
104 }
105
106 #[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 #[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 #[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 #[must_use]
129 pub fn with_columns(mut self, columns: u16) -> Self {
130 self.default_details.columns = columns;
131 self
132 }
133
134 #[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 #[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 #[must_use]
150 pub fn with_marker(mut self, marker: String) -> Self {
151 self.marker = marker;
152 self
153 }
154
155 #[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
163impl ColumnarMenu {
165 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 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 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 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 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 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 fn index(&self) -> usize {
267 let index = self.row_pos * self.get_cols() + self.col_pos;
268 index as usize
269 }
270
271 fn get_value(&self) -> Option<Suggestion> {
273 self.get_values().get(self.index()).cloned()
274 }
275
276 fn get_rows(&self) -> u16 {
278 let values = self.get_values().len() as u16;
279
280 if values == 0 {
281 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 fn get_width(&self) -> usize {
295 self.working_details.col_width
296 }
297
298 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 fn get_cols(&self) -> u16 {
320 self.working_details.columns.max(1)
321 }
322
323 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 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 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 fn name(&self) -> &str {
445 self.name.as_str()
446 }
447
448 fn indicator(&self) -> &str {
450 self.marker.as_str()
451 }
452
453 fn is_active(&self) -> bool {
455 self.active
456 }
457
458 fn can_quick_complete(&self) -> bool {
460 true
461 }
462
463 fn can_partially_complete(
466 &mut self,
467 values_updated: bool,
468 editor: &mut Editor,
469 completer: &mut dyn Completer,
470 ) -> bool {
471 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 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 self.update_values(editor, completer);
503
504 true
505 } else {
506 false
507 }
508 } else {
509 false
510 }
511 }
512
513 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 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 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 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 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 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 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 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 }
648 }
649 }
650 }
651
652 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 fn min_rows(&self) -> u16 {
679 self.get_rows().min(self.min_rows)
680 }
681
682 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 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 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 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 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 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 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 assert!(
831 editor.is_cursor_at_buffer_end(),
832 "cursor should be at the end after completion"
833 );
834 }
835}