Skip to main content

nu_command/platform/input/
list.rs

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