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, footer: Style, separator: Style, prompt_marker: Style, selected_marker: Style, table_header: Style, table_separator: Style, show_footer: bool, separator_char: String, show_separator: bool, prompt_marker_text: String, selected_marker_char: char, table_column_separator: char, table_header_separator: char, table_header_intersection: char, case_sensitivity: CaseSensitivity, }
51
52const DEFAULT_PROMPT_MARKER: &str = "> ";
53const DEFAULT_SELECTED_MARKER: char = '>';
54
55const DEFAULT_TABLE_COLUMN_SEPARATOR: char = '│';
56
57fn table_mode_to_separator(mode: TableMode) -> char {
59 match mode {
60 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql | TableMode::Markdown => '|',
62 TableMode::AsciiRounded => '|',
63 TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => '│',
65 TableMode::Reinforced | TableMode::Light => '│',
66 TableMode::Heavy => '┃',
68 TableMode::Double | TableMode::CompactDouble => '║',
70 TableMode::WithLove => '❤',
72 TableMode::Dots => ':',
73 TableMode::Restructured | TableMode::None => ' ',
75 }
76}
77
78fn table_mode_to_header_separator(mode: TableMode) -> (char, char) {
80 match mode {
81 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql => ('-', '+'),
83 TableMode::AsciiRounded => ('-', '+'),
84 TableMode::Markdown => ('-', '|'),
85 TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => ('─', '┼'),
87 TableMode::Reinforced => ('─', '┼'),
88 TableMode::Light => ('─', '─'), TableMode::Heavy => ('━', '╋'),
91 TableMode::Double | TableMode::CompactDouble => ('═', '╬'),
93 TableMode::WithLove => ('❤', '❤'),
95 TableMode::Dots => ('.', ':'),
96 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 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 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, cells: Option<Vec<(String, TextStyle)>>, value: Value, }
167
168struct TableLayout {
170 columns: Vec<String>, col_widths: Vec<usize>, truncated_cols: usize, }
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 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 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 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 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 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 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 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 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, }
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 prev_cursor: usize,
662 prev_scroll_offset: usize,
664 first_render: bool,
666 fuzzy_cursor_offset: usize,
668 results_changed: bool,
670 filter_text_changed: bool,
672 toggled_item: Option<usize>,
674 toggled_all: bool,
676 filter_cursor: usize,
678 config: InputListConfig,
680 term_width: u16,
682 separator_line: String,
684 table_layout: Option<TableLayout>,
686 horizontal_offset: usize,
688 horizontal_scroll_changed: bool,
690 width_changed: bool,
692 refined: bool,
694 refined_base_indices: Vec<usize>,
696 per_column: bool,
698 settings_changed: bool,
700 selected_marker_cached: String,
702 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 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 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 fn prompt_marker(&self) -> String {
779 self.config
780 .prompt_marker
781 .paint(&self.config.prompt_marker_text)
782 .to_string()
783 }
784
785 fn prompt_marker_width(&self) -> usize {
787 self.config.prompt_marker_text.width()
788 }
789
790 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 fn selected_marker(&self) -> &str {
799 &self.selected_marker_cached
800 }
801
802 fn is_table_mode(&self) -> bool {
804 self.table_layout.is_some()
805 }
806
807 fn is_multi_mode(&self) -> bool {
809 self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
810 }
811
812 fn is_fuzzy_mode(&self) -> bool {
814 self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
815 }
816
817 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 if !self.filter_text.is_empty() {
827 self.update_filter();
828 }
829 self.settings_changed = true;
830 }
831
832 fn toggle_per_column(&mut self) {
834 if self.is_table_mode() {
835 self.per_column = !self.per_column;
836 if !self.filter_text.is_empty() {
838 self.update_filter();
839 }
840 self.settings_changed = true;
841 }
842 }
843
844 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 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 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 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 "…".to_string()
905 } else {
906 if position_part.width() <= max_width {
908 let remaining = max_width - position_part.width();
910 if remaining <= 4 {
911 position_part
913 } else {
914 let target_width = remaining - 2; let mut current_width = 0;
917 let mut end_pos = 0;
918
919 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 let target_width = max_width - 2; 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 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 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 fn row_prefix_width(&self) -> usize {
982 match self.mode {
983 SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
986 }
987
988 fn table_column_separator(&self) -> String {
990 format!(" {} ", self.config.table_column_separator)
991 }
992
993 fn table_column_separator_width(&self) -> usize {
995 UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
996 }
997
998 fn calculate_visible_columns(&self) -> (usize, bool) {
1002 if let Some(cached) = self.visible_columns_cache {
1004 return cached;
1005 }
1006
1007 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 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 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 let sep_width = if i > horizontal_offset {
1045 separator_width
1046 } else {
1047 0
1048 };
1049 let needed = col_width + sep_width;
1050
1051 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) }
1069
1070 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 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 fn fuzzy_filter_row(&self) -> u16 {
1107 if self.prompt.is_some() { 1 } else { 0 }
1108 }
1109
1110 fn update_term_size(&mut self, width: u16, height: u16) {
1112 let new_width = width.saturating_sub(1);
1114 let width_changed = self.term_width != new_width;
1115 self.term_width = new_width;
1116
1117 if width_changed {
1119 self.width_changed = true;
1120 }
1121
1122 if width_changed && self.config.show_separator {
1124 self.generate_separator_line();
1125 }
1126
1127 if width_changed {
1129 self.update_table_layout();
1130 }
1131
1132 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; if self.config.show_separator {
1137 reserved += 1; }
1139 }
1140 if self.is_table_mode() {
1141 reserved += 2; }
1143 if self.config.show_footer {
1144 reserved += 1; }
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 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 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 self.clear_display(&mut stderr)?;
1194 self.update_term_size(width, height);
1195 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 if key.kind == KeyEventKind::Release {
1210 return KeyAction::Continue;
1211 }
1212
1213 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 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 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 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 KeyCode::Char('a' | 'A') if ctrl => {
1378 self.filter_cursor = 0;
1380 KeyAction::Continue
1381 }
1382 KeyCode::Char('e' | 'E') if ctrl => {
1383 self.filter_cursor = self.filter_text.len();
1385 KeyAction::Continue
1386 }
1387 KeyCode::Char('b' | 'B') if ctrl => {
1388 self.move_filter_cursor_left();
1390 KeyAction::Continue
1391 }
1392 KeyCode::Char('f' | 'F') if ctrl => {
1393 self.move_filter_cursor_right();
1395 KeyAction::Continue
1396 }
1397 KeyCode::Char('b' | 'B') if alt => {
1398 self.move_filter_cursor_word_left();
1400 KeyAction::Continue
1401 }
1402 KeyCode::Char('f' | 'F') if alt => {
1403 self.move_filter_cursor_word_right();
1405 KeyAction::Continue
1406 }
1407 KeyCode::Char('c' | 'C') if alt => {
1409 self.toggle_case_sensitivity();
1411 KeyAction::Continue
1412 }
1413 KeyCode::Char('p' | 'P') if alt => {
1414 self.toggle_per_column();
1416 KeyAction::Continue
1417 }
1418 KeyCode::Left if ctrl || alt => {
1419 self.move_filter_cursor_word_left();
1421 KeyAction::Continue
1422 }
1423 KeyCode::Right if ctrl || alt => {
1424 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 KeyCode::Char('u' | 'U') if ctrl => {
1439 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 self.filter_text.truncate(self.filter_cursor);
1448 self.update_filter();
1449 KeyAction::Continue
1450 }
1451 KeyCode::Char('d' | 'D') if ctrl => {
1452 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 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 self.delete_word_forwards();
1470 self.update_filter();
1471 KeyAction::Continue
1472 }
1473 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1475 self.delete_word_backwards();
1476 self.update_filter();
1477 KeyAction::Continue
1478 }
1479 KeyCode::Backspace if alt => {
1481 self.delete_word_backwards();
1482 self.update_filter();
1483 KeyAction::Continue
1484 }
1485 KeyCode::Backspace => {
1486 if self.filter_cursor > 0 {
1488 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 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 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 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 KeyCode::Char('r' | 'R') if ctrl => {
1549 self.refine_list();
1550 KeyAction::Continue
1551 }
1552
1553 KeyCode::Tab | KeyCode::Char('\t') => {
1556 self.toggle_current_fuzzy();
1557 self.navigate_down();
1558 KeyAction::Continue
1559 }
1560
1561 KeyCode::BackTab => {
1563 self.toggle_current_fuzzy();
1564 self.navigate_up();
1565 KeyAction::Continue
1566 }
1567
1568 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 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 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 KeyCode::Char('c' | 'C') if alt => {
1623 self.toggle_case_sensitivity();
1625 KeyAction::Continue
1626 }
1627 KeyCode::Char('p' | 'P') if alt => {
1628 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 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 KeyCode::Char('a' | 'A') if alt => {
1713 self.toggle_all_fuzzy();
1714 KeyAction::Continue
1715 }
1716
1717 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 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 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 self.cursor = list_len - 1;
1755 self.adjust_scroll_down();
1756 }
1757 }
1758
1759 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 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 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 fn navigate_home(&mut self) {
1796 self.cursor = 0;
1797 self.scroll_offset = 0;
1798 }
1799
1800 fn navigate_end(&mut self) {
1802 self.cursor = self.current_list_len().saturating_sub(1);
1803 self.adjust_scroll_down();
1804 }
1805
1806 fn navigate_page_up(&mut self) {
1808 let page_top = self.scroll_offset;
1809 if self.cursor == page_top {
1810 self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
1812 self.adjust_scroll_up();
1813 } else {
1814 self.cursor = page_top;
1816 }
1817 }
1818
1819 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 self.cursor =
1827 (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
1828 self.adjust_scroll_down();
1829 } else {
1830 self.cursor = page_bottom;
1832 }
1833 }
1834
1835 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 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 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 if self.refined && self.filtered_indices.is_empty() {
1868 return;
1869 }
1870 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 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 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 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 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 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 fn toggle_all_fuzzy(&mut self) {
1932 if self.filtered_indices.is_empty() {
1933 return;
1934 }
1935
1936 let all_selected = self
1938 .filtered_indices
1939 .iter()
1940 .all(|i| self.selected.contains(i));
1941
1942 if all_selected {
1943 for i in &self.filtered_indices {
1945 self.selected.remove(i);
1946 }
1947 } else {
1948 self.selected.extend(self.filtered_indices.iter().copied());
1950 }
1951 self.toggled_all = true;
1952 }
1953
1954 fn refine_list(&mut self) {
1957 if self.selected.is_empty() {
1958 return;
1959 }
1960
1961 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1963 indices.sort();
1964
1965 self.filtered_indices = indices.clone();
1968 self.refined_base_indices = indices;
1969
1970 self.cursor = 0;
1972 self.scroll_offset = 0;
1973
1974 if self.mode == SelectMode::FuzzyMulti {
1979 self.filter_text.clear();
1980 self.filter_cursor = 0;
1981 self.filter_text_changed = true;
1982 }
1983
1984 self.refined = true;
1986
1987 self.first_render = true;
1989 }
1990
1991 fn move_filter_cursor_left(&mut self) {
1993 if self.filter_cursor > 0 {
1994 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 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 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2022 pos -= 1;
2023 }
2024 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 while pos < len && !bytes[pos].is_ascii_whitespace() {
2040 pos += 1;
2041 }
2042 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 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 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 while end < len && !bytes[end].is_ascii_whitespace() {
2079 end += 1;
2080 }
2081 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 let len = self.filter_text.len();
2093 if len < 2 {
2094 return;
2095 }
2096
2097 if self.filter_cursor == 0 {
2099 return;
2100 }
2101
2102 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 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 let bytes = self.filter_text.as_bytes();
2123 if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2124 let bytes = unsafe { self.filter_text.as_bytes_mut() };
2126 bytes.swap(pos - 1, pos);
2127
2128 if self.filter_cursor < len {
2130 self.filter_cursor += 1;
2131 }
2132 }
2133 }
2134 }
2135
2136 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 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 let use_refined = self.refined && !self.refined_base_indices.is_empty();
2160
2161 if self.filter_text.is_empty() {
2162 self.filtered_indices = if use_refined {
2164 self.refined_base_indices.clone()
2165 } else {
2166 (0..self.items.len()).collect()
2167 };
2168 } else {
2169 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 scored.sort_by(|a, b| b.1.cmp(&a.1));
2182 self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2183 }
2184
2185 self.results_changed = old_indices != self.filtered_indices;
2187 self.filter_text_changed = true;
2188
2189 if self.results_changed {
2191 self.cursor = 0;
2192 self.scroll_offset = 0;
2193 }
2194
2195 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 fn auto_scroll_to_match_column(&mut self) {
2204 let Some(layout) = &self.table_layout else {
2205 return;
2206 };
2207
2208 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 let mut first_match_col: Option<usize> = None;
2217 for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2218 if self.per_column {
2219 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 let cell_start: usize = cells[..col_idx]
2232 .iter()
2233 .map(|(s, _)| s.chars().count() + 1) .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 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 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 self.horizontal_offset = match_col;
2261 self.horizontal_scroll_changed = true;
2262 self.update_table_layout();
2263 } else if match_col >= visible_end {
2264 self.horizontal_offset = match_col;
2267 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 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2295 indices.sort();
2296 InteractMode::Multi(Some(indices))
2297 }
2298 }
2299 }
2300
2301 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 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 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; }
2326 if self.filter_text_changed || self.results_changed {
2327 return false; }
2329 if let Some(toggled) = self.toggled_item {
2330 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 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 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 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 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 let filter_row = self.fuzzy_filter_row();
2379
2380 let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2382 execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2383
2384 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 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 if self.has_footer() {
2419 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 let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2427 execute!(stderr, MoveDown(down_to_footer))?;
2428
2429 self.render_footer_inline(stderr)?;
2431
2432 let up_to_filter = footer_row.saturating_sub(filter_row);
2434 execute!(stderr, MoveUp(up_to_filter))?;
2435 } else {
2436 let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2438 execute!(stderr, MoveUp(up_to_filter))?;
2439 }
2440
2441 self.position_fuzzy_cursor(stderr)?;
2443
2444 self.prev_cursor = self.cursor;
2446 self.toggled_item = None;
2447
2448 execute!(stderr, EndSynchronizedUpdate)?;
2449 stderr.flush()
2450 }
2451
2452 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; }
2461
2462 let display_row = (toggled - self.scroll_offset) as u16;
2464
2465 let items_rendered = self.rendered_lines - header_lines as usize;
2467
2468 let lines_up = (items_rendered as u16)
2471 .saturating_sub(1)
2472 .saturating_sub(display_row);
2473 execute!(stderr, MoveUp(lines_up))?;
2474
2475 execute!(stderr, MoveToColumn(2))?;
2477
2478 let checkbox = if self.selected.contains(&toggled) {
2480 "[x]"
2481 } else {
2482 "[ ]"
2483 };
2484 execute!(stderr, Print(checkbox))?;
2485
2486 execute!(stderr, MoveDown(lines_up))?;
2488
2489 if self.has_footer() {
2491 self.render_footer_inline(stderr)?;
2492 }
2493
2494 self.toggled_item = None;
2496
2497 execute!(stderr, EndSynchronizedUpdate)?;
2498 stderr.flush()
2499 }
2500
2501 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; }
2509
2510 let items_rendered = self.rendered_lines - header_lines as usize;
2512
2513 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 execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2520
2521 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 execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2531 if i + 1 < visible_count {
2532 execute!(stderr, MoveDown(1))?;
2533 }
2534 }
2535
2536 let remaining = items_rendered as u16 - visible_count as u16;
2538 if remaining > 0 {
2539 execute!(stderr, MoveDown(remaining))?;
2540 }
2541
2542 if self.has_footer() {
2544 self.render_footer_inline(stderr)?;
2545 }
2546
2547 self.toggled_all = false;
2549
2550 execute!(stderr, EndSynchronizedUpdate)?;
2551 stderr.flush()
2552 }
2553
2554 fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2556 execute!(stderr, BeginSynchronizedUpdate)?;
2557
2558 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 let filter_row = self.fuzzy_filter_row();
2567
2568 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 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 self.position_fuzzy_cursor(stderr)?;
2606
2607 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 if self.can_do_fuzzy_multi_toggle_all_update() {
2618 return self.render_fuzzy_multi_toggle_all_update(stderr);
2619 }
2620
2621 if self.can_do_multi_toggle_all_update() {
2623 return self.render_multi_toggle_all(stderr);
2624 }
2625
2626 if self.can_do_multi_toggle_only_update() {
2628 return self.render_multi_toggle_only(stderr);
2629 }
2630
2631 if self.can_do_fuzzy_multi_toggle_update() {
2633 return self.render_fuzzy_multi_toggle_update(stderr);
2634 }
2635
2636 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 let total_count = self.current_list_len();
2659 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2660 let has_scroll_indicator = self.has_footer();
2662 let items_to_render = end - self.scroll_offset;
2663
2664 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; if self.config.show_separator {
2672 lines_needed += 1;
2673 }
2674 }
2675 if self.is_table_mode() {
2676 lines_needed += 2; }
2678 lines_needed += items_to_render;
2679 if has_scroll_indicator {
2680 lines_needed += 1;
2681 }
2682
2683 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 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 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 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 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 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 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 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 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 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 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 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 execute!(stderr, MoveUp(extra_lines as u16))?;
2863 }
2864
2865 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 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2880 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 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; 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; 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 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 execute!(stderr, Print(text))?;
2985 } else if available_width <= 1 {
2986 execute!(stderr, Print("…"))?;
2988 } else {
2989 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 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 let mut char_buf = [0u8; 4];
3022
3023 if text_width <= available_width {
3024 let mut match_iter = match_indices.iter().peekable();
3027 for (idx, c) in text.chars().enumerate() {
3028 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 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 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 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 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 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 execute!(stderr, Print(" ".repeat(prefix_width)))?;
3105
3106 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 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 if i > 0 {
3125 let sep = self.table_column_separator();
3126 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3127 }
3128
3129 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut matches_in_hidden_left = false;
3327 let mut matches_in_hidden_right = false;
3328
3329 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 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; }
3355 }
3356 offsets
3357 } else {
3358 vec![]
3359 };
3360
3361 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); 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 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 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 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 let cell_matches: Option<Vec<usize>> =
3418 if self.per_column && !self.filter_text.is_empty() {
3419 per_column_matches.get(col_idx).cloned().flatten()
3421 } else if let Some(indices) = match_indices {
3422 if col_idx < cell_offsets.len() {
3424 let cell_start = cell_offsets[col_idx];
3425 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 self.render_table_cell(
3451 stderr,
3452 cell_text,
3453 cell_style,
3454 col_width,
3455 cell_matches.as_deref(),
3456 )?;
3457 }
3458
3459 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 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 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 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 if left_pad > 0 {
3529 execute!(stderr, Print(" ".repeat(left_pad)))?;
3530 }
3531
3532 if let Some(indices) = match_indices {
3533 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 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 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 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 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 execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3584 for _ in 1..self.rendered_lines {
3586 execute!(
3587 stderr,
3588 MoveUp(1),
3589 MoveToColumn(0),
3590 Clear(ClearType::CurrentLine)
3591 )?;
3592 }
3593 }
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 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 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}