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