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