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 = (self.term_width as usize)
874            .checked_div(sep_width)
875            .unwrap_or(self.term_width as usize);
876        self.separator_line = self.config.separator_char.repeat(repeat_count);
877    }
878
879    /// Get the styled prompt marker string (for fuzzy mode filter line)
880    fn prompt_marker(&self) -> String {
881        self.config
882            .prompt_marker
883            .paint(&self.config.prompt_marker_text)
884            .to_string()
885    }
886
887    /// Get the width of the prompt marker in characters
888    fn prompt_marker_width(&self) -> usize {
889        self.config.prompt_marker_text.width()
890    }
891
892    /// Position terminal cursor within the fuzzy filter text
893    fn position_fuzzy_cursor(&self, stderr: &mut Stderr) -> io::Result<()> {
894        let text_before_cursor = &self.filter_text[..self.filter_cursor];
895        let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
896        execute!(stderr, MoveToColumn(cursor_col as u16))
897    }
898
899    /// Get the styled selection marker string (for active items)
900    fn selected_marker(&self) -> &str {
901        &self.selected_marker_cached
902    }
903
904    /// Check if we're in table mode
905    fn is_table_mode(&self) -> bool {
906        self.table_layout.is_some()
907    }
908
909    /// Check if we're in a multi-selection mode
910    fn is_multi_mode(&self) -> bool {
911        self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
912    }
913
914    /// Check if we're in a fuzzy mode
915    fn is_fuzzy_mode(&self) -> bool {
916        self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
917    }
918
919    /// Try to convert a value into a SelectItem via the configured generator
920    fn make_select_item(&mut self, value: Value) -> SelectItem {
921        if let Some(r#gen) = self.item_generator.as_mut() {
922            r#gen(value)
923        } else {
924            // Defensive fallback for test-only widget construction paths.
925            // In normal command execution the generator is always present whenever streaming is
926            // active, so this branch should remain cold.
927            SelectItem {
928                name: value.to_expanded_string(", ", &Config::default()),
929                cells: None,
930                value,
931            }
932        }
933    }
934
935    /// Load more items from upstream stream when near the end of the loaded list.
936    fn load_more_items(&mut self, count: usize) {
937        if self.pending_stream.is_none() {
938            return;
939        }
940
941        let mut loaded = 0;
942        while loaded < count {
943            let next = self
944                .pending_stream
945                .as_mut()
946                .and_then(|stream| stream.next_value());
947            if let Some(val) = next {
948                let item = self.make_select_item(val);
949                self.items.push(item);
950                loaded += 1;
951            } else {
952                self.pending_stream = None;
953                break;
954            }
955        }
956
957        if loaded > 0 {
958            // Table widths may have expanded as more rows are loaded
959            if self.is_table_mode()
960                && let Some(layout) = &mut self.table_layout
961            {
962                *layout = InputList::calculate_table_layout(&layout.columns, &self.items);
963            }
964
965            let start_len = self.filtered_indices.len();
966            if self.filter_text.is_empty() && !self.refined {
967                self.filtered_indices.extend(start_len..self.items.len());
968            } else {
969                self.update_filter();
970            }
971        }
972    }
973
974    /// Drain the remaining pending stream into `self.items` without running `update_filter`.
975    ///
976    /// Used by `update_filter` so that fuzzy searches always operate on the complete dataset,
977    /// even when items were not yet loaded by the scroll-based prefetch heuristic.
978    fn drain_pending_stream(&mut self) {
979        if self.pending_stream.is_none() {
980            return;
981        }
982        while let Some(val) = self.pending_stream.as_mut().and_then(|s| s.next_value()) {
983            let item = self.make_select_item(val);
984            self.items.push(item);
985        }
986        self.pending_stream = None;
987        if self.is_table_mode()
988            && let Some(layout) = &mut self.table_layout
989        {
990            *layout = InputList::calculate_table_layout(&layout.columns, &self.items);
991        }
992    }
993
994    /// Ensure we have enough items to show around the cursor; stream if needed.
995    fn maybe_load_more(&mut self) {
996        if self.pending_stream.is_none() {
997            return;
998        }
999
1000        // Prefetch a little before hitting the end of loaded rows to avoid visible refill latency.
1001        let threshold = self.scroll_offset + self.visible_height as usize + STREAM_PREFETCH_MARGIN;
1002        if threshold >= self.items.len() {
1003            self.load_more_items(STREAM_LOAD_BATCH);
1004        }
1005    }
1006
1007    /// Cycle case sensitivity: Smart -> CaseSensitive -> CaseInsensitive -> Smart
1008    fn toggle_case_sensitivity(&mut self) {
1009        self.config.case_sensitivity = match self.config.case_sensitivity {
1010            CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
1011            CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
1012            CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
1013        };
1014        self.rebuild_matcher();
1015        // Re-run filter with new matcher
1016        if !self.filter_text.is_empty() {
1017            self.update_filter();
1018        }
1019        self.settings_changed = true;
1020    }
1021
1022    /// Toggle per-column matching (only meaningful in table mode)
1023    fn toggle_per_column(&mut self) {
1024        if self.is_table_mode() {
1025            self.per_column = !self.per_column;
1026            // Re-run filter with new matching mode
1027            if !self.filter_text.is_empty() {
1028                self.update_filter();
1029            }
1030            self.settings_changed = true;
1031        }
1032    }
1033
1034    /// Rebuild the fuzzy matcher with current case sensitivity setting
1035    fn rebuild_matcher(&mut self) {
1036        self.matcher = match self.config.case_sensitivity {
1037            CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
1038            CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
1039            CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
1040        };
1041    }
1042
1043    /// Get the settings indicator string for the footer (fuzzy modes only)
1044    /// Returns empty string if not in fuzzy mode, otherwise returns " [settings]"
1045    fn settings_indicator(&self) -> String {
1046        if !self.is_fuzzy_mode() {
1047            return String::new();
1048        }
1049
1050        let case_str = match self.config.case_sensitivity {
1051            CaseSensitivity::Smart => "smart",
1052            CaseSensitivity::CaseSensitive => "CASE",
1053            CaseSensitivity::CaseInsensitive => "nocase",
1054        };
1055
1056        if self.is_table_mode() && self.per_column {
1057            format!(" [{} col]", case_str)
1058        } else {
1059            format!(" [{}]", case_str)
1060        }
1061    }
1062
1063    /// Generate the footer string, truncating if necessary to fit terminal width
1064    fn generate_footer(&self) -> String {
1065        let total_count = self.current_list_len();
1066        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
1067        let settings = self.settings_indicator();
1068
1069        let position_part = if self.is_multi_mode() {
1070            format!(
1071                "[{}-{} of {}, {} selected]",
1072                self.scroll_offset + 1,
1073                end.min(total_count),
1074                total_count,
1075                self.selected.len()
1076            )
1077        } else {
1078            format!(
1079                "[{}-{} of {}]",
1080                self.scroll_offset + 1,
1081                end.min(total_count),
1082                total_count
1083            )
1084        };
1085
1086        let full_footer = format!("{}{}", position_part, settings);
1087
1088        // Truncate if footer exceeds terminal width
1089        let max_width = self.term_width as usize;
1090        if full_footer.width() <= max_width {
1091            full_footer
1092        } else if max_width <= 3 {
1093            // Too narrow, just show ellipsis
1094            "…".to_string()
1095        } else {
1096            // Try to fit position part + truncated settings, or just position part
1097            if position_part.width() <= max_width {
1098                // Position fits, truncate or drop settings
1099                let remaining = max_width - position_part.width();
1100                if remaining <= 4 {
1101                    // Not enough room for meaningful settings, just show position
1102                    position_part
1103                } else {
1104                    // Truncate settings portion
1105                    let target_width = remaining - 2; // Reserve space for "…]"
1106                    let mut current_width = 0;
1107                    let mut end_pos = 0;
1108
1109                    // Skip the leading " [" in settings
1110                    for (byte_pos, c) in settings.char_indices().skip(2) {
1111                        if c == ']' {
1112                            break;
1113                        }
1114                        let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1115                        if current_width + char_width > target_width {
1116                            break;
1117                        }
1118                        end_pos = byte_pos + c.len_utf8();
1119                        current_width += char_width;
1120                    }
1121                    if end_pos > 2 {
1122                        format!("{} [{}…]", position_part, &settings[2..end_pos])
1123                    } else {
1124                        position_part
1125                    }
1126                }
1127            } else {
1128                // Even position part doesn't fit, truncate it
1129                let target_width = max_width - 2; // Reserve space for "…]"
1130                let mut current_width = 0;
1131                let mut end_pos = 0;
1132
1133                for (byte_pos, c) in position_part.char_indices() {
1134                    if c == ']' {
1135                        break;
1136                    }
1137                    let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1138                    if current_width + char_width > target_width {
1139                        break;
1140                    }
1141                    end_pos = byte_pos + c.len_utf8();
1142                    current_width += char_width;
1143                }
1144                format!("{}…]", &position_part[..end_pos])
1145            }
1146        }
1147    }
1148
1149    /// Check if footer should be shown
1150    /// Footer is always shown in fuzzy modes (for settings display), multi modes (for selection count),
1151    /// or when the list is longer than visible height (for scroll position)
1152    fn has_footer(&self) -> bool {
1153        self.config.show_footer
1154            && (self.is_fuzzy_mode()
1155                || self.is_multi_mode()
1156                || self.current_list_len() > self.visible_height as usize)
1157    }
1158
1159    /// Render just the footer text at current cursor position (for optimized updates)
1160    fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
1161        let indicator = self.generate_footer();
1162        execute!(
1163            stderr,
1164            MoveToColumn(0),
1165            Print(self.config.footer.paint(&indicator)),
1166            Clear(ClearType::UntilNewLine),
1167        )
1168    }
1169
1170    /// Get the row prefix width (selection marker + optional checkbox)
1171    fn row_prefix_width(&self) -> usize {
1172        match self.mode {
1173            SelectMode::Multi | SelectMode::FuzzyMulti => 6, // "> [x] " or "  [ ] "
1174            _ => 2,                                          // "> " or "  "
1175        }
1176    }
1177
1178    /// Get the table column separator string (e.g., " │ ")
1179    fn table_column_separator(&self) -> String {
1180        format!(" {} ", self.config.table_column_separator)
1181    }
1182
1183    /// Get the width of the table column separator (char width + 2 for surrounding spaces)
1184    fn table_column_separator_width(&self) -> usize {
1185        UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
1186    }
1187
1188    /// Calculate how many columns fit starting from horizontal_offset
1189    /// Returns (number of columns that fit, whether there are more columns to the right)
1190    /// Uses cached value if available (cache is updated by update_table_layout)
1191    fn calculate_visible_columns(&self) -> (usize, bool) {
1192        // Use cache if available (populated by update_table_layout)
1193        if let Some(cached) = self.visible_columns_cache {
1194            return cached;
1195        }
1196
1197        // Fallback to computation (should rarely happen after first render)
1198        let Some(layout) = &self.table_layout else {
1199            return (0, false);
1200        };
1201
1202        Self::calculate_visible_columns_for_layout(
1203            layout,
1204            self.horizontal_offset,
1205            self.term_width as usize,
1206            self.row_prefix_width(),
1207            self.table_column_separator_width(),
1208        )
1209    }
1210
1211    /// Static helper to calculate visible columns without borrowing self
1212    fn calculate_visible_columns_for_layout(
1213        layout: &TableLayout,
1214        horizontal_offset: usize,
1215        term_width: usize,
1216        prefix_width: usize,
1217        separator_width: usize,
1218    ) -> (usize, bool) {
1219        // Account for scroll indicators: "… │ " on left (1 + separator_width)
1220        let scroll_indicator_width = if horizontal_offset > 0 {
1221            1 + separator_width
1222        } else {
1223            0
1224        };
1225        let available = term_width
1226            .saturating_sub(prefix_width)
1227            .saturating_sub(scroll_indicator_width);
1228
1229        let mut used_width = 0;
1230        let mut cols_fit = 0;
1231
1232        for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1233            // Add separator width for all but first visible column
1234            let sep_width = if i > horizontal_offset {
1235                separator_width
1236            } else {
1237                0
1238            };
1239            let needed = col_width + sep_width;
1240
1241            // Reserve space for right scroll indicator if not the last column: " │ …" (separator_width + 1)
1242            let reserve_right = if i + 1 < layout.col_widths.len() {
1243                separator_width + 1
1244            } else {
1245                0
1246            };
1247
1248            if used_width + needed + reserve_right <= available {
1249                used_width += needed;
1250                cols_fit += 1;
1251            } else {
1252                break;
1253            }
1254        }
1255
1256        let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1257        (cols_fit.max(1), has_more_right) // Always show at least 1 column
1258    }
1259
1260    /// Update table layout's truncated_cols based on current terminal width
1261    /// Also updates the visible_columns_cache
1262    fn update_table_layout(&mut self) {
1263        let prefix_width = self.row_prefix_width();
1264        let term_width = self.term_width as usize;
1265        let horizontal_offset = self.horizontal_offset;
1266        let separator_width = self.table_column_separator_width();
1267
1268        if let Some(layout) = &mut self.table_layout {
1269            let result = Self::calculate_visible_columns_for_layout(
1270                layout,
1271                horizontal_offset,
1272                term_width,
1273                prefix_width,
1274                separator_width,
1275            );
1276            layout.truncated_cols = result.0;
1277            self.visible_columns_cache = Some(result);
1278        } else {
1279            self.visible_columns_cache = Some((0, false));
1280        }
1281    }
1282
1283    /// Header lines for fuzzy modes (prompt + filter + separator + table header)
1284    fn fuzzy_header_lines(&self) -> u16 {
1285        let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1286        if self.config.show_separator {
1287            header_lines += 1;
1288        }
1289        if self.is_table_mode() {
1290            header_lines += 2;
1291        }
1292        header_lines
1293    }
1294
1295    /// Filter line row index for fuzzy modes
1296    fn fuzzy_filter_row(&self) -> u16 {
1297        if self.prompt.is_some() { 1 } else { 0 }
1298    }
1299
1300    /// Update terminal dimensions and recalculate visible height
1301    fn update_term_size(&mut self, width: u16, height: u16) {
1302        // Subtract 1 to avoid issues with writing to the very last terminal column
1303        let new_width = width.saturating_sub(1);
1304        let width_changed = self.term_width != new_width;
1305        self.term_width = new_width;
1306
1307        // Track width change for full redraw
1308        if width_changed {
1309            self.width_changed = true;
1310        }
1311
1312        // Regenerate separator line if width changed
1313        if width_changed && self.config.show_separator {
1314            self.generate_separator_line();
1315        }
1316
1317        // Update table layout if width changed
1318        if width_changed {
1319            self.update_table_layout();
1320        }
1321
1322        // Recalculate visible height
1323        let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1324        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1325            reserved += 1; // filter line
1326            if self.config.show_separator {
1327                reserved += 1; // separator line
1328            }
1329        }
1330        if self.is_table_mode() {
1331            reserved += 2; // table header + header separator
1332        }
1333        if self.config.show_footer {
1334            reserved += 1; // footer
1335        }
1336        self.visible_height = height.saturating_sub(reserved).max(1);
1337    }
1338
1339    fn run(&mut self) -> io::Result<InteractMode> {
1340        let mut stderr = io::stderr();
1341
1342        enable_raw_mode()?;
1343        scopeguard::defer! {
1344            let _ = disable_raw_mode();
1345        }
1346
1347        // Only hide cursor for non-fuzzy modes (fuzzy modes need visible cursor for text input)
1348        if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1349            execute!(stderr, Hide)?;
1350        }
1351        scopeguard::defer! {
1352            let _ = execute!(io::stderr(), Show);
1353        }
1354
1355        // Get initial terminal size and cache it
1356        let (term_width, term_height) = terminal::size()?;
1357        self.update_term_size(term_width, term_height);
1358
1359        self.render(&mut stderr)?;
1360
1361        loop {
1362            if event::poll(std::time::Duration::from_millis(100))? {
1363                match event::read()? {
1364                    Event::Key(key_event) => {
1365                        match self.handle_key(key_event) {
1366                            KeyAction::Continue => {}
1367                            KeyAction::Cancel => {
1368                                self.clear_display(&mut stderr)?;
1369                                return Ok(match self.mode {
1370                                    SelectMode::Multi => InteractMode::Multi(None),
1371                                    _ => InteractMode::Single(None),
1372                                });
1373                            }
1374                            KeyAction::Confirm => {
1375                                self.clear_display(&mut stderr)?;
1376                                return Ok(self.get_result());
1377                            }
1378                        }
1379                        self.render(&mut stderr)?;
1380                    }
1381                    Event::Resize(width, height) => {
1382                        // Clear old content first - terminal reflow may have corrupted positions
1383                        self.clear_display(&mut stderr)?;
1384                        self.update_term_size(width, height);
1385                        // Force full redraw on resize
1386                        self.first_render = true;
1387                        self.render(&mut stderr)?;
1388                    }
1389                    _ => {}
1390                }
1391            }
1392        }
1393    }
1394
1395    fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1396        // Only handle key press and repeat events, not release
1397        // This is important on Windows where crossterm sends press, repeat, and release events
1398        // We need Repeat events for key repeat to work when holding down a key on Windows
1399        if key.kind == KeyEventKind::Release {
1400            return KeyAction::Continue;
1401        }
1402
1403        // Ctrl+C always cancels
1404        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1405            return KeyAction::Cancel;
1406        }
1407
1408        match self.mode {
1409            SelectMode::Single => self.handle_single_key(key),
1410            SelectMode::Multi => self.handle_multi_key(key),
1411            SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1412            SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1413        }
1414    }
1415
1416    fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1417        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1418
1419        match key.code {
1420            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1421            KeyCode::Enter => KeyAction::Confirm,
1422            KeyCode::Char('p' | 'P') if ctrl => {
1423                self.navigate_up();
1424                KeyAction::Continue
1425            }
1426            KeyCode::Up | KeyCode::Char('k') => {
1427                self.navigate_up();
1428                KeyAction::Continue
1429            }
1430            KeyCode::Char('n' | 'N') if ctrl => {
1431                self.navigate_down();
1432                KeyAction::Continue
1433            }
1434            KeyCode::Down | KeyCode::Char('j') => {
1435                self.navigate_down();
1436                KeyAction::Continue
1437            }
1438            KeyCode::Left | KeyCode::Char('h') => {
1439                self.scroll_columns_left();
1440                KeyAction::Continue
1441            }
1442            KeyCode::Right | KeyCode::Char('l') => {
1443                self.scroll_columns_right();
1444                KeyAction::Continue
1445            }
1446            KeyCode::Home => {
1447                self.navigate_home();
1448                KeyAction::Continue
1449            }
1450            KeyCode::End => {
1451                self.navigate_end();
1452                KeyAction::Continue
1453            }
1454            KeyCode::PageUp => {
1455                self.navigate_page_up();
1456                KeyAction::Continue
1457            }
1458            KeyCode::PageDown => {
1459                self.navigate_page_down();
1460                KeyAction::Continue
1461            }
1462            KeyCode::Tab => {
1463                self.navigate_down();
1464                KeyAction::Continue
1465            }
1466            KeyCode::BackTab => {
1467                self.navigate_up();
1468                KeyAction::Continue
1469            }
1470            _ => KeyAction::Continue,
1471        }
1472    }
1473
1474    fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1475        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1476
1477        match key.code {
1478            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1479            KeyCode::Enter => KeyAction::Confirm,
1480            // Ctrl+R: Refine list to only show selected items
1481            KeyCode::Char('r' | 'R') if ctrl => {
1482                self.refine_list();
1483                KeyAction::Continue
1484            }
1485            KeyCode::Char('p' | 'P') if ctrl => {
1486                self.navigate_up();
1487                KeyAction::Continue
1488            }
1489            KeyCode::Up | KeyCode::Char('k') => {
1490                self.navigate_up();
1491                KeyAction::Continue
1492            }
1493            KeyCode::Char('n' | 'N') if ctrl => {
1494                self.navigate_down();
1495                KeyAction::Continue
1496            }
1497            KeyCode::Down | KeyCode::Char('j') => {
1498                self.navigate_down();
1499                KeyAction::Continue
1500            }
1501            KeyCode::Left | KeyCode::Char('h') => {
1502                self.scroll_columns_left();
1503                KeyAction::Continue
1504            }
1505            KeyCode::Right | KeyCode::Char('l') => {
1506                self.scroll_columns_right();
1507                KeyAction::Continue
1508            }
1509            KeyCode::Char(' ') => {
1510                self.toggle_current();
1511                KeyAction::Continue
1512            }
1513            KeyCode::Char('a') => {
1514                self.toggle_all();
1515                KeyAction::Continue
1516            }
1517            KeyCode::Home => {
1518                self.navigate_home();
1519                KeyAction::Continue
1520            }
1521            KeyCode::End => {
1522                self.navigate_end();
1523                KeyAction::Continue
1524            }
1525            KeyCode::PageUp => {
1526                self.navigate_page_up();
1527                KeyAction::Continue
1528            }
1529            KeyCode::PageDown => {
1530                self.navigate_page_down();
1531                KeyAction::Continue
1532            }
1533            KeyCode::Tab => {
1534                self.toggle_current();
1535                self.navigate_down();
1536                KeyAction::Continue
1537            }
1538            KeyCode::BackTab => {
1539                self.navigate_up();
1540                self.toggle_current();
1541                KeyAction::Continue
1542            }
1543            _ => KeyAction::Continue,
1544        }
1545    }
1546
1547    fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1548        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1549        let alt = key.modifiers.contains(KeyModifiers::ALT);
1550        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1551
1552        match key.code {
1553            KeyCode::Esc => KeyAction::Cancel,
1554            KeyCode::Enter => KeyAction::Confirm,
1555
1556            // Tab: navigate down (mirrors single/multi mode behavior)
1557            KeyCode::Tab | KeyCode::Char('\t') => {
1558                self.navigate_down();
1559                KeyAction::Continue
1560            }
1561            KeyCode::BackTab => {
1562                self.navigate_up();
1563                KeyAction::Continue
1564            }
1565
1566            // List navigation
1567            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1568                self.navigate_up();
1569                KeyAction::Continue
1570            }
1571            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1572                self.navigate_down();
1573                KeyAction::Continue
1574            }
1575            KeyCode::Up => {
1576                self.navigate_up();
1577                KeyAction::Continue
1578            }
1579            KeyCode::Down => {
1580                self.navigate_down();
1581                KeyAction::Continue
1582            }
1583
1584            // Horizontal scrolling for table mode (Shift+Left/Right)
1585            KeyCode::Left if shift => {
1586                self.scroll_columns_left();
1587                KeyAction::Continue
1588            }
1589            KeyCode::Right if shift => {
1590                self.scroll_columns_right();
1591                KeyAction::Continue
1592            }
1593
1594            // Readline: Cursor movement
1595            KeyCode::Char('a' | 'A') if ctrl => {
1596                // Ctrl-A: Move to beginning of line
1597                self.filter_cursor = 0;
1598                self.filter_text_changed = true;
1599                KeyAction::Continue
1600            }
1601            KeyCode::Char('e' | 'E') if ctrl => {
1602                // Ctrl-E: Move to end of line
1603                self.filter_cursor = self.filter_text.len();
1604                self.filter_text_changed = true;
1605                KeyAction::Continue
1606            }
1607            KeyCode::Char('b' | 'B') if ctrl => {
1608                // Ctrl-B: Move back one character
1609                self.move_filter_cursor_left();
1610                self.filter_text_changed = true;
1611                KeyAction::Continue
1612            }
1613            KeyCode::Char('f' | 'F') if ctrl => {
1614                // Ctrl-F: Move forward one character
1615                self.move_filter_cursor_right();
1616                self.filter_text_changed = true;
1617                KeyAction::Continue
1618            }
1619            KeyCode::Char('b' | 'B') if alt => {
1620                // Alt-B: Move back one word
1621                self.move_filter_cursor_word_left();
1622                self.filter_text_changed = true;
1623                KeyAction::Continue
1624            }
1625            KeyCode::Char('f' | 'F') if alt => {
1626                // Alt-F: Move forward one word
1627                self.move_filter_cursor_word_right();
1628                self.filter_text_changed = true;
1629                KeyAction::Continue
1630            }
1631            // Settings toggles
1632            KeyCode::Char('c' | 'C') if alt => {
1633                // Alt-C: Toggle case sensitivity
1634                self.toggle_case_sensitivity();
1635                KeyAction::Continue
1636            }
1637            KeyCode::Char('p' | 'P') if alt => {
1638                // Alt-P: Toggle per-column matching (table mode only)
1639                self.toggle_per_column();
1640                KeyAction::Continue
1641            }
1642            KeyCode::Left if ctrl || alt => {
1643                // Ctrl/Alt-Left: Move back one word
1644                self.move_filter_cursor_word_left();
1645                self.filter_text_changed = true;
1646                KeyAction::Continue
1647            }
1648            KeyCode::Right if ctrl || alt => {
1649                // Ctrl/Alt-Right: Move forward one word
1650                self.move_filter_cursor_word_right();
1651                self.filter_text_changed = true;
1652                KeyAction::Continue
1653            }
1654            KeyCode::Left => {
1655                self.move_filter_cursor_left();
1656                self.filter_text_changed = true;
1657                KeyAction::Continue
1658            }
1659            KeyCode::Right => {
1660                self.move_filter_cursor_right();
1661                self.filter_text_changed = true;
1662                KeyAction::Continue
1663            }
1664
1665            // Readline: Deletion
1666            KeyCode::Char('u' | 'U') if ctrl => {
1667                // Ctrl-U: Kill to beginning of line
1668                self.filter_text.drain(..self.filter_cursor);
1669                self.filter_cursor = 0;
1670                self.update_filter();
1671                KeyAction::Continue
1672            }
1673            KeyCode::Char('k' | 'K') if ctrl => {
1674                // Ctrl-K: Kill to end of line
1675                self.filter_text.truncate(self.filter_cursor);
1676                self.update_filter();
1677                KeyAction::Continue
1678            }
1679            KeyCode::Char('d' | 'D') if ctrl => {
1680                // Ctrl-D: Delete character at cursor
1681                if self.filter_cursor < self.filter_text.len() {
1682                    self.filter_text.remove(self.filter_cursor);
1683                    self.update_filter();
1684                }
1685                KeyAction::Continue
1686            }
1687            KeyCode::Delete => {
1688                // Delete: Delete character at cursor
1689                if self.filter_cursor < self.filter_text.len() {
1690                    self.filter_text.remove(self.filter_cursor);
1691                    self.update_filter();
1692                }
1693                KeyAction::Continue
1694            }
1695            KeyCode::Char('d' | 'D') if alt => {
1696                // Alt-D: Delete word forward
1697                self.delete_word_forwards();
1698                self.update_filter();
1699                KeyAction::Continue
1700            }
1701            // Ctrl-W or Ctrl-H (Ctrl-Backspace) to delete previous word
1702            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1703                self.delete_word_backwards();
1704                self.update_filter();
1705                KeyAction::Continue
1706            }
1707            // Alt-Backspace: delete previous word
1708            KeyCode::Backspace if alt => {
1709                self.delete_word_backwards();
1710                self.update_filter();
1711                KeyAction::Continue
1712            }
1713            KeyCode::Backspace => {
1714                // Delete character before cursor (handle UTF-8)
1715                if self.filter_cursor > 0 {
1716                    // Find previous char boundary
1717                    let mut new_pos = self.filter_cursor - 1;
1718                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1719                        new_pos -= 1;
1720                    }
1721                    self.filter_cursor = new_pos;
1722                    self.filter_text.remove(self.filter_cursor);
1723                    self.update_filter();
1724                }
1725                KeyAction::Continue
1726            }
1727            // Ctrl-T: Transpose characters
1728            KeyCode::Char('t' | 'T') if ctrl => {
1729                let old_text = self.filter_text.clone();
1730                self.transpose_chars();
1731                if self.filter_text != old_text {
1732                    self.update_filter();
1733                }
1734                KeyAction::Continue
1735            }
1736
1737            // Character input
1738            KeyCode::Char(c) => {
1739                self.filter_text.insert(self.filter_cursor, c);
1740                self.filter_cursor += c.len_utf8();
1741                self.update_filter();
1742                KeyAction::Continue
1743            }
1744
1745            // List navigation with Home/End/PageUp/PageDown
1746            KeyCode::Home => {
1747                self.navigate_home();
1748                KeyAction::Continue
1749            }
1750            KeyCode::End => {
1751                self.navigate_end();
1752                KeyAction::Continue
1753            }
1754            KeyCode::PageUp => {
1755                self.navigate_page_up();
1756                KeyAction::Continue
1757            }
1758            KeyCode::PageDown => {
1759                self.navigate_page_down();
1760                KeyAction::Continue
1761            }
1762            _ => KeyAction::Continue,
1763        }
1764    }
1765
1766    fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1767        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1768        let alt = key.modifiers.contains(KeyModifiers::ALT);
1769        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1770
1771        match key.code {
1772            KeyCode::Esc => KeyAction::Cancel,
1773            KeyCode::Enter => KeyAction::Confirm,
1774
1775            // Ctrl+R: Refine list to only show selected items
1776            KeyCode::Char('r' | 'R') if ctrl => {
1777                self.refine_list();
1778                KeyAction::Continue
1779            }
1780
1781            // Tab: Toggle selection of current item and move down
1782            // Note: Some terminals may report Tab as Char('\t')
1783            KeyCode::Tab | KeyCode::Char('\t') => {
1784                self.toggle_current_fuzzy();
1785                self.navigate_down();
1786                KeyAction::Continue
1787            }
1788
1789            // Shift-Tab: Toggle selection and move up
1790            KeyCode::BackTab => {
1791                self.navigate_up();
1792                self.toggle_current_fuzzy();
1793                KeyAction::Continue
1794            }
1795
1796            // List navigation
1797            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1798                self.navigate_up();
1799                KeyAction::Continue
1800            }
1801            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1802                self.navigate_down();
1803                KeyAction::Continue
1804            }
1805            KeyCode::Up => {
1806                self.navigate_up();
1807                KeyAction::Continue
1808            }
1809            KeyCode::Down => {
1810                self.navigate_down();
1811                KeyAction::Continue
1812            }
1813
1814            // Horizontal scrolling for table mode (Shift+Left/Right)
1815            KeyCode::Left if shift => {
1816                self.scroll_columns_left();
1817                KeyAction::Continue
1818            }
1819            KeyCode::Right if shift => {
1820                self.scroll_columns_right();
1821                KeyAction::Continue
1822            }
1823
1824            // Readline: Cursor movement
1825            KeyCode::Char('a' | 'A') if ctrl => {
1826                self.filter_cursor = 0;
1827                self.filter_text_changed = true;
1828                KeyAction::Continue
1829            }
1830            KeyCode::Char('e' | 'E') if ctrl => {
1831                self.filter_cursor = self.filter_text.len();
1832                self.filter_text_changed = true;
1833                KeyAction::Continue
1834            }
1835            KeyCode::Char('b' | 'B') if ctrl => {
1836                self.move_filter_cursor_left();
1837                self.filter_text_changed = true;
1838                KeyAction::Continue
1839            }
1840            KeyCode::Char('f' | 'F') if ctrl => {
1841                self.move_filter_cursor_right();
1842                self.filter_text_changed = true;
1843                KeyAction::Continue
1844            }
1845            KeyCode::Char('b' | 'B') if alt => {
1846                self.move_filter_cursor_word_left();
1847                self.filter_text_changed = true;
1848                KeyAction::Continue
1849            }
1850            KeyCode::Char('f' | 'F') if alt => {
1851                self.move_filter_cursor_word_right();
1852                self.filter_text_changed = true;
1853                KeyAction::Continue
1854            }
1855            // Settings toggles
1856            KeyCode::Char('c' | 'C') if alt => {
1857                // Alt-C: Toggle case sensitivity
1858                self.toggle_case_sensitivity();
1859                KeyAction::Continue
1860            }
1861            KeyCode::Char('p' | 'P') if alt => {
1862                // Alt-P: Toggle per-column matching (table mode only)
1863                self.toggle_per_column();
1864                KeyAction::Continue
1865            }
1866            KeyCode::Left if ctrl || alt => {
1867                self.move_filter_cursor_word_left();
1868                self.filter_text_changed = true;
1869                KeyAction::Continue
1870            }
1871            KeyCode::Right if ctrl || alt => {
1872                self.move_filter_cursor_word_right();
1873                self.filter_text_changed = true;
1874                KeyAction::Continue
1875            }
1876            KeyCode::Left => {
1877                self.move_filter_cursor_left();
1878                self.filter_text_changed = true;
1879                KeyAction::Continue
1880            }
1881            KeyCode::Right => {
1882                self.move_filter_cursor_right();
1883                self.filter_text_changed = true;
1884                KeyAction::Continue
1885            }
1886
1887            // Readline: Deletion
1888            KeyCode::Char('u' | 'U') if ctrl => {
1889                self.filter_text.drain(..self.filter_cursor);
1890                self.filter_cursor = 0;
1891                self.update_filter();
1892                KeyAction::Continue
1893            }
1894            KeyCode::Char('k' | 'K') if ctrl => {
1895                self.filter_text.truncate(self.filter_cursor);
1896                self.update_filter();
1897                KeyAction::Continue
1898            }
1899            KeyCode::Char('d' | 'D') if ctrl => {
1900                if self.filter_cursor < self.filter_text.len() {
1901                    self.filter_text.remove(self.filter_cursor);
1902                    self.update_filter();
1903                }
1904                KeyAction::Continue
1905            }
1906            KeyCode::Delete => {
1907                if self.filter_cursor < self.filter_text.len() {
1908                    self.filter_text.remove(self.filter_cursor);
1909                    self.update_filter();
1910                }
1911                KeyAction::Continue
1912            }
1913            KeyCode::Char('d' | 'D') if alt => {
1914                self.delete_word_forwards();
1915                self.update_filter();
1916                KeyAction::Continue
1917            }
1918            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1919                self.delete_word_backwards();
1920                self.update_filter();
1921                KeyAction::Continue
1922            }
1923            KeyCode::Backspace if alt => {
1924                self.delete_word_backwards();
1925                self.update_filter();
1926                KeyAction::Continue
1927            }
1928            KeyCode::Backspace => {
1929                if self.filter_cursor > 0 {
1930                    let mut new_pos = self.filter_cursor - 1;
1931                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1932                        new_pos -= 1;
1933                    }
1934                    self.filter_cursor = new_pos;
1935                    self.filter_text.remove(self.filter_cursor);
1936                    self.update_filter();
1937                }
1938                KeyAction::Continue
1939            }
1940            KeyCode::Char('t' | 'T') if ctrl => {
1941                let old_text = self.filter_text.clone();
1942                self.transpose_chars();
1943                if self.filter_text != old_text {
1944                    self.update_filter();
1945                }
1946                KeyAction::Continue
1947            }
1948
1949            // Alt-A: Toggle all filtered items in fuzzy multi mode
1950            KeyCode::Char('a' | 'A') if alt => {
1951                self.toggle_all_fuzzy();
1952                KeyAction::Continue
1953            }
1954
1955            // Character input
1956            KeyCode::Char(c) => {
1957                self.filter_text.insert(self.filter_cursor, c);
1958                self.filter_cursor += c.len_utf8();
1959                self.update_filter();
1960                KeyAction::Continue
1961            }
1962
1963            // List navigation with Home/End/PageUp/PageDown
1964            KeyCode::Home => {
1965                self.navigate_home();
1966                KeyAction::Continue
1967            }
1968            KeyCode::End => {
1969                self.navigate_end();
1970                KeyAction::Continue
1971            }
1972            KeyCode::PageUp => {
1973                self.navigate_page_up();
1974                KeyAction::Continue
1975            }
1976            KeyCode::PageDown => {
1977                self.navigate_page_down();
1978                KeyAction::Continue
1979            }
1980            _ => KeyAction::Continue,
1981        }
1982    }
1983
1984    /// Move cursor up with wrapping
1985    fn navigate_up(&mut self) {
1986        let list_len = self.current_list_len();
1987        if self.cursor > 0 {
1988            self.cursor -= 1;
1989            self.adjust_scroll_up();
1990        } else if list_len > 0 {
1991            // Wrap to bottom: drain the full stream first so we land on the true last item.
1992            if self.pending_stream.is_some() {
1993                self.drain_pending_stream();
1994                if !self.filter_text.is_empty() {
1995                    self.update_filter();
1996                } else if !self.refined {
1997                    self.filtered_indices = (0..self.items.len()).collect();
1998                }
1999            }
2000            let list_len = self.current_list_len();
2001            self.cursor = list_len.saturating_sub(1);
2002            self.adjust_scroll_down();
2003        }
2004    }
2005
2006    /// Move cursor down with wrapping
2007    fn navigate_down(&mut self) {
2008        self.maybe_load_more();
2009
2010        let list_len = self.current_list_len();
2011        if self.cursor + 1 < list_len {
2012            self.cursor += 1;
2013            self.adjust_scroll_down();
2014        } else {
2015            // If we still have a pending stream, attempt to load more and stay in place
2016            if self.pending_stream.is_some() {
2017                self.maybe_load_more();
2018                let list_len = self.current_list_len();
2019                if self.cursor + 1 < list_len {
2020                    self.cursor += 1;
2021                    self.adjust_scroll_down();
2022                    return;
2023                }
2024            }
2025
2026            // Wrap to top
2027            self.cursor = 0;
2028            self.scroll_offset = 0;
2029        }
2030    }
2031
2032    fn adjust_scroll_down(&mut self) {
2033        let max_visible = self.scroll_offset + self.visible_height as usize;
2034        if self.cursor >= max_visible {
2035            self.scroll_offset = self.cursor - self.visible_height as usize + 1;
2036        }
2037    }
2038
2039    fn adjust_scroll_up(&mut self) {
2040        if self.cursor < self.scroll_offset {
2041            self.scroll_offset = self.cursor;
2042        }
2043    }
2044
2045    /// Get the current list length (filtered for fuzzy modes or refined multi, full for others)
2046    fn current_list_len(&self) -> usize {
2047        match self.mode {
2048            SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
2049            SelectMode::Multi if self.refined => self.filtered_indices.len(),
2050            _ => self.items.len(),
2051        }
2052    }
2053
2054    /// Navigate to the start of the list
2055    fn navigate_home(&mut self) {
2056        self.cursor = 0;
2057        self.scroll_offset = 0;
2058    }
2059
2060    /// Navigate to the end of the list
2061    fn navigate_end(&mut self) {
2062        // Drain the full stream so End lands on the true last item.
2063        if self.pending_stream.is_some() {
2064            self.drain_pending_stream();
2065            if !self.filter_text.is_empty() {
2066                self.update_filter();
2067            } else if !self.refined {
2068                self.filtered_indices = (0..self.items.len()).collect();
2069            }
2070        }
2071        self.cursor = self.current_list_len().saturating_sub(1);
2072        self.adjust_scroll_down();
2073    }
2074
2075    /// Navigate page up: go to top of current page, or previous page if already at top
2076    fn navigate_page_up(&mut self) {
2077        let page_top = self.scroll_offset;
2078        if self.cursor == page_top {
2079            // Already at top of page, go to previous page
2080            self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
2081            self.adjust_scroll_up();
2082        } else {
2083            // Go to top of current page
2084            self.cursor = page_top;
2085        }
2086    }
2087
2088    /// Navigate page down: go to bottom of current page, or next page if already at bottom
2089    fn navigate_page_down(&mut self) {
2090        self.maybe_load_more();
2091
2092        let list_len = self.current_list_len();
2093        let page_bottom =
2094            (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
2095        if self.cursor == page_bottom {
2096            // Already at bottom of page, go to next page
2097            self.cursor =
2098                (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
2099            self.adjust_scroll_down();
2100        } else {
2101            // Go to bottom of current page
2102            self.cursor = page_bottom;
2103        }
2104
2105        self.maybe_load_more();
2106    }
2107
2108    /// Scroll table columns left (show earlier columns)
2109    fn scroll_columns_left(&mut self) -> bool {
2110        if !self.is_table_mode() || self.horizontal_offset == 0 {
2111            return false;
2112        }
2113        self.horizontal_offset -= 1;
2114        self.horizontal_scroll_changed = true;
2115        self.update_table_layout();
2116        true
2117    }
2118
2119    /// Scroll table columns right (show later columns)
2120    fn scroll_columns_right(&mut self) -> bool {
2121        let Some(layout) = &self.table_layout else {
2122            return false;
2123        };
2124        let (cols_visible, has_more_right) = self.calculate_visible_columns();
2125        if !has_more_right {
2126            return false;
2127        }
2128        // Don't scroll past the last column
2129        if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
2130            return false;
2131        }
2132        self.horizontal_offset += 1;
2133        self.horizontal_scroll_changed = true;
2134        self.update_table_layout();
2135        true
2136    }
2137
2138    fn toggle_current(&mut self) {
2139        // Guard against empty list when refined
2140        if self.refined && self.filtered_indices.is_empty() {
2141            return;
2142        }
2143        // Get the real item index (may differ from cursor when refined)
2144        let real_idx = if self.refined {
2145            self.filtered_indices[self.cursor]
2146        } else {
2147            self.cursor
2148        };
2149        self.toggle_index(real_idx);
2150    }
2151
2152    /// Toggle selection of a specific item by its real index
2153    fn toggle_index(&mut self, real_idx: usize) {
2154        if self.selected.contains(&real_idx) {
2155            self.selected.remove(&real_idx);
2156        } else {
2157            self.selected.insert(real_idx);
2158        }
2159        self.toggled_item = Some(self.cursor);
2160    }
2161
2162    /// Toggle selection of current item in fuzzy multi mode (uses filtered_indices)
2163    /// Returns true if an item was toggled, false if list was empty
2164    fn toggle_current_fuzzy(&mut self) -> bool {
2165        if self.filtered_indices.is_empty() {
2166            return false;
2167        }
2168        let real_idx = self.filtered_indices[self.cursor];
2169        self.toggle_index(real_idx);
2170        true
2171    }
2172
2173    fn toggle_all(&mut self) {
2174        // Check if all current items are selected
2175        let all_selected = if self.refined {
2176            self.filtered_indices
2177                .iter()
2178                .all(|i| self.selected.contains(i))
2179        } else {
2180            (0..self.items.len()).all(|i| self.selected.contains(&i))
2181        };
2182
2183        if all_selected {
2184            // Deselect all current items
2185            if self.refined {
2186                for i in &self.filtered_indices {
2187                    self.selected.remove(i);
2188                }
2189            } else {
2190                self.selected.clear();
2191            }
2192        } else {
2193            // Select all current items
2194            if self.refined {
2195                self.selected.extend(self.filtered_indices.iter().copied());
2196            } else {
2197                self.selected.extend(0..self.items.len());
2198            }
2199        }
2200        self.toggled_all = true;
2201    }
2202
2203    /// Toggle all items in fuzzy multi mode (only the currently filtered items)
2204    fn toggle_all_fuzzy(&mut self) {
2205        if self.filtered_indices.is_empty() {
2206            return;
2207        }
2208
2209        // Check if all filtered items are selected
2210        let all_selected = self
2211            .filtered_indices
2212            .iter()
2213            .all(|i| self.selected.contains(i));
2214
2215        if all_selected {
2216            // Deselect all filtered items
2217            for i in &self.filtered_indices {
2218                self.selected.remove(i);
2219            }
2220        } else {
2221            // Select all filtered items
2222            self.selected.extend(self.filtered_indices.iter().copied());
2223        }
2224        self.toggled_all = true;
2225    }
2226
2227    /// Refine the list to only show currently selected items
2228    /// This allows users to narrow down to their selections and continue selecting
2229    fn refine_list(&mut self) {
2230        if self.selected.is_empty() {
2231            return;
2232        }
2233
2234        // Set filtered_indices to sorted selected indices
2235        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2236        indices.sort();
2237
2238        // Store as base indices for filtering in FuzzyMulti mode
2239        // Clone once for both vectors instead of cloning refined_base_indices
2240        self.filtered_indices = indices.clone();
2241        self.refined_base_indices = indices;
2242
2243        // Reset cursor and scroll
2244        self.cursor = 0;
2245        self.scroll_offset = 0;
2246
2247        // Keep all items selected (don't clear selection)
2248        // User can deselect items they don't want
2249
2250        // Clear filter text in FuzzyMulti mode
2251        if self.mode == SelectMode::FuzzyMulti {
2252            self.filter_text.clear();
2253            self.filter_cursor = 0;
2254            self.filter_text_changed = true;
2255        }
2256
2257        // Mark as refined (for Multi mode rendering)
2258        self.refined = true;
2259
2260        // Force full redraw
2261        self.first_render = true;
2262    }
2263
2264    // Filter cursor movement helpers
2265    fn move_filter_cursor_left(&mut self) {
2266        if self.filter_cursor > 0 {
2267            // Move back one character (handle UTF-8)
2268            let mut new_pos = self.filter_cursor - 1;
2269            while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2270                new_pos -= 1;
2271            }
2272            self.filter_cursor = new_pos;
2273        }
2274    }
2275
2276    fn move_filter_cursor_right(&mut self) {
2277        if self.filter_cursor < self.filter_text.len() {
2278            // Move forward one character (handle UTF-8)
2279            let mut new_pos = self.filter_cursor + 1;
2280            while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2281                new_pos += 1;
2282            }
2283            self.filter_cursor = new_pos;
2284        }
2285    }
2286
2287    fn move_filter_cursor_word_left(&mut self) {
2288        if self.filter_cursor == 0 {
2289            return;
2290        }
2291        let bytes = self.filter_text.as_bytes();
2292        let mut pos = self.filter_cursor;
2293        // Skip whitespace
2294        while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2295            pos -= 1;
2296        }
2297        // Skip word characters
2298        while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2299            pos -= 1;
2300        }
2301        self.filter_cursor = pos;
2302    }
2303
2304    fn move_filter_cursor_word_right(&mut self) {
2305        let len = self.filter_text.len();
2306        if self.filter_cursor >= len {
2307            return;
2308        }
2309        let bytes = self.filter_text.as_bytes();
2310        let mut pos = self.filter_cursor;
2311        // Skip current word characters
2312        while pos < len && !bytes[pos].is_ascii_whitespace() {
2313            pos += 1;
2314        }
2315        // Skip whitespace
2316        while pos < len && bytes[pos].is_ascii_whitespace() {
2317            pos += 1;
2318        }
2319        self.filter_cursor = pos;
2320    }
2321
2322    fn delete_word_backwards(&mut self) {
2323        if self.filter_cursor == 0 {
2324            return;
2325        }
2326        let start = self.filter_cursor;
2327        // Skip whitespace
2328        while self.filter_cursor > 0
2329            && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2330        {
2331            self.filter_cursor -= 1;
2332        }
2333        // Skip word characters
2334        while self.filter_cursor > 0
2335            && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2336        {
2337            self.filter_cursor -= 1;
2338        }
2339        self.filter_text.drain(self.filter_cursor..start);
2340    }
2341
2342    fn delete_word_forwards(&mut self) {
2343        let len = self.filter_text.len();
2344        if self.filter_cursor >= len {
2345            return;
2346        }
2347        let start = self.filter_cursor;
2348        let bytes = self.filter_text.as_bytes();
2349        let mut end = start;
2350        // Skip word characters
2351        while end < len && !bytes[end].is_ascii_whitespace() {
2352            end += 1;
2353        }
2354        // Skip whitespace
2355        while end < len && bytes[end].is_ascii_whitespace() {
2356            end += 1;
2357        }
2358        self.filter_text.drain(start..end);
2359    }
2360
2361    fn transpose_chars(&mut self) {
2362        // Ctrl-T: swap the two characters before the cursor
2363        // If at end of line, swap last two chars
2364        // If at position 1 or beyond with at least 2 chars, swap char before cursor with one before that
2365        let len = self.filter_text.len();
2366        if len < 2 {
2367            return;
2368        }
2369
2370        // If cursor is at start, nothing to transpose
2371        if self.filter_cursor == 0 {
2372            return;
2373        }
2374
2375        // If cursor is at end, transpose last two characters and keep cursor at end
2376        // Otherwise, transpose char at cursor-1 with char at cursor, then move cursor right
2377        let pos = if self.filter_cursor >= len {
2378            len - 1
2379        } else {
2380            self.filter_cursor
2381        };
2382
2383        if pos == 0 {
2384            return;
2385        }
2386
2387        // Only transpose if both positions are ASCII (single-byte) characters.
2388        // For multi-byte UTF-8 characters, transposition is more complex and skipped.
2389        if self.filter_text.is_char_boundary(pos - 1)
2390            && self.filter_text.is_char_boundary(pos)
2391            && pos < len
2392            && self.filter_text.is_char_boundary(pos + 1)
2393        {
2394            // Check both chars are single-byte ASCII
2395            let bytes = self.filter_text.as_bytes();
2396            if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2397                // SAFETY: We verified both bytes are ASCII, so swapping them is safe
2398                let bytes = unsafe { self.filter_text.as_bytes_mut() };
2399                bytes.swap(pos - 1, pos);
2400
2401                // Move cursor right if not at end
2402                if self.filter_cursor < len {
2403                    self.filter_cursor += 1;
2404                }
2405            }
2406        }
2407    }
2408
2409    /// Score an item using per-column matching (best column wins)
2410    fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
2411        item.cells.as_ref().and_then(|cells| {
2412            cells
2413                .iter()
2414                .filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
2415                .max()
2416        })
2417    }
2418
2419    /// Score an item - uses per-column matching if enabled and in table mode
2420    fn score_item(&self, item: &SelectItem) -> Option<i64> {
2421        if self.per_column && item.cells.is_some() {
2422            self.score_per_column(item)
2423        } else {
2424            self.matcher.fuzzy_match(&item.name, &self.filter_text)
2425        }
2426    }
2427
2428    fn update_filter(&mut self) {
2429        // When a filter is active, eagerly drain any remaining stream items so searches
2430        // operate on the complete dataset, not just the initial prefetch window.
2431        if self.pending_stream.is_some() && !self.filter_text.is_empty() {
2432            self.drain_pending_stream();
2433        }
2434
2435        let old_indices = std::mem::take(&mut self.filtered_indices);
2436
2437        // Determine whether to filter from refined subset or all items
2438        let use_refined = self.refined && !self.refined_base_indices.is_empty();
2439
2440        if self.filter_text.is_empty() {
2441            // When empty, copy the base indices
2442            self.filtered_indices = if use_refined {
2443                self.refined_base_indices.clone()
2444            } else {
2445                (0..self.items.len()).collect()
2446            };
2447        } else {
2448            // When filtering, iterate without cloning the base indices
2449            let mut scored: Vec<(usize, i64)> = if use_refined {
2450                self.refined_base_indices
2451                    .iter()
2452                    .filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
2453                    .collect()
2454            } else {
2455                (0..self.items.len())
2456                    .filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
2457                    .collect()
2458            };
2459            // Sort by score descending
2460            scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
2461            self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2462        }
2463
2464        // Check if results actually changed
2465        self.results_changed = old_indices != self.filtered_indices;
2466        self.filter_text_changed = true;
2467
2468        // Only reset cursor/scroll if results changed
2469        if self.results_changed {
2470            self.cursor = 0;
2471            self.scroll_offset = 0;
2472        }
2473
2474        // In table mode, auto-scroll horizontally to show the first column with matches
2475        if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2476        {
2477            self.auto_scroll_to_match_column();
2478        }
2479    }
2480
2481    /// In table mode, scroll horizontally to ensure the first column with matches is visible
2482    fn auto_scroll_to_match_column(&mut self) {
2483        let Some(layout) = &self.table_layout else {
2484            return;
2485        };
2486
2487        // Look at the top result to find which column has the best match
2488        let first_idx = self.filtered_indices[0];
2489        let item = &self.items[first_idx];
2490        let Some(cells) = &item.cells else {
2491            return;
2492        };
2493
2494        // Find the first column (leftmost) that has a match
2495        let mut first_match_col: Option<usize> = None;
2496        for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2497            if self.per_column {
2498                // Per-column mode: check each cell individually
2499                if self
2500                    .matcher
2501                    .fuzzy_match(cell_text, &self.filter_text)
2502                    .is_some()
2503                {
2504                    first_match_col = Some(col_idx);
2505                    break;
2506                }
2507            } else {
2508                // Standard mode: check if this cell's portion of item.name has matches
2509                // Calculate the character offset for this cell in the concatenated name
2510                let cell_start: usize = cells[..col_idx]
2511                    .iter()
2512                    .map(|(s, _)| s.chars().count() + 1) // +1 for space separator
2513                    .sum();
2514                let cell_char_count = cell_text.chars().count();
2515
2516                if let Some((_, indices)) =
2517                    self.matcher.fuzzy_indices(&item.name, &self.filter_text)
2518                {
2519                    // Check if any match indices fall within this cell
2520                    if indices
2521                        .iter()
2522                        .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
2523                    {
2524                        first_match_col = Some(col_idx);
2525                        break;
2526                    }
2527                }
2528            }
2529        }
2530
2531        // If we found a matching column, ensure it's visible
2532        if let Some(match_col) = first_match_col {
2533            let (cols_visible, _) = self.calculate_visible_columns();
2534            let visible_start = self.horizontal_offset;
2535            let visible_end = self.horizontal_offset + cols_visible;
2536
2537            if match_col < visible_start {
2538                // Match is to the left, scroll left
2539                self.horizontal_offset = match_col;
2540                self.horizontal_scroll_changed = true;
2541                self.update_table_layout();
2542            } else if match_col >= visible_end {
2543                // Match is to the right, scroll right
2544                // Set offset so match_col is the first visible column
2545                self.horizontal_offset = match_col;
2546                // But don't scroll past what's possible
2547                let max_offset = layout.col_widths.len().saturating_sub(1);
2548                self.horizontal_offset = self.horizontal_offset.min(max_offset);
2549                self.horizontal_scroll_changed = true;
2550                self.update_table_layout();
2551            }
2552        }
2553    }
2554
2555    fn get_result(&self) -> InteractMode {
2556        match self.mode {
2557            SelectMode::Single => InteractMode::Single(Some(self.cursor)),
2558            SelectMode::Multi => {
2559                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2560                indices.sort();
2561                InteractMode::Multi(Some(indices))
2562            }
2563            SelectMode::Fuzzy => {
2564                if self.filtered_indices.is_empty() {
2565                    InteractMode::Single(None)
2566                } else {
2567                    InteractMode::Single(Some(self.filtered_indices[self.cursor]))
2568                }
2569            }
2570            SelectMode::FuzzyMulti => {
2571                // Return all selected items regardless of current filter
2572                // This allows selecting items across multiple filter searches
2573                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2574                indices.sort();
2575                InteractMode::Multi(Some(indices))
2576            }
2577        }
2578    }
2579
2580    /// Check if we can do a toggle-only update in multi mode
2581    /// (just toggled a single visible item, no cursor movement)
2582    fn can_do_multi_toggle_only_update(&self) -> bool {
2583        if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
2584            return false;
2585        }
2586        // If the cursor also moved (e.g. Tab toggles and navigates), a full redraw
2587        // is needed so the ">" indicator follows the cursor.
2588        if self.cursor != self.prev_cursor {
2589            return false;
2590        }
2591        if let Some(toggled) = self.toggled_item {
2592            // Check if toggled item is visible
2593            let visible_start = self.scroll_offset;
2594            let visible_end = self.scroll_offset + self.visible_height as usize;
2595            toggled >= visible_start && toggled < visible_end
2596        } else {
2597            false
2598        }
2599    }
2600
2601    /// Check if we can do a toggle+move update in fuzzy multi mode
2602    /// (toggled an item and moved cursor, both visible, no scroll change)
2603    fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
2604        if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
2605            return false;
2606        }
2607        if self.scroll_offset != self.prev_scroll_offset {
2608            return false; // Scrolled, need full redraw
2609        }
2610        if self.filter_text_changed || self.results_changed {
2611            return false; // Filter changed, need full redraw
2612        }
2613        if let Some(toggled) = self.toggled_item {
2614            // Check if both toggled item and new cursor are visible
2615            let visible_start = self.scroll_offset;
2616            let visible_end = self.scroll_offset + self.visible_height as usize;
2617            let toggled_visible = toggled >= visible_start && toggled < visible_end;
2618            let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
2619            toggled_visible && cursor_visible
2620        } else {
2621            false
2622        }
2623    }
2624
2625    /// Check if we can do a toggle-all update in fuzzy multi mode
2626    /// (toggled all filtered items with Alt+A)
2627    fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
2628        !self.first_render
2629            && !self.width_changed
2630            && self.mode == SelectMode::FuzzyMulti
2631            && self.toggled_all
2632            && !self.filter_text_changed
2633            && !self.results_changed
2634            && self.scroll_offset == self.prev_scroll_offset
2635            && !self.horizontal_scroll_changed
2636    }
2637
2638    /// Check if we can do a toggle-all update in multi mode
2639    /// (toggled all items with 'a' key)
2640    fn can_do_multi_toggle_all_update(&self) -> bool {
2641        !self.first_render
2642            && !self.width_changed
2643            && self.mode == SelectMode::Multi
2644            && self.toggled_all
2645    }
2646
2647    /// FuzzyMulti mode: update toggled row and new cursor row
2648    fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2649        let toggled = self.toggled_item.expect("toggled_item must be Some");
2650        execute!(stderr, BeginSynchronizedUpdate)?;
2651
2652        // Calculate header lines (prompt + filter + separator + table header)
2653        let header_lines = self.fuzzy_header_lines();
2654
2655        let toggled_display_row = (toggled - self.scroll_offset) as u16;
2656        let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
2657
2658        let toggled_item_row = header_lines + toggled_display_row;
2659        let cursor_item_row = header_lines + cursor_display_row;
2660
2661        // We're at the filter line
2662        let filter_row = self.fuzzy_filter_row();
2663
2664        // Move to toggled row and redraw it (checkbox changed, marker removed)
2665        let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2666        execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2667
2668        // Redraw toggled row (now without marker, checkbox state changed)
2669        let toggled_real_idx = self.filtered_indices[toggled];
2670        let toggled_item = &self.items[toggled_real_idx];
2671        let toggled_checked = self.selected.contains(&toggled_real_idx);
2672        if self.is_table_mode() {
2673            self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
2674        } else {
2675            self.render_fuzzy_multi_item_inline(
2676                stderr,
2677                &toggled_item.name,
2678                toggled_checked,
2679                false,
2680            )?;
2681        }
2682
2683        // Move to cursor row and redraw it (marker added)
2684        if cursor_item_row > toggled_item_row {
2685            let lines_down = cursor_item_row - toggled_item_row;
2686            execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
2687        } else if cursor_item_row < toggled_item_row {
2688            let lines_up = toggled_item_row - cursor_item_row;
2689            execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
2690        }
2691
2692        let cursor_real_idx = self.filtered_indices[self.cursor];
2693        let cursor_item = &self.items[cursor_real_idx];
2694        let cursor_checked = self.selected.contains(&cursor_real_idx);
2695        if self.is_table_mode() {
2696            self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
2697        } else {
2698            self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
2699        }
2700
2701        // Update footer to reflect new selection count
2702        if self.has_footer() {
2703            // Calculate footer row position
2704            let total_count = self.current_list_len();
2705            let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2706            let visible_count = (end - self.scroll_offset) as u16;
2707            let footer_row = header_lines + visible_count;
2708
2709            // Move from cursor row to footer
2710            let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2711            execute!(stderr, MoveDown(down_to_footer))?;
2712
2713            // Update footer
2714            self.render_footer_inline(stderr)?;
2715
2716            // Move back to filter line
2717            let up_to_filter = footer_row.saturating_sub(filter_row);
2718            execute!(stderr, MoveUp(up_to_filter))?;
2719        } else {
2720            // Move back to filter line
2721            let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2722            execute!(stderr, MoveUp(up_to_filter))?;
2723        }
2724
2725        // Position cursor within filter text
2726        self.position_fuzzy_cursor(stderr)?;
2727
2728        // Update state
2729        self.prev_cursor = self.cursor;
2730        self.toggled_item = None;
2731
2732        execute!(stderr, EndSynchronizedUpdate)?;
2733        stderr.flush()
2734    }
2735
2736    /// Multi mode: only update the checkbox for the toggled item
2737    fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2738        let toggled = self.toggled_item.expect("toggled_item must be Some");
2739        execute!(stderr, BeginSynchronizedUpdate)?;
2740
2741        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2742        if self.is_table_mode() {
2743            header_lines += 2; // table header + header separator line
2744        }
2745
2746        // Calculate display position of toggled item relative to scroll
2747        let display_row = (toggled - self.scroll_offset) as u16;
2748
2749        // Current position is at end of rendered content
2750        let items_rendered = self.rendered_lines - header_lines as usize;
2751
2752        // Move to the toggled row
2753        // Cursor is at end of last content line, so subtract 1 from items_rendered
2754        let lines_up = (items_rendered as u16)
2755            .saturating_sub(1)
2756            .saturating_sub(display_row);
2757        execute!(stderr, MoveUp(lines_up))?;
2758
2759        // Move to checkbox column (after "> " or "  ")
2760        execute!(stderr, MoveToColumn(2))?;
2761
2762        // Write new checkbox state
2763        let checkbox = if self.selected.contains(&toggled) {
2764            "[x]"
2765        } else {
2766            "[ ]"
2767        };
2768        execute!(stderr, Print(checkbox))?;
2769
2770        // Move back to end position (footer line if shown, else last item line)
2771        execute!(stderr, MoveDown(lines_up))?;
2772
2773        // Update footer to reflect new selection count
2774        if self.has_footer() {
2775            self.render_footer_inline(stderr)?;
2776        }
2777
2778        // Reset toggle tracking
2779        self.toggled_item = None;
2780
2781        execute!(stderr, EndSynchronizedUpdate)?;
2782        stderr.flush()
2783    }
2784
2785    /// Multi mode: update all visible checkboxes (toggle all with 'a')
2786    fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2787        execute!(stderr, BeginSynchronizedUpdate)?;
2788
2789        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2790        if self.is_table_mode() {
2791            header_lines += 2; // table header + header separator line
2792        }
2793
2794        // Current position is at end of rendered content
2795        let items_rendered = self.rendered_lines - header_lines as usize;
2796
2797        // Calculate visible range
2798        let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
2799        let visible_count = visible_end - self.scroll_offset;
2800
2801        // Move to first item row
2802        // Cursor is at end of last content line, so subtract 1 to get to first item
2803        execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2804
2805        // Update each visible item's checkbox
2806        for i in 0..visible_count {
2807            let item_idx = self.scroll_offset + i;
2808            let checkbox = if self.selected.contains(&item_idx) {
2809                "[x]"
2810            } else {
2811                "[ ]"
2812            };
2813            // Move to checkbox column and update
2814            execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2815            if i + 1 < visible_count {
2816                execute!(stderr, MoveDown(1))?;
2817            }
2818        }
2819
2820        // Move back to end position (footer line if shown, else last item line)
2821        let remaining = items_rendered as u16 - visible_count as u16;
2822        if remaining > 0 {
2823            execute!(stderr, MoveDown(remaining))?;
2824        }
2825
2826        // Update footer to reflect new selection count
2827        if self.has_footer() {
2828            self.render_footer_inline(stderr)?;
2829        }
2830
2831        // Reset toggle tracking
2832        self.toggled_all = false;
2833
2834        execute!(stderr, EndSynchronizedUpdate)?;
2835        stderr.flush()
2836    }
2837
2838    /// FuzzyMulti mode: update all visible rows (toggle all with Alt+A)
2839    fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2840        execute!(stderr, BeginSynchronizedUpdate)?;
2841
2842        // Calculate header lines (prompt + filter + separator + table header)
2843        let header_lines = self.fuzzy_header_lines();
2844
2845        let total_count = self.current_list_len();
2846        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2847        let visible_count = end.saturating_sub(self.scroll_offset);
2848
2849        // We're at the filter line
2850        let filter_row = self.fuzzy_filter_row();
2851
2852        // Move to first item row
2853        let down_to_first = header_lines.saturating_sub(filter_row);
2854        execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
2855
2856        for (i, idx) in (self.scroll_offset..end).enumerate() {
2857            let real_idx = self.filtered_indices[idx];
2858            let item = &self.items[real_idx];
2859            let checked = self.selected.contains(&real_idx);
2860            let active = idx == self.cursor;
2861
2862            if self.is_table_mode() {
2863                self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
2864            } else {
2865                self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
2866            }
2867
2868            if i + 1 < visible_count {
2869                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2870            }
2871        }
2872
2873        // Move to footer (if present) and update it
2874        if self.has_footer() {
2875            let footer_row = header_lines + visible_count as u16;
2876            let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
2877            let down_to_footer = footer_row.saturating_sub(last_item_row);
2878            execute!(stderr, MoveDown(down_to_footer))?;
2879            self.render_footer_inline(stderr)?;
2880            let up_to_filter = footer_row.saturating_sub(filter_row);
2881            execute!(stderr, MoveUp(up_to_filter))?;
2882        } else {
2883            let up_to_filter =
2884                (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
2885            execute!(stderr, MoveUp(up_to_filter))?;
2886        }
2887
2888        // Position cursor within filter text
2889        self.position_fuzzy_cursor(stderr)?;
2890
2891        // Reset toggle tracking
2892        self.toggled_all = false;
2893
2894        execute!(stderr, EndSynchronizedUpdate)?;
2895        stderr.flush()
2896    }
2897
2898    #[allow(clippy::collapsible_if)]
2899    fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2900        self.maybe_load_more();
2901
2902        // Check for fuzzy multi mode toggle-all optimization
2903        if self.can_do_fuzzy_multi_toggle_all_update() {
2904            return self.render_fuzzy_multi_toggle_all_update(stderr);
2905        }
2906
2907        // Check for multi mode toggle-all optimization
2908        if self.can_do_multi_toggle_all_update() {
2909            return self.render_multi_toggle_all(stderr);
2910        }
2911
2912        // Check for multi mode toggle-only optimization
2913        if self.can_do_multi_toggle_only_update() {
2914            return self.render_multi_toggle_only(stderr);
2915        }
2916
2917        // Check for fuzzy multi mode toggle+move optimization
2918        if self.can_do_fuzzy_multi_toggle_update() {
2919            return self.render_fuzzy_multi_toggle_update(stderr);
2920        }
2921
2922        // The old cursor-only navigation optimizations were removed because
2923        // they were brittle and caused wrapping bugs.  We now always perform a
2924        // full redraw for simple cursor moves; other optimizations (toggle
2925        // updates) are still available above.
2926
2927        // If nothing changed (e.g., PageDown at bottom of list), skip render entirely
2928        if !self.first_render
2929            && !self.width_changed
2930            && self.cursor == self.prev_cursor
2931            && self.scroll_offset == self.prev_scroll_offset
2932            && !self.results_changed
2933            && !self.filter_text_changed
2934            && !self.horizontal_scroll_changed
2935            && !self.settings_changed
2936            && !self.toggled_all
2937        {
2938            return Ok(());
2939        }
2940
2941        execute!(stderr, BeginSynchronizedUpdate)?;
2942
2943        // Calculate how many lines we'll render
2944        let total_count = self.current_list_len();
2945        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2946        // Show footer in fuzzy modes (for settings), multi modes (for selection count), or when scrolling is needed
2947        let has_scroll_indicator = self.has_footer();
2948        let items_to_render = end - self.scroll_offset;
2949
2950        // Calculate total lines needed for this render
2951        let mut lines_needed: usize = 0;
2952        if self.prompt.is_some() {
2953            lines_needed += 1;
2954        }
2955        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2956            lines_needed += 1; // filter line
2957            if self.config.show_separator {
2958                lines_needed += 1;
2959            }
2960        }
2961        if self.is_table_mode() {
2962            lines_needed += 2; // table header + header separator
2963        }
2964        lines_needed += items_to_render;
2965        if has_scroll_indicator {
2966            lines_needed += 1;
2967        }
2968
2969        // On first render, claim vertical space by printing newlines (causes scroll if needed)
2970        if self.first_render && lines_needed > 1 {
2971            for _ in 0..(lines_needed - 1) {
2972                execute!(stderr, Print("\n"))?;
2973            }
2974            execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
2975        }
2976
2977        // In fuzzy mode, cursor may be at filter line; move to last content line first
2978        if self.fuzzy_cursor_offset > 0 {
2979            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
2980            self.fuzzy_cursor_offset = 0;
2981        }
2982
2983        // Move to start of our render area (first line, column 0)
2984        // Cursor is on last content line, move up to first line
2985        if self.rendered_lines > 1 {
2986            execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
2987        }
2988        execute!(stderr, MoveToColumn(0))?;
2989
2990        let mut lines_rendered: usize = 0;
2991
2992        // Render prompt (only on first render, it doesn't change)
2993        if self.first_render {
2994            if let Some(prompt) = self.prompt {
2995                execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
2996            }
2997        }
2998        if self.prompt.is_some() {
2999            lines_rendered += 1;
3000            if lines_rendered < lines_needed {
3001                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3002            }
3003        }
3004
3005        // Render filter line for fuzzy modes
3006        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3007            execute!(
3008                stderr,
3009                Print(self.prompt_marker()),
3010                Print(&self.filter_text),
3011                Clear(ClearType::UntilNewLine),
3012            )?;
3013            lines_rendered += 1;
3014            if lines_rendered < lines_needed {
3015                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3016            }
3017
3018            // Render separator line
3019            if self.config.show_separator {
3020                execute!(
3021                    stderr,
3022                    Print(self.config.separator.paint(&self.separator_line)),
3023                    Clear(ClearType::UntilNewLine),
3024                )?;
3025                lines_rendered += 1;
3026                if lines_rendered < lines_needed {
3027                    execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3028                }
3029            }
3030        }
3031
3032        // Render table header and separator if in table mode
3033        // Only redraw if first render or horizontal scroll changed
3034        if self.is_table_mode() {
3035            let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
3036            if need_header_redraw {
3037                self.render_table_header(stderr)?;
3038            }
3039            lines_rendered += 1;
3040            if lines_rendered < lines_needed {
3041                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3042            }
3043            if need_header_redraw {
3044                self.render_table_header_separator(stderr)?;
3045            }
3046            lines_rendered += 1;
3047            if lines_rendered < lines_needed {
3048                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3049            }
3050        }
3051
3052        // Render items
3053        for idx in self.scroll_offset..end {
3054            let is_active = idx == self.cursor;
3055            let is_last_line = lines_rendered + 1 == lines_needed;
3056
3057            if self.is_table_mode() {
3058                // Table mode rendering
3059                match self.mode {
3060                    SelectMode::Single => {
3061                        let item = &self.items[idx];
3062                        self.render_table_row_single(stderr, item, is_active)?;
3063                    }
3064                    SelectMode::Multi => {
3065                        let real_idx = if self.refined {
3066                            self.filtered_indices[idx]
3067                        } else {
3068                            idx
3069                        };
3070                        let item = &self.items[real_idx];
3071                        let is_checked = self.selected.contains(&real_idx);
3072                        self.render_table_row_multi(stderr, item, is_checked, is_active)?;
3073                    }
3074                    SelectMode::Fuzzy => {
3075                        let real_idx = self.filtered_indices[idx];
3076                        let item = &self.items[real_idx];
3077                        self.render_table_row_fuzzy(stderr, item, is_active)?;
3078                    }
3079                    SelectMode::FuzzyMulti => {
3080                        let real_idx = self.filtered_indices[idx];
3081                        let item = &self.items[real_idx];
3082                        let is_checked = self.selected.contains(&real_idx);
3083                        self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
3084                    }
3085                }
3086            } else {
3087                // Single-line mode rendering
3088                match self.mode {
3089                    SelectMode::Single => {
3090                        let item = &self.items[idx];
3091                        self.render_single_item_inline(stderr, &item.name, is_active)?;
3092                    }
3093                    SelectMode::Multi => {
3094                        let real_idx = if self.refined {
3095                            self.filtered_indices[idx]
3096                        } else {
3097                            idx
3098                        };
3099                        let item = &self.items[real_idx];
3100                        let is_checked = self.selected.contains(&real_idx);
3101                        self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
3102                    }
3103                    SelectMode::Fuzzy => {
3104                        let real_idx = self.filtered_indices[idx];
3105                        let item = &self.items[real_idx];
3106                        self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
3107                    }
3108                    SelectMode::FuzzyMulti => {
3109                        let real_idx = self.filtered_indices[idx];
3110                        let item = &self.items[real_idx];
3111                        let is_checked = self.selected.contains(&real_idx);
3112                        self.render_fuzzy_multi_item_inline(
3113                            stderr, &item.name, is_checked, is_active,
3114                        )?;
3115                    }
3116                }
3117            }
3118            lines_rendered += 1;
3119            if !is_last_line {
3120                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3121            }
3122        }
3123
3124        // Show scroll indicator if needed
3125        if has_scroll_indicator {
3126            let indicator = self.generate_footer();
3127            execute!(
3128                stderr,
3129                Print(self.config.footer.paint(&indicator)),
3130                Clear(ClearType::UntilNewLine),
3131            )?;
3132            lines_rendered += 1;
3133        }
3134
3135        // Clear any extra lines from previous render
3136        // Cursor is on last rendered line
3137        if lines_rendered < self.rendered_lines {
3138            let extra_lines = self.rendered_lines - lines_rendered;
3139            for _ in 0..extra_lines {
3140                execute!(
3141                    stderr,
3142                    MoveDown(1),
3143                    MoveToColumn(0),
3144                    Clear(ClearType::CurrentLine)
3145                )?;
3146            }
3147            // Move back to last content line
3148            execute!(stderr, MoveUp(extra_lines as u16))?;
3149        }
3150
3151        // Update state
3152        self.rendered_lines = lines_rendered;
3153        self.prev_cursor = self.cursor;
3154        self.prev_scroll_offset = self.scroll_offset;
3155        self.first_render = false;
3156        self.filter_text_changed = false;
3157        self.results_changed = false;
3158        self.horizontal_scroll_changed = false;
3159        self.width_changed = false;
3160        self.toggled_item = None;
3161        self.toggled_all = false;
3162        self.settings_changed = false;
3163
3164        // In fuzzy modes, position cursor within filter text
3165        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3166            // Cursor is on last content line, move up to filter line
3167            let filter_row = self.fuzzy_filter_row() as usize;
3168            self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3169            if self.fuzzy_cursor_offset > 0 {
3170                execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3171            }
3172            // Position cursor after prompt marker + text up to filter_cursor
3173            self.position_fuzzy_cursor(stderr)?;
3174        }
3175
3176        execute!(stderr, EndSynchronizedUpdate)?;
3177        stderr.flush()
3178    }
3179
3180    fn render_single_item_inline(
3181        &self,
3182        stderr: &mut Stderr,
3183        text: &str,
3184        active: bool,
3185    ) -> io::Result<()> {
3186        let prefix = if active { self.selected_marker() } else { "  " };
3187        let prefix_width = 2;
3188
3189        execute!(stderr, Print(prefix))?;
3190        self.render_truncated_text(stderr, text, prefix_width)?;
3191        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3192        Ok(())
3193    }
3194
3195    fn render_multi_item_inline(
3196        &self,
3197        stderr: &mut Stderr,
3198        text: &str,
3199        checked: bool,
3200        active: bool,
3201    ) -> io::Result<()> {
3202        let cursor = if active { self.selected_marker() } else { "  " };
3203        let checkbox = if checked { "[x] " } else { "[ ] " };
3204        let prefix_width = 6; // "> [x] " or "  [ ] "
3205
3206        execute!(stderr, Print(cursor), Print(checkbox))?;
3207        self.render_truncated_text(stderr, text, prefix_width)?;
3208        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3209        Ok(())
3210    }
3211
3212    fn render_fuzzy_item_inline(
3213        &self,
3214        stderr: &mut Stderr,
3215        text: &str,
3216        active: bool,
3217    ) -> io::Result<()> {
3218        let prefix = if active { self.selected_marker() } else { "  " };
3219        let prefix_width = 2;
3220        execute!(stderr, Print(prefix))?;
3221
3222        if self.filter_text.is_empty() {
3223            self.render_truncated_text(stderr, text, prefix_width)?;
3224        } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3225        {
3226            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3227        } else {
3228            self.render_truncated_text(stderr, text, prefix_width)?;
3229        }
3230        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3231        Ok(())
3232    }
3233
3234    fn render_fuzzy_multi_item_inline(
3235        &self,
3236        stderr: &mut Stderr,
3237        text: &str,
3238        checked: bool,
3239        active: bool,
3240    ) -> io::Result<()> {
3241        let cursor = if active { self.selected_marker() } else { "  " };
3242        let checkbox = if checked { "[x] " } else { "[ ] " };
3243        let prefix_width = 6; // "> [x] " or "  [ ] "
3244        execute!(stderr, Print(cursor), Print(checkbox))?;
3245
3246        if self.filter_text.is_empty() {
3247            self.render_truncated_text(stderr, text, prefix_width)?;
3248        } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3249        {
3250            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3251        } else {
3252            self.render_truncated_text(stderr, text, prefix_width)?;
3253        }
3254        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3255        Ok(())
3256    }
3257
3258    /// Render text, truncating with ellipsis if it exceeds available width.
3259    fn render_truncated_text(
3260        &self,
3261        stderr: &mut Stderr,
3262        text: &str,
3263        prefix_width: usize,
3264    ) -> io::Result<()> {
3265        let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3266        let text_width = UnicodeWidthStr::width(text);
3267
3268        if text_width <= available_width {
3269            // Text fits, render as-is
3270            execute!(stderr, Print(text))?;
3271        } else if available_width <= 1 {
3272            // Only room for ellipsis
3273            execute!(stderr, Print("…"))?;
3274        } else {
3275            // Find the substring that fits in available_width - 1 (reserve 1 for ellipsis)
3276            let target_width = available_width - 1;
3277            let mut current_width = 0;
3278            let mut end_pos = 0;
3279
3280            for (byte_pos, c) in text.char_indices() {
3281                let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3282                if current_width + char_width > target_width {
3283                    break;
3284                }
3285                end_pos = byte_pos + c.len_utf8();
3286                current_width += char_width;
3287            }
3288            execute!(stderr, Print(&text[..end_pos]))?;
3289            execute!(stderr, Print("…"))?;
3290        }
3291        Ok(())
3292    }
3293
3294    /// Render fuzzy-highlighted text, truncating with ellipsis if needed.
3295    /// The ellipsis is highlighted if any matches fall in the truncated portion.
3296    fn render_truncated_fuzzy_text(
3297        &self,
3298        stderr: &mut Stderr,
3299        text: &str,
3300        match_indices: &[usize],
3301        prefix_width: usize,
3302    ) -> io::Result<()> {
3303        let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3304        let text_width = UnicodeWidthStr::width(text);
3305
3306        // Reusable single-char buffer for styled output (avoids allocation per char)
3307        let mut char_buf = [0u8; 4];
3308
3309        if text_width <= available_width {
3310            // Text fits, render with highlighting.
3311            // match_indices is sorted, so use two-pointer approach for O(n) instead of O(n*m)
3312            let mut match_iter = match_indices.iter().peekable();
3313            for (idx, c) in text.chars().enumerate() {
3314                // Advance match_iter past any indices we've passed
3315                while match_iter.peek().is_some_and(|&&i| i < idx) {
3316                    match_iter.next();
3317                }
3318                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3319                if is_match {
3320                    let s = c.encode_utf8(&mut char_buf);
3321                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3322                } else {
3323                    execute!(stderr, Print(c))?;
3324                }
3325            }
3326        } else if available_width <= 1 {
3327            // Only room for ellipsis
3328            let has_any_matches = !match_indices.is_empty();
3329            if has_any_matches {
3330                execute!(stderr, Print(self.config.match_text.paint("…")))?;
3331            } else {
3332                execute!(stderr, Print("…"))?;
3333            }
3334        } else {
3335            // Find how many chars fit in available_width - 1 (reserve 1 for ellipsis)
3336            let target_width = available_width - 1;
3337            let mut current_width = 0;
3338            let mut chars_to_render: usize = 0;
3339
3340            for c in text.chars() {
3341                let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3342                if current_width + char_width > target_width {
3343                    break;
3344                }
3345                current_width += char_width;
3346                chars_to_render += 1;
3347            }
3348
3349            // Render the characters that fit, using two-pointer approach for efficiency
3350            let mut match_iter = match_indices.iter().peekable();
3351            for (idx, c) in text.chars().enumerate() {
3352                if idx >= chars_to_render {
3353                    break;
3354                }
3355                while match_iter.peek().is_some_and(|&&i| i < idx) {
3356                    match_iter.next();
3357                }
3358                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3359                if is_match {
3360                    let s = c.encode_utf8(&mut char_buf);
3361                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3362                } else {
3363                    execute!(stderr, Print(c))?;
3364                }
3365            }
3366
3367            // Check if any matches are in the truncated portion (remaining in match_iter)
3368            let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
3369
3370            if has_hidden_matches {
3371                execute!(stderr, Print(self.config.match_text.paint("…")))?;
3372            } else {
3373                execute!(stderr, Print("…"))?;
3374            }
3375        }
3376        Ok(())
3377    }
3378
3379    /// Render the table header row
3380    fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3381        let Some(layout) = &self.table_layout else {
3382            return Ok(());
3383        };
3384
3385        let prefix_width = self.row_prefix_width();
3386        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3387        let has_more_left = self.horizontal_offset > 0;
3388
3389        // Render prefix space (no marker for header)
3390        execute!(stderr, Print(" ".repeat(prefix_width)))?;
3391
3392        // Left scroll indicator (ellipsis + column separator)
3393        if has_more_left {
3394            let sep = self.table_column_separator();
3395            execute!(
3396                stderr,
3397                Print(self.config.table_separator.paint("…")),
3398                Print(self.config.table_separator.paint(&sep))
3399            )?;
3400        }
3401
3402        // Render visible column headers
3403        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3404        for (i, col_idx) in visible_range.enumerate() {
3405            if col_idx >= layout.columns.len() {
3406                break;
3407            }
3408
3409            // Separator between columns
3410            if i > 0 {
3411                let sep = self.table_column_separator();
3412                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3413            }
3414
3415            // Render column header, center-aligned to column width
3416            let header = &layout.columns[col_idx];
3417            let col_width = layout.col_widths[col_idx];
3418            let header_width = header.width();
3419            let padding = col_width.saturating_sub(header_width);
3420            let left_pad = padding / 2;
3421            let right_pad = padding - left_pad;
3422            let header_padded = format!(
3423                "{}{}{}",
3424                " ".repeat(left_pad),
3425                header,
3426                " ".repeat(right_pad)
3427            );
3428            execute!(
3429                stderr,
3430                Print(self.config.table_header.paint(&header_padded))
3431            )?;
3432        }
3433
3434        // Right scroll indicator (column separator + ellipsis)
3435        if has_more_right {
3436            let sep = self.table_column_separator();
3437            execute!(
3438                stderr,
3439                Print(self.config.table_separator.paint(&sep)),
3440                Print(self.config.table_separator.paint("…"))
3441            )?;
3442        }
3443
3444        execute!(stderr, Clear(ClearType::UntilNewLine))?;
3445        Ok(())
3446    }
3447
3448    /// Render the separator line between table header and data rows
3449    fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3450        let Some(layout) = &self.table_layout else {
3451            return Ok(());
3452        };
3453
3454        let prefix_width = self.row_prefix_width();
3455        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3456        let has_more_left = self.horizontal_offset > 0;
3457
3458        let h_char = self.config.table_header_separator;
3459        let int_char = self.config.table_header_intersection;
3460
3461        // Render prefix as horizontal line
3462        let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3463        execute!(
3464            stderr,
3465            Print(self.config.table_separator.paint(&prefix_line))
3466        )?;
3467
3468        // Left scroll indicator (as horizontal continuation with intersection)
3469        // Width matches "… │ " = 1 + separator_width
3470        if has_more_left {
3471            let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3472            execute!(
3473                stderr,
3474                Print(self.config.table_separator.paint(&left_indicator))
3475            )?;
3476        }
3477
3478        // Render horizontal lines for visible columns with intersections
3479        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3480        for (i, col_idx) in visible_range.enumerate() {
3481            if col_idx >= layout.col_widths.len() {
3482                break;
3483            }
3484
3485            // Intersection between columns (must match width of column separator " │ ")
3486            if i > 0 {
3487                let intersection = format!("{}{}{}", h_char, int_char, h_char);
3488                execute!(
3489                    stderr,
3490                    Print(self.config.table_separator.paint(&intersection))
3491                )?;
3492            }
3493
3494            // Horizontal line for this column's width
3495            let col_width = layout.col_widths[col_idx];
3496            let line: String = std::iter::repeat_n(h_char, col_width).collect();
3497            execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
3498        }
3499
3500        // Right scroll indicator (as horizontal continuation with intersection)
3501        // Width matches " │ …" = separator_width + 1
3502        if has_more_right {
3503            let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
3504            execute!(
3505                stderr,
3506                Print(self.config.table_separator.paint(&right_indicator))
3507            )?;
3508        }
3509
3510        execute!(stderr, Clear(ClearType::UntilNewLine))?;
3511        Ok(())
3512    }
3513
3514    /// Render a table row in single-select mode
3515    fn render_table_row_single(
3516        &self,
3517        stderr: &mut Stderr,
3518        item: &SelectItem,
3519        active: bool,
3520    ) -> io::Result<()> {
3521        let prefix = if active { self.selected_marker() } else { "  " };
3522        execute!(stderr, Print(prefix))?;
3523        self.render_table_cells(stderr, item, None)?;
3524        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3525        Ok(())
3526    }
3527
3528    /// Render a table row in multi-select mode
3529    fn render_table_row_multi(
3530        &self,
3531        stderr: &mut Stderr,
3532        item: &SelectItem,
3533        checked: bool,
3534        active: bool,
3535    ) -> io::Result<()> {
3536        let cursor = if active { self.selected_marker() } else { "  " };
3537        let checkbox = if checked { "[x] " } else { "[ ] " };
3538        execute!(stderr, Print(cursor), Print(checkbox))?;
3539        self.render_table_cells(stderr, item, None)?;
3540        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3541        Ok(())
3542    }
3543
3544    /// Render a table row in fuzzy mode with match highlighting
3545    fn render_table_row_fuzzy(
3546        &self,
3547        stderr: &mut Stderr,
3548        item: &SelectItem,
3549        active: bool,
3550    ) -> io::Result<()> {
3551        let prefix = if active { self.selected_marker() } else { "  " };
3552        execute!(stderr, Print(prefix))?;
3553
3554        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
3555        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3556            self.matcher
3557                .fuzzy_indices(&item.name, &self.filter_text)
3558                .map(|(_, indices)| indices)
3559        } else {
3560            None
3561        };
3562
3563        self.render_table_cells(stderr, item, match_indices.as_deref())?;
3564        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3565        Ok(())
3566    }
3567
3568    /// Render a table row in fuzzy-multi mode with match highlighting and checkbox
3569    fn render_table_row_fuzzy_multi(
3570        &self,
3571        stderr: &mut Stderr,
3572        item: &SelectItem,
3573        checked: bool,
3574        active: bool,
3575    ) -> io::Result<()> {
3576        let cursor = if active { self.selected_marker() } else { "  " };
3577        let checkbox = if checked { "[x] " } else { "[ ] " };
3578        execute!(stderr, Print(cursor), Print(checkbox))?;
3579
3580        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
3581        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3582            self.matcher
3583                .fuzzy_indices(&item.name, &self.filter_text)
3584                .map(|(_, indices)| indices)
3585        } else {
3586            None
3587        };
3588
3589        self.render_table_cells(stderr, item, match_indices.as_deref())?;
3590        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3591        Ok(())
3592    }
3593
3594    /// Render table cells with proper alignment and optional fuzzy highlighting
3595    fn render_table_cells(
3596        &self,
3597        stderr: &mut Stderr,
3598        item: &SelectItem,
3599        match_indices: Option<&[usize]>,
3600    ) -> io::Result<()> {
3601        let Some(layout) = &self.table_layout else {
3602            return Ok(());
3603        };
3604        let Some(cells) = &item.cells else {
3605            return Ok(());
3606        };
3607
3608        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3609        let has_more_left = self.horizontal_offset > 0;
3610
3611        // Track if there are matches in hidden columns (for scroll indicator highlighting)
3612        let mut matches_in_hidden_left = false;
3613        let mut matches_in_hidden_right = false;
3614
3615        // For per-column mode, pre-compute match indices for each cell
3616        let per_column_matches: Vec<Option<Vec<usize>>> =
3617            if self.per_column && !self.filter_text.is_empty() {
3618                cells
3619                    .iter()
3620                    .map(|(cell_text, _)| {
3621                        self.matcher
3622                            .fuzzy_indices(cell_text, &self.filter_text)
3623                            .map(|(_, indices)| indices)
3624                    })
3625                    .collect()
3626            } else {
3627                vec![]
3628            };
3629
3630        // Calculate character offset for each cell to map match indices (for non-per-column mode)
3631        // The search text (item.name) is space-separated cells, so we need to track offsets
3632        let cell_offsets: Vec<usize> = if match_indices.is_some() {
3633            let mut offsets = Vec::with_capacity(cells.len());
3634            let mut offset = 0;
3635            for (i, (cell_text, _)) in cells.iter().enumerate() {
3636                offsets.push(offset);
3637                offset += cell_text.chars().count();
3638                if i + 1 < cells.len() {
3639                    offset += 1; // For the space separator
3640                }
3641            }
3642            offsets
3643        } else {
3644            vec![]
3645        };
3646
3647        // Check for matches in hidden left columns
3648        if self.per_column && !self.filter_text.is_empty() {
3649            for col_idx in 0..self.horizontal_offset {
3650                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3651                    matches_in_hidden_left = true;
3652                    break;
3653                }
3654            }
3655        } else if let Some(indices) = match_indices {
3656            for col_idx in 0..self.horizontal_offset {
3657                if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
3658                    let cell_start = cell_offsets[col_idx];
3659                    let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); // -1 for space
3660                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3661                        matches_in_hidden_left = true;
3662                        break;
3663                    }
3664                }
3665            }
3666        }
3667
3668        // Left scroll indicator (ellipsis + column separator)
3669        if has_more_left {
3670            let sep = self.table_column_separator();
3671            if matches_in_hidden_left {
3672                execute!(
3673                    stderr,
3674                    Print(self.config.match_text.paint("…")),
3675                    Print(self.config.table_separator.paint(&sep))
3676                )?;
3677            } else {
3678                execute!(
3679                    stderr,
3680                    Print(self.config.table_separator.paint("…")),
3681                    Print(self.config.table_separator.paint(&sep))
3682                )?;
3683            }
3684        }
3685
3686        // Render visible cells
3687        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3688        for (i, col_idx) in visible_range.enumerate() {
3689            if col_idx >= cells.len() {
3690                break;
3691            }
3692
3693            // Separator between columns
3694            if i > 0 {
3695                let sep = self.table_column_separator();
3696                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3697            }
3698
3699            let (cell_text, cell_style) = &cells[col_idx];
3700            let col_width = layout.col_widths[col_idx];
3701
3702            // Get match indices for this cell
3703            let cell_matches: Option<Vec<usize>> =
3704                if self.per_column && !self.filter_text.is_empty() {
3705                    // Per-column mode: use pre-computed per-cell indices
3706                    per_column_matches.get(col_idx).cloned().flatten()
3707                } else if let Some(indices) = match_indices {
3708                    // Standard mode: map global indices to cell-relative
3709                    if col_idx < cell_offsets.len() {
3710                        let cell_start = cell_offsets[col_idx];
3711                        // Filter indices that fall within this cell and adjust to cell-relative
3712                        let cell_char_count = cell_text.chars().count();
3713                        let relative_indices: Vec<usize> = indices
3714                            .iter()
3715                            .filter_map(|&idx| {
3716                                if idx >= cell_start && idx < cell_start + cell_char_count {
3717                                    Some(idx - cell_start)
3718                                } else {
3719                                    None
3720                                }
3721                            })
3722                            .collect();
3723                        if relative_indices.is_empty() {
3724                            None
3725                        } else {
3726                            Some(relative_indices)
3727                        }
3728                    } else {
3729                        None
3730                    }
3731                } else {
3732                    None
3733                };
3734
3735            // Render cell with padding and type-based styling
3736            self.render_table_cell(
3737                stderr,
3738                cell_text,
3739                cell_style,
3740                col_width,
3741                cell_matches.as_deref(),
3742            )?;
3743        }
3744
3745        // Check for matches in hidden right columns
3746        if self.per_column && !self.filter_text.is_empty() {
3747            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3748                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3749                    matches_in_hidden_right = true;
3750                    break;
3751                }
3752            }
3753        } else if let Some(indices) = match_indices {
3754            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3755                if col_idx < cell_offsets.len() {
3756                    let cell_start = cell_offsets[col_idx];
3757                    let cell_end = if col_idx + 1 < cell_offsets.len() {
3758                        cell_offsets[col_idx + 1].saturating_sub(1)
3759                    } else {
3760                        item.name.chars().count()
3761                    };
3762                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3763                        matches_in_hidden_right = true;
3764                        break;
3765                    }
3766                }
3767            }
3768        }
3769
3770        // Right scroll indicator (column separator + ellipsis)
3771        if has_more_right {
3772            let sep = self.table_column_separator();
3773            if matches_in_hidden_right {
3774                execute!(
3775                    stderr,
3776                    Print(self.config.table_separator.paint(&sep)),
3777                    Print(self.config.match_text.paint("…"))
3778                )?;
3779            } else {
3780                execute!(
3781                    stderr,
3782                    Print(self.config.table_separator.paint(&sep)),
3783                    Print(self.config.table_separator.paint("…"))
3784                )?;
3785            }
3786        }
3787
3788        Ok(())
3789    }
3790
3791    /// Render a single table cell with padding, type-based styling, alignment, and optional match highlighting
3792    fn render_table_cell(
3793        &self,
3794        stderr: &mut Stderr,
3795        cell: &str,
3796        cell_style: &TextStyle,
3797        col_width: usize,
3798        match_indices: Option<&[usize]>,
3799    ) -> io::Result<()> {
3800        let cell_width = cell.width();
3801        let padding_needed = col_width.saturating_sub(cell_width);
3802
3803        // Calculate left and right padding based on alignment from TextStyle
3804        let (left_pad, right_pad) = match cell_style.alignment {
3805            Alignment::Left => (0, padding_needed),
3806            Alignment::Right => (padding_needed, 0),
3807            Alignment::Center => {
3808                let left = padding_needed / 2;
3809                (left, padding_needed - left)
3810            }
3811        };
3812
3813        // Add left padding
3814        if left_pad > 0 {
3815            execute!(stderr, Print(" ".repeat(left_pad)))?;
3816        }
3817
3818        if let Some(indices) = match_indices {
3819            // Render with fuzzy highlighting (match highlighting takes priority over type styling)
3820            let mut char_buf = [0u8; 4];
3821            let mut match_iter = indices.iter().peekable();
3822
3823            for (idx, c) in cell.chars().enumerate() {
3824                while match_iter.peek().is_some_and(|&&i| i < idx) {
3825                    match_iter.next();
3826                }
3827                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3828                if is_match {
3829                    let s = c.encode_utf8(&mut char_buf);
3830                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3831                } else {
3832                    // Apply type-based style for non-match characters
3833                    let s = c.encode_utf8(&mut char_buf);
3834                    if let Some(color) = cell_style.color_style {
3835                        execute!(stderr, Print(color.paint(&*s)))?;
3836                    } else {
3837                        execute!(stderr, Print(&*s))?;
3838                    }
3839                }
3840            }
3841        } else {
3842            // Render with type-based styling
3843            if let Some(color) = cell_style.color_style {
3844                execute!(stderr, Print(color.paint(cell)))?;
3845            } else {
3846                execute!(stderr, Print(cell))?;
3847            }
3848        }
3849
3850        // Add right padding
3851        if right_pad > 0 {
3852            execute!(stderr, Print(" ".repeat(right_pad)))?;
3853        }
3854
3855        Ok(())
3856    }
3857
3858    fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3859        // In fuzzy mode, cursor may be at filter line; move back to end first
3860        if self.fuzzy_cursor_offset > 0 {
3861            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3862            self.fuzzy_cursor_offset = 0;
3863        }
3864
3865        if self.rendered_lines > 0 {
3866            // Clear each line by moving up from current position and clearing.
3867            // This doesn't assume we know exactly where the cursor is.
3868            // First, move to column 0 and clear current line.
3869            execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3870            // Then move up and clear each remaining line
3871            for _ in 1..self.rendered_lines {
3872                execute!(
3873                    stderr,
3874                    MoveUp(1),
3875                    MoveToColumn(0),
3876                    Clear(ClearType::CurrentLine)
3877                )?;
3878            }
3879            // Now we're at the first rendered line, which is where output should go
3880        }
3881        self.rendered_lines = 0;
3882        stderr.flush()
3883    }
3884}
3885
3886enum KeyAction {
3887    Continue,
3888    Cancel,
3889    Confirm,
3890}
3891
3892#[cfg(test)]
3893mod test {
3894    use super::*;
3895
3896    fn make_widget(items: &[&str]) -> SelectWidget<'static> {
3897        let options: Vec<SelectItem> = items
3898            .iter()
3899            .map(|s| SelectItem {
3900                name: s.to_string(),
3901                cells: None,
3902                value: nu_protocol::Value::nothing(nu_protocol::Span::test_data()),
3903            })
3904            .collect();
3905
3906        SelectWidget::new(
3907            SelectMode::Single,
3908            None,
3909            options,
3910            InputListConfig::default(),
3911            None,
3912            false,
3913            StreamState {
3914                pending_stream: None,
3915                item_generator: None,
3916            },
3917        )
3918    }
3919
3920    #[test]
3921    fn wrap_up_and_down_cycles() {
3922        let mut w = make_widget(&["A", "B", "C"]);
3923        // navigate up three times, expect proper cycling
3924        w.navigate_up();
3925        assert_eq!(w.cursor, 2);
3926        w.navigate_up();
3927        assert_eq!(w.cursor, 1);
3928        w.navigate_up();
3929        assert_eq!(w.cursor, 0);
3930
3931        // navigate down three times, expect cycling as well
3932        w.navigate_down();
3933        assert_eq!(w.cursor, 1);
3934        w.navigate_down();
3935        assert_eq!(w.cursor, 2);
3936        w.navigate_down();
3937        assert_eq!(w.cursor, 0);
3938    }
3939
3940    #[test]
3941    fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
3942        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3943        w.first_render = false;
3944        w.prev_cursor = 0;
3945        w.prev_scroll_offset = 0;
3946        w.cursor = 0;
3947        w.scroll_offset = 0;
3948
3949        let mut stderr = io::stderr();
3950
3951        for _ in 0..7 {
3952            w.navigate_down();
3953            w.render(&mut stderr)?;
3954            assert_eq!(w.scroll_offset, 0);
3955        }
3956
3957        Ok(())
3958    }
3959
3960    #[test]
3961    fn up_arrow_sequence_state_and_render() -> io::Result<()> {
3962        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3963        w.first_render = false;
3964        w.prev_cursor = 0;
3965        w.prev_scroll_offset = 0;
3966        w.cursor = 0;
3967        w.scroll_offset = 0;
3968
3969        let mut stderr = io::stderr();
3970
3971        w.render(&mut stderr)?;
3972        assert_eq!(w.cursor, 0);
3973
3974        w.navigate_up();
3975        w.render(&mut stderr)?;
3976        assert_eq!(w.cursor, 2);
3977
3978        w.navigate_up();
3979        w.render(&mut stderr)?;
3980        assert_eq!(w.cursor, 1);
3981
3982        Ok(())
3983    }
3984
3985    #[test]
3986    fn test_examples() -> nu_test_support::Result {
3987        nu_test_support::test().examples(InputList)
3988    }
3989}