Skip to main content

nu_command/platform/input/
list.rs

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