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