Skip to main content

shannon_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, Ctrl+n/p: 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: "[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: "[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: "ls | input list --fuzzy 'Select the target'",
555                result: None,
556            },
557            Example {
558                description: "Choose an item from a range.",
559                example: "1..10 | input list",
560                result: None,
561            },
562            Example {
563                description: "Return the index of a selected item.",
564                example: "[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: "[[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: "[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: "ls | input list --fuzzy --no-footer",
585                result: None,
586            },
587            Example {
588                description: "Fuzzy search without the separator line",
589                example: "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: "ls | input list --no-table",
605                result: None,
606            },
607            Example {
608                description: "Fuzzy search with multiple selection (use Tab to toggle)",
609                example: "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        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1228
1229        match key.code {
1230            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1231            KeyCode::Enter => KeyAction::Confirm,
1232            KeyCode::Char('p' | 'P') if ctrl => {
1233                self.navigate_up();
1234                KeyAction::Continue
1235            }
1236            KeyCode::Up | KeyCode::Char('k') => {
1237                self.navigate_up();
1238                KeyAction::Continue
1239            }
1240            KeyCode::Char('n' | 'N') if ctrl => {
1241                self.navigate_down();
1242                KeyAction::Continue
1243            }
1244            KeyCode::Down | KeyCode::Char('j') => {
1245                self.navigate_down();
1246                KeyAction::Continue
1247            }
1248            KeyCode::Left | KeyCode::Char('h') => {
1249                self.scroll_columns_left();
1250                KeyAction::Continue
1251            }
1252            KeyCode::Right | KeyCode::Char('l') => {
1253                self.scroll_columns_right();
1254                KeyAction::Continue
1255            }
1256            KeyCode::Home => {
1257                self.navigate_home();
1258                KeyAction::Continue
1259            }
1260            KeyCode::End => {
1261                self.navigate_end();
1262                KeyAction::Continue
1263            }
1264            KeyCode::PageUp => {
1265                self.navigate_page_up();
1266                KeyAction::Continue
1267            }
1268            KeyCode::PageDown => {
1269                self.navigate_page_down();
1270                KeyAction::Continue
1271            }
1272            _ => KeyAction::Continue,
1273        }
1274    }
1275
1276    fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1277        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1278
1279        match key.code {
1280            KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1281            KeyCode::Enter => KeyAction::Confirm,
1282            // Ctrl+R: Refine list to only show selected items
1283            KeyCode::Char('r' | 'R') if ctrl => {
1284                self.refine_list();
1285                KeyAction::Continue
1286            }
1287            KeyCode::Char('p' | 'P') if ctrl => {
1288                self.navigate_up();
1289                KeyAction::Continue
1290            }
1291            KeyCode::Up | KeyCode::Char('k') => {
1292                self.navigate_up();
1293                KeyAction::Continue
1294            }
1295            KeyCode::Char('n' | 'N') if ctrl => {
1296                self.navigate_down();
1297                KeyAction::Continue
1298            }
1299            KeyCode::Down | KeyCode::Char('j') => {
1300                self.navigate_down();
1301                KeyAction::Continue
1302            }
1303            KeyCode::Left | KeyCode::Char('h') => {
1304                self.scroll_columns_left();
1305                KeyAction::Continue
1306            }
1307            KeyCode::Right | KeyCode::Char('l') => {
1308                self.scroll_columns_right();
1309                KeyAction::Continue
1310            }
1311            KeyCode::Char(' ') => {
1312                self.toggle_current();
1313                KeyAction::Continue
1314            }
1315            KeyCode::Char('a') => {
1316                self.toggle_all();
1317                KeyAction::Continue
1318            }
1319            KeyCode::Home => {
1320                self.navigate_home();
1321                KeyAction::Continue
1322            }
1323            KeyCode::End => {
1324                self.navigate_end();
1325                KeyAction::Continue
1326            }
1327            KeyCode::PageUp => {
1328                self.navigate_page_up();
1329                KeyAction::Continue
1330            }
1331            KeyCode::PageDown => {
1332                self.navigate_page_down();
1333                KeyAction::Continue
1334            }
1335            _ => KeyAction::Continue,
1336        }
1337    }
1338
1339    fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1340        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1341        let alt = key.modifiers.contains(KeyModifiers::ALT);
1342        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1343
1344        match key.code {
1345            KeyCode::Esc => KeyAction::Cancel,
1346            KeyCode::Enter => KeyAction::Confirm,
1347
1348            // List navigation
1349            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1350                self.navigate_up();
1351                KeyAction::Continue
1352            }
1353            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1354                self.navigate_down();
1355                KeyAction::Continue
1356            }
1357            KeyCode::Up => {
1358                self.navigate_up();
1359                KeyAction::Continue
1360            }
1361            KeyCode::Down => {
1362                self.navigate_down();
1363                KeyAction::Continue
1364            }
1365
1366            // Horizontal scrolling for table mode (Shift+Left/Right)
1367            KeyCode::Left if shift => {
1368                self.scroll_columns_left();
1369                KeyAction::Continue
1370            }
1371            KeyCode::Right if shift => {
1372                self.scroll_columns_right();
1373                KeyAction::Continue
1374            }
1375
1376            // Readline: Cursor movement
1377            KeyCode::Char('a' | 'A') if ctrl => {
1378                // Ctrl-A: Move to beginning of line
1379                self.filter_cursor = 0;
1380                KeyAction::Continue
1381            }
1382            KeyCode::Char('e' | 'E') if ctrl => {
1383                // Ctrl-E: Move to end of line
1384                self.filter_cursor = self.filter_text.len();
1385                KeyAction::Continue
1386            }
1387            KeyCode::Char('b' | 'B') if ctrl => {
1388                // Ctrl-B: Move back one character
1389                self.move_filter_cursor_left();
1390                KeyAction::Continue
1391            }
1392            KeyCode::Char('f' | 'F') if ctrl => {
1393                // Ctrl-F: Move forward one character
1394                self.move_filter_cursor_right();
1395                KeyAction::Continue
1396            }
1397            KeyCode::Char('b' | 'B') if alt => {
1398                // Alt-B: Move back one word
1399                self.move_filter_cursor_word_left();
1400                KeyAction::Continue
1401            }
1402            KeyCode::Char('f' | 'F') if alt => {
1403                // Alt-F: Move forward one word
1404                self.move_filter_cursor_word_right();
1405                KeyAction::Continue
1406            }
1407            // Settings toggles
1408            KeyCode::Char('c' | 'C') if alt => {
1409                // Alt-C: Toggle case sensitivity
1410                self.toggle_case_sensitivity();
1411                KeyAction::Continue
1412            }
1413            KeyCode::Char('p' | 'P') if alt => {
1414                // Alt-P: Toggle per-column matching (table mode only)
1415                self.toggle_per_column();
1416                KeyAction::Continue
1417            }
1418            KeyCode::Left if ctrl || alt => {
1419                // Ctrl/Alt-Left: Move back one word
1420                self.move_filter_cursor_word_left();
1421                KeyAction::Continue
1422            }
1423            KeyCode::Right if ctrl || alt => {
1424                // Ctrl/Alt-Right: Move forward one word
1425                self.move_filter_cursor_word_right();
1426                KeyAction::Continue
1427            }
1428            KeyCode::Left => {
1429                self.move_filter_cursor_left();
1430                KeyAction::Continue
1431            }
1432            KeyCode::Right => {
1433                self.move_filter_cursor_right();
1434                KeyAction::Continue
1435            }
1436
1437            // Readline: Deletion
1438            KeyCode::Char('u' | 'U') if ctrl => {
1439                // Ctrl-U: Kill to beginning of line
1440                self.filter_text.drain(..self.filter_cursor);
1441                self.filter_cursor = 0;
1442                self.update_filter();
1443                KeyAction::Continue
1444            }
1445            KeyCode::Char('k' | 'K') if ctrl => {
1446                // Ctrl-K: Kill to end of line
1447                self.filter_text.truncate(self.filter_cursor);
1448                self.update_filter();
1449                KeyAction::Continue
1450            }
1451            KeyCode::Char('d' | 'D') if ctrl => {
1452                // Ctrl-D: Delete character at cursor
1453                if self.filter_cursor < self.filter_text.len() {
1454                    self.filter_text.remove(self.filter_cursor);
1455                    self.update_filter();
1456                }
1457                KeyAction::Continue
1458            }
1459            KeyCode::Delete => {
1460                // Delete: Delete character at cursor
1461                if self.filter_cursor < self.filter_text.len() {
1462                    self.filter_text.remove(self.filter_cursor);
1463                    self.update_filter();
1464                }
1465                KeyAction::Continue
1466            }
1467            KeyCode::Char('d' | 'D') if alt => {
1468                // Alt-D: Delete word forward
1469                self.delete_word_forwards();
1470                self.update_filter();
1471                KeyAction::Continue
1472            }
1473            // Ctrl-W or Ctrl-H (Ctrl-Backspace) to delete previous word
1474            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1475                self.delete_word_backwards();
1476                self.update_filter();
1477                KeyAction::Continue
1478            }
1479            // Alt-Backspace: delete previous word
1480            KeyCode::Backspace if alt => {
1481                self.delete_word_backwards();
1482                self.update_filter();
1483                KeyAction::Continue
1484            }
1485            KeyCode::Backspace => {
1486                // Delete character before cursor (handle UTF-8)
1487                if self.filter_cursor > 0 {
1488                    // Find previous char boundary
1489                    let mut new_pos = self.filter_cursor - 1;
1490                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1491                        new_pos -= 1;
1492                    }
1493                    self.filter_cursor = new_pos;
1494                    self.filter_text.remove(self.filter_cursor);
1495                    self.update_filter();
1496                }
1497                KeyAction::Continue
1498            }
1499            // Ctrl-T: Transpose characters
1500            KeyCode::Char('t' | 'T') if ctrl => {
1501                let old_text = self.filter_text.clone();
1502                self.transpose_chars();
1503                if self.filter_text != old_text {
1504                    self.update_filter();
1505                }
1506                KeyAction::Continue
1507            }
1508
1509            // Character input
1510            KeyCode::Char(c) => {
1511                self.filter_text.insert(self.filter_cursor, c);
1512                self.filter_cursor += c.len_utf8();
1513                self.update_filter();
1514                KeyAction::Continue
1515            }
1516
1517            // List navigation with Home/End/PageUp/PageDown
1518            KeyCode::Home => {
1519                self.navigate_home();
1520                KeyAction::Continue
1521            }
1522            KeyCode::End => {
1523                self.navigate_end();
1524                KeyAction::Continue
1525            }
1526            KeyCode::PageUp => {
1527                self.navigate_page_up();
1528                KeyAction::Continue
1529            }
1530            KeyCode::PageDown => {
1531                self.navigate_page_down();
1532                KeyAction::Continue
1533            }
1534            _ => KeyAction::Continue,
1535        }
1536    }
1537
1538    fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1539        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1540        let alt = key.modifiers.contains(KeyModifiers::ALT);
1541        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1542
1543        match key.code {
1544            KeyCode::Esc => KeyAction::Cancel,
1545            KeyCode::Enter => KeyAction::Confirm,
1546
1547            // Ctrl+R: Refine list to only show selected items
1548            KeyCode::Char('r' | 'R') if ctrl => {
1549                self.refine_list();
1550                KeyAction::Continue
1551            }
1552
1553            // Tab: Toggle selection of current item and move down
1554            // Note: Some terminals may report Tab as Char('\t')
1555            KeyCode::Tab | KeyCode::Char('\t') => {
1556                self.toggle_current_fuzzy();
1557                self.navigate_down();
1558                KeyAction::Continue
1559            }
1560
1561            // Shift-Tab: Toggle selection and move up
1562            KeyCode::BackTab => {
1563                self.toggle_current_fuzzy();
1564                self.navigate_up();
1565                KeyAction::Continue
1566            }
1567
1568            // List navigation
1569            KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1570                self.navigate_up();
1571                KeyAction::Continue
1572            }
1573            KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1574                self.navigate_down();
1575                KeyAction::Continue
1576            }
1577            KeyCode::Up => {
1578                self.navigate_up();
1579                KeyAction::Continue
1580            }
1581            KeyCode::Down => {
1582                self.navigate_down();
1583                KeyAction::Continue
1584            }
1585
1586            // Horizontal scrolling for table mode (Shift+Left/Right)
1587            KeyCode::Left if shift => {
1588                self.scroll_columns_left();
1589                KeyAction::Continue
1590            }
1591            KeyCode::Right if shift => {
1592                self.scroll_columns_right();
1593                KeyAction::Continue
1594            }
1595
1596            // Readline: Cursor movement
1597            KeyCode::Char('a' | 'A') if ctrl => {
1598                self.filter_cursor = 0;
1599                KeyAction::Continue
1600            }
1601            KeyCode::Char('e' | 'E') if ctrl => {
1602                self.filter_cursor = self.filter_text.len();
1603                KeyAction::Continue
1604            }
1605            KeyCode::Char('b' | 'B') if ctrl => {
1606                self.move_filter_cursor_left();
1607                KeyAction::Continue
1608            }
1609            KeyCode::Char('f' | 'F') if ctrl => {
1610                self.move_filter_cursor_right();
1611                KeyAction::Continue
1612            }
1613            KeyCode::Char('b' | 'B') if alt => {
1614                self.move_filter_cursor_word_left();
1615                KeyAction::Continue
1616            }
1617            KeyCode::Char('f' | 'F') if alt => {
1618                self.move_filter_cursor_word_right();
1619                KeyAction::Continue
1620            }
1621            // Settings toggles
1622            KeyCode::Char('c' | 'C') if alt => {
1623                // Alt-C: Toggle case sensitivity
1624                self.toggle_case_sensitivity();
1625                KeyAction::Continue
1626            }
1627            KeyCode::Char('p' | 'P') if alt => {
1628                // Alt-P: Toggle per-column matching (table mode only)
1629                self.toggle_per_column();
1630                KeyAction::Continue
1631            }
1632            KeyCode::Left if ctrl || alt => {
1633                self.move_filter_cursor_word_left();
1634                KeyAction::Continue
1635            }
1636            KeyCode::Right if ctrl || alt => {
1637                self.move_filter_cursor_word_right();
1638                KeyAction::Continue
1639            }
1640            KeyCode::Left => {
1641                self.move_filter_cursor_left();
1642                KeyAction::Continue
1643            }
1644            KeyCode::Right => {
1645                self.move_filter_cursor_right();
1646                KeyAction::Continue
1647            }
1648
1649            // Readline: Deletion
1650            KeyCode::Char('u' | 'U') if ctrl => {
1651                self.filter_text.drain(..self.filter_cursor);
1652                self.filter_cursor = 0;
1653                self.update_filter();
1654                KeyAction::Continue
1655            }
1656            KeyCode::Char('k' | 'K') if ctrl => {
1657                self.filter_text.truncate(self.filter_cursor);
1658                self.update_filter();
1659                KeyAction::Continue
1660            }
1661            KeyCode::Char('d' | 'D') if ctrl => {
1662                if self.filter_cursor < self.filter_text.len() {
1663                    self.filter_text.remove(self.filter_cursor);
1664                    self.update_filter();
1665                }
1666                KeyAction::Continue
1667            }
1668            KeyCode::Delete => {
1669                if self.filter_cursor < self.filter_text.len() {
1670                    self.filter_text.remove(self.filter_cursor);
1671                    self.update_filter();
1672                }
1673                KeyAction::Continue
1674            }
1675            KeyCode::Char('d' | 'D') if alt => {
1676                self.delete_word_forwards();
1677                self.update_filter();
1678                KeyAction::Continue
1679            }
1680            KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1681                self.delete_word_backwards();
1682                self.update_filter();
1683                KeyAction::Continue
1684            }
1685            KeyCode::Backspace if alt => {
1686                self.delete_word_backwards();
1687                self.update_filter();
1688                KeyAction::Continue
1689            }
1690            KeyCode::Backspace => {
1691                if self.filter_cursor > 0 {
1692                    let mut new_pos = self.filter_cursor - 1;
1693                    while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1694                        new_pos -= 1;
1695                    }
1696                    self.filter_cursor = new_pos;
1697                    self.filter_text.remove(self.filter_cursor);
1698                    self.update_filter();
1699                }
1700                KeyAction::Continue
1701            }
1702            KeyCode::Char('t' | 'T') if ctrl => {
1703                let old_text = self.filter_text.clone();
1704                self.transpose_chars();
1705                if self.filter_text != old_text {
1706                    self.update_filter();
1707                }
1708                KeyAction::Continue
1709            }
1710
1711            // Alt-A: Toggle all filtered items in fuzzy multi mode
1712            KeyCode::Char('a' | 'A') if alt => {
1713                self.toggle_all_fuzzy();
1714                KeyAction::Continue
1715            }
1716
1717            // Character input
1718            KeyCode::Char(c) => {
1719                self.filter_text.insert(self.filter_cursor, c);
1720                self.filter_cursor += c.len_utf8();
1721                self.update_filter();
1722                KeyAction::Continue
1723            }
1724
1725            // List navigation with Home/End/PageUp/PageDown
1726            KeyCode::Home => {
1727                self.navigate_home();
1728                KeyAction::Continue
1729            }
1730            KeyCode::End => {
1731                self.navigate_end();
1732                KeyAction::Continue
1733            }
1734            KeyCode::PageUp => {
1735                self.navigate_page_up();
1736                KeyAction::Continue
1737            }
1738            KeyCode::PageDown => {
1739                self.navigate_page_down();
1740                KeyAction::Continue
1741            }
1742            _ => KeyAction::Continue,
1743        }
1744    }
1745
1746    /// Move cursor up with wrapping
1747    fn navigate_up(&mut self) {
1748        let list_len = self.current_list_len();
1749        if self.cursor > 0 {
1750            self.cursor -= 1;
1751            self.adjust_scroll_up();
1752        } else if list_len > 0 {
1753            // Wrap to bottom
1754            self.cursor = list_len - 1;
1755            self.adjust_scroll_down();
1756        }
1757    }
1758
1759    /// Move cursor down with wrapping
1760    fn navigate_down(&mut self) {
1761        let list_len = self.current_list_len();
1762        if self.cursor + 1 < list_len {
1763            self.cursor += 1;
1764            self.adjust_scroll_down();
1765        } else {
1766            // Wrap to top
1767            self.cursor = 0;
1768            self.scroll_offset = 0;
1769        }
1770    }
1771
1772    fn adjust_scroll_down(&mut self) {
1773        let max_visible = self.scroll_offset + self.visible_height as usize;
1774        if self.cursor >= max_visible {
1775            self.scroll_offset = self.cursor - self.visible_height as usize + 1;
1776        }
1777    }
1778
1779    fn adjust_scroll_up(&mut self) {
1780        if self.cursor < self.scroll_offset {
1781            self.scroll_offset = self.cursor;
1782        }
1783    }
1784
1785    /// Get the current list length (filtered for fuzzy modes or refined multi, full for others)
1786    fn current_list_len(&self) -> usize {
1787        match self.mode {
1788            SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
1789            SelectMode::Multi if self.refined => self.filtered_indices.len(),
1790            _ => self.items.len(),
1791        }
1792    }
1793
1794    /// Navigate to the start of the list
1795    fn navigate_home(&mut self) {
1796        self.cursor = 0;
1797        self.scroll_offset = 0;
1798    }
1799
1800    /// Navigate to the end of the list
1801    fn navigate_end(&mut self) {
1802        self.cursor = self.current_list_len().saturating_sub(1);
1803        self.adjust_scroll_down();
1804    }
1805
1806    /// Navigate page up: go to top of current page, or previous page if already at top
1807    fn navigate_page_up(&mut self) {
1808        let page_top = self.scroll_offset;
1809        if self.cursor == page_top {
1810            // Already at top of page, go to previous page
1811            self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
1812            self.adjust_scroll_up();
1813        } else {
1814            // Go to top of current page
1815            self.cursor = page_top;
1816        }
1817    }
1818
1819    /// Navigate page down: go to bottom of current page, or next page if already at bottom
1820    fn navigate_page_down(&mut self) {
1821        let list_len = self.current_list_len();
1822        let page_bottom =
1823            (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
1824        if self.cursor == page_bottom {
1825            // Already at bottom of page, go to next page
1826            self.cursor =
1827                (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
1828            self.adjust_scroll_down();
1829        } else {
1830            // Go to bottom of current page
1831            self.cursor = page_bottom;
1832        }
1833    }
1834
1835    /// Scroll table columns left (show earlier columns)
1836    fn scroll_columns_left(&mut self) -> bool {
1837        if !self.is_table_mode() || self.horizontal_offset == 0 {
1838            return false;
1839        }
1840        self.horizontal_offset -= 1;
1841        self.horizontal_scroll_changed = true;
1842        self.update_table_layout();
1843        true
1844    }
1845
1846    /// Scroll table columns right (show later columns)
1847    fn scroll_columns_right(&mut self) -> bool {
1848        let Some(layout) = &self.table_layout else {
1849            return false;
1850        };
1851        let (cols_visible, has_more_right) = self.calculate_visible_columns();
1852        if !has_more_right {
1853            return false;
1854        }
1855        // Don't scroll past the last column
1856        if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
1857            return false;
1858        }
1859        self.horizontal_offset += 1;
1860        self.horizontal_scroll_changed = true;
1861        self.update_table_layout();
1862        true
1863    }
1864
1865    fn toggle_current(&mut self) {
1866        // Guard against empty list when refined
1867        if self.refined && self.filtered_indices.is_empty() {
1868            return;
1869        }
1870        // Get the real item index (may differ from cursor when refined)
1871        let real_idx = if self.refined {
1872            self.filtered_indices[self.cursor]
1873        } else {
1874            self.cursor
1875        };
1876        self.toggle_index(real_idx);
1877    }
1878
1879    /// Toggle selection of a specific item by its real index
1880    fn toggle_index(&mut self, real_idx: usize) {
1881        if self.selected.contains(&real_idx) {
1882            self.selected.remove(&real_idx);
1883        } else {
1884            self.selected.insert(real_idx);
1885        }
1886        self.toggled_item = Some(self.cursor);
1887    }
1888
1889    /// Toggle selection of current item in fuzzy multi mode (uses filtered_indices)
1890    /// Returns true if an item was toggled, false if list was empty
1891    fn toggle_current_fuzzy(&mut self) -> bool {
1892        if self.filtered_indices.is_empty() {
1893            return false;
1894        }
1895        let real_idx = self.filtered_indices[self.cursor];
1896        self.toggle_index(real_idx);
1897        true
1898    }
1899
1900    fn toggle_all(&mut self) {
1901        // Check if all current items are selected
1902        let all_selected = if self.refined {
1903            self.filtered_indices
1904                .iter()
1905                .all(|i| self.selected.contains(i))
1906        } else {
1907            (0..self.items.len()).all(|i| self.selected.contains(&i))
1908        };
1909
1910        if all_selected {
1911            // Deselect all current items
1912            if self.refined {
1913                for i in &self.filtered_indices {
1914                    self.selected.remove(i);
1915                }
1916            } else {
1917                self.selected.clear();
1918            }
1919        } else {
1920            // Select all current items
1921            if self.refined {
1922                self.selected.extend(self.filtered_indices.iter().copied());
1923            } else {
1924                self.selected.extend(0..self.items.len());
1925            }
1926        }
1927        self.toggled_all = true;
1928    }
1929
1930    /// Toggle all items in fuzzy multi mode (only the currently filtered items)
1931    fn toggle_all_fuzzy(&mut self) {
1932        if self.filtered_indices.is_empty() {
1933            return;
1934        }
1935
1936        // Check if all filtered items are selected
1937        let all_selected = self
1938            .filtered_indices
1939            .iter()
1940            .all(|i| self.selected.contains(i));
1941
1942        if all_selected {
1943            // Deselect all filtered items
1944            for i in &self.filtered_indices {
1945                self.selected.remove(i);
1946            }
1947        } else {
1948            // Select all filtered items
1949            self.selected.extend(self.filtered_indices.iter().copied());
1950        }
1951        self.toggled_all = true;
1952    }
1953
1954    /// Refine the list to only show currently selected items
1955    /// This allows users to narrow down to their selections and continue selecting
1956    fn refine_list(&mut self) {
1957        if self.selected.is_empty() {
1958            return;
1959        }
1960
1961        // Set filtered_indices to sorted selected indices
1962        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1963        indices.sort();
1964
1965        // Store as base indices for filtering in FuzzyMulti mode
1966        // Clone once for both vectors instead of cloning refined_base_indices
1967        self.filtered_indices = indices.clone();
1968        self.refined_base_indices = indices;
1969
1970        // Reset cursor and scroll
1971        self.cursor = 0;
1972        self.scroll_offset = 0;
1973
1974        // Keep all items selected (don't clear selection)
1975        // User can deselect items they don't want
1976
1977        // Clear filter text in FuzzyMulti mode
1978        if self.mode == SelectMode::FuzzyMulti {
1979            self.filter_text.clear();
1980            self.filter_cursor = 0;
1981            self.filter_text_changed = true;
1982        }
1983
1984        // Mark as refined (for Multi mode rendering)
1985        self.refined = true;
1986
1987        // Force full redraw
1988        self.first_render = true;
1989    }
1990
1991    // Filter cursor movement helpers
1992    fn move_filter_cursor_left(&mut self) {
1993        if self.filter_cursor > 0 {
1994            // Move back one character (handle UTF-8)
1995            let mut new_pos = self.filter_cursor - 1;
1996            while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1997                new_pos -= 1;
1998            }
1999            self.filter_cursor = new_pos;
2000        }
2001    }
2002
2003    fn move_filter_cursor_right(&mut self) {
2004        if self.filter_cursor < self.filter_text.len() {
2005            // Move forward one character (handle UTF-8)
2006            let mut new_pos = self.filter_cursor + 1;
2007            while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2008                new_pos += 1;
2009            }
2010            self.filter_cursor = new_pos;
2011        }
2012    }
2013
2014    fn move_filter_cursor_word_left(&mut self) {
2015        if self.filter_cursor == 0 {
2016            return;
2017        }
2018        let bytes = self.filter_text.as_bytes();
2019        let mut pos = self.filter_cursor;
2020        // Skip whitespace
2021        while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2022            pos -= 1;
2023        }
2024        // Skip word characters
2025        while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2026            pos -= 1;
2027        }
2028        self.filter_cursor = pos;
2029    }
2030
2031    fn move_filter_cursor_word_right(&mut self) {
2032        let len = self.filter_text.len();
2033        if self.filter_cursor >= len {
2034            return;
2035        }
2036        let bytes = self.filter_text.as_bytes();
2037        let mut pos = self.filter_cursor;
2038        // Skip current word characters
2039        while pos < len && !bytes[pos].is_ascii_whitespace() {
2040            pos += 1;
2041        }
2042        // Skip whitespace
2043        while pos < len && bytes[pos].is_ascii_whitespace() {
2044            pos += 1;
2045        }
2046        self.filter_cursor = pos;
2047    }
2048
2049    fn delete_word_backwards(&mut self) {
2050        if self.filter_cursor == 0 {
2051            return;
2052        }
2053        let start = self.filter_cursor;
2054        // Skip whitespace
2055        while self.filter_cursor > 0
2056            && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2057        {
2058            self.filter_cursor -= 1;
2059        }
2060        // Skip word characters
2061        while self.filter_cursor > 0
2062            && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2063        {
2064            self.filter_cursor -= 1;
2065        }
2066        self.filter_text.drain(self.filter_cursor..start);
2067    }
2068
2069    fn delete_word_forwards(&mut self) {
2070        let len = self.filter_text.len();
2071        if self.filter_cursor >= len {
2072            return;
2073        }
2074        let start = self.filter_cursor;
2075        let bytes = self.filter_text.as_bytes();
2076        let mut end = start;
2077        // Skip word characters
2078        while end < len && !bytes[end].is_ascii_whitespace() {
2079            end += 1;
2080        }
2081        // Skip whitespace
2082        while end < len && bytes[end].is_ascii_whitespace() {
2083            end += 1;
2084        }
2085        self.filter_text.drain(start..end);
2086    }
2087
2088    fn transpose_chars(&mut self) {
2089        // Ctrl-T: swap the two characters before the cursor
2090        // If at end of line, swap last two chars
2091        // If at position 1 or beyond with at least 2 chars, swap char before cursor with one before that
2092        let len = self.filter_text.len();
2093        if len < 2 {
2094            return;
2095        }
2096
2097        // If cursor is at start, nothing to transpose
2098        if self.filter_cursor == 0 {
2099            return;
2100        }
2101
2102        // If cursor is at end, transpose last two characters and keep cursor at end
2103        // Otherwise, transpose char at cursor-1 with char at cursor, then move cursor right
2104        let pos = if self.filter_cursor >= len {
2105            len - 1
2106        } else {
2107            self.filter_cursor
2108        };
2109
2110        if pos == 0 {
2111            return;
2112        }
2113
2114        // Only transpose if both positions are ASCII (single-byte) characters.
2115        // For multi-byte UTF-8 characters, transposition is more complex and skipped.
2116        if self.filter_text.is_char_boundary(pos - 1)
2117            && self.filter_text.is_char_boundary(pos)
2118            && pos < len
2119            && self.filter_text.is_char_boundary(pos + 1)
2120        {
2121            // Check both chars are single-byte ASCII
2122            let bytes = self.filter_text.as_bytes();
2123            if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2124                // SAFETY: We verified both bytes are ASCII, so swapping them is safe
2125                let bytes = unsafe { self.filter_text.as_bytes_mut() };
2126                bytes.swap(pos - 1, pos);
2127
2128                // Move cursor right if not at end
2129                if self.filter_cursor < len {
2130                    self.filter_cursor += 1;
2131                }
2132            }
2133        }
2134    }
2135
2136    /// Score an item using per-column matching (best column wins)
2137    fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
2138        item.cells.as_ref().and_then(|cells| {
2139            cells
2140                .iter()
2141                .filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
2142                .max()
2143        })
2144    }
2145
2146    /// Score an item - uses per-column matching if enabled and in table mode
2147    fn score_item(&self, item: &SelectItem) -> Option<i64> {
2148        if self.per_column && item.cells.is_some() {
2149            self.score_per_column(item)
2150        } else {
2151            self.matcher.fuzzy_match(&item.name, &self.filter_text)
2152        }
2153    }
2154
2155    fn update_filter(&mut self) {
2156        let old_indices = std::mem::take(&mut self.filtered_indices);
2157
2158        // Determine whether to filter from refined subset or all items
2159        let use_refined = self.refined && !self.refined_base_indices.is_empty();
2160
2161        if self.filter_text.is_empty() {
2162            // When empty, copy the base indices
2163            self.filtered_indices = if use_refined {
2164                self.refined_base_indices.clone()
2165            } else {
2166                (0..self.items.len()).collect()
2167            };
2168        } else {
2169            // When filtering, iterate without cloning the base indices
2170            let mut scored: Vec<(usize, i64)> = if use_refined {
2171                self.refined_base_indices
2172                    .iter()
2173                    .filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
2174                    .collect()
2175            } else {
2176                (0..self.items.len())
2177                    .filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
2178                    .collect()
2179            };
2180            // Sort by score descending
2181            scored.sort_by(|a, b| b.1.cmp(&a.1));
2182            self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2183        }
2184
2185        // Check if results actually changed
2186        self.results_changed = old_indices != self.filtered_indices;
2187        self.filter_text_changed = true;
2188
2189        // Only reset cursor/scroll if results changed
2190        if self.results_changed {
2191            self.cursor = 0;
2192            self.scroll_offset = 0;
2193        }
2194
2195        // In table mode, auto-scroll horizontally to show the first column with matches
2196        if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2197        {
2198            self.auto_scroll_to_match_column();
2199        }
2200    }
2201
2202    /// In table mode, scroll horizontally to ensure the first column with matches is visible
2203    fn auto_scroll_to_match_column(&mut self) {
2204        let Some(layout) = &self.table_layout else {
2205            return;
2206        };
2207
2208        // Look at the top result to find which column has the best match
2209        let first_idx = self.filtered_indices[0];
2210        let item = &self.items[first_idx];
2211        let Some(cells) = &item.cells else {
2212            return;
2213        };
2214
2215        // Find the first column (leftmost) that has a match
2216        let mut first_match_col: Option<usize> = None;
2217        for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2218            if self.per_column {
2219                // Per-column mode: check each cell individually
2220                if self
2221                    .matcher
2222                    .fuzzy_match(cell_text, &self.filter_text)
2223                    .is_some()
2224                {
2225                    first_match_col = Some(col_idx);
2226                    break;
2227                }
2228            } else {
2229                // Standard mode: check if this cell's portion of item.name has matches
2230                // Calculate the character offset for this cell in the concatenated name
2231                let cell_start: usize = cells[..col_idx]
2232                    .iter()
2233                    .map(|(s, _)| s.chars().count() + 1) // +1 for space separator
2234                    .sum();
2235                let cell_char_count = cell_text.chars().count();
2236
2237                if let Some((_, indices)) =
2238                    self.matcher.fuzzy_indices(&item.name, &self.filter_text)
2239                {
2240                    // Check if any match indices fall within this cell
2241                    if indices
2242                        .iter()
2243                        .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
2244                    {
2245                        first_match_col = Some(col_idx);
2246                        break;
2247                    }
2248                }
2249            }
2250        }
2251
2252        // If we found a matching column, ensure it's visible
2253        if let Some(match_col) = first_match_col {
2254            let (cols_visible, _) = self.calculate_visible_columns();
2255            let visible_start = self.horizontal_offset;
2256            let visible_end = self.horizontal_offset + cols_visible;
2257
2258            if match_col < visible_start {
2259                // Match is to the left, scroll left
2260                self.horizontal_offset = match_col;
2261                self.horizontal_scroll_changed = true;
2262                self.update_table_layout();
2263            } else if match_col >= visible_end {
2264                // Match is to the right, scroll right
2265                // Set offset so match_col is the first visible column
2266                self.horizontal_offset = match_col;
2267                // But don't scroll past what's possible
2268                let max_offset = layout.col_widths.len().saturating_sub(1);
2269                self.horizontal_offset = self.horizontal_offset.min(max_offset);
2270                self.horizontal_scroll_changed = true;
2271                self.update_table_layout();
2272            }
2273        }
2274    }
2275
2276    fn get_result(&self) -> InteractMode {
2277        match self.mode {
2278            SelectMode::Single => InteractMode::Single(Some(self.cursor)),
2279            SelectMode::Multi => {
2280                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2281                indices.sort();
2282                InteractMode::Multi(Some(indices))
2283            }
2284            SelectMode::Fuzzy => {
2285                if self.filtered_indices.is_empty() {
2286                    InteractMode::Single(None)
2287                } else {
2288                    InteractMode::Single(Some(self.filtered_indices[self.cursor]))
2289                }
2290            }
2291            SelectMode::FuzzyMulti => {
2292                // Return all selected items regardless of current filter
2293                // This allows selecting items across multiple filter searches
2294                let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2295                indices.sort();
2296                InteractMode::Multi(Some(indices))
2297            }
2298        }
2299    }
2300
2301    /// Check if we can do a toggle-only update in multi mode
2302    /// (just toggled a single visible item, no cursor movement)
2303    fn can_do_multi_toggle_only_update(&self) -> bool {
2304        if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
2305            return false;
2306        }
2307        if let Some(toggled) = self.toggled_item {
2308            // Check if toggled item is visible
2309            let visible_start = self.scroll_offset;
2310            let visible_end = self.scroll_offset + self.visible_height as usize;
2311            toggled >= visible_start && toggled < visible_end
2312        } else {
2313            false
2314        }
2315    }
2316
2317    /// Check if we can do a toggle+move update in fuzzy multi mode
2318    /// (toggled an item and moved cursor, both visible, no scroll change)
2319    fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
2320        if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
2321            return false;
2322        }
2323        if self.scroll_offset != self.prev_scroll_offset {
2324            return false; // Scrolled, need full redraw
2325        }
2326        if self.filter_text_changed || self.results_changed {
2327            return false; // Filter changed, need full redraw
2328        }
2329        if let Some(toggled) = self.toggled_item {
2330            // Check if both toggled item and new cursor are visible
2331            let visible_start = self.scroll_offset;
2332            let visible_end = self.scroll_offset + self.visible_height as usize;
2333            let toggled_visible = toggled >= visible_start && toggled < visible_end;
2334            let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
2335            toggled_visible && cursor_visible
2336        } else {
2337            false
2338        }
2339    }
2340
2341    /// Check if we can do a toggle-all update in fuzzy multi mode
2342    /// (toggled all filtered items with Alt+A)
2343    fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
2344        !self.first_render
2345            && !self.width_changed
2346            && self.mode == SelectMode::FuzzyMulti
2347            && self.toggled_all
2348            && !self.filter_text_changed
2349            && !self.results_changed
2350            && self.scroll_offset == self.prev_scroll_offset
2351            && !self.horizontal_scroll_changed
2352    }
2353
2354    /// Check if we can do a toggle-all update in multi mode
2355    /// (toggled all items with 'a' key)
2356    fn can_do_multi_toggle_all_update(&self) -> bool {
2357        !self.first_render
2358            && !self.width_changed
2359            && self.mode == SelectMode::Multi
2360            && self.toggled_all
2361    }
2362
2363    /// FuzzyMulti mode: update toggled row and new cursor row
2364    fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2365        let toggled = self.toggled_item.expect("toggled_item must be Some");
2366        execute!(stderr, BeginSynchronizedUpdate)?;
2367
2368        // Calculate header lines (prompt + filter + separator + table header)
2369        let header_lines = self.fuzzy_header_lines();
2370
2371        let toggled_display_row = (toggled - self.scroll_offset) as u16;
2372        let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
2373
2374        let toggled_item_row = header_lines + toggled_display_row;
2375        let cursor_item_row = header_lines + cursor_display_row;
2376
2377        // We're at the filter line
2378        let filter_row = self.fuzzy_filter_row();
2379
2380        // Move to toggled row and redraw it (checkbox changed, marker removed)
2381        let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2382        execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2383
2384        // Redraw toggled row (now without marker, checkbox state changed)
2385        let toggled_real_idx = self.filtered_indices[toggled];
2386        let toggled_item = &self.items[toggled_real_idx];
2387        let toggled_checked = self.selected.contains(&toggled_real_idx);
2388        if self.is_table_mode() {
2389            self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
2390        } else {
2391            self.render_fuzzy_multi_item_inline(
2392                stderr,
2393                &toggled_item.name,
2394                toggled_checked,
2395                false,
2396            )?;
2397        }
2398
2399        // Move to cursor row and redraw it (marker added)
2400        if cursor_item_row > toggled_item_row {
2401            let lines_down = cursor_item_row - toggled_item_row;
2402            execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
2403        } else if cursor_item_row < toggled_item_row {
2404            let lines_up = toggled_item_row - cursor_item_row;
2405            execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
2406        }
2407
2408        let cursor_real_idx = self.filtered_indices[self.cursor];
2409        let cursor_item = &self.items[cursor_real_idx];
2410        let cursor_checked = self.selected.contains(&cursor_real_idx);
2411        if self.is_table_mode() {
2412            self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
2413        } else {
2414            self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
2415        }
2416
2417        // Update footer to reflect new selection count
2418        if self.has_footer() {
2419            // Calculate footer row position
2420            let total_count = self.current_list_len();
2421            let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2422            let visible_count = (end - self.scroll_offset) as u16;
2423            let footer_row = header_lines + visible_count;
2424
2425            // Move from cursor row to footer
2426            let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2427            execute!(stderr, MoveDown(down_to_footer))?;
2428
2429            // Update footer
2430            self.render_footer_inline(stderr)?;
2431
2432            // Move back to filter line
2433            let up_to_filter = footer_row.saturating_sub(filter_row);
2434            execute!(stderr, MoveUp(up_to_filter))?;
2435        } else {
2436            // Move back to filter line
2437            let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2438            execute!(stderr, MoveUp(up_to_filter))?;
2439        }
2440
2441        // Position cursor within filter text
2442        self.position_fuzzy_cursor(stderr)?;
2443
2444        // Update state
2445        self.prev_cursor = self.cursor;
2446        self.toggled_item = None;
2447
2448        execute!(stderr, EndSynchronizedUpdate)?;
2449        stderr.flush()
2450    }
2451
2452    /// Multi mode: only update the checkbox for the toggled item
2453    fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2454        let toggled = self.toggled_item.expect("toggled_item must be Some");
2455        execute!(stderr, BeginSynchronizedUpdate)?;
2456
2457        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2458        if self.is_table_mode() {
2459            header_lines += 2; // table header + header separator line
2460        }
2461
2462        // Calculate display position of toggled item relative to scroll
2463        let display_row = (toggled - self.scroll_offset) as u16;
2464
2465        // Current position is at end of rendered content
2466        let items_rendered = self.rendered_lines - header_lines as usize;
2467
2468        // Move to the toggled row
2469        // Cursor is at end of last content line, so subtract 1 from items_rendered
2470        let lines_up = (items_rendered as u16)
2471            .saturating_sub(1)
2472            .saturating_sub(display_row);
2473        execute!(stderr, MoveUp(lines_up))?;
2474
2475        // Move to checkbox column (after "> " or "  ")
2476        execute!(stderr, MoveToColumn(2))?;
2477
2478        // Write new checkbox state
2479        let checkbox = if self.selected.contains(&toggled) {
2480            "[x]"
2481        } else {
2482            "[ ]"
2483        };
2484        execute!(stderr, Print(checkbox))?;
2485
2486        // Move back to end position (footer line if shown, else last item line)
2487        execute!(stderr, MoveDown(lines_up))?;
2488
2489        // Update footer to reflect new selection count
2490        if self.has_footer() {
2491            self.render_footer_inline(stderr)?;
2492        }
2493
2494        // Reset toggle tracking
2495        self.toggled_item = None;
2496
2497        execute!(stderr, EndSynchronizedUpdate)?;
2498        stderr.flush()
2499    }
2500
2501    /// Multi mode: update all visible checkboxes (toggle all with 'a')
2502    fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2503        execute!(stderr, BeginSynchronizedUpdate)?;
2504
2505        let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2506        if self.is_table_mode() {
2507            header_lines += 2; // table header + header separator line
2508        }
2509
2510        // Current position is at end of rendered content
2511        let items_rendered = self.rendered_lines - header_lines as usize;
2512
2513        // Calculate visible range
2514        let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
2515        let visible_count = visible_end - self.scroll_offset;
2516
2517        // Move to first item row
2518        // Cursor is at end of last content line, so subtract 1 to get to first item
2519        execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2520
2521        // Update each visible item's checkbox
2522        for i in 0..visible_count {
2523            let item_idx = self.scroll_offset + i;
2524            let checkbox = if self.selected.contains(&item_idx) {
2525                "[x]"
2526            } else {
2527                "[ ]"
2528            };
2529            // Move to checkbox column and update
2530            execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2531            if i + 1 < visible_count {
2532                execute!(stderr, MoveDown(1))?;
2533            }
2534        }
2535
2536        // Move back to end position (footer line if shown, else last item line)
2537        let remaining = items_rendered as u16 - visible_count as u16;
2538        if remaining > 0 {
2539            execute!(stderr, MoveDown(remaining))?;
2540        }
2541
2542        // Update footer to reflect new selection count
2543        if self.has_footer() {
2544            self.render_footer_inline(stderr)?;
2545        }
2546
2547        // Reset toggle tracking
2548        self.toggled_all = false;
2549
2550        execute!(stderr, EndSynchronizedUpdate)?;
2551        stderr.flush()
2552    }
2553
2554    /// FuzzyMulti mode: update all visible rows (toggle all with Alt+A)
2555    fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2556        execute!(stderr, BeginSynchronizedUpdate)?;
2557
2558        // Calculate header lines (prompt + filter + separator + table header)
2559        let header_lines = self.fuzzy_header_lines();
2560
2561        let total_count = self.current_list_len();
2562        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2563        let visible_count = end.saturating_sub(self.scroll_offset);
2564
2565        // We're at the filter line
2566        let filter_row = self.fuzzy_filter_row();
2567
2568        // Move to first item row
2569        let down_to_first = header_lines.saturating_sub(filter_row);
2570        execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
2571
2572        for (i, idx) in (self.scroll_offset..end).enumerate() {
2573            let real_idx = self.filtered_indices[idx];
2574            let item = &self.items[real_idx];
2575            let checked = self.selected.contains(&real_idx);
2576            let active = idx == self.cursor;
2577
2578            if self.is_table_mode() {
2579                self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
2580            } else {
2581                self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
2582            }
2583
2584            if i + 1 < visible_count {
2585                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2586            }
2587        }
2588
2589        // Move to footer (if present) and update it
2590        if self.has_footer() {
2591            let footer_row = header_lines + visible_count as u16;
2592            let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
2593            let down_to_footer = footer_row.saturating_sub(last_item_row);
2594            execute!(stderr, MoveDown(down_to_footer))?;
2595            self.render_footer_inline(stderr)?;
2596            let up_to_filter = footer_row.saturating_sub(filter_row);
2597            execute!(stderr, MoveUp(up_to_filter))?;
2598        } else {
2599            let up_to_filter =
2600                (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
2601            execute!(stderr, MoveUp(up_to_filter))?;
2602        }
2603
2604        // Position cursor within filter text
2605        self.position_fuzzy_cursor(stderr)?;
2606
2607        // Reset toggle tracking
2608        self.toggled_all = false;
2609
2610        execute!(stderr, EndSynchronizedUpdate)?;
2611        stderr.flush()
2612    }
2613
2614    #[allow(clippy::collapsible_if)]
2615    fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2616        // Check for fuzzy multi mode toggle-all optimization
2617        if self.can_do_fuzzy_multi_toggle_all_update() {
2618            return self.render_fuzzy_multi_toggle_all_update(stderr);
2619        }
2620
2621        // Check for multi mode toggle-all optimization
2622        if self.can_do_multi_toggle_all_update() {
2623            return self.render_multi_toggle_all(stderr);
2624        }
2625
2626        // Check for multi mode toggle-only optimization
2627        if self.can_do_multi_toggle_only_update() {
2628            return self.render_multi_toggle_only(stderr);
2629        }
2630
2631        // Check for fuzzy multi mode toggle+move optimization
2632        if self.can_do_fuzzy_multi_toggle_update() {
2633            return self.render_fuzzy_multi_toggle_update(stderr);
2634        }
2635
2636        // The old cursor-only navigation optimizations were removed because
2637        // they were brittle and caused wrapping bugs.  We now always perform a
2638        // full redraw for simple cursor moves; other optimizations (toggle
2639        // updates) are still available above.
2640
2641        // If nothing changed (e.g., PageDown at bottom of list), skip render entirely
2642        if !self.first_render
2643            && !self.width_changed
2644            && self.cursor == self.prev_cursor
2645            && self.scroll_offset == self.prev_scroll_offset
2646            && !self.results_changed
2647            && !self.filter_text_changed
2648            && !self.horizontal_scroll_changed
2649            && !self.settings_changed
2650            && !self.toggled_all
2651        {
2652            return Ok(());
2653        }
2654
2655        execute!(stderr, BeginSynchronizedUpdate)?;
2656
2657        // Calculate how many lines we'll render
2658        let total_count = self.current_list_len();
2659        let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2660        // Show footer in fuzzy modes (for settings), multi modes (for selection count), or when scrolling is needed
2661        let has_scroll_indicator = self.has_footer();
2662        let items_to_render = end - self.scroll_offset;
2663
2664        // Calculate total lines needed for this render
2665        let mut lines_needed: usize = 0;
2666        if self.prompt.is_some() {
2667            lines_needed += 1;
2668        }
2669        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2670            lines_needed += 1; // filter line
2671            if self.config.show_separator {
2672                lines_needed += 1;
2673            }
2674        }
2675        if self.is_table_mode() {
2676            lines_needed += 2; // table header + header separator
2677        }
2678        lines_needed += items_to_render;
2679        if has_scroll_indicator {
2680            lines_needed += 1;
2681        }
2682
2683        // On first render, claim vertical space by printing newlines (causes scroll if needed)
2684        if self.first_render && lines_needed > 1 {
2685            for _ in 0..(lines_needed - 1) {
2686                execute!(stderr, Print("\n"))?;
2687            }
2688            execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
2689        }
2690
2691        // In fuzzy mode, cursor may be at filter line; move to last content line first
2692        if self.fuzzy_cursor_offset > 0 {
2693            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
2694            self.fuzzy_cursor_offset = 0;
2695        }
2696
2697        // Move to start of our render area (first line, column 0)
2698        // Cursor is on last content line, move up to first line
2699        if self.rendered_lines > 1 {
2700            execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
2701        }
2702        execute!(stderr, MoveToColumn(0))?;
2703
2704        let mut lines_rendered: usize = 0;
2705
2706        // Render prompt (only on first render, it doesn't change)
2707        if self.first_render {
2708            if let Some(prompt) = self.prompt {
2709                execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
2710            }
2711        }
2712        if self.prompt.is_some() {
2713            lines_rendered += 1;
2714            if lines_rendered < lines_needed {
2715                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2716            }
2717        }
2718
2719        // Render filter line for fuzzy modes
2720        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2721            execute!(
2722                stderr,
2723                Print(self.prompt_marker()),
2724                Print(&self.filter_text),
2725                Clear(ClearType::UntilNewLine),
2726            )?;
2727            lines_rendered += 1;
2728            if lines_rendered < lines_needed {
2729                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2730            }
2731
2732            // Render separator line
2733            if self.config.show_separator {
2734                execute!(
2735                    stderr,
2736                    Print(self.config.separator.paint(&self.separator_line)),
2737                    Clear(ClearType::UntilNewLine),
2738                )?;
2739                lines_rendered += 1;
2740                if lines_rendered < lines_needed {
2741                    execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2742                }
2743            }
2744        }
2745
2746        // Render table header and separator if in table mode
2747        // Only redraw if first render or horizontal scroll changed
2748        if self.is_table_mode() {
2749            let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
2750            if need_header_redraw {
2751                self.render_table_header(stderr)?;
2752            }
2753            lines_rendered += 1;
2754            if lines_rendered < lines_needed {
2755                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2756            }
2757            if need_header_redraw {
2758                self.render_table_header_separator(stderr)?;
2759            }
2760            lines_rendered += 1;
2761            if lines_rendered < lines_needed {
2762                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2763            }
2764        }
2765
2766        // Render items
2767        for idx in self.scroll_offset..end {
2768            let is_active = idx == self.cursor;
2769            let is_last_line = lines_rendered + 1 == lines_needed;
2770
2771            if self.is_table_mode() {
2772                // Table mode rendering
2773                match self.mode {
2774                    SelectMode::Single => {
2775                        let item = &self.items[idx];
2776                        self.render_table_row_single(stderr, item, is_active)?;
2777                    }
2778                    SelectMode::Multi => {
2779                        let real_idx = if self.refined {
2780                            self.filtered_indices[idx]
2781                        } else {
2782                            idx
2783                        };
2784                        let item = &self.items[real_idx];
2785                        let is_checked = self.selected.contains(&real_idx);
2786                        self.render_table_row_multi(stderr, item, is_checked, is_active)?;
2787                    }
2788                    SelectMode::Fuzzy => {
2789                        let real_idx = self.filtered_indices[idx];
2790                        let item = &self.items[real_idx];
2791                        self.render_table_row_fuzzy(stderr, item, is_active)?;
2792                    }
2793                    SelectMode::FuzzyMulti => {
2794                        let real_idx = self.filtered_indices[idx];
2795                        let item = &self.items[real_idx];
2796                        let is_checked = self.selected.contains(&real_idx);
2797                        self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
2798                    }
2799                }
2800            } else {
2801                // Single-line mode rendering
2802                match self.mode {
2803                    SelectMode::Single => {
2804                        let item = &self.items[idx];
2805                        self.render_single_item_inline(stderr, &item.name, is_active)?;
2806                    }
2807                    SelectMode::Multi => {
2808                        let real_idx = if self.refined {
2809                            self.filtered_indices[idx]
2810                        } else {
2811                            idx
2812                        };
2813                        let item = &self.items[real_idx];
2814                        let is_checked = self.selected.contains(&real_idx);
2815                        self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
2816                    }
2817                    SelectMode::Fuzzy => {
2818                        let real_idx = self.filtered_indices[idx];
2819                        let item = &self.items[real_idx];
2820                        self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
2821                    }
2822                    SelectMode::FuzzyMulti => {
2823                        let real_idx = self.filtered_indices[idx];
2824                        let item = &self.items[real_idx];
2825                        let is_checked = self.selected.contains(&real_idx);
2826                        self.render_fuzzy_multi_item_inline(
2827                            stderr, &item.name, is_checked, is_active,
2828                        )?;
2829                    }
2830                }
2831            }
2832            lines_rendered += 1;
2833            if !is_last_line {
2834                execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2835            }
2836        }
2837
2838        // Show scroll indicator if needed
2839        if has_scroll_indicator {
2840            let indicator = self.generate_footer();
2841            execute!(
2842                stderr,
2843                Print(self.config.footer.paint(&indicator)),
2844                Clear(ClearType::UntilNewLine),
2845            )?;
2846            lines_rendered += 1;
2847        }
2848
2849        // Clear any extra lines from previous render
2850        // Cursor is on last rendered line
2851        if lines_rendered < self.rendered_lines {
2852            let extra_lines = self.rendered_lines - lines_rendered;
2853            for _ in 0..extra_lines {
2854                execute!(
2855                    stderr,
2856                    MoveDown(1),
2857                    MoveToColumn(0),
2858                    Clear(ClearType::CurrentLine)
2859                )?;
2860            }
2861            // Move back to last content line
2862            execute!(stderr, MoveUp(extra_lines as u16))?;
2863        }
2864
2865        // Update state
2866        self.rendered_lines = lines_rendered;
2867        self.prev_cursor = self.cursor;
2868        self.prev_scroll_offset = self.scroll_offset;
2869        self.first_render = false;
2870        self.filter_text_changed = false;
2871        self.results_changed = false;
2872        self.horizontal_scroll_changed = false;
2873        self.width_changed = false;
2874        self.toggled_item = None;
2875        self.toggled_all = false;
2876        self.settings_changed = false;
2877
2878        // In fuzzy modes, position cursor within filter text
2879        if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2880            // Cursor is on last content line, move up to filter line
2881            let filter_row = self.fuzzy_filter_row() as usize;
2882            self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
2883            if self.fuzzy_cursor_offset > 0 {
2884                execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
2885            }
2886            // Position cursor after prompt marker + text up to filter_cursor
2887            self.position_fuzzy_cursor(stderr)?;
2888        }
2889
2890        execute!(stderr, EndSynchronizedUpdate)?;
2891        stderr.flush()
2892    }
2893
2894    fn render_single_item_inline(
2895        &self,
2896        stderr: &mut Stderr,
2897        text: &str,
2898        active: bool,
2899    ) -> io::Result<()> {
2900        let prefix = if active { self.selected_marker() } else { "  " };
2901        let prefix_width = 2;
2902
2903        execute!(stderr, Print(prefix))?;
2904        self.render_truncated_text(stderr, text, prefix_width)?;
2905        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
2906        Ok(())
2907    }
2908
2909    fn render_multi_item_inline(
2910        &self,
2911        stderr: &mut Stderr,
2912        text: &str,
2913        checked: bool,
2914        active: bool,
2915    ) -> io::Result<()> {
2916        let cursor = if active { self.selected_marker() } else { "  " };
2917        let checkbox = if checked { "[x] " } else { "[ ] " };
2918        let prefix_width = 6; // "> [x] " or "  [ ] "
2919
2920        execute!(stderr, Print(cursor), Print(checkbox))?;
2921        self.render_truncated_text(stderr, text, prefix_width)?;
2922        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
2923        Ok(())
2924    }
2925
2926    fn render_fuzzy_item_inline(
2927        &self,
2928        stderr: &mut Stderr,
2929        text: &str,
2930        active: bool,
2931    ) -> io::Result<()> {
2932        let prefix = if active { self.selected_marker() } else { "  " };
2933        let prefix_width = 2;
2934        execute!(stderr, Print(prefix))?;
2935
2936        if self.filter_text.is_empty() {
2937            self.render_truncated_text(stderr, text, prefix_width)?;
2938        } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
2939        {
2940            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
2941        } else {
2942            self.render_truncated_text(stderr, text, prefix_width)?;
2943        }
2944        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
2945        Ok(())
2946    }
2947
2948    fn render_fuzzy_multi_item_inline(
2949        &self,
2950        stderr: &mut Stderr,
2951        text: &str,
2952        checked: bool,
2953        active: bool,
2954    ) -> io::Result<()> {
2955        let cursor = if active { self.selected_marker() } else { "  " };
2956        let checkbox = if checked { "[x] " } else { "[ ] " };
2957        let prefix_width = 6; // "> [x] " or "  [ ] "
2958        execute!(stderr, Print(cursor), Print(checkbox))?;
2959
2960        if self.filter_text.is_empty() {
2961            self.render_truncated_text(stderr, text, prefix_width)?;
2962        } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
2963        {
2964            self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
2965        } else {
2966            self.render_truncated_text(stderr, text, prefix_width)?;
2967        }
2968        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
2969        Ok(())
2970    }
2971
2972    /// Render text, truncating with ellipsis if it exceeds available width.
2973    fn render_truncated_text(
2974        &self,
2975        stderr: &mut Stderr,
2976        text: &str,
2977        prefix_width: usize,
2978    ) -> io::Result<()> {
2979        let available_width = (self.term_width as usize).saturating_sub(prefix_width);
2980        let text_width = UnicodeWidthStr::width(text);
2981
2982        if text_width <= available_width {
2983            // Text fits, render as-is
2984            execute!(stderr, Print(text))?;
2985        } else if available_width <= 1 {
2986            // Only room for ellipsis
2987            execute!(stderr, Print("…"))?;
2988        } else {
2989            // Find the substring that fits in available_width - 1 (reserve 1 for ellipsis)
2990            let target_width = available_width - 1;
2991            let mut current_width = 0;
2992            let mut end_pos = 0;
2993
2994            for (byte_pos, c) in text.char_indices() {
2995                let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
2996                if current_width + char_width > target_width {
2997                    break;
2998                }
2999                end_pos = byte_pos + c.len_utf8();
3000                current_width += char_width;
3001            }
3002            execute!(stderr, Print(&text[..end_pos]))?;
3003            execute!(stderr, Print("…"))?;
3004        }
3005        Ok(())
3006    }
3007
3008    /// Render fuzzy-highlighted text, truncating with ellipsis if needed.
3009    /// The ellipsis is highlighted if any matches fall in the truncated portion.
3010    fn render_truncated_fuzzy_text(
3011        &self,
3012        stderr: &mut Stderr,
3013        text: &str,
3014        match_indices: &[usize],
3015        prefix_width: usize,
3016    ) -> io::Result<()> {
3017        let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3018        let text_width = UnicodeWidthStr::width(text);
3019
3020        // Reusable single-char buffer for styled output (avoids allocation per char)
3021        let mut char_buf = [0u8; 4];
3022
3023        if text_width <= available_width {
3024            // Text fits, render with highlighting.
3025            // match_indices is sorted, so use two-pointer approach for O(n) instead of O(n*m)
3026            let mut match_iter = match_indices.iter().peekable();
3027            for (idx, c) in text.chars().enumerate() {
3028                // Advance match_iter past any indices we've passed
3029                while match_iter.peek().is_some_and(|&&i| i < idx) {
3030                    match_iter.next();
3031                }
3032                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3033                if is_match {
3034                    let s = c.encode_utf8(&mut char_buf);
3035                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3036                } else {
3037                    execute!(stderr, Print(c))?;
3038                }
3039            }
3040        } else if available_width <= 1 {
3041            // Only room for ellipsis
3042            let has_any_matches = !match_indices.is_empty();
3043            if has_any_matches {
3044                execute!(stderr, Print(self.config.match_text.paint("…")))?;
3045            } else {
3046                execute!(stderr, Print("…"))?;
3047            }
3048        } else {
3049            // Find how many chars fit in available_width - 1 (reserve 1 for ellipsis)
3050            let target_width = available_width - 1;
3051            let mut current_width = 0;
3052            let mut chars_to_render: usize = 0;
3053
3054            for c in text.chars() {
3055                let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3056                if current_width + char_width > target_width {
3057                    break;
3058                }
3059                current_width += char_width;
3060                chars_to_render += 1;
3061            }
3062
3063            // Render the characters that fit, using two-pointer approach for efficiency
3064            let mut match_iter = match_indices.iter().peekable();
3065            for (idx, c) in text.chars().enumerate() {
3066                if idx >= chars_to_render {
3067                    break;
3068                }
3069                while match_iter.peek().is_some_and(|&&i| i < idx) {
3070                    match_iter.next();
3071                }
3072                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3073                if is_match {
3074                    let s = c.encode_utf8(&mut char_buf);
3075                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3076                } else {
3077                    execute!(stderr, Print(c))?;
3078                }
3079            }
3080
3081            // Check if any matches are in the truncated portion (remaining in match_iter)
3082            let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
3083
3084            if has_hidden_matches {
3085                execute!(stderr, Print(self.config.match_text.paint("…")))?;
3086            } else {
3087                execute!(stderr, Print("…"))?;
3088            }
3089        }
3090        Ok(())
3091    }
3092
3093    /// Render the table header row
3094    fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3095        let Some(layout) = &self.table_layout else {
3096            return Ok(());
3097        };
3098
3099        let prefix_width = self.row_prefix_width();
3100        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3101        let has_more_left = self.horizontal_offset > 0;
3102
3103        // Render prefix space (no marker for header)
3104        execute!(stderr, Print(" ".repeat(prefix_width)))?;
3105
3106        // Left scroll indicator (ellipsis + column separator)
3107        if has_more_left {
3108            let sep = self.table_column_separator();
3109            execute!(
3110                stderr,
3111                Print(self.config.table_separator.paint("…")),
3112                Print(self.config.table_separator.paint(&sep))
3113            )?;
3114        }
3115
3116        // Render visible column headers
3117        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3118        for (i, col_idx) in visible_range.enumerate() {
3119            if col_idx >= layout.columns.len() {
3120                break;
3121            }
3122
3123            // Separator between columns
3124            if i > 0 {
3125                let sep = self.table_column_separator();
3126                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3127            }
3128
3129            // Render column header, center-aligned to column width
3130            let header = &layout.columns[col_idx];
3131            let col_width = layout.col_widths[col_idx];
3132            let header_width = header.width();
3133            let padding = col_width.saturating_sub(header_width);
3134            let left_pad = padding / 2;
3135            let right_pad = padding - left_pad;
3136            let header_padded = format!(
3137                "{}{}{}",
3138                " ".repeat(left_pad),
3139                header,
3140                " ".repeat(right_pad)
3141            );
3142            execute!(
3143                stderr,
3144                Print(self.config.table_header.paint(&header_padded))
3145            )?;
3146        }
3147
3148        // Right scroll indicator (column separator + ellipsis)
3149        if has_more_right {
3150            let sep = self.table_column_separator();
3151            execute!(
3152                stderr,
3153                Print(self.config.table_separator.paint(&sep)),
3154                Print(self.config.table_separator.paint("…"))
3155            )?;
3156        }
3157
3158        execute!(stderr, Clear(ClearType::UntilNewLine))?;
3159        Ok(())
3160    }
3161
3162    /// Render the separator line between table header and data rows
3163    fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3164        let Some(layout) = &self.table_layout else {
3165            return Ok(());
3166        };
3167
3168        let prefix_width = self.row_prefix_width();
3169        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3170        let has_more_left = self.horizontal_offset > 0;
3171
3172        let h_char = self.config.table_header_separator;
3173        let int_char = self.config.table_header_intersection;
3174
3175        // Render prefix as horizontal line
3176        let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3177        execute!(
3178            stderr,
3179            Print(self.config.table_separator.paint(&prefix_line))
3180        )?;
3181
3182        // Left scroll indicator (as horizontal continuation with intersection)
3183        // Width matches "… │ " = 1 + separator_width
3184        if has_more_left {
3185            let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3186            execute!(
3187                stderr,
3188                Print(self.config.table_separator.paint(&left_indicator))
3189            )?;
3190        }
3191
3192        // Render horizontal lines for visible columns with intersections
3193        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3194        for (i, col_idx) in visible_range.enumerate() {
3195            if col_idx >= layout.col_widths.len() {
3196                break;
3197            }
3198
3199            // Intersection between columns (must match width of column separator " │ ")
3200            if i > 0 {
3201                let intersection = format!("{}{}{}", h_char, int_char, h_char);
3202                execute!(
3203                    stderr,
3204                    Print(self.config.table_separator.paint(&intersection))
3205                )?;
3206            }
3207
3208            // Horizontal line for this column's width
3209            let col_width = layout.col_widths[col_idx];
3210            let line: String = std::iter::repeat_n(h_char, col_width).collect();
3211            execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
3212        }
3213
3214        // Right scroll indicator (as horizontal continuation with intersection)
3215        // Width matches " │ …" = separator_width + 1
3216        if has_more_right {
3217            let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
3218            execute!(
3219                stderr,
3220                Print(self.config.table_separator.paint(&right_indicator))
3221            )?;
3222        }
3223
3224        execute!(stderr, Clear(ClearType::UntilNewLine))?;
3225        Ok(())
3226    }
3227
3228    /// Render a table row in single-select mode
3229    fn render_table_row_single(
3230        &self,
3231        stderr: &mut Stderr,
3232        item: &SelectItem,
3233        active: bool,
3234    ) -> io::Result<()> {
3235        let prefix = if active { self.selected_marker() } else { "  " };
3236        execute!(stderr, Print(prefix))?;
3237        self.render_table_cells(stderr, item, None)?;
3238        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3239        Ok(())
3240    }
3241
3242    /// Render a table row in multi-select mode
3243    fn render_table_row_multi(
3244        &self,
3245        stderr: &mut Stderr,
3246        item: &SelectItem,
3247        checked: bool,
3248        active: bool,
3249    ) -> io::Result<()> {
3250        let cursor = if active { self.selected_marker() } else { "  " };
3251        let checkbox = if checked { "[x] " } else { "[ ] " };
3252        execute!(stderr, Print(cursor), Print(checkbox))?;
3253        self.render_table_cells(stderr, item, None)?;
3254        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3255        Ok(())
3256    }
3257
3258    /// Render a table row in fuzzy mode with match highlighting
3259    fn render_table_row_fuzzy(
3260        &self,
3261        stderr: &mut Stderr,
3262        item: &SelectItem,
3263        active: bool,
3264    ) -> io::Result<()> {
3265        let prefix = if active { self.selected_marker() } else { "  " };
3266        execute!(stderr, Print(prefix))?;
3267
3268        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
3269        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3270            self.matcher
3271                .fuzzy_indices(&item.name, &self.filter_text)
3272                .map(|(_, indices)| indices)
3273        } else {
3274            None
3275        };
3276
3277        self.render_table_cells(stderr, item, match_indices.as_deref())?;
3278        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3279        Ok(())
3280    }
3281
3282    /// Render a table row in fuzzy-multi mode with match highlighting and checkbox
3283    fn render_table_row_fuzzy_multi(
3284        &self,
3285        stderr: &mut Stderr,
3286        item: &SelectItem,
3287        checked: bool,
3288        active: bool,
3289    ) -> io::Result<()> {
3290        let cursor = if active { self.selected_marker() } else { "  " };
3291        let checkbox = if checked { "[x] " } else { "[ ] " };
3292        execute!(stderr, Print(cursor), Print(checkbox))?;
3293
3294        // Get match indices for highlighting (skip if per_column - handled in render_table_cells)
3295        let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3296            self.matcher
3297                .fuzzy_indices(&item.name, &self.filter_text)
3298                .map(|(_, indices)| indices)
3299        } else {
3300            None
3301        };
3302
3303        self.render_table_cells(stderr, item, match_indices.as_deref())?;
3304        execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3305        Ok(())
3306    }
3307
3308    /// Render table cells with proper alignment and optional fuzzy highlighting
3309    fn render_table_cells(
3310        &self,
3311        stderr: &mut Stderr,
3312        item: &SelectItem,
3313        match_indices: Option<&[usize]>,
3314    ) -> io::Result<()> {
3315        let Some(layout) = &self.table_layout else {
3316            return Ok(());
3317        };
3318        let Some(cells) = &item.cells else {
3319            return Ok(());
3320        };
3321
3322        let (cols_visible, has_more_right) = self.calculate_visible_columns();
3323        let has_more_left = self.horizontal_offset > 0;
3324
3325        // Track if there are matches in hidden columns (for scroll indicator highlighting)
3326        let mut matches_in_hidden_left = false;
3327        let mut matches_in_hidden_right = false;
3328
3329        // For per-column mode, pre-compute match indices for each cell
3330        let per_column_matches: Vec<Option<Vec<usize>>> =
3331            if self.per_column && !self.filter_text.is_empty() {
3332                cells
3333                    .iter()
3334                    .map(|(cell_text, _)| {
3335                        self.matcher
3336                            .fuzzy_indices(cell_text, &self.filter_text)
3337                            .map(|(_, indices)| indices)
3338                    })
3339                    .collect()
3340            } else {
3341                vec![]
3342            };
3343
3344        // Calculate character offset for each cell to map match indices (for non-per-column mode)
3345        // The search text (item.name) is space-separated cells, so we need to track offsets
3346        let cell_offsets: Vec<usize> = if match_indices.is_some() {
3347            let mut offsets = Vec::with_capacity(cells.len());
3348            let mut offset = 0;
3349            for (i, (cell_text, _)) in cells.iter().enumerate() {
3350                offsets.push(offset);
3351                offset += cell_text.chars().count();
3352                if i + 1 < cells.len() {
3353                    offset += 1; // For the space separator
3354                }
3355            }
3356            offsets
3357        } else {
3358            vec![]
3359        };
3360
3361        // Check for matches in hidden left columns
3362        if self.per_column && !self.filter_text.is_empty() {
3363            for col_idx in 0..self.horizontal_offset {
3364                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3365                    matches_in_hidden_left = true;
3366                    break;
3367                }
3368            }
3369        } else if let Some(indices) = match_indices {
3370            for col_idx in 0..self.horizontal_offset {
3371                if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
3372                    let cell_start = cell_offsets[col_idx];
3373                    let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); // -1 for space
3374                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3375                        matches_in_hidden_left = true;
3376                        break;
3377                    }
3378                }
3379            }
3380        }
3381
3382        // Left scroll indicator (ellipsis + column separator)
3383        if has_more_left {
3384            let sep = self.table_column_separator();
3385            if matches_in_hidden_left {
3386                execute!(
3387                    stderr,
3388                    Print(self.config.match_text.paint("…")),
3389                    Print(self.config.table_separator.paint(&sep))
3390                )?;
3391            } else {
3392                execute!(
3393                    stderr,
3394                    Print(self.config.table_separator.paint("…")),
3395                    Print(self.config.table_separator.paint(&sep))
3396                )?;
3397            }
3398        }
3399
3400        // Render visible cells
3401        let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3402        for (i, col_idx) in visible_range.enumerate() {
3403            if col_idx >= cells.len() {
3404                break;
3405            }
3406
3407            // Separator between columns
3408            if i > 0 {
3409                let sep = self.table_column_separator();
3410                execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3411            }
3412
3413            let (cell_text, cell_style) = &cells[col_idx];
3414            let col_width = layout.col_widths[col_idx];
3415
3416            // Get match indices for this cell
3417            let cell_matches: Option<Vec<usize>> =
3418                if self.per_column && !self.filter_text.is_empty() {
3419                    // Per-column mode: use pre-computed per-cell indices
3420                    per_column_matches.get(col_idx).cloned().flatten()
3421                } else if let Some(indices) = match_indices {
3422                    // Standard mode: map global indices to cell-relative
3423                    if col_idx < cell_offsets.len() {
3424                        let cell_start = cell_offsets[col_idx];
3425                        // Filter indices that fall within this cell and adjust to cell-relative
3426                        let cell_char_count = cell_text.chars().count();
3427                        let relative_indices: Vec<usize> = indices
3428                            .iter()
3429                            .filter_map(|&idx| {
3430                                if idx >= cell_start && idx < cell_start + cell_char_count {
3431                                    Some(idx - cell_start)
3432                                } else {
3433                                    None
3434                                }
3435                            })
3436                            .collect();
3437                        if relative_indices.is_empty() {
3438                            None
3439                        } else {
3440                            Some(relative_indices)
3441                        }
3442                    } else {
3443                        None
3444                    }
3445                } else {
3446                    None
3447                };
3448
3449            // Render cell with padding and type-based styling
3450            self.render_table_cell(
3451                stderr,
3452                cell_text,
3453                cell_style,
3454                col_width,
3455                cell_matches.as_deref(),
3456            )?;
3457        }
3458
3459        // Check for matches in hidden right columns
3460        if self.per_column && !self.filter_text.is_empty() {
3461            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3462                if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3463                    matches_in_hidden_right = true;
3464                    break;
3465                }
3466            }
3467        } else if let Some(indices) = match_indices {
3468            for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3469                if col_idx < cell_offsets.len() {
3470                    let cell_start = cell_offsets[col_idx];
3471                    let cell_end = if col_idx + 1 < cell_offsets.len() {
3472                        cell_offsets[col_idx + 1].saturating_sub(1)
3473                    } else {
3474                        item.name.chars().count()
3475                    };
3476                    if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3477                        matches_in_hidden_right = true;
3478                        break;
3479                    }
3480                }
3481            }
3482        }
3483
3484        // Right scroll indicator (column separator + ellipsis)
3485        if has_more_right {
3486            let sep = self.table_column_separator();
3487            if matches_in_hidden_right {
3488                execute!(
3489                    stderr,
3490                    Print(self.config.table_separator.paint(&sep)),
3491                    Print(self.config.match_text.paint("…"))
3492                )?;
3493            } else {
3494                execute!(
3495                    stderr,
3496                    Print(self.config.table_separator.paint(&sep)),
3497                    Print(self.config.table_separator.paint("…"))
3498                )?;
3499            }
3500        }
3501
3502        Ok(())
3503    }
3504
3505    /// Render a single table cell with padding, type-based styling, alignment, and optional match highlighting
3506    fn render_table_cell(
3507        &self,
3508        stderr: &mut Stderr,
3509        cell: &str,
3510        cell_style: &TextStyle,
3511        col_width: usize,
3512        match_indices: Option<&[usize]>,
3513    ) -> io::Result<()> {
3514        let cell_width = cell.width();
3515        let padding_needed = col_width.saturating_sub(cell_width);
3516
3517        // Calculate left and right padding based on alignment from TextStyle
3518        let (left_pad, right_pad) = match cell_style.alignment {
3519            Alignment::Left => (0, padding_needed),
3520            Alignment::Right => (padding_needed, 0),
3521            Alignment::Center => {
3522                let left = padding_needed / 2;
3523                (left, padding_needed - left)
3524            }
3525        };
3526
3527        // Add left padding
3528        if left_pad > 0 {
3529            execute!(stderr, Print(" ".repeat(left_pad)))?;
3530        }
3531
3532        if let Some(indices) = match_indices {
3533            // Render with fuzzy highlighting (match highlighting takes priority over type styling)
3534            let mut char_buf = [0u8; 4];
3535            let mut match_iter = indices.iter().peekable();
3536
3537            for (idx, c) in cell.chars().enumerate() {
3538                while match_iter.peek().is_some_and(|&&i| i < idx) {
3539                    match_iter.next();
3540                }
3541                let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3542                if is_match {
3543                    let s = c.encode_utf8(&mut char_buf);
3544                    execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3545                } else {
3546                    // Apply type-based style for non-match characters
3547                    let s = c.encode_utf8(&mut char_buf);
3548                    if let Some(color) = cell_style.color_style {
3549                        execute!(stderr, Print(color.paint(&*s)))?;
3550                    } else {
3551                        execute!(stderr, Print(&*s))?;
3552                    }
3553                }
3554            }
3555        } else {
3556            // Render with type-based styling
3557            if let Some(color) = cell_style.color_style {
3558                execute!(stderr, Print(color.paint(cell)))?;
3559            } else {
3560                execute!(stderr, Print(cell))?;
3561            }
3562        }
3563
3564        // Add right padding
3565        if right_pad > 0 {
3566            execute!(stderr, Print(" ".repeat(right_pad)))?;
3567        }
3568
3569        Ok(())
3570    }
3571
3572    fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3573        // In fuzzy mode, cursor may be at filter line; move back to end first
3574        if self.fuzzy_cursor_offset > 0 {
3575            execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3576            self.fuzzy_cursor_offset = 0;
3577        }
3578
3579        if self.rendered_lines > 0 {
3580            // Clear each line by moving up from current position and clearing.
3581            // This doesn't assume we know exactly where the cursor is.
3582            // First, move to column 0 and clear current line.
3583            execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3584            // Then move up and clear each remaining line
3585            for _ in 1..self.rendered_lines {
3586                execute!(
3587                    stderr,
3588                    MoveUp(1),
3589                    MoveToColumn(0),
3590                    Clear(ClearType::CurrentLine)
3591                )?;
3592            }
3593            // Now we're at the first rendered line, which is where output should go
3594        }
3595        self.rendered_lines = 0;
3596        stderr.flush()
3597    }
3598}
3599
3600enum KeyAction {
3601    Continue,
3602    Cancel,
3603    Confirm,
3604}
3605
3606#[cfg(test)]
3607mod test {
3608    use super::*;
3609
3610    fn make_widget(items: &[&str]) -> SelectWidget<'static> {
3611        let options: Vec<SelectItem> = items
3612            .iter()
3613            .map(|s| SelectItem {
3614                name: s.to_string(),
3615                cells: None,
3616                value: nu_protocol::Value::nothing(nu_protocol::Span::unknown()),
3617            })
3618            .collect();
3619        let leaked: &'static [SelectItem] = Box::leak(options.into_boxed_slice());
3620
3621        SelectWidget::new(
3622            SelectMode::Single,
3623            None,
3624            leaked,
3625            InputListConfig::default(),
3626            None,
3627            false,
3628        )
3629    }
3630
3631    #[test]
3632    fn wrap_up_and_down_cycles() {
3633        let mut w = make_widget(&["A", "B", "C"]);
3634        // navigate up three times, expect proper cycling
3635        w.navigate_up();
3636        assert_eq!(w.cursor, 2);
3637        w.navigate_up();
3638        assert_eq!(w.cursor, 1);
3639        w.navigate_up();
3640        assert_eq!(w.cursor, 0);
3641
3642        // navigate down three times, expect cycling as well
3643        w.navigate_down();
3644        assert_eq!(w.cursor, 1);
3645        w.navigate_down();
3646        assert_eq!(w.cursor, 2);
3647        w.navigate_down();
3648        assert_eq!(w.cursor, 0);
3649    }
3650
3651    #[test]
3652    fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
3653        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3654        w.first_render = false;
3655        w.prev_cursor = 0;
3656        w.prev_scroll_offset = 0;
3657        w.cursor = 0;
3658        w.scroll_offset = 0;
3659
3660        let mut stderr = io::stderr();
3661
3662        for _ in 0..7 {
3663            w.navigate_down();
3664            w.render(&mut stderr)?;
3665            assert_eq!(w.scroll_offset, 0);
3666        }
3667
3668        Ok(())
3669    }
3670
3671    #[test]
3672    fn up_arrow_sequence_state_and_render() -> io::Result<()> {
3673        let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3674        w.first_render = false;
3675        w.prev_cursor = 0;
3676        w.prev_scroll_offset = 0;
3677        w.cursor = 0;
3678        w.scroll_offset = 0;
3679
3680        let mut stderr = io::stderr();
3681
3682        w.render(&mut stderr)?;
3683        assert_eq!(w.cursor, 0);
3684
3685        w.navigate_up();
3686        w.render(&mut stderr)?;
3687        assert_eq!(w.cursor, 2);
3688
3689        w.navigate_up();
3690        w.render(&mut stderr)?;
3691        assert_eq!(w.cursor, 1);
3692
3693        Ok(())
3694    }
3695
3696    #[test]
3697    fn test_examples() -> nu_test_support::Result {
3698        nu_test_support::test().examples(InputList)
3699    }
3700}