1use crossterm::{
2 cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
3 event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
4 execute,
5 style::Print,
6 terminal::{
7 self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, disable_raw_mode,
8 enable_raw_mode,
9 },
10};
11use nu_ansi_term::{Style, ansi::RESET};
12use nu_color_config::{Alignment, StyleComputer, TextStyle};
13use nu_engine::{ClosureEval, command_prelude::*, get_columns};
14use nu_protocol::engine::Closure;
15use nu_protocol::{Config, ListStream, Signals, TableMode, shell_error::io::IoError};
16use nu_table::common::nu_value_to_string;
17use nucleo_matcher::{
18 Config as NucleoConfig, Matcher as NucleoMatcher, Utf32Str,
19 pattern::{Atom, AtomKind, CaseMatching, Normalization},
20};
21use std::{
22 borrow::Cow,
23 collections::HashSet,
24 io::{self, Stderr, Write},
25 sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError},
26 thread,
27 time::Duration,
28};
29use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32enum CaseSensitivity {
33 #[default]
34 Smart,
35 CaseSensitive,
36 CaseInsensitive,
37}
38
39#[derive(Debug, Clone)]
40struct InputListConfig {
41 match_text: Style, footer: Style, separator: Style, prompt_marker: Style, selected_marker: Style, table_header: Style, table_separator: Style, show_footer: bool, separator_char: String, show_separator: bool, prompt_marker_text: String, selected_marker_char: char, table_column_separator: char, table_header_separator: char, table_header_intersection: char, case_sensitivity: CaseSensitivity, }
58
59const DEFAULT_PROMPT_MARKER: &str = "> ";
60const DEFAULT_SELECTED_MARKER: char = '>';
61
62const DEFAULT_TABLE_COLUMN_SEPARATOR: char = '│';
63
64const INITIAL_STREAM_COLLECT_TIMEOUT: Duration = Duration::from_millis(250);
76const INITIAL_STREAM_MAX_ITEMS: usize = 100_000;
77const STREAM_LOAD_BATCH: usize = 512;
78const STREAM_PREFETCH_MARGIN: usize = 2;
79const STREAM_CHANNEL_CAPACITY: usize = 8192;
80const STREAM_SPINNER_FRAMES: &[&str] = &["-", "\\", "|", "/"];
81const STREAM_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(16);
82const STREAM_POLL_INTERVAL: Duration = Duration::from_millis(16);
83const STREAM_FOOTER_UPDATE_INTERVAL: Duration = Duration::from_millis(125);
84const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(100);
85const FUZZY_FILTER_INTERRUPT_CHECK_INTERVAL: usize = 1024;
86const FUZZY_FILTER_MIN_INTERRUPT_TIME: Duration = Duration::from_millis(16);
87
88fn io_context(context: &'static str) -> impl FnOnce(io::Error) -> io::Error {
89 move |err| io::Error::new(err.kind(), format!("{context}: {err}"))
90}
91
92fn terminal_char_width(c: char, current_column: usize) -> usize {
93 match c {
94 '\t' => {
95 let next_tab_stop = ((current_column / 8) + 1) * 8;
96 next_tab_stop - current_column
97 }
98 c if c.is_control() => 0,
99 c => UnicodeWidthChar::width(c).unwrap_or(0),
100 }
101}
102
103fn terminal_text_width_from(text: &str, start_column: usize) -> usize {
104 let mut current_column = start_column;
105 let mut chars = text.chars().peekable();
106
107 while let Some(c) = chars.next() {
108 if c == '\u{1b}' {
109 skip_ansi_escape(&mut chars);
110 } else {
111 current_column += terminal_char_width(c, current_column);
112 }
113 }
114
115 current_column - start_column
116}
117
118struct DisplaySegment {
123 source_index: Option<usize>,
124 text: String,
125}
126
127struct SanitizedText {
128 segments: Vec<DisplaySegment>,
129 text: String,
130 source_chars: usize,
131 truncated: bool,
132}
133
134fn skip_ansi_escape<I>(chars: &mut std::iter::Peekable<I>)
138where
139 I: Iterator<Item = char>,
140{
141 match chars.next() {
142 Some('[') => {
143 for c in chars.by_ref() {
144 if ('@'..='~').contains(&c) {
145 break;
146 }
147 }
148 }
149 Some(']') => {
150 while let Some(c) = chars.next() {
151 if c == '\u{7}' {
152 break;
153 }
154 if c == '\u{1b}' && chars.next_if_eq(&'\\').is_some() {
155 break;
156 }
157 }
158 }
159 Some(_) | None => {}
160 }
161}
162
163fn collect_ansi_escape<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
166where
167 I: Iterator<Item = char>,
168{
169 let mut escape = String::from('\u{1b}');
170
171 match chars.next() {
172 Some('[') => {
173 escape.push('[');
174 for c in chars.by_ref() {
175 escape.push(c);
176 if ('@'..='~').contains(&c) {
177 return Some(escape);
178 }
179 }
180 Some(escape)
181 }
182 Some(']') => {
183 escape.push(']');
184 while let Some(c) = chars.next() {
185 escape.push(c);
186 if c == '\u{7}' {
187 return Some(escape);
188 }
189 if c == '\u{1b}' && chars.next_if_eq(&'\\').is_some() {
190 escape.push('\\');
191 return Some(escape);
192 }
193 }
194 Some(escape)
195 }
196 Some(c) => {
197 escape.push(c);
198 Some(escape)
199 }
200 None => Some(escape),
201 }
202}
203
204fn sanitize_text_for_display(
205 text: &str,
206 target_width: usize,
207 start_column: usize,
208) -> SanitizedText {
209 let mut current_column = start_column;
210 let max_column = start_column + target_width;
211 let mut segments = Vec::new();
212 let mut sanitized = String::new();
213 let mut chars = text.chars().peekable();
214 let mut source_index = 0;
215 let mut truncated = false;
216
217 while let Some(c) = chars.next() {
218 if c == '\u{1b}' {
219 if let Some(escape) = collect_ansi_escape(&mut chars) {
220 sanitized.push_str(&escape);
221 segments.push(DisplaySegment {
222 source_index: None,
223 text: escape,
224 });
225 }
226 continue;
227 }
228
229 let char_width = terminal_char_width(c, current_column);
230 if current_column + char_width > max_column {
231 truncated = true;
232 break;
233 }
234
235 let mut display = String::new();
236 if c == '\t' {
237 display.extend(std::iter::repeat_n(' ', char_width));
238 } else if !c.is_control() {
239 display.push(c);
240 }
241
242 if !display.is_empty() {
243 sanitized.push_str(&display);
244 segments.push(DisplaySegment {
245 source_index: Some(source_index),
246 text: display,
247 });
248 }
249 current_column += char_width;
250 source_index += 1;
251 }
252
253 SanitizedText {
254 segments,
255 text: sanitized,
256 source_chars: source_index,
257 truncated,
258 }
259}
260
261#[cfg(test)]
262fn truncate_ansi_aware_text(text: &str, available_width: usize) -> Cow<'_, str> {
263 truncate_ansi_aware_text_at(text, available_width, 0)
264}
265
266fn truncate_ansi_aware_text_at(
267 text: &str,
268 available_width: usize,
269 start_column: usize,
270) -> Cow<'_, str> {
271 let sanitized = sanitize_text_for_display(text, available_width, start_column);
272 if !sanitized.truncated {
273 Cow::Owned(sanitized.text)
274 } else if available_width <= 1 {
275 Cow::Borrowed("…")
276 } else {
277 let target_width = available_width - 1;
278 let mut sanitized = sanitize_text_for_display(text, target_width, start_column).text;
279 sanitized.push('…');
280 Cow::Owned(sanitized)
281 }
282}
283
284fn table_mode_to_separator(mode: TableMode) -> char {
286 match mode {
287 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql | TableMode::Markdown => '|',
289 TableMode::AsciiRounded => '|',
290 TableMode::Thin
292 | TableMode::Rounded
293 | TableMode::Single
294 | TableMode::Compact
295 | TableMode::Frameless => '│',
296 TableMode::Reinforced | TableMode::Light => '│',
297 TableMode::Heavy => '┃',
299 TableMode::Double | TableMode::CompactDouble => '║',
301 TableMode::WithLove => '❤',
303 TableMode::Dots => ':',
304 TableMode::Restructured | TableMode::None => ' ',
306 }
307}
308
309fn table_mode_to_header_separator(mode: TableMode) -> (char, char) {
311 match mode {
312 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql => ('-', '+'),
314 TableMode::AsciiRounded => ('-', '+'),
315 TableMode::Markdown => ('-', '|'),
316 TableMode::Thin
318 | TableMode::Rounded
319 | TableMode::Single
320 | TableMode::Compact
321 | TableMode::Frameless => ('─', '┼'),
322 TableMode::Reinforced => ('─', '┼'),
323 TableMode::Light => ('─', '─'), TableMode::Heavy => ('━', '╋'),
326 TableMode::Double | TableMode::CompactDouble => ('═', '╬'),
328 TableMode::WithLove => ('❤', '❤'),
330 TableMode::Dots => ('.', ':'),
331 TableMode::Restructured | TableMode::None => (' ', ' '),
333 }
334}
335
336impl Default for InputListConfig {
337 fn default() -> Self {
338 Self {
339 match_text: Style::new().fg(nu_ansi_term::Color::Yellow),
340 footer: Style::new().fg(nu_ansi_term::Color::DarkGray),
341 separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
342 prompt_marker: Style::new().fg(nu_ansi_term::Color::Green),
343 selected_marker: Style::new().fg(nu_ansi_term::Color::Green),
344 table_header: Style::new().bold(),
345 table_separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
346 show_footer: true,
347 separator_char: "─".to_string(),
348 show_separator: true,
349 prompt_marker_text: DEFAULT_PROMPT_MARKER.to_string(),
350 selected_marker_char: DEFAULT_SELECTED_MARKER,
351 table_column_separator: DEFAULT_TABLE_COLUMN_SEPARATOR,
352 table_header_separator: '─',
353 table_header_intersection: '┼',
354 case_sensitivity: CaseSensitivity::default(),
355 }
356 }
357}
358
359impl InputListConfig {
360 fn from_nu_config(
361 config: &nu_protocol::Config,
362 style_computer: &StyleComputer,
363 span: Span,
364 ) -> Self {
365 let mut ret = Self::default();
366
367 let color_config_header = style_computer.compute("header", &Value::string("", span));
369 let color_config_separator = style_computer.compute("separator", &Value::nothing(span));
370 let color_config_search_result =
371 style_computer.compute("search_result", &Value::string("", span));
372 let color_config_hints = style_computer.compute("hints", &Value::nothing(span));
373 let color_config_row_index = style_computer.compute("row_index", &Value::string("", span));
374
375 ret.table_header = color_config_header;
376 ret.table_separator = color_config_separator;
377 ret.separator = color_config_separator;
378 ret.match_text = color_config_search_result;
379 ret.footer = color_config_hints;
380 ret.prompt_marker = color_config_row_index;
381 ret.selected_marker = color_config_row_index;
382
383 ret.table_column_separator = table_mode_to_separator(config.table.mode);
385 let (header_sep, header_int) = table_mode_to_header_separator(config.table.mode);
386 ret.table_header_separator = header_sep;
387 ret.table_header_intersection = header_int;
388
389 ret
390 }
391}
392
393enum InteractMode {
394 Single(Option<usize>),
395 Multi(Option<Vec<usize>>),
396}
397
398struct SelectItem {
399 name: String, cells: Option<Vec<(String, TextStyle)>>, value: Value, }
403
404#[derive(Clone)]
406enum DisplayMode {
407 Default,
408 CellPath(Vec<nu_protocol::ast::PathMember>),
409 Closure(Closure),
410}
411
412struct TableLayout {
414 columns: Vec<String>, col_widths: Vec<usize>, truncated_cols: usize, }
418
419#[derive(Clone)]
420pub struct InputList;
421
422const INTERACT_ERROR: &str = "Interact error, could not process options";
423
424impl Command for InputList {
425 fn name(&self) -> &str {
426 "input list"
427 }
428
429 fn signature(&self) -> Signature {
430 Signature::build("input list")
431 .input_output_types(vec![
432 (Type::List(Box::new(Type::Any)), Type::Any),
433 (Type::Range, Type::Int),
434 ])
435 .optional("prompt", SyntaxShape::String, "The prompt to display.")
436 .switch(
437 "multi",
438 "Use multiple results, you can press a to toggle all, Ctrl+R to refine.",
439 Some('m'),
440 )
441 .switch("fuzzy", "Use a fuzzy select.", Some('f'))
442 .switch("index", "Returns list indexes.", Some('i'))
443 .switch(
444 "no-footer",
445 "Hide the footer showing item count and selection count.",
446 Some('n'),
447 )
448 .switch(
449 "no-separator",
450 "Hide the separator line between the search box and results.",
451 None,
452 )
453 .named(
454 "case-sensitive",
455 SyntaxShape::OneOf(vec![SyntaxShape::Boolean, SyntaxShape::String]),
456 "Case sensitivity for fuzzy matching: true, false, or 'smart' (case-insensitive unless query has uppercase)",
457 Some('s'),
458 )
459 .named(
460 "display",
461 SyntaxShape::OneOf(vec![
462 SyntaxShape::CellPath,
463 SyntaxShape::Closure(Some(vec![SyntaxShape::Any])),
464 ]),
465 "Field or closure to generate display value for search (returns original value when selected)",
466 Some('d'),
467 )
468 .switch(
469 "no-table",
470 "Disable table rendering for table input (show as single lines).",
471 Some('t'),
472 )
473 .switch(
474 "per-column",
475 "Match filter text against each column independently (table mode only).",
476 Some('c'),
477 )
478 .allow_variants_without_examples(true)
479 .category(Category::Platform)
480 }
481
482 fn description(&self) -> &str {
483 "Display an interactive list for user selection."
484 }
485
486 fn extra_description(&self) -> &str {
487 r#"Presents an interactive list in the terminal for selecting items.
488
489Four modes are available:
490- Single (default): Select one item with arrow keys, confirm with Enter
491- Multi (--multi): Select multiple items with Space, toggle all with 'a'
492- Fuzzy (--fuzzy): Type to filter, matches are highlighted
493- Fuzzy Multi (--fuzzy --multi): Type to filter AND select multiple items with Tab, toggle all with Alt+A
494
495Multi mode features:
496- The footer always shows the selection count (e.g., "[1-5 of 10, 3 selected]")
497- Use Ctrl+R to "refine" the list: narrow down to only selected items, keeping them
498 selected so you can deselect the ones you don't want. Can be used multiple times.
499
500Table rendering:
501When piping a table (list of records), items are displayed with aligned columns.
502Use Left/Right arrows (or h/l) to scroll horizontally when columns exceed terminal width.
503In fuzzy mode, use Shift+Left/Right for horizontal scrolling.
504Ellipsis (…) shows when more columns are available in each direction.
505In fuzzy mode, the ellipsis is highlighted when matches exist in hidden columns.
506Use --no-table to disable table rendering and show records as single lines.
507Use --per-column to match filter text against each column independently (best match wins).
508This prevents false positives from matches spanning column boundaries.
509Use --display to specify a column or closure for display/search text (disables table mode).
510The --display flag accepts either a cell path (e.g., -d name) or a closure (e.g., -d {|it| $it.name}).
511The closure receives each item and should return the string to display and search on.
512The original value is always returned when selected, regardless of what --display shows.
513
514Keyboard shortcuts:
515- Up/Down, j/k, Ctrl+n/p: Navigate items
516- Left/Right, h/l: Scroll columns horizontally (table mode, single/multi)
517- Shift+Left/Right: Scroll columns horizontally (fuzzy mode)
518- Home/End: Jump to first/last item
519- PageUp/PageDown: Navigate by page
520- Space: Toggle selection (multi mode)
521- Tab: Toggle selection and move down (fuzzy multi mode)
522- Shift+Tab: Toggle selection and move up (fuzzy multi mode)
523- a: Toggle all items (multi mode), Alt+A in fuzzy multi mode
524- Ctrl+R: Refine list to only selected items (multi modes)
525- Alt+C: Cycle case sensitivity (smart -> CASE -> nocase) in fuzzy modes
526- Alt+P: Toggle per-column matching in fuzzy table mode
527- Enter: Confirm selection
528- Esc: Cancel (all modes)
529- q: Cancel (single/multi modes only)
530- Ctrl+C: Cancel (all modes)
531
532Fuzzy mode supports readline-style editing:
533- Ctrl+A/E: Beginning/end of line
534- Ctrl+B/F, Left/Right: Move cursor
535- Alt+B/F: Move by word
536- Ctrl+U/K: Kill to beginning/end of line
537- Ctrl+W, Alt+Backspace: Delete previous word
538- Ctrl+D, Delete: Delete character at cursor
539
540Styling (inherited from $env.config.color_config):
541- search_result: Match highlighting in fuzzy mode
542- hints: Footer text
543- separator: Separator line and table column separators
544- row_index: Prompt marker and selection marker
545- header: Table column headers
546- Table column characters inherit from $env.config.table.mode
547
548Use --no-footer and --no-separator to hide the footer and separator line."#
549 }
550
551 fn search_terms(&self) -> Vec<&str> {
552 vec![
553 "prompt", "ask", "menu", "select", "pick", "choose", "fzf", "fuzzy",
554 ]
555 }
556
557 fn run(
558 &self,
559 engine_state: &EngineState,
560 stack: &mut Stack,
561 call: &Call,
562 input: PipelineData,
563 ) -> Result<PipelineData, ShellError> {
564 let head = call.head;
565 let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
566 let multi = call.has_flag(engine_state, stack, "multi")?;
567 let fuzzy = call.has_flag(engine_state, stack, "fuzzy")?;
568 let index = call.has_flag(engine_state, stack, "index")?;
569 let display_flag: Option<Value> = call.get_flag(engine_state, stack, "display")?;
570 let no_footer = call.has_flag(engine_state, stack, "no-footer")?;
571 let no_separator = call.has_flag(engine_state, stack, "no-separator")?;
572 let case_sensitive: Option<Value> = call.get_flag(engine_state, stack, "case-sensitive")?;
573 let no_table = call.has_flag(engine_state, stack, "no-table")?;
574 let per_column = call.has_flag(engine_state, stack, "per-column")?;
575 let config = stack.get_config(engine_state);
576 let style_computer = StyleComputer::from_config(engine_state, stack);
577 let mut input_list_config = InputListConfig::from_nu_config(&config, &style_computer, head);
578 if no_footer {
579 input_list_config.show_footer = false;
580 }
581 if no_separator {
582 input_list_config.show_separator = false;
583 }
584 if let Some(cs) = case_sensitive {
585 input_list_config.case_sensitivity = match &cs {
586 Value::Bool { val: true, .. } => CaseSensitivity::CaseSensitive,
587 Value::Bool { val: false, .. } => CaseSensitivity::CaseInsensitive,
588 Value::String { val, .. } if val == "smart" => CaseSensitivity::Smart,
589 Value::String { val, .. } if val == "true" => CaseSensitivity::CaseSensitive,
590 Value::String { val, .. } if val == "false" => CaseSensitivity::CaseInsensitive,
591 _ => {
592 return Err(ShellError::InvalidValue {
593 valid: "true, false, or 'smart'".to_string(),
594 actual: cs.to_abbreviated_string(&config),
595 span: cs.span(),
596 });
597 }
598 };
599 }
600
601 let (initial_values, pending_stream) =
602 Self::initial_values_from_input(input, head, engine_state.signals().clone())?;
603
604 let display_mode = match &display_flag {
606 Some(Value::CellPath { val: cellpath, .. }) => {
607 DisplayMode::CellPath(cellpath.members.clone())
608 }
609 Some(Value::Closure { val: closure, .. }) => {
610 DisplayMode::Closure(Closure::clone(closure))
611 }
612 _ => DisplayMode::Default,
613 };
614
615 let columns = if matches!(display_mode, DisplayMode::Default) && !no_table {
617 get_columns(&initial_values)
618 } else {
619 vec![]
620 };
621 let is_table_mode = !columns.is_empty();
622
623 let options: Vec<SelectItem> = initial_values
625 .into_iter()
626 .map(|val| {
627 InputList::make_select_item(
628 val,
629 &columns,
630 &display_mode,
631 &config,
632 engine_state,
633 stack,
634 head,
635 )
636 })
637 .collect();
638
639 let table_layout = if is_table_mode {
640 Some(Self::calculate_table_layout(&columns, &options))
641 } else {
642 None
643 };
644
645 if options.is_empty() && pending_stream.is_none() {
646 return Err(ShellError::TypeMismatch {
647 err_message: "expected a list or table, it can also be a problem with the inner type of your list.".to_string(),
648 span: head,
649 });
650 }
651
652 let mode = if multi && fuzzy {
653 SelectMode::FuzzyMulti
654 } else if multi {
655 SelectMode::Multi
656 } else if fuzzy {
657 SelectMode::Fuzzy
658 } else {
659 SelectMode::Single
660 };
661
662 let config_clone = config.clone();
663 let columns_clone = columns.clone();
664 let display_mode_clone = display_mode.clone();
665
666 let item_generator: Box<dyn FnMut(Value) -> SelectItem + '_> =
670 Box::new(move |val: Value| {
671 InputList::make_select_item(
672 val,
673 &columns_clone,
674 &display_mode_clone,
675 &config_clone,
676 engine_state,
677 stack,
678 head,
679 )
680 });
681
682 let mut widget = SelectWidget::new(
683 mode,
684 prompt.as_deref(),
685 options,
686 input_list_config,
687 table_layout,
688 per_column,
689 StreamState {
690 stream_reader: pending_stream,
691 item_generator: Some(item_generator),
692 },
693 );
694 let answer = widget.run().map_err(|err| {
695 IoError::new_with_additional_context(err, call.head, None, INTERACT_ERROR)
696 })?;
697
698 Ok(match answer {
699 InteractMode::Multi(res) => {
700 if index {
701 match res {
702 Some(opts) => Value::list(
703 opts.into_iter()
704 .map(|s| Value::int(s as i64, head))
705 .collect(),
706 head,
707 ),
708 None => Value::nothing(head),
709 }
710 } else {
711 match res {
712 Some(opts) => Value::list(
713 opts.iter()
714 .map(|s| widget.items[*s].value.clone())
715 .collect(),
716 head,
717 ),
718 None => Value::nothing(head),
719 }
720 }
721 }
722 InteractMode::Single(res) => {
723 if index {
724 match res {
725 Some(opt) => Value::int(opt as i64, head),
726 None => Value::nothing(head),
727 }
728 } else {
729 match res {
730 Some(opt) => widget.items[opt].value.clone(),
731 None => Value::nothing(head),
732 }
733 }
734 }
735 }
736 .into_pipeline_data())
737 }
738
739 fn examples(&self) -> Vec<Example<'_>> {
740 vec![
741 Example {
742 description: "Return a single value from a list.",
743 example: "[1 2 3 4 5] | input list 'Rate it'",
744 result: None,
745 },
746 Example {
747 description: "Return multiple values from a list.",
748 example: "[Banana Kiwi Pear Peach Strawberry] | input list --multi 'Add fruits to the basket'",
749 result: None,
750 },
751 Example {
752 description: "Return a single record from a table with fuzzy search.",
753 example: "ls | input list --fuzzy 'Select the target'",
754 result: None,
755 },
756 Example {
757 description: "Choose an item from a range.",
758 example: "1..10 | input list",
759 result: None,
760 },
761 Example {
762 description: "Return the index of a selected item.",
763 example: "[Banana Kiwi Pear Peach Strawberry] | input list --index",
764 result: None,
765 },
766 Example {
767 description: "Choose an item from a table using a column as display value.",
768 example: "[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d name",
769 result: None,
770 },
771 Example {
772 description: "Choose an item using a closure to generate display text",
773 example: r#"[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d {|it| $"($it.name): $($it.price)"}"#,
774 result: None,
775 },
776 Example {
777 description: "Fuzzy search with case-sensitive matching",
778 example: "[abc ABC aBc] | input list --fuzzy --case-sensitive true",
779 result: None,
780 },
781 Example {
782 description: "Fuzzy search without the footer showing item count",
783 example: "ls | input list --fuzzy --no-footer",
784 result: None,
785 },
786 Example {
787 description: "Fuzzy search without the separator line",
788 example: "ls | input list --fuzzy --no-separator",
789 result: None,
790 },
791 Example {
792 description: "Fuzzy search with custom match highlighting color",
793 example: r#"$env.config.color_config.search_result = "red"; ls | input list --fuzzy"#,
794 result: None,
795 },
796 Example {
797 description: "Display a table with column rendering",
798 example: r#"[[name size]; [file1.txt "1.2 KB"] [file2.txt "3.4 KB"]] | input list"#,
799 result: None,
800 },
801 Example {
802 description: "Display a table as single lines (no table rendering)",
803 example: "ls | input list --no-table",
804 result: None,
805 },
806 Example {
807 description: "Fuzzy search with multiple selection (use Tab to toggle)",
808 example: "ls | input list --fuzzy --multi",
809 result: None,
810 },
811 ]
812 }
813}
814
815impl InputList {
816 fn initial_values_from_input(
821 input: PipelineData,
822 head: Span,
823 signals: Signals,
824 ) -> Result<(Vec<Value>, Option<StreamReader>), ShellError> {
825 match input {
826 PipelineData::ListStream(stream, ..) => Ok(Self::read_initial_stream_values(stream)),
827 PipelineData::Value(Value::List { vals, .. }, ..) => Ok((vals, None)),
828 input @ PipelineData::Value(Value::Range { .. }, ..) => {
829 let stream = ListStream::new(input.into_iter(), head, signals);
830 Ok(Self::read_initial_stream_values(stream))
831 }
832 _ => Err(ShellError::TypeMismatch {
833 err_message: "expected a list, a table, or a range".to_string(),
834 span: head,
835 }),
836 }
837 }
838
839 fn read_initial_stream_values(stream: ListStream) -> (Vec<Value>, Option<StreamReader>) {
844 let mut reader = StreamReader::new(stream);
845 let values =
846 reader.drain_available_until(INITIAL_STREAM_MAX_ITEMS, INITIAL_STREAM_COLLECT_TIMEOUT);
847 let pending_stream = if reader.is_finished() {
848 None
849 } else {
850 Some(reader)
851 };
852
853 (values, pending_stream)
854 }
855
856 fn make_select_item(
858 value: Value,
859 columns: &[String],
860 display_mode: &DisplayMode,
861 config: &Config,
862 engine_state: &EngineState,
863 stack: &mut Stack,
864 span: Span,
865 ) -> SelectItem {
866 if !columns.is_empty() {
867 let style_computer = StyleComputer::from_config(engine_state, stack);
870
871 let cells: Vec<(String, TextStyle)> = columns
872 .iter()
873 .map(|col| {
874 if let Value::Record { val: record, .. } = &value {
875 record
876 .get(col)
877 .map(|v| nu_value_to_string(v, config, &style_computer))
878 .unwrap_or_else(|| (String::new(), TextStyle::default()))
879 } else {
880 (String::new(), TextStyle::default())
881 }
882 })
883 .collect();
884
885 let name = cells
886 .iter()
887 .map(|(s, _)| s.as_str())
888 .collect::<Vec<_>>()
889 .join(" ");
890 SelectItem {
891 name,
892 cells: Some(cells),
893 value,
894 }
895 } else {
896 let display_value = match display_mode {
897 DisplayMode::CellPath(cellpath) => value
898 .follow_cell_path(cellpath)
899 .map(|v| v.to_expanded_string(", ", config))
900 .unwrap_or_else(|_| value.to_expanded_string(", ", config)),
901 DisplayMode::Closure(closure) => {
902 let mut closure_eval =
903 ClosureEval::new(engine_state, stack, Closure::clone(closure));
904 closure_eval
905 .run_with_value(value.clone())
906 .and_then(|data| data.into_value(span))
907 .map(|v| v.to_expanded_string(", ", config))
908 .unwrap_or_else(|_| value.to_expanded_string(", ", config))
909 }
910 DisplayMode::Default => value.to_expanded_string(", ", config),
911 };
912 SelectItem {
913 name: display_value,
914 cells: None,
915 value,
916 }
917 }
918 }
919
920 fn calculate_table_layout(columns: &[String], options: &[SelectItem]) -> TableLayout {
922 let mut layout = TableLayout {
923 columns: columns.to_vec(),
924 col_widths: columns.iter().map(|c| c.width()).collect(),
925 truncated_cols: 0, };
927
928 Self::update_table_layout_with_items(&mut layout, options);
929 layout
930 }
931
932 fn update_table_layout_with_items(layout: &mut TableLayout, items: &[SelectItem]) -> bool {
933 let mut changed = false;
934 for item in items {
935 if let Some(cells) = &item.cells {
936 for (i, (cell_text, _)) in cells.iter().enumerate() {
937 if i < layout.col_widths.len() {
938 let cell_width = terminal_text_width_from(cell_text, 0);
939 if cell_width > layout.col_widths[i] {
940 layout.col_widths[i] = cell_width;
941 changed = true;
942 }
943 }
944 }
945 }
946 }
947 changed
948 }
949}
950
951#[derive(Clone, Copy, PartialEq, Eq)]
952enum SelectMode {
953 Single,
954 Multi,
955 Fuzzy,
956 FuzzyMulti,
957}
958
959struct StreamState<'a> {
964 stream_reader: Option<StreamReader>,
965 item_generator: Option<Box<dyn FnMut(Value) -> SelectItem + 'a>>,
966}
967
968enum StreamMessage {
969 Item(Value),
970 End,
971}
972
973struct StreamReader {
974 receiver: Receiver<StreamMessage>,
975 finished: bool,
976}
977
978impl StreamReader {
979 fn new(stream: ListStream) -> Self {
980 let (sender, receiver) = mpsc::sync_channel(STREAM_CHANNEL_CAPACITY);
981
982 thread::spawn(move || {
983 for value in stream {
984 if sender.send(StreamMessage::Item(value)).is_err() {
985 return;
986 }
987 }
988
989 let _ = sender.send(StreamMessage::End);
990 });
991
992 Self {
993 receiver,
994 finished: false,
995 }
996 }
997
998 fn is_finished(&self) -> bool {
999 self.finished
1000 }
1001
1002 fn drain_available(&mut self, count: usize) -> Vec<Value> {
1003 let mut values = Vec::new();
1004
1005 while values.len() < count && !self.finished {
1006 match self.receiver.try_recv() {
1007 Ok(StreamMessage::Item(value)) => values.push(value),
1008 Ok(StreamMessage::End) | Err(TryRecvError::Disconnected) => {
1009 self.finished = true;
1010 break;
1011 }
1012 Err(TryRecvError::Empty) => break,
1013 }
1014 }
1015
1016 values
1017 }
1018
1019 fn drain_available_for(&mut self, max_duration: Duration) -> Vec<Value> {
1020 let start = nu_utils::time::Instant::now();
1021 let mut values = Vec::new();
1022
1023 while !self.finished {
1024 match self.receiver.try_recv() {
1025 Ok(StreamMessage::Item(value)) => values.push(value),
1026 Ok(StreamMessage::End) | Err(TryRecvError::Disconnected) => {
1027 self.finished = true;
1028 break;
1029 }
1030 Err(TryRecvError::Empty) => break,
1031 }
1032
1033 if start.elapsed() >= max_duration {
1034 break;
1035 }
1036 }
1037
1038 values
1039 }
1040
1041 fn drain_available_until(&mut self, count: usize, max_duration: Duration) -> Vec<Value> {
1042 let start = nu_utils::time::Instant::now();
1043 let mut values = Vec::new();
1044
1045 while values.len() < count && !self.finished {
1046 let elapsed = start.elapsed();
1047 let Some(remaining) = max_duration.checked_sub(elapsed) else {
1048 break;
1049 };
1050
1051 match self.receiver.recv_timeout(remaining) {
1052 Ok(StreamMessage::Item(value)) => values.push(value),
1053 Ok(StreamMessage::End) | Err(RecvTimeoutError::Disconnected) => {
1054 self.finished = true;
1055 break;
1056 }
1057 Err(RecvTimeoutError::Timeout) => break,
1058 }
1059 }
1060
1061 values
1062 }
1063}
1064
1065struct SelectWidget<'a> {
1066 mode: SelectMode,
1067 prompt: Option<&'a str>,
1068 items: Vec<SelectItem>,
1069 cursor: usize,
1070 selected: HashSet<usize>,
1071 filter_text: String,
1072 filtered_indices: Vec<usize>,
1073 scroll_offset: usize,
1074 stream_reader: Option<StreamReader>,
1075 item_generator: Option<Box<dyn FnMut(Value) -> SelectItem + 'a>>,
1076 visible_height: u16,
1077 matcher: NucleoMatcher,
1078 last_filter_text: String,
1079 force_full_filter: bool,
1080 rendered_lines: usize,
1081 prev_cursor: usize,
1083 prev_scroll_offset: usize,
1085 first_render: bool,
1087 fuzzy_cursor_offset: usize,
1089 results_changed: bool,
1091 filter_text_changed: bool,
1093 toggled_item: Option<usize>,
1095 toggled_all: bool,
1097 filter_cursor: usize,
1099 config: InputListConfig,
1101 term_width: u16,
1103 separator_line: String,
1105 table_layout: Option<TableLayout>,
1107 horizontal_offset: usize,
1109 horizontal_scroll_changed: bool,
1111 width_changed: bool,
1113 table_layout_changed: bool,
1115 refined: bool,
1117 follow_stream_to_end: bool,
1119 stream_spinner_frame: usize,
1121 stream_footer_item_count: usize,
1123 last_stream_footer_update: nu_utils::time::Instant,
1125 refined_base_indices: Vec<usize>,
1127 per_column: bool,
1129 settings_changed: bool,
1131 selected_marker_cached: String,
1133 visible_columns_cache: Option<(usize, bool)>,
1136}
1137
1138impl<'a> SelectWidget<'a> {
1139 fn make_matcher() -> NucleoMatcher {
1140 NucleoMatcher::new({
1141 let mut config = NucleoConfig::DEFAULT;
1142 config.prefer_prefix = true;
1143 config
1144 })
1145 }
1146
1147 fn new(
1148 mode: SelectMode,
1149 prompt: Option<&'a str>,
1150 items: Vec<SelectItem>,
1151 config: InputListConfig,
1152 table_layout: Option<TableLayout>,
1153 per_column: bool,
1154 stream_state: StreamState<'a>,
1155 ) -> Self {
1156 let filtered_indices: Vec<usize> = (0..items.len()).collect();
1157 let matcher = Self::make_matcher();
1158 let selected_marker_cached = format!(
1160 "{} ",
1161 config
1162 .selected_marker
1163 .paint(config.selected_marker_char.to_string())
1164 );
1165 let initial_item_count = items.len();
1166 Self {
1167 mode,
1168 prompt,
1169 items,
1170 cursor: 0,
1171 selected: HashSet::new(),
1172 filter_text: String::new(),
1173 filtered_indices,
1174 scroll_offset: 0,
1175 visible_height: 10,
1176 matcher,
1177 last_filter_text: String::new(),
1178 force_full_filter: false,
1179 rendered_lines: 0,
1180 prev_cursor: 0,
1181 prev_scroll_offset: 0,
1182 first_render: true,
1183 fuzzy_cursor_offset: 0,
1184 results_changed: true,
1185 filter_text_changed: false,
1186 toggled_item: None,
1187 toggled_all: false,
1188 filter_cursor: 0,
1189 config,
1190 term_width: 0,
1191 separator_line: String::new(),
1192 table_layout,
1193 horizontal_offset: 0,
1194 horizontal_scroll_changed: false,
1195 width_changed: false,
1196 table_layout_changed: false,
1197 refined: false,
1198 follow_stream_to_end: false,
1199 stream_spinner_frame: 0,
1200 stream_footer_item_count: initial_item_count,
1201 last_stream_footer_update: nu_utils::time::Instant::now(),
1202 refined_base_indices: Vec::new(),
1203 per_column,
1204 settings_changed: false,
1205 selected_marker_cached,
1206 stream_reader: stream_state.stream_reader,
1207 item_generator: stream_state.item_generator,
1208 visible_columns_cache: None,
1209 }
1210 }
1211
1212 fn generate_separator_line(&mut self) {
1214 let sep_width = self.config.separator_char.width();
1215 let repeat_count = (self.term_width as usize)
1216 .checked_div(sep_width)
1217 .unwrap_or(self.term_width as usize);
1218 self.separator_line = self.config.separator_char.repeat(repeat_count);
1219 }
1220
1221 fn prompt_marker(&self) -> String {
1223 self.config
1224 .prompt_marker
1225 .paint(&self.config.prompt_marker_text)
1226 .to_string()
1227 }
1228
1229 fn prompt_marker_width(&self) -> usize {
1231 self.config.prompt_marker_text.width()
1232 }
1233
1234 fn position_fuzzy_cursor(&self, stderr: &mut Stderr) -> io::Result<()> {
1236 let text_before_cursor = &self.filter_text[..self.filter_cursor];
1237 let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
1238 execute!(stderr, MoveToColumn(cursor_col as u16))
1239 }
1240
1241 fn selected_marker(&self) -> &str {
1243 &self.selected_marker_cached
1244 }
1245
1246 fn is_table_mode(&self) -> bool {
1248 self.table_layout.is_some()
1249 }
1250
1251 fn is_multi_mode(&self) -> bool {
1253 self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
1254 }
1255
1256 fn is_fuzzy_mode(&self) -> bool {
1258 self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
1259 }
1260
1261 fn make_select_item(&mut self, value: Value) -> SelectItem {
1263 if let Some(r#gen) = self.item_generator.as_mut() {
1264 r#gen(value)
1265 } else {
1266 SelectItem {
1270 name: value.to_expanded_string(", ", &Config::default()),
1271 cells: None,
1272 value,
1273 }
1274 }
1275 }
1276
1277 fn load_more_items(&mut self, count: usize) -> bool {
1279 let Some(reader) = self.stream_reader.as_mut() else {
1280 return false;
1281 };
1282
1283 let values = reader.drain_available(count);
1284 let stream_finished = reader.is_finished();
1285 self.append_streamed_values(values, stream_finished)
1286 }
1287
1288 fn load_more_items_for(&mut self, max_duration: Duration) -> bool {
1289 let Some(reader) = self.stream_reader.as_mut() else {
1290 return false;
1291 };
1292
1293 let values = reader.drain_available_for(max_duration);
1294 let stream_finished = reader.is_finished();
1295 self.append_streamed_values(values, stream_finished)
1296 }
1297
1298 fn append_streamed_values(&mut self, values: Vec<Value>, stream_finished: bool) -> bool {
1299 if stream_finished {
1300 self.stream_reader = None;
1301 self.stream_footer_item_count = self.items.len() + values.len();
1302 self.settings_changed = true;
1303 }
1304
1305 if values.is_empty() {
1306 if stream_finished {
1307 return true;
1308 }
1309 return false;
1310 }
1311
1312 let old_filtered_indices = if self.filter_text.is_empty() && !self.refined {
1313 None
1314 } else {
1315 Some(self.filtered_indices.clone())
1316 };
1317 let start_index = self.items.len();
1318 for value in values {
1319 let item = self.make_select_item(value);
1320 self.items.push(item);
1321 }
1322
1323 if self.items.len() > start_index {
1324 if self.is_table_mode()
1326 && let Some(layout) = &mut self.table_layout
1327 && InputList::update_table_layout_with_items(layout, &self.items[start_index..])
1328 {
1329 self.table_layout_changed = true;
1330 self.update_table_layout();
1331 }
1332
1333 if self.filter_text.is_empty() && !self.refined {
1334 self.filtered_indices.extend(start_index..self.items.len());
1335 } else {
1336 self.force_full_filter = true;
1337 self.update_filter();
1338 }
1339
1340 if let Some(old_filtered_indices) = old_filtered_indices {
1341 self.results_changed =
1342 self.results_changed || old_filtered_indices != self.filtered_indices;
1343 }
1344 true
1345 } else {
1346 false
1347 }
1348 }
1349
1350 fn maybe_load_more(&mut self) -> bool {
1352 if self.stream_reader.is_none() {
1353 return false;
1354 }
1355
1356 let threshold = self.scroll_offset + self.visible_height as usize + STREAM_PREFETCH_MARGIN;
1358 if self.is_fuzzy_mode() && !self.filter_text.is_empty() || threshold >= self.items.len() {
1359 self.load_more_items(STREAM_LOAD_BATCH)
1360 } else {
1361 false
1362 }
1363 }
1364
1365 fn toggle_case_sensitivity(&mut self) {
1367 self.config.case_sensitivity = match self.config.case_sensitivity {
1368 CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
1369 CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
1370 CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
1371 };
1372 self.rebuild_matcher();
1373 if !self.filter_text.is_empty() {
1375 self.force_full_filter = true;
1376 self.update_filter();
1377 }
1378 self.settings_changed = true;
1379 }
1380
1381 fn toggle_per_column(&mut self) {
1383 if self.is_table_mode() {
1384 self.per_column = !self.per_column;
1385 if !self.filter_text.is_empty() {
1387 self.force_full_filter = true;
1388 self.update_filter();
1389 }
1390 self.settings_changed = true;
1391 }
1392 }
1393
1394 fn rebuild_matcher(&mut self) {
1396 self.matcher = Self::make_matcher();
1397 }
1398
1399 fn settings_indicator(&self) -> String {
1402 if !self.is_fuzzy_mode() {
1403 return String::new();
1404 }
1405
1406 let case_str = match self.config.case_sensitivity {
1407 CaseSensitivity::Smart => "smart",
1408 CaseSensitivity::CaseSensitive => "CASE",
1409 CaseSensitivity::CaseInsensitive => "nocase",
1410 };
1411
1412 if self.is_table_mode() && self.per_column {
1413 format!(" [{} col]", case_str)
1414 } else {
1415 format!(" [{}]", case_str)
1416 }
1417 }
1418
1419 fn stream_is_pending(&self) -> bool {
1420 self.stream_reader.is_some()
1421 }
1422
1423 fn stream_spinner(&self) -> &'static str {
1424 STREAM_SPINNER_FRAMES[self.stream_spinner_frame % STREAM_SPINNER_FRAMES.len()]
1425 }
1426
1427 fn update_stream_footer(&mut self) {
1428 if !self.stream_is_pending() {
1429 return;
1430 }
1431
1432 if self.last_stream_footer_update.elapsed() >= STREAM_FOOTER_UPDATE_INTERVAL {
1433 self.stream_spinner_frame =
1434 (self.stream_spinner_frame + 1) % STREAM_SPINNER_FRAMES.len();
1435 self.stream_footer_item_count = self.items.len();
1436 self.last_stream_footer_update = nu_utils::time::Instant::now();
1437 self.settings_changed = true;
1438 }
1439 }
1440
1441 fn generate_footer(&self) -> String {
1443 let total_count = self.current_list_len();
1444 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
1445 let settings = self.settings_indicator();
1446 let stream_is_pending = self.stream_is_pending();
1447 let count_text = if stream_is_pending {
1448 format!(
1449 "{} {}",
1450 self.stream_footer_item_count,
1451 self.stream_spinner()
1452 )
1453 } else {
1454 total_count.to_string()
1455 };
1456
1457 let position_part = if self.is_multi_mode() {
1458 format!(
1459 "[{}-{} of {}, {} selected]",
1460 self.scroll_offset + 1,
1461 end.min(total_count),
1462 count_text,
1463 self.selected.len()
1464 )
1465 } else {
1466 format!(
1467 "[{}-{} of {}]",
1468 self.scroll_offset + 1,
1469 end.min(total_count),
1470 count_text
1471 )
1472 };
1473
1474 let full_footer = format!("{}{}", position_part, settings);
1475
1476 let max_width = self.term_width as usize;
1478 if full_footer.width() <= max_width {
1479 full_footer
1480 } else if max_width <= 3 {
1481 "…".to_string()
1483 } else {
1484 if position_part.width() <= max_width {
1486 let remaining = max_width - position_part.width();
1488 if remaining <= 4 {
1489 position_part
1491 } else {
1492 let target_width = remaining - 2; let mut current_width = 0;
1495 let mut end_pos = 0;
1496
1497 for (byte_pos, c) in settings.char_indices().skip(2) {
1499 if c == ']' {
1500 break;
1501 }
1502 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1503 if current_width + char_width > target_width {
1504 break;
1505 }
1506 end_pos = byte_pos + c.len_utf8();
1507 current_width += char_width;
1508 }
1509 if end_pos > 2 {
1510 format!("{} [{}…]", position_part, &settings[2..end_pos])
1511 } else {
1512 position_part
1513 }
1514 }
1515 } else {
1516 let target_width = max_width - 2; let mut current_width = 0;
1519 let mut end_pos = 0;
1520
1521 for (byte_pos, c) in position_part.char_indices() {
1522 if c == ']' {
1523 break;
1524 }
1525 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1526 if current_width + char_width > target_width {
1527 break;
1528 }
1529 end_pos = byte_pos + c.len_utf8();
1530 current_width += char_width;
1531 }
1532 format!("{}…]", &position_part[..end_pos])
1533 }
1534 }
1535 }
1536
1537 fn has_footer(&self) -> bool {
1541 self.config.show_footer
1542 && (self.is_fuzzy_mode()
1543 || self.is_multi_mode()
1544 || self.current_list_len() >= self.visible_height as usize
1545 || self.stream_is_pending())
1546 }
1547
1548 fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
1550 let indicator = self.generate_footer();
1551 execute!(
1552 stderr,
1553 MoveToColumn(0),
1554 Print(self.config.footer.paint(&indicator)),
1555 Clear(ClearType::UntilNewLine),
1556 )
1557 }
1558
1559 fn row_prefix_width(&self) -> usize {
1561 match self.mode {
1562 SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
1565 }
1566
1567 fn table_column_separator(&self) -> String {
1569 format!(" {} ", self.config.table_column_separator)
1570 }
1571
1572 fn table_column_separator_width(&self) -> usize {
1574 UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
1575 }
1576
1577 fn calculate_visible_columns(&self) -> (usize, bool) {
1581 if let Some(cached) = self.visible_columns_cache {
1583 return cached;
1584 }
1585
1586 let Some(layout) = &self.table_layout else {
1588 return (0, false);
1589 };
1590
1591 Self::calculate_visible_columns_for_layout(
1592 layout,
1593 self.horizontal_offset,
1594 self.term_width as usize,
1595 self.row_prefix_width(),
1596 self.table_column_separator_width(),
1597 )
1598 }
1599
1600 fn calculate_visible_columns_for_layout(
1602 layout: &TableLayout,
1603 horizontal_offset: usize,
1604 term_width: usize,
1605 prefix_width: usize,
1606 separator_width: usize,
1607 ) -> (usize, bool) {
1608 let scroll_indicator_width = if horizontal_offset > 0 {
1610 1 + separator_width
1611 } else {
1612 0
1613 };
1614 let available = term_width
1615 .saturating_sub(prefix_width)
1616 .saturating_sub(scroll_indicator_width);
1617
1618 let mut used_width = 0;
1619 let mut cols_fit = 0;
1620
1621 for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1622 let sep_width = if i > horizontal_offset {
1624 separator_width
1625 } else {
1626 0
1627 };
1628 let needed = col_width + sep_width;
1629
1630 let reserve_right = if i + 1 < layout.col_widths.len() {
1632 separator_width + 1
1633 } else {
1634 0
1635 };
1636
1637 if used_width + needed + reserve_right <= available {
1638 used_width += needed;
1639 cols_fit += 1;
1640 } else {
1641 break;
1642 }
1643 }
1644
1645 let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1646 (cols_fit.max(1), has_more_right) }
1648
1649 fn update_table_layout(&mut self) {
1652 let prefix_width = self.row_prefix_width();
1653 let term_width = self.term_width as usize;
1654 let horizontal_offset = self.horizontal_offset;
1655 let separator_width = self.table_column_separator_width();
1656
1657 if let Some(layout) = &mut self.table_layout {
1658 let result = Self::calculate_visible_columns_for_layout(
1659 layout,
1660 horizontal_offset,
1661 term_width,
1662 prefix_width,
1663 separator_width,
1664 );
1665 layout.truncated_cols = result.0;
1666 self.visible_columns_cache = Some(result);
1667 } else {
1668 self.visible_columns_cache = Some((0, false));
1669 }
1670 }
1671
1672 fn fuzzy_header_lines(&self) -> u16 {
1674 let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1675 if self.config.show_separator {
1676 header_lines += 1;
1677 }
1678 if self.is_table_mode() {
1679 header_lines += 2;
1680 }
1681 header_lines
1682 }
1683
1684 fn fuzzy_filter_row(&self) -> u16 {
1686 if self.prompt.is_some() { 1 } else { 0 }
1687 }
1688
1689 fn update_term_size(&mut self, width: u16, height: u16) {
1691 let new_width = width.saturating_sub(1);
1693 let width_changed = self.term_width != new_width;
1694 self.term_width = new_width;
1695
1696 if width_changed {
1698 self.width_changed = true;
1699 }
1700
1701 if width_changed && self.config.show_separator {
1703 self.generate_separator_line();
1704 }
1705
1706 if width_changed {
1708 self.update_table_layout();
1709 }
1710
1711 let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1713 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1714 reserved += 1; if self.config.show_separator {
1716 reserved += 1; }
1718 }
1719 if self.is_table_mode() {
1720 reserved += 2; }
1722 if self.config.show_footer {
1723 reserved += 1; }
1725 self.visible_height = height.saturating_sub(reserved).max(1);
1726 }
1727
1728 fn run(&mut self) -> io::Result<InteractMode> {
1729 let mut stderr = io::stderr();
1730
1731 enable_raw_mode().map_err(io_context("enable raw mode"))?;
1732 scopeguard::defer! {
1733 let _ = disable_raw_mode();
1734 }
1735
1736 if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1738 execute!(stderr, Hide).map_err(io_context("hide terminal cursor"))?;
1739 }
1740 scopeguard::defer! {
1741 let _ = execute!(io::stderr(), Show);
1742 }
1743
1744 let (term_width, term_height) =
1746 terminal::size().map_err(io_context("read terminal size"))?;
1747 self.update_term_size(term_width, term_height);
1748
1749 self.render(&mut stderr)
1750 .map_err(io_context("render input list"))?;
1751
1752 loop {
1753 let poll_interval = if self.stream_is_pending() {
1754 STREAM_POLL_INTERVAL
1755 } else {
1756 IDLE_POLL_INTERVAL
1757 };
1758 let has_event =
1759 event::poll(poll_interval).map_err(io_context("poll terminal event"))?;
1760
1761 if has_event {
1762 match event::read().map_err(io_context("read terminal event"))? {
1763 Event::Key(key_event) => {
1764 match self.handle_key(key_event) {
1765 KeyAction::Continue => {}
1766 KeyAction::Cancel => {
1767 self.clear_display(&mut stderr)
1768 .map_err(io_context("clear input list after cancel"))?;
1769 return Ok(match self.mode {
1770 SelectMode::Multi => InteractMode::Multi(None),
1771 _ => InteractMode::Single(None),
1772 });
1773 }
1774 KeyAction::Confirm => {
1775 self.clear_display(&mut stderr)
1776 .map_err(io_context("clear input list after confirm"))?;
1777 return Ok(self.get_result());
1778 }
1779 }
1780 self.render(&mut stderr)
1781 .map_err(io_context("render input list after key event"))?;
1782 }
1783 Event::Resize(width, height) => {
1784 self.clear_display(&mut stderr)
1786 .map_err(io_context("clear input list after resize"))?;
1787 self.update_term_size(width, height);
1788 self.first_render = true;
1790 self.render(&mut stderr)
1791 .map_err(io_context("render input list after resize"))?;
1792 }
1793 _ => {}
1794 }
1795 } else if self.stream_is_pending() {
1796 self.render(&mut stderr)
1797 .map_err(io_context("render input list after stream update"))?;
1798 }
1799 }
1800 }
1801
1802 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1803 if key.kind == KeyEventKind::Release {
1807 return KeyAction::Continue;
1808 }
1809
1810 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1812 return KeyAction::Cancel;
1813 }
1814
1815 match self.mode {
1816 SelectMode::Single => self.handle_single_key(key),
1817 SelectMode::Multi => self.handle_multi_key(key),
1818 SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1819 SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1820 }
1821 }
1822
1823 fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1824 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1825
1826 match key.code {
1827 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1828 KeyCode::Enter => KeyAction::Confirm,
1829 KeyCode::Char('p' | 'P') if ctrl => {
1830 self.navigate_up();
1831 KeyAction::Continue
1832 }
1833 KeyCode::Up | KeyCode::Char('k') => {
1834 self.navigate_up();
1835 KeyAction::Continue
1836 }
1837 KeyCode::Char('n' | 'N') if ctrl => {
1838 self.navigate_down();
1839 KeyAction::Continue
1840 }
1841 KeyCode::Down | KeyCode::Char('j') => {
1842 self.navigate_down();
1843 KeyAction::Continue
1844 }
1845 KeyCode::Left | KeyCode::Char('h') => {
1846 self.scroll_columns_left();
1847 KeyAction::Continue
1848 }
1849 KeyCode::Right | KeyCode::Char('l') => {
1850 self.scroll_columns_right();
1851 KeyAction::Continue
1852 }
1853 KeyCode::Home => {
1854 self.navigate_home();
1855 KeyAction::Continue
1856 }
1857 KeyCode::End => {
1858 self.navigate_end();
1859 KeyAction::Continue
1860 }
1861 KeyCode::PageUp => {
1862 self.navigate_page_up();
1863 KeyAction::Continue
1864 }
1865 KeyCode::PageDown => {
1866 self.navigate_page_down();
1867 KeyAction::Continue
1868 }
1869 KeyCode::Tab => {
1870 self.navigate_down();
1871 KeyAction::Continue
1872 }
1873 KeyCode::BackTab => {
1874 self.navigate_up();
1875 KeyAction::Continue
1876 }
1877 _ => KeyAction::Continue,
1878 }
1879 }
1880
1881 fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1882 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1883
1884 match key.code {
1885 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1886 KeyCode::Enter => KeyAction::Confirm,
1887 KeyCode::Char('r' | 'R') if ctrl => {
1889 self.refine_list();
1890 KeyAction::Continue
1891 }
1892 KeyCode::Char('p' | 'P') if ctrl => {
1893 self.navigate_up();
1894 KeyAction::Continue
1895 }
1896 KeyCode::Up | KeyCode::Char('k') => {
1897 self.navigate_up();
1898 KeyAction::Continue
1899 }
1900 KeyCode::Char('n' | 'N') if ctrl => {
1901 self.navigate_down();
1902 KeyAction::Continue
1903 }
1904 KeyCode::Down | KeyCode::Char('j') => {
1905 self.navigate_down();
1906 KeyAction::Continue
1907 }
1908 KeyCode::Left | KeyCode::Char('h') => {
1909 self.scroll_columns_left();
1910 KeyAction::Continue
1911 }
1912 KeyCode::Right | KeyCode::Char('l') => {
1913 self.scroll_columns_right();
1914 KeyAction::Continue
1915 }
1916 KeyCode::Char(' ') => {
1917 self.toggle_current();
1918 KeyAction::Continue
1919 }
1920 KeyCode::Char('a') => {
1921 self.toggle_all();
1922 KeyAction::Continue
1923 }
1924 KeyCode::Home => {
1925 self.navigate_home();
1926 KeyAction::Continue
1927 }
1928 KeyCode::End => {
1929 self.navigate_end();
1930 KeyAction::Continue
1931 }
1932 KeyCode::PageUp => {
1933 self.navigate_page_up();
1934 KeyAction::Continue
1935 }
1936 KeyCode::PageDown => {
1937 self.navigate_page_down();
1938 KeyAction::Continue
1939 }
1940 KeyCode::Tab => {
1941 self.toggle_current();
1942 self.navigate_down();
1943 KeyAction::Continue
1944 }
1945 KeyCode::BackTab => {
1946 self.navigate_up();
1947 self.toggle_current();
1948 KeyAction::Continue
1949 }
1950 _ => KeyAction::Continue,
1951 }
1952 }
1953
1954 fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1955 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1956 let alt = key.modifiers.contains(KeyModifiers::ALT);
1957 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1958
1959 match key.code {
1960 KeyCode::Esc => KeyAction::Cancel,
1961 KeyCode::Enter => KeyAction::Confirm,
1962
1963 KeyCode::Tab | KeyCode::Char('\t') => {
1965 self.navigate_down();
1966 KeyAction::Continue
1967 }
1968 KeyCode::BackTab => {
1969 self.navigate_up();
1970 KeyAction::Continue
1971 }
1972
1973 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1975 self.navigate_up();
1976 KeyAction::Continue
1977 }
1978 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1979 self.navigate_down();
1980 KeyAction::Continue
1981 }
1982 KeyCode::Up => {
1983 self.navigate_up();
1984 KeyAction::Continue
1985 }
1986 KeyCode::Down => {
1987 self.navigate_down();
1988 KeyAction::Continue
1989 }
1990
1991 KeyCode::Left if shift => {
1993 self.scroll_columns_left();
1994 KeyAction::Continue
1995 }
1996 KeyCode::Right if shift => {
1997 self.scroll_columns_right();
1998 KeyAction::Continue
1999 }
2000
2001 KeyCode::Char('a' | 'A') if ctrl => {
2003 self.filter_cursor = 0;
2005 self.filter_text_changed = true;
2006 KeyAction::Continue
2007 }
2008 KeyCode::Char('e' | 'E') if ctrl => {
2009 self.filter_cursor = self.filter_text.len();
2011 self.filter_text_changed = true;
2012 KeyAction::Continue
2013 }
2014 KeyCode::Char('b' | 'B') if ctrl => {
2015 self.move_filter_cursor_left();
2017 self.filter_text_changed = true;
2018 KeyAction::Continue
2019 }
2020 KeyCode::Char('f' | 'F') if ctrl => {
2021 self.move_filter_cursor_right();
2023 self.filter_text_changed = true;
2024 KeyAction::Continue
2025 }
2026 KeyCode::Char('b' | 'B') if alt => {
2027 self.move_filter_cursor_word_left();
2029 self.filter_text_changed = true;
2030 KeyAction::Continue
2031 }
2032 KeyCode::Char('f' | 'F') if alt => {
2033 self.move_filter_cursor_word_right();
2035 self.filter_text_changed = true;
2036 KeyAction::Continue
2037 }
2038 KeyCode::Char('c' | 'C') if alt => {
2040 self.toggle_case_sensitivity();
2042 KeyAction::Continue
2043 }
2044 KeyCode::Char('p' | 'P') if alt => {
2045 self.toggle_per_column();
2047 KeyAction::Continue
2048 }
2049 KeyCode::Left if ctrl || alt => {
2050 self.move_filter_cursor_word_left();
2052 self.filter_text_changed = true;
2053 KeyAction::Continue
2054 }
2055 KeyCode::Right if ctrl || alt => {
2056 self.move_filter_cursor_word_right();
2058 self.filter_text_changed = true;
2059 KeyAction::Continue
2060 }
2061 KeyCode::Left => {
2062 self.move_filter_cursor_left();
2063 self.filter_text_changed = true;
2064 KeyAction::Continue
2065 }
2066 KeyCode::Right => {
2067 self.move_filter_cursor_right();
2068 self.filter_text_changed = true;
2069 KeyAction::Continue
2070 }
2071
2072 KeyCode::Char('u' | 'U') if ctrl => {
2074 self.filter_text.drain(..self.filter_cursor);
2076 self.filter_cursor = 0;
2077 self.update_filter();
2078 KeyAction::Continue
2079 }
2080 KeyCode::Char('k' | 'K') if ctrl => {
2081 self.filter_text.truncate(self.filter_cursor);
2083 self.update_filter();
2084 KeyAction::Continue
2085 }
2086 KeyCode::Char('d' | 'D') if ctrl => {
2087 if self.filter_cursor < self.filter_text.len() {
2089 self.filter_text.remove(self.filter_cursor);
2090 self.update_filter();
2091 }
2092 KeyAction::Continue
2093 }
2094 KeyCode::Delete => {
2095 if self.filter_cursor < self.filter_text.len() {
2097 self.filter_text.remove(self.filter_cursor);
2098 self.update_filter();
2099 }
2100 KeyAction::Continue
2101 }
2102 KeyCode::Char('d' | 'D') if alt => {
2103 self.delete_word_forwards();
2105 self.update_filter();
2106 KeyAction::Continue
2107 }
2108 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
2110 self.delete_word_backwards();
2111 self.update_filter();
2112 KeyAction::Continue
2113 }
2114 KeyCode::Backspace if alt => {
2116 self.delete_word_backwards();
2117 self.update_filter();
2118 KeyAction::Continue
2119 }
2120 KeyCode::Backspace => {
2121 if self.filter_cursor > 0 {
2123 let mut new_pos = self.filter_cursor - 1;
2125 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2126 new_pos -= 1;
2127 }
2128 self.filter_cursor = new_pos;
2129 self.filter_text.remove(self.filter_cursor);
2130 self.update_filter();
2131 }
2132 KeyAction::Continue
2133 }
2134 KeyCode::Char('t' | 'T') if ctrl => {
2136 let old_text = self.filter_text.clone();
2137 self.transpose_chars();
2138 if self.filter_text != old_text {
2139 self.update_filter();
2140 }
2141 KeyAction::Continue
2142 }
2143
2144 KeyCode::Char(c) => {
2146 self.filter_text.insert(self.filter_cursor, c);
2147 self.filter_cursor += c.len_utf8();
2148 self.update_filter();
2149 KeyAction::Continue
2150 }
2151
2152 KeyCode::Home => {
2154 self.navigate_home();
2155 KeyAction::Continue
2156 }
2157 KeyCode::End => {
2158 self.navigate_end();
2159 KeyAction::Continue
2160 }
2161 KeyCode::PageUp => {
2162 self.navigate_page_up();
2163 KeyAction::Continue
2164 }
2165 KeyCode::PageDown => {
2166 self.navigate_page_down();
2167 KeyAction::Continue
2168 }
2169 _ => KeyAction::Continue,
2170 }
2171 }
2172
2173 fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
2174 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
2175 let alt = key.modifiers.contains(KeyModifiers::ALT);
2176 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
2177
2178 match key.code {
2179 KeyCode::Esc => KeyAction::Cancel,
2180 KeyCode::Enter => KeyAction::Confirm,
2181
2182 KeyCode::Char('r' | 'R') if ctrl => {
2184 self.refine_list();
2185 KeyAction::Continue
2186 }
2187
2188 KeyCode::Tab | KeyCode::Char('\t') => {
2191 self.toggle_current_fuzzy();
2192 self.navigate_down();
2193 KeyAction::Continue
2194 }
2195
2196 KeyCode::BackTab => {
2198 self.navigate_up();
2199 self.toggle_current_fuzzy();
2200 KeyAction::Continue
2201 }
2202
2203 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
2205 self.navigate_up();
2206 KeyAction::Continue
2207 }
2208 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
2209 self.navigate_down();
2210 KeyAction::Continue
2211 }
2212 KeyCode::Up => {
2213 self.navigate_up();
2214 KeyAction::Continue
2215 }
2216 KeyCode::Down => {
2217 self.navigate_down();
2218 KeyAction::Continue
2219 }
2220
2221 KeyCode::Left if shift => {
2223 self.scroll_columns_left();
2224 KeyAction::Continue
2225 }
2226 KeyCode::Right if shift => {
2227 self.scroll_columns_right();
2228 KeyAction::Continue
2229 }
2230
2231 KeyCode::Char('a' | 'A') if ctrl => {
2233 self.filter_cursor = 0;
2234 self.filter_text_changed = true;
2235 KeyAction::Continue
2236 }
2237 KeyCode::Char('e' | 'E') if ctrl => {
2238 self.filter_cursor = self.filter_text.len();
2239 self.filter_text_changed = true;
2240 KeyAction::Continue
2241 }
2242 KeyCode::Char('b' | 'B') if ctrl => {
2243 self.move_filter_cursor_left();
2244 self.filter_text_changed = true;
2245 KeyAction::Continue
2246 }
2247 KeyCode::Char('f' | 'F') if ctrl => {
2248 self.move_filter_cursor_right();
2249 self.filter_text_changed = true;
2250 KeyAction::Continue
2251 }
2252 KeyCode::Char('b' | 'B') if alt => {
2253 self.move_filter_cursor_word_left();
2254 self.filter_text_changed = true;
2255 KeyAction::Continue
2256 }
2257 KeyCode::Char('f' | 'F') if alt => {
2258 self.move_filter_cursor_word_right();
2259 self.filter_text_changed = true;
2260 KeyAction::Continue
2261 }
2262 KeyCode::Char('c' | 'C') if alt => {
2264 self.toggle_case_sensitivity();
2266 KeyAction::Continue
2267 }
2268 KeyCode::Char('p' | 'P') if alt => {
2269 self.toggle_per_column();
2271 KeyAction::Continue
2272 }
2273 KeyCode::Left if ctrl || alt => {
2274 self.move_filter_cursor_word_left();
2275 self.filter_text_changed = true;
2276 KeyAction::Continue
2277 }
2278 KeyCode::Right if ctrl || alt => {
2279 self.move_filter_cursor_word_right();
2280 self.filter_text_changed = true;
2281 KeyAction::Continue
2282 }
2283 KeyCode::Left => {
2284 self.move_filter_cursor_left();
2285 self.filter_text_changed = true;
2286 KeyAction::Continue
2287 }
2288 KeyCode::Right => {
2289 self.move_filter_cursor_right();
2290 self.filter_text_changed = true;
2291 KeyAction::Continue
2292 }
2293
2294 KeyCode::Char('u' | 'U') if ctrl => {
2296 self.filter_text.drain(..self.filter_cursor);
2297 self.filter_cursor = 0;
2298 self.update_filter();
2299 KeyAction::Continue
2300 }
2301 KeyCode::Char('k' | 'K') if ctrl => {
2302 self.filter_text.truncate(self.filter_cursor);
2303 self.update_filter();
2304 KeyAction::Continue
2305 }
2306 KeyCode::Char('d' | 'D') if ctrl => {
2307 if self.filter_cursor < self.filter_text.len() {
2308 self.filter_text.remove(self.filter_cursor);
2309 self.update_filter();
2310 }
2311 KeyAction::Continue
2312 }
2313 KeyCode::Delete => {
2314 if self.filter_cursor < self.filter_text.len() {
2315 self.filter_text.remove(self.filter_cursor);
2316 self.update_filter();
2317 }
2318 KeyAction::Continue
2319 }
2320 KeyCode::Char('d' | 'D') if alt => {
2321 self.delete_word_forwards();
2322 self.update_filter();
2323 KeyAction::Continue
2324 }
2325 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
2326 self.delete_word_backwards();
2327 self.update_filter();
2328 KeyAction::Continue
2329 }
2330 KeyCode::Backspace if alt => {
2331 self.delete_word_backwards();
2332 self.update_filter();
2333 KeyAction::Continue
2334 }
2335 KeyCode::Backspace => {
2336 if self.filter_cursor > 0 {
2337 let mut new_pos = self.filter_cursor - 1;
2338 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2339 new_pos -= 1;
2340 }
2341 self.filter_cursor = new_pos;
2342 self.filter_text.remove(self.filter_cursor);
2343 self.update_filter();
2344 }
2345 KeyAction::Continue
2346 }
2347 KeyCode::Char('t' | 'T') if ctrl => {
2348 let old_text = self.filter_text.clone();
2349 self.transpose_chars();
2350 if self.filter_text != old_text {
2351 self.update_filter();
2352 }
2353 KeyAction::Continue
2354 }
2355
2356 KeyCode::Char('a' | 'A') if alt => {
2358 self.toggle_all_fuzzy();
2359 KeyAction::Continue
2360 }
2361
2362 KeyCode::Char(c) => {
2364 self.filter_text.insert(self.filter_cursor, c);
2365 self.filter_cursor += c.len_utf8();
2366 self.update_filter();
2367 KeyAction::Continue
2368 }
2369
2370 KeyCode::Home => {
2372 self.navigate_home();
2373 KeyAction::Continue
2374 }
2375 KeyCode::End => {
2376 self.navigate_end();
2377 KeyAction::Continue
2378 }
2379 KeyCode::PageUp => {
2380 self.navigate_page_up();
2381 KeyAction::Continue
2382 }
2383 KeyCode::PageDown => {
2384 self.navigate_page_down();
2385 KeyAction::Continue
2386 }
2387 _ => KeyAction::Continue,
2388 }
2389 }
2390
2391 fn navigate_up(&mut self) {
2393 self.follow_stream_to_end = false;
2394 let list_len = self.current_list_len();
2395 if self.cursor > 0 {
2396 self.cursor -= 1;
2397 self.adjust_scroll_up();
2398 } else if list_len > 0 {
2399 self.maybe_load_more();
2400 let list_len = self.current_list_len();
2401 self.cursor = list_len.saturating_sub(1);
2402 self.adjust_scroll_down();
2403 }
2404 }
2405
2406 fn navigate_down(&mut self) {
2408 self.follow_stream_to_end = false;
2409 self.maybe_load_more();
2410
2411 let list_len = self.current_list_len();
2412 if self.cursor + 1 < list_len {
2413 self.cursor += 1;
2414 self.adjust_scroll_down();
2415 } else {
2416 if self.stream_reader.is_some() {
2418 self.load_more_items(STREAM_LOAD_BATCH);
2419 let list_len = self.current_list_len();
2420 if self.cursor + 1 < list_len {
2421 self.cursor += 1;
2422 self.adjust_scroll_down();
2423 return;
2424 }
2425 }
2426
2427 self.cursor = 0;
2429 self.scroll_offset = 0;
2430 }
2431 }
2432
2433 fn adjust_scroll_down(&mut self) {
2434 let max_visible = self.scroll_offset + self.visible_height as usize;
2435 if self.cursor >= max_visible {
2436 self.scroll_offset = self.cursor - self.visible_height as usize + 1;
2437 }
2438 }
2439
2440 fn adjust_scroll_up(&mut self) {
2441 if self.cursor < self.scroll_offset {
2442 self.scroll_offset = self.cursor;
2443 }
2444 }
2445
2446 fn current_list_len(&self) -> usize {
2448 match self.mode {
2449 SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
2450 SelectMode::Multi if self.refined => self.filtered_indices.len(),
2451 _ => self.items.len(),
2452 }
2453 }
2454
2455 fn navigate_home(&mut self) {
2457 self.follow_stream_to_end = false;
2458 self.cursor = 0;
2459 self.scroll_offset = 0;
2460 }
2461
2462 fn navigate_end(&mut self) {
2464 self.follow_stream_to_end = true;
2465 self.load_more_items(STREAM_CHANNEL_CAPACITY);
2466 self.cursor = self.current_list_len().saturating_sub(1);
2467 self.adjust_scroll_down();
2468 }
2469
2470 fn navigate_page_up(&mut self) {
2472 self.follow_stream_to_end = false;
2473 let page_top = self.scroll_offset;
2474 if self.cursor == page_top {
2475 self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
2477 self.adjust_scroll_up();
2478 } else {
2479 self.cursor = page_top;
2481 }
2482 }
2483
2484 fn navigate_page_down(&mut self) {
2486 self.follow_stream_to_end = false;
2487 self.maybe_load_more();
2488
2489 let list_len = self.current_list_len();
2490 let page_bottom =
2491 (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
2492 if self.cursor == page_bottom {
2493 self.cursor =
2495 (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
2496 self.adjust_scroll_down();
2497 } else {
2498 self.cursor = page_bottom;
2500 }
2501
2502 self.maybe_load_more();
2503 }
2504
2505 fn scroll_columns_left(&mut self) -> bool {
2507 if !self.is_table_mode() || self.horizontal_offset == 0 {
2508 return false;
2509 }
2510 self.horizontal_offset -= 1;
2511 self.horizontal_scroll_changed = true;
2512 self.update_table_layout();
2513 true
2514 }
2515
2516 fn scroll_columns_right(&mut self) -> bool {
2518 let Some(layout) = &self.table_layout else {
2519 return false;
2520 };
2521 let (cols_visible, has_more_right) = self.calculate_visible_columns();
2522 if !has_more_right {
2523 return false;
2524 }
2525 if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
2527 return false;
2528 }
2529 self.horizontal_offset += 1;
2530 self.horizontal_scroll_changed = true;
2531 self.update_table_layout();
2532 true
2533 }
2534
2535 fn toggle_current(&mut self) {
2536 if self.refined && self.filtered_indices.is_empty() {
2538 return;
2539 }
2540 let real_idx = if self.refined {
2542 self.filtered_indices[self.cursor]
2543 } else {
2544 self.cursor
2545 };
2546 self.toggle_index(real_idx);
2547 }
2548
2549 fn toggle_index(&mut self, real_idx: usize) {
2551 if self.selected.contains(&real_idx) {
2552 self.selected.remove(&real_idx);
2553 } else {
2554 self.selected.insert(real_idx);
2555 }
2556 self.toggled_item = Some(self.cursor);
2557 }
2558
2559 fn toggle_current_fuzzy(&mut self) -> bool {
2562 if self.filtered_indices.is_empty() {
2563 return false;
2564 }
2565 let real_idx = self.filtered_indices[self.cursor];
2566 self.toggle_index(real_idx);
2567 true
2568 }
2569
2570 fn toggle_all(&mut self) {
2571 let all_selected = if self.refined {
2573 self.filtered_indices
2574 .iter()
2575 .all(|i| self.selected.contains(i))
2576 } else {
2577 (0..self.items.len()).all(|i| self.selected.contains(&i))
2578 };
2579
2580 if all_selected {
2581 if self.refined {
2583 for i in &self.filtered_indices {
2584 self.selected.remove(i);
2585 }
2586 } else {
2587 self.selected.clear();
2588 }
2589 } else {
2590 if self.refined {
2592 self.selected.extend(self.filtered_indices.iter().copied());
2593 } else {
2594 self.selected.extend(0..self.items.len());
2595 }
2596 }
2597 self.toggled_all = true;
2598 }
2599
2600 fn toggle_all_fuzzy(&mut self) {
2602 if self.filtered_indices.is_empty() {
2603 return;
2604 }
2605
2606 let all_selected = self
2608 .filtered_indices
2609 .iter()
2610 .all(|i| self.selected.contains(i));
2611
2612 if all_selected {
2613 for i in &self.filtered_indices {
2615 self.selected.remove(i);
2616 }
2617 } else {
2618 self.selected.extend(self.filtered_indices.iter().copied());
2620 }
2621 self.toggled_all = true;
2622 }
2623
2624 fn refine_list(&mut self) {
2627 if self.selected.is_empty() {
2628 return;
2629 }
2630
2631 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2633 indices.sort();
2634
2635 self.filtered_indices = indices.clone();
2638 self.refined_base_indices = indices;
2639
2640 self.cursor = 0;
2642 self.scroll_offset = 0;
2643
2644 if self.mode == SelectMode::FuzzyMulti {
2649 self.filter_text.clear();
2650 self.filter_cursor = 0;
2651 self.last_filter_text.clear();
2652 self.force_full_filter = true;
2653 self.filter_text_changed = true;
2654 }
2655
2656 self.refined = true;
2658
2659 self.first_render = true;
2661 }
2662
2663 fn move_filter_cursor_left(&mut self) {
2665 if self.filter_cursor > 0 {
2666 let mut new_pos = self.filter_cursor - 1;
2668 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2669 new_pos -= 1;
2670 }
2671 self.filter_cursor = new_pos;
2672 }
2673 }
2674
2675 fn move_filter_cursor_right(&mut self) {
2676 if self.filter_cursor < self.filter_text.len() {
2677 let mut new_pos = self.filter_cursor + 1;
2679 while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2680 new_pos += 1;
2681 }
2682 self.filter_cursor = new_pos;
2683 }
2684 }
2685
2686 fn move_filter_cursor_word_left(&mut self) {
2687 if self.filter_cursor == 0 {
2688 return;
2689 }
2690 let bytes = self.filter_text.as_bytes();
2691 let mut pos = self.filter_cursor;
2692 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2694 pos -= 1;
2695 }
2696 while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2698 pos -= 1;
2699 }
2700 self.filter_cursor = pos;
2701 }
2702
2703 fn move_filter_cursor_word_right(&mut self) {
2704 let len = self.filter_text.len();
2705 if self.filter_cursor >= len {
2706 return;
2707 }
2708 let bytes = self.filter_text.as_bytes();
2709 let mut pos = self.filter_cursor;
2710 while pos < len && !bytes[pos].is_ascii_whitespace() {
2712 pos += 1;
2713 }
2714 while pos < len && bytes[pos].is_ascii_whitespace() {
2716 pos += 1;
2717 }
2718 self.filter_cursor = pos;
2719 }
2720
2721 fn delete_word_backwards(&mut self) {
2722 if self.filter_cursor == 0 {
2723 return;
2724 }
2725 let start = self.filter_cursor;
2726 while self.filter_cursor > 0
2728 && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2729 {
2730 self.filter_cursor -= 1;
2731 }
2732 while self.filter_cursor > 0
2734 && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2735 {
2736 self.filter_cursor -= 1;
2737 }
2738 self.filter_text.drain(self.filter_cursor..start);
2739 }
2740
2741 fn delete_word_forwards(&mut self) {
2742 let len = self.filter_text.len();
2743 if self.filter_cursor >= len {
2744 return;
2745 }
2746 let start = self.filter_cursor;
2747 let bytes = self.filter_text.as_bytes();
2748 let mut end = start;
2749 while end < len && !bytes[end].is_ascii_whitespace() {
2751 end += 1;
2752 }
2753 while end < len && bytes[end].is_ascii_whitespace() {
2755 end += 1;
2756 }
2757 self.filter_text.drain(start..end);
2758 }
2759
2760 fn transpose_chars(&mut self) {
2761 let len = self.filter_text.len();
2765 if len < 2 {
2766 return;
2767 }
2768
2769 if self.filter_cursor == 0 {
2771 return;
2772 }
2773
2774 let pos = if self.filter_cursor >= len {
2777 len - 1
2778 } else {
2779 self.filter_cursor
2780 };
2781
2782 if pos == 0 {
2783 return;
2784 }
2785
2786 if self.filter_text.is_char_boundary(pos - 1)
2789 && self.filter_text.is_char_boundary(pos)
2790 && pos < len
2791 && self.filter_text.is_char_boundary(pos + 1)
2792 {
2793 let bytes = self.filter_text.as_bytes();
2795 if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2796 let bytes = unsafe { self.filter_text.as_bytes_mut() };
2798 bytes.swap(pos - 1, pos);
2799
2800 if self.filter_cursor < len {
2802 self.filter_cursor += 1;
2803 }
2804 }
2805 }
2806 }
2807
2808 fn case_matching(&self) -> CaseMatching {
2809 match self.config.case_sensitivity {
2810 CaseSensitivity::Smart => CaseMatching::Smart,
2811 CaseSensitivity::CaseSensitive => CaseMatching::Respect,
2812 CaseSensitivity::CaseInsensitive => CaseMatching::Ignore,
2813 }
2814 }
2815
2816 fn fuzzy_atom(&self) -> Atom {
2817 Atom::new(
2818 &self.filter_text,
2819 self.case_matching(),
2820 Normalization::Smart,
2821 AtomKind::Fuzzy,
2822 false,
2823 )
2824 }
2825
2826 fn score_text(
2827 matcher: &mut NucleoMatcher,
2828 atom: &Atom,
2829 text: &str,
2830 buf: &mut Vec<char>,
2831 ) -> Option<u16> {
2832 atom.score(Utf32Str::new(text, buf), matcher)
2833 }
2834
2835 fn fuzzy_text_matches(&self, text: &str) -> bool {
2836 let atom = self.fuzzy_atom();
2837 let mut matcher = Self::make_matcher();
2838 let mut buf = Vec::new();
2839 Self::score_text(&mut matcher, &atom, text, &mut buf).is_some()
2840 }
2841
2842 fn fuzzy_match_indices(&self, text: &str) -> Option<Vec<usize>> {
2843 let atom = self.fuzzy_atom();
2844 let mut matcher = Self::make_matcher();
2845 let mut buf = Vec::new();
2846 let mut indices = Vec::new();
2847 atom.indices(Utf32Str::new(text, &mut buf), &mut matcher, &mut indices)?;
2848
2849 let mut indices = indices
2850 .into_iter()
2851 .map(usize::try_from)
2852 .collect::<Result<Vec<_>, _>>()
2853 .ok()?;
2854 indices.sort_unstable();
2855 indices.dedup();
2856 Some(indices)
2857 }
2858
2859 fn score_per_column(
2861 matcher: &mut NucleoMatcher,
2862 atom: &Atom,
2863 item: &SelectItem,
2864 buf: &mut Vec<char>,
2865 ) -> Option<u16> {
2866 item.cells.as_ref().and_then(|cells| {
2867 cells
2868 .iter()
2869 .filter_map(|(cell_text, _)| Self::score_text(matcher, atom, cell_text, buf))
2870 .max()
2871 })
2872 }
2873
2874 fn score_item(
2876 matcher: &mut NucleoMatcher,
2877 atom: &Atom,
2878 per_column: bool,
2879 item: &SelectItem,
2880 buf: &mut Vec<char>,
2881 ) -> Option<u16> {
2882 if per_column && item.cells.is_some() {
2883 Self::score_per_column(matcher, atom, item, buf)
2884 } else {
2885 Self::score_text(matcher, atom, &item.name, buf)
2886 }
2887 }
2888
2889 fn should_yield_filter(start: nu_utils::time::Instant, checked: usize) -> bool {
2890 checked > 0
2891 && checked.is_multiple_of(FUZZY_FILTER_INTERRUPT_CHECK_INTERVAL)
2892 && start.elapsed() >= FUZZY_FILTER_MIN_INTERRUPT_TIME
2893 && event::poll(Duration::ZERO).is_ok_and(|has_event| has_event)
2894 }
2895
2896 fn score_filter_candidates<I>(
2897 &mut self,
2898 candidates: I,
2899 atom: &Atom,
2900 start: nu_utils::time::Instant,
2901 ) -> Option<Vec<(usize, u16)>>
2902 where
2903 I: Iterator<Item = usize>,
2904 {
2905 let mut scored = Vec::new();
2906 let mut buf = Vec::new();
2907 for (checked, i) in candidates.enumerate() {
2908 if Self::should_yield_filter(start, checked) {
2909 return None;
2910 }
2911
2912 if let Some(score) = Self::score_item(
2913 &mut self.matcher,
2914 atom,
2915 self.per_column,
2916 &self.items[i],
2917 &mut buf,
2918 ) {
2919 scored.push((i, score));
2920 }
2921 }
2922
2923 Some(scored)
2924 }
2925
2926 fn update_filter(&mut self) {
2927 let old_indices = std::mem::take(&mut self.filtered_indices);
2928 let start = nu_utils::time::Instant::now();
2929
2930 let use_refined = self.refined && !self.refined_base_indices.is_empty();
2932
2933 if self.filter_text.is_empty() {
2934 self.filtered_indices = if use_refined {
2936 self.refined_base_indices.clone()
2937 } else {
2938 (0..self.items.len()).collect()
2939 };
2940 self.last_filter_text.clear();
2941 self.force_full_filter = false;
2942 } else {
2943 let atom = self.fuzzy_atom();
2944 let can_reuse_previous = !self.force_full_filter
2945 && !self.last_filter_text.is_empty()
2946 && self.filter_text.starts_with(&self.last_filter_text);
2947
2948 let mut scored = if can_reuse_previous {
2949 self.score_filter_candidates(old_indices.iter().copied(), &atom, start)
2950 } else if use_refined {
2951 let refined_base_indices = self.refined_base_indices.clone();
2952 self.score_filter_candidates(refined_base_indices.into_iter(), &atom, start)
2953 } else {
2954 self.score_filter_candidates(0..self.items.len(), &atom, start)
2955 };
2956
2957 let Some(mut scored) = scored.take() else {
2958 self.filtered_indices = old_indices;
2959 self.results_changed = false;
2960 self.filter_text_changed = true;
2961 return;
2962 };
2963 scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
2965 self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2966 self.last_filter_text = self.filter_text.clone();
2967 self.force_full_filter = false;
2968 }
2969
2970 self.results_changed = old_indices != self.filtered_indices;
2972 self.filter_text_changed = true;
2973
2974 if self.results_changed {
2976 self.cursor = 0;
2977 self.scroll_offset = 0;
2978 }
2979
2980 if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2982 {
2983 self.auto_scroll_to_match_column();
2984 }
2985 }
2986
2987 fn auto_scroll_to_match_column(&mut self) {
2989 let Some(layout) = &self.table_layout else {
2990 return;
2991 };
2992
2993 let first_idx = self.filtered_indices[0];
2995 let item = &self.items[first_idx];
2996 let Some(cells) = &item.cells else {
2997 return;
2998 };
2999
3000 let mut first_match_col: Option<usize> = None;
3002 for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
3003 if self.per_column {
3004 if self.fuzzy_text_matches(cell_text) {
3006 first_match_col = Some(col_idx);
3007 break;
3008 }
3009 } else {
3010 let cell_start: usize = cells[..col_idx]
3013 .iter()
3014 .map(|(s, _)| s.chars().count() + 1) .sum();
3016 let cell_char_count = cell_text.chars().count();
3017
3018 if let Some(indices) = self.fuzzy_match_indices(&item.name) {
3019 if indices
3021 .iter()
3022 .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
3023 {
3024 first_match_col = Some(col_idx);
3025 break;
3026 }
3027 }
3028 }
3029 }
3030
3031 if let Some(match_col) = first_match_col {
3033 let (cols_visible, _) = self.calculate_visible_columns();
3034 let visible_start = self.horizontal_offset;
3035 let visible_end = self.horizontal_offset + cols_visible;
3036
3037 if match_col < visible_start {
3038 self.horizontal_offset = match_col;
3040 self.horizontal_scroll_changed = true;
3041 self.update_table_layout();
3042 } else if match_col >= visible_end {
3043 self.horizontal_offset = match_col;
3046 let max_offset = layout.col_widths.len().saturating_sub(1);
3048 self.horizontal_offset = self.horizontal_offset.min(max_offset);
3049 self.horizontal_scroll_changed = true;
3050 self.update_table_layout();
3051 }
3052 }
3053 }
3054
3055 fn get_result(&self) -> InteractMode {
3056 match self.mode {
3057 SelectMode::Single => InteractMode::Single(Some(self.cursor)),
3058 SelectMode::Multi => {
3059 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
3060 indices.sort();
3061 InteractMode::Multi(Some(indices))
3062 }
3063 SelectMode::Fuzzy => {
3064 if self.filtered_indices.is_empty() {
3065 InteractMode::Single(None)
3066 } else {
3067 InteractMode::Single(Some(self.filtered_indices[self.cursor]))
3068 }
3069 }
3070 SelectMode::FuzzyMulti => {
3071 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
3074 indices.sort();
3075 InteractMode::Multi(Some(indices))
3076 }
3077 }
3078 }
3079
3080 fn can_do_multi_toggle_only_update(&self) -> bool {
3083 if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
3084 return false;
3085 }
3086 if self.table_layout_changed {
3087 return false;
3088 }
3089 if self.cursor != self.prev_cursor {
3092 return false;
3093 }
3094 if let Some(toggled) = self.toggled_item {
3095 let visible_start = self.scroll_offset;
3097 let visible_end = self.scroll_offset + self.visible_height as usize;
3098 toggled >= visible_start && toggled < visible_end
3099 } else {
3100 false
3101 }
3102 }
3103
3104 fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
3107 if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
3108 return false;
3109 }
3110 if self.table_layout_changed {
3111 return false;
3112 }
3113 if self.scroll_offset != self.prev_scroll_offset {
3114 return false; }
3116 if self.filter_text_changed || self.results_changed {
3117 return false; }
3119 if let Some(toggled) = self.toggled_item {
3120 let visible_start = self.scroll_offset;
3122 let visible_end = self.scroll_offset + self.visible_height as usize;
3123 let toggled_visible = toggled >= visible_start && toggled < visible_end;
3124 let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
3125 toggled_visible && cursor_visible
3126 } else {
3127 false
3128 }
3129 }
3130
3131 fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
3134 !self.first_render
3135 && !self.width_changed
3136 && self.mode == SelectMode::FuzzyMulti
3137 && self.toggled_all
3138 && !self.filter_text_changed
3139 && !self.results_changed
3140 && self.scroll_offset == self.prev_scroll_offset
3141 && !self.horizontal_scroll_changed
3142 && !self.table_layout_changed
3143 }
3144
3145 fn can_do_multi_toggle_all_update(&self) -> bool {
3148 !self.first_render
3149 && !self.width_changed
3150 && self.mode == SelectMode::Multi
3151 && self.toggled_all
3152 && !self.table_layout_changed
3153 }
3154
3155 fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3157 let toggled = self.toggled_item.expect("toggled_item must be Some");
3158 execute!(stderr, BeginSynchronizedUpdate)?;
3159
3160 let header_lines = self.fuzzy_header_lines();
3162
3163 let toggled_display_row = (toggled - self.scroll_offset) as u16;
3164 let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
3165
3166 let toggled_item_row = header_lines + toggled_display_row;
3167 let cursor_item_row = header_lines + cursor_display_row;
3168
3169 let filter_row = self.fuzzy_filter_row();
3171
3172 let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
3174 execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
3175
3176 let toggled_real_idx = self.filtered_indices[toggled];
3178 let toggled_item = &self.items[toggled_real_idx];
3179 let toggled_checked = self.selected.contains(&toggled_real_idx);
3180 if self.is_table_mode() {
3181 self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
3182 } else {
3183 self.render_fuzzy_multi_item_inline(
3184 stderr,
3185 &toggled_item.name,
3186 toggled_checked,
3187 false,
3188 )?;
3189 }
3190
3191 if cursor_item_row > toggled_item_row {
3193 let lines_down = cursor_item_row - toggled_item_row;
3194 execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
3195 } else if cursor_item_row < toggled_item_row {
3196 let lines_up = toggled_item_row - cursor_item_row;
3197 execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
3198 }
3199
3200 let cursor_real_idx = self.filtered_indices[self.cursor];
3201 let cursor_item = &self.items[cursor_real_idx];
3202 let cursor_checked = self.selected.contains(&cursor_real_idx);
3203 if self.is_table_mode() {
3204 self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
3205 } else {
3206 self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
3207 }
3208
3209 if self.has_footer() {
3211 let total_count = self.current_list_len();
3213 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3214 let visible_count = (end - self.scroll_offset) as u16;
3215 let footer_row = header_lines + visible_count;
3216
3217 let down_to_footer = footer_row.saturating_sub(cursor_item_row);
3219 execute!(stderr, MoveDown(down_to_footer))?;
3220
3221 self.render_footer_inline(stderr)?;
3223
3224 let up_to_filter = footer_row.saturating_sub(filter_row);
3226 execute!(stderr, MoveUp(up_to_filter))?;
3227 } else {
3228 let up_to_filter = cursor_item_row.saturating_sub(filter_row);
3230 execute!(stderr, MoveUp(up_to_filter))?;
3231 }
3232
3233 self.position_fuzzy_cursor(stderr)?;
3235
3236 self.prev_cursor = self.cursor;
3238 self.toggled_item = None;
3239
3240 execute!(stderr, EndSynchronizedUpdate)?;
3241 stderr.flush()
3242 }
3243
3244 fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3246 let toggled = self.toggled_item.expect("toggled_item must be Some");
3247 execute!(stderr, BeginSynchronizedUpdate)?;
3248
3249 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
3250 if self.is_table_mode() {
3251 header_lines += 2; }
3253
3254 let display_row = (toggled - self.scroll_offset) as u16;
3256
3257 let items_rendered = self.rendered_lines - header_lines as usize;
3259
3260 let lines_up = (items_rendered as u16)
3263 .saturating_sub(1)
3264 .saturating_sub(display_row);
3265 execute!(stderr, MoveUp(lines_up))?;
3266
3267 execute!(stderr, MoveToColumn(2))?;
3269
3270 let checkbox = if self.selected.contains(&toggled) {
3272 "[x]"
3273 } else {
3274 "[ ]"
3275 };
3276 execute!(stderr, Print(checkbox))?;
3277
3278 execute!(stderr, MoveDown(lines_up))?;
3280
3281 if self.has_footer() {
3283 self.render_footer_inline(stderr)?;
3284 }
3285
3286 self.toggled_item = None;
3288
3289 execute!(stderr, EndSynchronizedUpdate)?;
3290 stderr.flush()
3291 }
3292
3293 fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3295 execute!(stderr, BeginSynchronizedUpdate)?;
3296
3297 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
3298 if self.is_table_mode() {
3299 header_lines += 2; }
3301
3302 let items_rendered = self.rendered_lines - header_lines as usize;
3304
3305 let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
3307 let visible_count = visible_end - self.scroll_offset;
3308
3309 execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
3312
3313 for i in 0..visible_count {
3315 let item_idx = self.scroll_offset + i;
3316 let checkbox = if self.selected.contains(&item_idx) {
3317 "[x]"
3318 } else {
3319 "[ ]"
3320 };
3321 execute!(stderr, MoveToColumn(2), Print(checkbox))?;
3323 if i + 1 < visible_count {
3324 execute!(stderr, MoveDown(1))?;
3325 }
3326 }
3327
3328 let remaining = items_rendered as u16 - visible_count as u16;
3330 if remaining > 0 {
3331 execute!(stderr, MoveDown(remaining))?;
3332 }
3333
3334 if self.has_footer() {
3336 self.render_footer_inline(stderr)?;
3337 }
3338
3339 self.toggled_all = false;
3341
3342 execute!(stderr, EndSynchronizedUpdate)?;
3343 stderr.flush()
3344 }
3345
3346 fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3348 execute!(stderr, BeginSynchronizedUpdate)?;
3349
3350 let header_lines = self.fuzzy_header_lines();
3352
3353 let total_count = self.current_list_len();
3354 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3355 let visible_count = end.saturating_sub(self.scroll_offset);
3356
3357 let filter_row = self.fuzzy_filter_row();
3359
3360 let down_to_first = header_lines.saturating_sub(filter_row);
3362 execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
3363
3364 for (i, idx) in (self.scroll_offset..end).enumerate() {
3365 let real_idx = self.filtered_indices[idx];
3366 let item = &self.items[real_idx];
3367 let checked = self.selected.contains(&real_idx);
3368 let active = idx == self.cursor;
3369
3370 if self.is_table_mode() {
3371 self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
3372 } else {
3373 self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
3374 }
3375
3376 if i + 1 < visible_count {
3377 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3378 }
3379 }
3380
3381 if self.has_footer() {
3383 let footer_row = header_lines + visible_count as u16;
3384 let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
3385 let down_to_footer = footer_row.saturating_sub(last_item_row);
3386 execute!(stderr, MoveDown(down_to_footer))?;
3387 self.render_footer_inline(stderr)?;
3388 let up_to_filter = footer_row.saturating_sub(filter_row);
3389 execute!(stderr, MoveUp(up_to_filter))?;
3390 } else {
3391 let up_to_filter =
3392 (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
3393 execute!(stderr, MoveUp(up_to_filter))?;
3394 }
3395
3396 self.position_fuzzy_cursor(stderr)?;
3398
3399 self.toggled_all = false;
3401
3402 execute!(stderr, EndSynchronizedUpdate)?;
3403 stderr.flush()
3404 }
3405
3406 #[allow(clippy::collapsible_if)]
3407 fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3408 let loaded_stream_items = self.load_more_items_for(STREAM_DRAIN_TIME_BUDGET);
3412 if loaded_stream_items && self.follow_stream_to_end {
3413 self.cursor = self.current_list_len().saturating_sub(1);
3414 self.adjust_scroll_down();
3415 }
3416 self.update_stream_footer();
3417
3418 if self.can_do_fuzzy_multi_toggle_all_update() {
3420 return self.render_fuzzy_multi_toggle_all_update(stderr);
3421 }
3422
3423 if self.can_do_multi_toggle_all_update() {
3425 return self.render_multi_toggle_all(stderr);
3426 }
3427
3428 if self.can_do_multi_toggle_only_update() {
3430 return self.render_multi_toggle_only(stderr);
3431 }
3432
3433 if self.can_do_fuzzy_multi_toggle_update() {
3435 return self.render_fuzzy_multi_toggle_update(stderr);
3436 }
3437
3438 if !self.first_render
3445 && !self.width_changed
3446 && self.cursor == self.prev_cursor
3447 && self.scroll_offset == self.prev_scroll_offset
3448 && !loaded_stream_items
3449 && !self.results_changed
3450 && !self.filter_text_changed
3451 && !self.horizontal_scroll_changed
3452 && !self.table_layout_changed
3453 && !self.settings_changed
3454 && !self.toggled_all
3455 {
3456 return Ok(());
3457 }
3458
3459 execute!(stderr, BeginSynchronizedUpdate)?;
3460
3461 let total_count = self.current_list_len();
3463 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
3464 let has_scroll_indicator = self.has_footer();
3466 let items_to_render = end - self.scroll_offset;
3467
3468 let mut lines_needed: usize = 0;
3470 if self.prompt.is_some() {
3471 lines_needed += 1;
3472 }
3473 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3474 lines_needed += 1; if self.config.show_separator {
3476 lines_needed += 1;
3477 }
3478 }
3479 if self.is_table_mode() {
3480 lines_needed += 2; }
3482 lines_needed += items_to_render;
3483 if has_scroll_indicator {
3484 lines_needed += 1;
3485 }
3486
3487 if self.first_render && lines_needed > 1 {
3489 for _ in 0..(lines_needed - 1) {
3490 execute!(stderr, Print("\n"))?;
3491 }
3492 execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
3493 }
3494
3495 if self.fuzzy_cursor_offset > 0 {
3497 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3498 self.fuzzy_cursor_offset = 0;
3499 }
3500
3501 if !self.first_render && lines_needed > self.rendered_lines {
3505 let lines_to_add = lines_needed - self.rendered_lines;
3506 for _ in 0..lines_to_add {
3507 execute!(stderr, Print("\n"))?;
3508 }
3509 execute!(stderr, MoveUp(lines_to_add as u16))?;
3510 }
3511
3512 if self.rendered_lines > 1 {
3515 execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
3516 }
3517 execute!(stderr, MoveToColumn(0))?;
3518
3519 let mut lines_rendered: usize = 0;
3520
3521 if self.first_render {
3523 if let Some(prompt) = self.prompt {
3524 execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
3525 }
3526 }
3527 if self.prompt.is_some() {
3528 lines_rendered += 1;
3529 if lines_rendered < lines_needed {
3530 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3531 }
3532 }
3533
3534 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3536 execute!(
3537 stderr,
3538 Print(self.prompt_marker()),
3539 Print(&self.filter_text),
3540 Clear(ClearType::UntilNewLine),
3541 )?;
3542 lines_rendered += 1;
3543 if lines_rendered < lines_needed {
3544 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3545 }
3546
3547 if self.config.show_separator {
3549 execute!(
3550 stderr,
3551 Print(self.config.separator.paint(&self.separator_line)),
3552 Clear(ClearType::UntilNewLine),
3553 )?;
3554 lines_rendered += 1;
3555 if lines_rendered < lines_needed {
3556 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3557 }
3558 }
3559 }
3560
3561 if self.is_table_mode() {
3564 let need_header_redraw =
3565 self.first_render || self.horizontal_scroll_changed || self.table_layout_changed;
3566 if need_header_redraw {
3567 self.render_table_header(stderr)?;
3568 }
3569 lines_rendered += 1;
3570 if lines_rendered < lines_needed {
3571 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3572 }
3573 if need_header_redraw {
3574 self.render_table_header_separator(stderr)?;
3575 }
3576 lines_rendered += 1;
3577 if lines_rendered < lines_needed {
3578 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3579 }
3580 }
3581
3582 for idx in self.scroll_offset..end {
3584 let is_active = idx == self.cursor;
3585 let is_last_line = lines_rendered + 1 == lines_needed;
3586
3587 if self.is_table_mode() {
3588 match self.mode {
3590 SelectMode::Single => {
3591 let item = &self.items[idx];
3592 self.render_table_row_single(stderr, item, is_active)?;
3593 }
3594 SelectMode::Multi => {
3595 let real_idx = if self.refined {
3596 self.filtered_indices[idx]
3597 } else {
3598 idx
3599 };
3600 let item = &self.items[real_idx];
3601 let is_checked = self.selected.contains(&real_idx);
3602 self.render_table_row_multi(stderr, item, is_checked, is_active)?;
3603 }
3604 SelectMode::Fuzzy => {
3605 let real_idx = self.filtered_indices[idx];
3606 let item = &self.items[real_idx];
3607 self.render_table_row_fuzzy(stderr, item, is_active)?;
3608 }
3609 SelectMode::FuzzyMulti => {
3610 let real_idx = self.filtered_indices[idx];
3611 let item = &self.items[real_idx];
3612 let is_checked = self.selected.contains(&real_idx);
3613 self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
3614 }
3615 }
3616 } else {
3617 match self.mode {
3619 SelectMode::Single => {
3620 let item = &self.items[idx];
3621 self.render_single_item_inline(stderr, &item.name, is_active)?;
3622 }
3623 SelectMode::Multi => {
3624 let real_idx = if self.refined {
3625 self.filtered_indices[idx]
3626 } else {
3627 idx
3628 };
3629 let item = &self.items[real_idx];
3630 let is_checked = self.selected.contains(&real_idx);
3631 self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
3632 }
3633 SelectMode::Fuzzy => {
3634 let real_idx = self.filtered_indices[idx];
3635 let item = &self.items[real_idx];
3636 self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
3637 }
3638 SelectMode::FuzzyMulti => {
3639 let real_idx = self.filtered_indices[idx];
3640 let item = &self.items[real_idx];
3641 let is_checked = self.selected.contains(&real_idx);
3642 self.render_fuzzy_multi_item_inline(
3643 stderr, &item.name, is_checked, is_active,
3644 )?;
3645 }
3646 }
3647 }
3648 lines_rendered += 1;
3649 if !is_last_line {
3650 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3651 }
3652 }
3653
3654 if has_scroll_indicator {
3656 let indicator = self.generate_footer();
3657 execute!(
3658 stderr,
3659 Print(self.config.footer.paint(&indicator)),
3660 Clear(ClearType::UntilNewLine),
3661 )?;
3662 lines_rendered += 1;
3663 }
3664
3665 if lines_rendered < self.rendered_lines {
3668 let extra_lines = self.rendered_lines - lines_rendered;
3669 for _ in 0..extra_lines {
3670 execute!(
3671 stderr,
3672 MoveDown(1),
3673 MoveToColumn(0),
3674 Clear(ClearType::CurrentLine)
3675 )?;
3676 }
3677 execute!(stderr, MoveUp(extra_lines as u16))?;
3679 }
3680
3681 self.rendered_lines = lines_rendered;
3683 self.prev_cursor = self.cursor;
3684 self.prev_scroll_offset = self.scroll_offset;
3685 self.first_render = false;
3686 self.filter_text_changed = false;
3687 self.results_changed = false;
3688 self.horizontal_scroll_changed = false;
3689 self.width_changed = false;
3690 self.table_layout_changed = false;
3691 self.toggled_item = None;
3692 self.toggled_all = false;
3693 self.settings_changed = false;
3694
3695 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3697 let filter_row = self.fuzzy_filter_row() as usize;
3699 self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3700 if self.fuzzy_cursor_offset > 0 {
3701 execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3702 }
3703 self.position_fuzzy_cursor(stderr)?;
3705 }
3706
3707 execute!(stderr, EndSynchronizedUpdate)?;
3708 stderr.flush()
3709 }
3710
3711 fn render_single_item_inline(
3712 &self,
3713 stderr: &mut Stderr,
3714 text: &str,
3715 active: bool,
3716 ) -> io::Result<()> {
3717 let prefix = if active { self.selected_marker() } else { " " };
3718 let prefix_width = 2;
3719
3720 execute!(stderr, Print(prefix))?;
3721 self.render_truncated_text(stderr, text, prefix_width)?;
3722 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3723 Ok(())
3724 }
3725
3726 fn render_multi_item_inline(
3727 &self,
3728 stderr: &mut Stderr,
3729 text: &str,
3730 checked: bool,
3731 active: bool,
3732 ) -> io::Result<()> {
3733 let cursor = if active { self.selected_marker() } else { " " };
3734 let checkbox = if checked { "[x] " } else { "[ ] " };
3735 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3738 self.render_truncated_text(stderr, text, prefix_width)?;
3739 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3740 Ok(())
3741 }
3742
3743 fn render_fuzzy_item_inline(
3744 &self,
3745 stderr: &mut Stderr,
3746 text: &str,
3747 active: bool,
3748 ) -> io::Result<()> {
3749 let prefix = if active { self.selected_marker() } else { " " };
3750 let prefix_width = 2;
3751 execute!(stderr, Print(prefix))?;
3752
3753 if self.filter_text.is_empty() {
3754 self.render_truncated_text(stderr, text, prefix_width)?;
3755 } else if let Some(indices) = self.fuzzy_match_indices(text) {
3756 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3757 } else {
3758 self.render_truncated_text(stderr, text, prefix_width)?;
3759 }
3760 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3761 Ok(())
3762 }
3763
3764 fn render_fuzzy_multi_item_inline(
3765 &self,
3766 stderr: &mut Stderr,
3767 text: &str,
3768 checked: bool,
3769 active: bool,
3770 ) -> io::Result<()> {
3771 let cursor = if active { self.selected_marker() } else { " " };
3772 let checkbox = if checked { "[x] " } else { "[ ] " };
3773 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3775
3776 if self.filter_text.is_empty() {
3777 self.render_truncated_text(stderr, text, prefix_width)?;
3778 } else if let Some(indices) = self.fuzzy_match_indices(text) {
3779 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3780 } else {
3781 self.render_truncated_text(stderr, text, prefix_width)?;
3782 }
3783 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3784 Ok(())
3785 }
3786
3787 fn item_text_width(&self, prefix_width: usize) -> usize {
3789 self.term_width
3792 .saturating_sub(prefix_width as u16)
3793 .saturating_sub(1) as usize
3794 }
3795
3796 fn render_truncated_text(
3797 &self,
3798 stderr: &mut Stderr,
3799 text: &str,
3800 prefix_width: usize,
3801 ) -> io::Result<()> {
3802 let available_width = self.item_text_width(prefix_width);
3803 let text = truncate_ansi_aware_text_at(text, available_width, prefix_width);
3804 execute!(stderr, Print(text.as_ref()))?;
3805 Ok(())
3806 }
3807
3808 fn render_display_segments(
3809 &self,
3810 stderr: &mut Stderr,
3811 sanitized: &SanitizedText,
3812 match_indices: Option<&[usize]>,
3813 base_style: Option<Style>,
3814 ) -> io::Result<()> {
3815 let mut match_iter = match_indices.map(|indices| indices.iter().peekable());
3816
3817 for segment in &sanitized.segments {
3818 let is_match = if let (Some(source_index), Some(match_iter)) =
3819 (segment.source_index, match_iter.as_mut())
3820 {
3821 while match_iter.peek().is_some_and(|&&idx| idx < source_index) {
3822 match_iter.next();
3823 }
3824 match_iter.peek().is_some_and(|&&idx| idx == source_index)
3825 } else {
3826 false
3827 };
3828
3829 if is_match {
3830 execute!(stderr, Print(self.config.match_text.paint(&segment.text)))?;
3831 } else if let Some(style) = base_style {
3832 execute!(stderr, Print(style.paint(&segment.text)))?;
3833 } else {
3834 execute!(stderr, Print(&segment.text))?;
3835 }
3836 }
3837
3838 Ok(())
3839 }
3840
3841 fn render_truncated_fuzzy_text(
3844 &self,
3845 stderr: &mut Stderr,
3846 text: &str,
3847 match_indices: &[usize],
3848 prefix_width: usize,
3849 ) -> io::Result<()> {
3850 let available_width = self.item_text_width(prefix_width);
3851
3852 if available_width <= 1 {
3853 let has_any_matches = !match_indices.is_empty();
3855 if has_any_matches {
3856 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3857 } else {
3858 execute!(stderr, Print("…"))?;
3859 }
3860 return Ok(());
3861 }
3862
3863 let sanitized = sanitize_text_for_display(text, available_width, prefix_width);
3864 if !sanitized.truncated {
3865 self.render_display_segments(stderr, &sanitized, Some(match_indices), None)?;
3866 return Ok(());
3867 }
3868
3869 let sanitized = sanitize_text_for_display(text, available_width - 1, prefix_width);
3870 self.render_display_segments(stderr, &sanitized, Some(match_indices), None)?;
3871
3872 let has_hidden_matches = match_indices
3873 .iter()
3874 .any(|&idx| idx >= sanitized.source_chars);
3875 if has_hidden_matches {
3876 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3877 } else {
3878 execute!(stderr, Print("…"))?;
3879 }
3880 Ok(())
3881 }
3882
3883 fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3885 let Some(layout) = &self.table_layout else {
3886 return Ok(());
3887 };
3888
3889 let prefix_width = self.row_prefix_width();
3890 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3891 let has_more_left = self.horizontal_offset > 0;
3892
3893 execute!(stderr, Print(" ".repeat(prefix_width)))?;
3895
3896 if has_more_left {
3898 let sep = self.table_column_separator();
3899 execute!(
3900 stderr,
3901 Print(self.config.table_separator.paint("…")),
3902 Print(self.config.table_separator.paint(&sep))
3903 )?;
3904 }
3905
3906 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3908 for (i, col_idx) in visible_range.enumerate() {
3909 if col_idx >= layout.columns.len() {
3910 break;
3911 }
3912
3913 if i > 0 {
3915 let sep = self.table_column_separator();
3916 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3917 }
3918
3919 let header = &layout.columns[col_idx];
3921 let col_width = layout.col_widths[col_idx];
3922 let header_width = header.width();
3923 let padding = col_width.saturating_sub(header_width);
3924 let left_pad = padding / 2;
3925 let right_pad = padding - left_pad;
3926 let header_padded = format!(
3927 "{}{}{}",
3928 " ".repeat(left_pad),
3929 header,
3930 " ".repeat(right_pad)
3931 );
3932 execute!(
3933 stderr,
3934 Print(self.config.table_header.paint(&header_padded))
3935 )?;
3936 }
3937
3938 if has_more_right {
3940 let sep = self.table_column_separator();
3941 execute!(
3942 stderr,
3943 Print(self.config.table_separator.paint(&sep)),
3944 Print(self.config.table_separator.paint("…"))
3945 )?;
3946 }
3947
3948 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3949 Ok(())
3950 }
3951
3952 fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3954 let Some(layout) = &self.table_layout else {
3955 return Ok(());
3956 };
3957
3958 let prefix_width = self.row_prefix_width();
3959 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3960 let has_more_left = self.horizontal_offset > 0;
3961
3962 let h_char = self.config.table_header_separator;
3963 let int_char = self.config.table_header_intersection;
3964
3965 let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3967 execute!(
3968 stderr,
3969 Print(self.config.table_separator.paint(&prefix_line))
3970 )?;
3971
3972 if has_more_left {
3975 let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3976 execute!(
3977 stderr,
3978 Print(self.config.table_separator.paint(&left_indicator))
3979 )?;
3980 }
3981
3982 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3984 for (i, col_idx) in visible_range.enumerate() {
3985 if col_idx >= layout.col_widths.len() {
3986 break;
3987 }
3988
3989 if i > 0 {
3991 let intersection = format!("{}{}{}", h_char, int_char, h_char);
3992 execute!(
3993 stderr,
3994 Print(self.config.table_separator.paint(&intersection))
3995 )?;
3996 }
3997
3998 let col_width = layout.col_widths[col_idx];
4000 let line: String = std::iter::repeat_n(h_char, col_width).collect();
4001 execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
4002 }
4003
4004 if has_more_right {
4007 let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
4008 execute!(
4009 stderr,
4010 Print(self.config.table_separator.paint(&right_indicator))
4011 )?;
4012 }
4013
4014 execute!(stderr, Clear(ClearType::UntilNewLine))?;
4015 Ok(())
4016 }
4017
4018 fn render_table_row_single(
4020 &self,
4021 stderr: &mut Stderr,
4022 item: &SelectItem,
4023 active: bool,
4024 ) -> io::Result<()> {
4025 let prefix = if active { self.selected_marker() } else { " " };
4026 execute!(stderr, Print(prefix))?;
4027 self.render_table_cells(stderr, item, None)?;
4028 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4029 Ok(())
4030 }
4031
4032 fn render_table_row_multi(
4034 &self,
4035 stderr: &mut Stderr,
4036 item: &SelectItem,
4037 checked: bool,
4038 active: bool,
4039 ) -> io::Result<()> {
4040 let cursor = if active { self.selected_marker() } else { " " };
4041 let checkbox = if checked { "[x] " } else { "[ ] " };
4042 execute!(stderr, Print(cursor), Print(checkbox))?;
4043 self.render_table_cells(stderr, item, None)?;
4044 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4045 Ok(())
4046 }
4047
4048 fn render_table_row_fuzzy(
4050 &self,
4051 stderr: &mut Stderr,
4052 item: &SelectItem,
4053 active: bool,
4054 ) -> io::Result<()> {
4055 let prefix = if active { self.selected_marker() } else { " " };
4056 execute!(stderr, Print(prefix))?;
4057
4058 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
4060 self.fuzzy_match_indices(&item.name)
4061 } else {
4062 None
4063 };
4064
4065 self.render_table_cells(stderr, item, match_indices.as_deref())?;
4066 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4067 Ok(())
4068 }
4069
4070 fn render_table_row_fuzzy_multi(
4072 &self,
4073 stderr: &mut Stderr,
4074 item: &SelectItem,
4075 checked: bool,
4076 active: bool,
4077 ) -> io::Result<()> {
4078 let cursor = if active { self.selected_marker() } else { " " };
4079 let checkbox = if checked { "[x] " } else { "[ ] " };
4080 execute!(stderr, Print(cursor), Print(checkbox))?;
4081
4082 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
4084 self.fuzzy_match_indices(&item.name)
4085 } else {
4086 None
4087 };
4088
4089 self.render_table_cells(stderr, item, match_indices.as_deref())?;
4090 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
4091 Ok(())
4092 }
4093
4094 fn render_table_cells(
4096 &self,
4097 stderr: &mut Stderr,
4098 item: &SelectItem,
4099 match_indices: Option<&[usize]>,
4100 ) -> io::Result<()> {
4101 let Some(layout) = &self.table_layout else {
4102 return Ok(());
4103 };
4104 let Some(cells) = &item.cells else {
4105 return Ok(());
4106 };
4107
4108 let (cols_visible, has_more_right) = self.calculate_visible_columns();
4109 let has_more_left = self.horizontal_offset > 0;
4110
4111 let mut matches_in_hidden_left = false;
4113 let mut matches_in_hidden_right = false;
4114
4115 let per_column_matches: Vec<Option<Vec<usize>>> =
4117 if self.per_column && !self.filter_text.is_empty() {
4118 cells
4119 .iter()
4120 .map(|(cell_text, _)| self.fuzzy_match_indices(cell_text))
4121 .collect()
4122 } else {
4123 vec![]
4124 };
4125
4126 let cell_offsets: Vec<usize> = if match_indices.is_some() {
4129 let mut offsets = Vec::with_capacity(cells.len());
4130 let mut offset = 0;
4131 for (i, (cell_text, _)) in cells.iter().enumerate() {
4132 offsets.push(offset);
4133 offset += cell_text.chars().count();
4134 if i + 1 < cells.len() {
4135 offset += 1; }
4137 }
4138 offsets
4139 } else {
4140 vec![]
4141 };
4142
4143 if self.per_column && !self.filter_text.is_empty() {
4145 for col_idx in 0..self.horizontal_offset {
4146 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
4147 matches_in_hidden_left = true;
4148 break;
4149 }
4150 }
4151 } else if let Some(indices) = match_indices {
4152 for col_idx in 0..self.horizontal_offset {
4153 if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
4154 let cell_start = cell_offsets[col_idx];
4155 let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
4157 matches_in_hidden_left = true;
4158 break;
4159 }
4160 }
4161 }
4162 }
4163
4164 if has_more_left {
4166 let sep = self.table_column_separator();
4167 if matches_in_hidden_left {
4168 execute!(
4169 stderr,
4170 Print(self.config.match_text.paint("…")),
4171 Print(self.config.table_separator.paint(&sep))
4172 )?;
4173 } else {
4174 execute!(
4175 stderr,
4176 Print(self.config.table_separator.paint("…")),
4177 Print(self.config.table_separator.paint(&sep))
4178 )?;
4179 }
4180 }
4181
4182 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
4184 for (i, col_idx) in visible_range.enumerate() {
4185 if col_idx >= cells.len() {
4186 break;
4187 }
4188
4189 if i > 0 {
4191 let sep = self.table_column_separator();
4192 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
4193 }
4194
4195 let (cell_text, cell_style) = &cells[col_idx];
4196 let col_width = layout.col_widths[col_idx];
4197
4198 let cell_matches: Option<Vec<usize>> =
4200 if self.per_column && !self.filter_text.is_empty() {
4201 per_column_matches.get(col_idx).cloned().flatten()
4203 } else if let Some(indices) = match_indices {
4204 if col_idx < cell_offsets.len() {
4206 let cell_start = cell_offsets[col_idx];
4207 let cell_char_count = cell_text.chars().count();
4209 let relative_indices: Vec<usize> = indices
4210 .iter()
4211 .filter_map(|&idx| {
4212 if idx >= cell_start && idx < cell_start + cell_char_count {
4213 Some(idx - cell_start)
4214 } else {
4215 None
4216 }
4217 })
4218 .collect();
4219 if relative_indices.is_empty() {
4220 None
4221 } else {
4222 Some(relative_indices)
4223 }
4224 } else {
4225 None
4226 }
4227 } else {
4228 None
4229 };
4230
4231 self.render_table_cell(
4233 stderr,
4234 cell_text,
4235 cell_style,
4236 col_width,
4237 cell_matches.as_deref(),
4238 )?;
4239 }
4240
4241 if self.per_column && !self.filter_text.is_empty() {
4243 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
4244 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
4245 matches_in_hidden_right = true;
4246 break;
4247 }
4248 }
4249 } else if let Some(indices) = match_indices {
4250 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
4251 if col_idx < cell_offsets.len() {
4252 let cell_start = cell_offsets[col_idx];
4253 let cell_end = if col_idx + 1 < cell_offsets.len() {
4254 cell_offsets[col_idx + 1].saturating_sub(1)
4255 } else {
4256 item.name.chars().count()
4257 };
4258 if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
4259 matches_in_hidden_right = true;
4260 break;
4261 }
4262 }
4263 }
4264 }
4265
4266 if has_more_right {
4268 let sep = self.table_column_separator();
4269 if matches_in_hidden_right {
4270 execute!(
4271 stderr,
4272 Print(self.config.table_separator.paint(&sep)),
4273 Print(self.config.match_text.paint("…"))
4274 )?;
4275 } else {
4276 execute!(
4277 stderr,
4278 Print(self.config.table_separator.paint(&sep)),
4279 Print(self.config.table_separator.paint("…"))
4280 )?;
4281 }
4282 }
4283
4284 Ok(())
4285 }
4286
4287 fn render_table_cell(
4289 &self,
4290 stderr: &mut Stderr,
4291 cell: &str,
4292 cell_style: &TextStyle,
4293 col_width: usize,
4294 match_indices: Option<&[usize]>,
4295 ) -> io::Result<()> {
4296 let cell_width = terminal_text_width_from(cell, 0);
4297 let padding_needed = col_width.saturating_sub(cell_width);
4298
4299 let (left_pad, right_pad) = match cell_style.alignment {
4301 Alignment::Left => (0, padding_needed),
4302 Alignment::Right => (padding_needed, 0),
4303 Alignment::Center => {
4304 let left = padding_needed / 2;
4305 (left, padding_needed - left)
4306 }
4307 };
4308
4309 if left_pad > 0 {
4311 execute!(stderr, Print(" ".repeat(left_pad)))?;
4312 }
4313
4314 let sanitized = sanitize_text_for_display(cell, cell_width, 0);
4317 self.render_display_segments(stderr, &sanitized, match_indices, cell_style.color_style)?;
4318
4319 if right_pad > 0 {
4321 execute!(stderr, Print(" ".repeat(right_pad)))?;
4322 }
4323
4324 Ok(())
4325 }
4326
4327 fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
4328 if self.fuzzy_cursor_offset > 0 {
4330 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
4331 self.fuzzy_cursor_offset = 0;
4332 }
4333
4334 if self.rendered_lines > 0 {
4335 execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
4339 for _ in 1..self.rendered_lines {
4341 execute!(
4342 stderr,
4343 MoveUp(1),
4344 MoveToColumn(0),
4345 Clear(ClearType::CurrentLine)
4346 )?;
4347 }
4348 }
4350 self.rendered_lines = 0;
4351 stderr.flush()
4352 }
4353}
4354
4355enum KeyAction {
4356 Continue,
4357 Cancel,
4358 Confirm,
4359}
4360
4361#[cfg(test)]
4362mod test {
4363 use super::*;
4364
4365 fn make_widget(items: &[&str]) -> SelectWidget<'static> {
4366 let options: Vec<SelectItem> = items
4367 .iter()
4368 .map(|s| SelectItem {
4369 name: s.to_string(),
4370 cells: None,
4371 value: nu_protocol::Value::nothing(nu_protocol::Span::test_data()),
4372 })
4373 .collect();
4374
4375 SelectWidget::new(
4376 SelectMode::Single,
4377 None,
4378 options,
4379 InputListConfig::default(),
4380 None,
4381 false,
4382 StreamState {
4383 stream_reader: None,
4384 item_generator: None,
4385 },
4386 )
4387 }
4388
4389 #[test]
4390 fn wrap_up_and_down_cycles() {
4391 let mut w = make_widget(&["A", "B", "C"]);
4392 w.navigate_up();
4394 assert_eq!(w.cursor, 2);
4395 w.navigate_up();
4396 assert_eq!(w.cursor, 1);
4397 w.navigate_up();
4398 assert_eq!(w.cursor, 0);
4399
4400 w.navigate_down();
4402 assert_eq!(w.cursor, 1);
4403 w.navigate_down();
4404 assert_eq!(w.cursor, 2);
4405 w.navigate_down();
4406 assert_eq!(w.cursor, 0);
4407 }
4408
4409 #[test]
4410 fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
4411 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
4412 w.first_render = false;
4413 w.prev_cursor = 0;
4414 w.prev_scroll_offset = 0;
4415 w.cursor = 0;
4416 w.scroll_offset = 0;
4417
4418 let mut stderr = io::stderr();
4419
4420 for _ in 0..7 {
4421 w.navigate_down();
4422 w.render(&mut stderr)?;
4423 assert_eq!(w.scroll_offset, 0);
4424 }
4425
4426 Ok(())
4427 }
4428
4429 #[test]
4430 fn up_arrow_sequence_state_and_render() -> io::Result<()> {
4431 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
4432 w.first_render = false;
4433 w.prev_cursor = 0;
4434 w.prev_scroll_offset = 0;
4435 w.cursor = 0;
4436 w.scroll_offset = 0;
4437
4438 let mut stderr = io::stderr();
4439
4440 w.render(&mut stderr)?;
4441 assert_eq!(w.cursor, 0);
4442
4443 w.navigate_up();
4444 w.render(&mut stderr)?;
4445 assert_eq!(w.cursor, 2);
4446
4447 w.navigate_up();
4448 w.render(&mut stderr)?;
4449 assert_eq!(w.cursor, 1);
4450
4451 Ok(())
4452 }
4453
4454 #[test]
4455 fn ansi_styled_text_that_visibly_fits_is_not_truncated() {
4456 let text = "\u{1b}[1;37mabcdef\u{1b}[0m";
4457
4458 let rendered = truncate_ansi_aware_text(text, 6);
4459
4460 assert_eq!(
4461 nu_utils::strip_ansi_unlikely(rendered.as_ref()).as_ref(),
4462 "abcdef"
4463 );
4464 assert!(!rendered.contains('…'));
4465 }
4466
4467 #[test]
4468 fn ansi_styled_text_truncates_by_visible_width() {
4469 let text = "\u{1b}[1;37mabcdef\u{1b}[0m";
4470
4471 let rendered = truncate_ansi_aware_text(text, 4);
4472
4473 assert_eq!(
4474 nu_utils::strip_ansi_unlikely(rendered.as_ref()).as_ref(),
4475 "abc…"
4476 );
4477 }
4478
4479 #[test]
4480 fn tabbed_text_truncates_by_terminal_width() {
4481 let rendered = truncate_ansi_aware_text("ab\tcdef", 6);
4482
4483 assert_eq!(rendered.as_ref(), "ab…");
4484 }
4485
4486 #[test]
4487 fn tabbed_text_truncates_from_prefixed_column() {
4488 let rendered = truncate_ansi_aware_text_at("\t--hostname-bin", 6, 2);
4489
4490 assert_eq!(rendered.as_ref(), "…");
4491 }
4492
4493 #[test]
4494 fn tabbed_text_expands_when_not_truncated() {
4495 let rendered = truncate_ansi_aware_text_at("\t--hostname-bin", 32, 2);
4496
4497 assert_eq!(rendered.as_ref(), " --hostname-bin");
4498 }
4499
4500 #[test]
4501 fn sanitizer_tracks_source_indices_after_expanding_tabs() {
4502 let sanitized = sanitize_text_for_display("a\tb\u{7}c", 16, 0);
4503
4504 assert_eq!(sanitized.text, "a bc");
4505 assert_eq!(
4506 sanitized
4507 .segments
4508 .iter()
4509 .filter_map(|segment| segment.source_index)
4510 .collect::<Vec<_>>(),
4511 vec![0, 1, 2, 4]
4512 );
4513 }
4514
4515 #[test]
4516 fn item_text_width_reserves_prefix() {
4517 let mut w = make_widget(&[""]);
4518 w.term_width = 129;
4519
4520 let available_width = w.item_text_width(2);
4521 let rendered = truncate_ansi_aware_text_at(
4522 "\t--hostname-bin # Run a program to get this system's hostname",
4523 available_width,
4524 2,
4525 );
4526
4527 assert_eq!(available_width, 126);
4528 assert!(terminal_text_width_from(rendered.as_ref(), 2) <= available_width);
4529 }
4530
4531 #[test]
4532 fn table_layout_uses_sanitized_terminal_width() {
4533 let span = nu_protocol::Span::test_data();
4534 let columns = vec!["name".to_string()];
4535 let items = vec![SelectItem {
4536 name: "\tname".to_string(),
4537 cells: Some(vec![("\tname".to_string(), TextStyle::default())]),
4538 value: Value::nothing(span),
4539 }];
4540
4541 let layout = InputList::calculate_table_layout(&columns, &items);
4542
4543 assert_eq!(layout.col_widths, vec![12]);
4544 }
4545
4546 #[test]
4547 fn fuzzy_filter_does_not_drain_pending_stream() {
4548 let span = nu_protocol::Span::test_data();
4549 let mut w = make_widget(&["needle"]);
4550 w.mode = SelectMode::Fuzzy;
4551 w.filter_text = "needle".to_string();
4552 w.filter_cursor = w.filter_text.len();
4553 w.stream_reader = Some(StreamReader::new(ListStream::new(
4554 (0..10_000).map(move |i| Value::string(format!("row-{i}"), span)),
4555 span,
4556 nu_protocol::Signals::empty(),
4557 )));
4558
4559 w.update_filter();
4560
4561 assert_eq!(w.items.len(), 1);
4562 assert!(w.stream_is_pending());
4563 }
4564
4565 #[test]
4566 fn initial_read_collects_fast_finite_stream() {
4567 let span = nu_protocol::Span::test_data();
4568 let stream = ListStream::new(
4569 (0..5).map(move |i| Value::int(i, span)),
4570 span,
4571 nu_protocol::Signals::empty(),
4572 );
4573
4574 let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4575
4576 assert_eq!(values.len(), 5);
4577 assert!(pending_stream.is_none());
4578 }
4579
4580 #[test]
4581 fn initial_read_stops_before_exhausting_unbounded_stream() {
4582 let span = nu_protocol::Span::test_data();
4583 let stream = ListStream::new(
4584 (0..).map(move |i| Value::int(i, span)),
4585 span,
4586 nu_protocol::Signals::empty(),
4587 );
4588
4589 let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4590
4591 assert!(!values.is_empty());
4592 assert!(values.len() <= INITIAL_STREAM_MAX_ITEMS);
4593 assert!(pending_stream.is_some());
4594 }
4595
4596 #[test]
4597 fn initial_read_timeout_does_not_block_on_slow_stream() {
4598 let span = nu_protocol::Span::test_data();
4599 let (sender, receiver) = std::sync::mpsc::channel::<Value>();
4600 let stream = ListStream::new(receiver.into_iter(), span, nu_protocol::Signals::empty());
4601 let start = nu_utils::time::Instant::now();
4602
4603 let (values, pending_stream) = InputList::read_initial_stream_values(stream);
4604
4605 assert!(values.is_empty());
4606 assert!(pending_stream.is_some());
4607 assert!(start.elapsed() < INITIAL_STREAM_COLLECT_TIMEOUT * 2);
4608
4609 drop(sender);
4610 }
4611
4612 #[test]
4613 fn materialized_list_input_is_not_streamed() {
4614 let span = nu_protocol::Span::test_data();
4615 let values: Vec<Value> = (0..=INITIAL_STREAM_MAX_ITEMS)
4616 .map(|i| Value::int(i as i64, span))
4617 .collect();
4618 let input = Value::list(values, span).into_pipeline_data();
4619
4620 let (values, pending_stream) =
4621 InputList::initial_values_from_input(input, span, nu_protocol::Signals::empty())
4622 .expect("materialized list input should be accepted");
4623
4624 assert_eq!(values.len(), INITIAL_STREAM_MAX_ITEMS + 1);
4625 assert!(pending_stream.is_none());
4626 }
4627
4628 #[test]
4629 fn footer_marks_pending_stream() {
4630 let span = nu_protocol::Span::test_data();
4631 let mut w = make_widget(&["one", "two"]);
4632 w.term_width = 80;
4633 w.stream_reader = Some(StreamReader::new(ListStream::new(
4634 (0..100).map(move |i| Value::string(format!("row-{i}"), span)),
4635 span,
4636 nu_protocol::Signals::empty(),
4637 )));
4638
4639 assert_eq!(w.generate_footer(), "[1-2 of 2 -]");
4640 }
4641
4642 #[test]
4643 fn streaming_footer_updates_at_slower_interval() {
4644 let span = nu_protocol::Span::test_data();
4645 let mut w = make_widget(&["one"]);
4646 let (_sender, receiver) = mpsc::sync_channel(1);
4647 w.stream_reader = Some(StreamReader {
4648 receiver,
4649 finished: false,
4650 });
4651 w.items.push(SelectItem {
4652 name: "two".to_string(),
4653 cells: None,
4654 value: Value::string("two", span),
4655 });
4656 w.settings_changed = false;
4657
4658 w.update_stream_footer();
4659
4660 assert_eq!(w.stream_spinner_frame, 0);
4661 assert_eq!(w.stream_footer_item_count, 1);
4662 assert!(!w.settings_changed);
4663
4664 w.last_stream_footer_update =
4665 nu_utils::time::Instant::now() - STREAM_FOOTER_UPDATE_INTERVAL;
4666
4667 w.update_stream_footer();
4668
4669 assert_eq!(w.stream_spinner_frame, 1);
4670 assert_eq!(w.stream_footer_item_count, 2);
4671 assert!(w.settings_changed);
4672 }
4673
4674 #[test]
4675 fn footer_shows_when_items_fill_reserved_area() {
4676 let mut w = make_widget(&["one", "two"]);
4677 w.term_width = 80;
4678 w.visible_height = 2;
4679
4680 assert!(w.has_footer());
4681 assert_eq!(w.generate_footer(), "[1-2 of 2]");
4682 }
4683
4684 #[test]
4685 fn final_stream_drain_marks_footer_dirty() {
4686 let span = nu_protocol::Span::test_data();
4687 let mut w = make_widget(&["one"]);
4688 w.term_width = 80;
4689 let (sender, receiver) = mpsc::sync_channel(8);
4690 for i in 0..2 {
4691 sender
4692 .send(StreamMessage::Item(Value::string(format!("row-{i}"), span)))
4693 .expect("test stream receiver should be open");
4694 }
4695 drop(sender);
4696 w.stream_reader = Some(StreamReader {
4697 receiver,
4698 finished: false,
4699 });
4700 w.settings_changed = false;
4701
4702 assert!(w.load_more_items(STREAM_LOAD_BATCH));
4703
4704 assert!(w.stream_reader.is_none());
4705 assert!(w.settings_changed);
4706 assert_eq!(w.generate_footer(), "[1-3 of 3]");
4707 }
4708
4709 #[test]
4710 fn streamed_table_width_growth_marks_header_dirty() {
4711 let span = nu_protocol::Span::test_data();
4712 let columns = vec!["name".to_string()];
4713 let items = vec![SelectItem {
4714 name: "sh".to_string(),
4715 cells: Some(vec![("sh".to_string(), TextStyle::default())]),
4716 value: Value::nothing(span),
4717 }];
4718 let table_layout = InputList::calculate_table_layout(&columns, &items);
4719 let (sender, receiver) = mpsc::sync_channel(1);
4720 sender
4721 .send(StreamMessage::Item(Value::string(
4722 "long-streamed-name",
4723 span,
4724 )))
4725 .expect("test stream receiver should be open");
4726 drop(sender);
4727
4728 let mut w = SelectWidget::new(
4729 SelectMode::Single,
4730 None,
4731 items,
4732 InputListConfig::default(),
4733 Some(table_layout),
4734 false,
4735 StreamState {
4736 stream_reader: Some(StreamReader {
4737 receiver,
4738 finished: false,
4739 }),
4740 item_generator: Some(Box::new(move |value| SelectItem {
4741 name: "long-streamed-name".to_string(),
4742 cells: Some(vec![(
4743 "long-streamed-name".to_string(),
4744 TextStyle::default(),
4745 )]),
4746 value,
4747 })),
4748 },
4749 );
4750
4751 assert!(w.load_more_items(STREAM_LOAD_BATCH));
4752 assert!(w.table_layout_changed);
4753 }
4754
4755 #[test]
4756 fn end_navigation_uses_available_streamed_rows() {
4757 let span = nu_protocol::Span::test_data();
4758 let mut w = make_widget(&["initial"]);
4759 let (sender, receiver) = mpsc::sync_channel(8);
4760 for i in 0..5 {
4761 sender
4762 .send(StreamMessage::Item(Value::string(format!("row-{i}"), span)))
4763 .expect("test stream receiver should be open");
4764 }
4765 drop(sender);
4766 w.stream_reader = Some(StreamReader {
4767 receiver,
4768 finished: false,
4769 });
4770
4771 w.navigate_end();
4772
4773 assert_eq!(w.items.len(), 6);
4774 assert_eq!(w.cursor, 5);
4775 assert!(w.follow_stream_to_end);
4776 }
4777
4778 #[test]
4779 fn test_examples() -> nu_test_support::Result {
4780 nu_test_support::test().examples(InputList)
4781 }
4782}