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