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 drain_pending_stream(&mut self) {
981 if self.pending_stream.is_none() {
982 return;
983 }
984 while let Some(val) = self.pending_stream.as_mut().and_then(|s| s.next_value()) {
985 let item = self.make_select_item(val);
986 self.items.push(item);
987 }
988 self.pending_stream = None;
989 if self.is_table_mode()
990 && let Some(layout) = &mut self.table_layout
991 {
992 *layout = InputList::calculate_table_layout(&layout.columns, &self.items);
993 }
994 }
995
996 fn maybe_load_more(&mut self) {
998 if self.pending_stream.is_none() {
999 return;
1000 }
1001
1002 let threshold = self.scroll_offset + self.visible_height as usize + STREAM_PREFETCH_MARGIN;
1004 if threshold >= self.items.len() {
1005 self.load_more_items(STREAM_LOAD_BATCH);
1006 }
1007 }
1008
1009 fn toggle_case_sensitivity(&mut self) {
1011 self.config.case_sensitivity = match self.config.case_sensitivity {
1012 CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
1013 CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
1014 CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
1015 };
1016 self.rebuild_matcher();
1017 if !self.filter_text.is_empty() {
1019 self.update_filter();
1020 }
1021 self.settings_changed = true;
1022 }
1023
1024 fn toggle_per_column(&mut self) {
1026 if self.is_table_mode() {
1027 self.per_column = !self.per_column;
1028 if !self.filter_text.is_empty() {
1030 self.update_filter();
1031 }
1032 self.settings_changed = true;
1033 }
1034 }
1035
1036 fn rebuild_matcher(&mut self) {
1038 self.matcher = match self.config.case_sensitivity {
1039 CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
1040 CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
1041 CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
1042 };
1043 }
1044
1045 fn settings_indicator(&self) -> String {
1048 if !self.is_fuzzy_mode() {
1049 return String::new();
1050 }
1051
1052 let case_str = match self.config.case_sensitivity {
1053 CaseSensitivity::Smart => "smart",
1054 CaseSensitivity::CaseSensitive => "CASE",
1055 CaseSensitivity::CaseInsensitive => "nocase",
1056 };
1057
1058 if self.is_table_mode() && self.per_column {
1059 format!(" [{} col]", case_str)
1060 } else {
1061 format!(" [{}]", case_str)
1062 }
1063 }
1064
1065 fn generate_footer(&self) -> String {
1067 let total_count = self.current_list_len();
1068 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
1069 let settings = self.settings_indicator();
1070
1071 let position_part = if self.is_multi_mode() {
1072 format!(
1073 "[{}-{} of {}, {} selected]",
1074 self.scroll_offset + 1,
1075 end.min(total_count),
1076 total_count,
1077 self.selected.len()
1078 )
1079 } else {
1080 format!(
1081 "[{}-{} of {}]",
1082 self.scroll_offset + 1,
1083 end.min(total_count),
1084 total_count
1085 )
1086 };
1087
1088 let full_footer = format!("{}{}", position_part, settings);
1089
1090 let max_width = self.term_width as usize;
1092 if full_footer.width() <= max_width {
1093 full_footer
1094 } else if max_width <= 3 {
1095 "…".to_string()
1097 } else {
1098 if position_part.width() <= max_width {
1100 let remaining = max_width - position_part.width();
1102 if remaining <= 4 {
1103 position_part
1105 } else {
1106 let target_width = remaining - 2; let mut current_width = 0;
1109 let mut end_pos = 0;
1110
1111 for (byte_pos, c) in settings.char_indices().skip(2) {
1113 if c == ']' {
1114 break;
1115 }
1116 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1117 if current_width + char_width > target_width {
1118 break;
1119 }
1120 end_pos = byte_pos + c.len_utf8();
1121 current_width += char_width;
1122 }
1123 if end_pos > 2 {
1124 format!("{} [{}…]", position_part, &settings[2..end_pos])
1125 } else {
1126 position_part
1127 }
1128 }
1129 } else {
1130 let target_width = max_width - 2; let mut current_width = 0;
1133 let mut end_pos = 0;
1134
1135 for (byte_pos, c) in position_part.char_indices() {
1136 if c == ']' {
1137 break;
1138 }
1139 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1140 if current_width + char_width > target_width {
1141 break;
1142 }
1143 end_pos = byte_pos + c.len_utf8();
1144 current_width += char_width;
1145 }
1146 format!("{}…]", &position_part[..end_pos])
1147 }
1148 }
1149 }
1150
1151 fn has_footer(&self) -> bool {
1155 self.config.show_footer
1156 && (self.is_fuzzy_mode()
1157 || self.is_multi_mode()
1158 || self.current_list_len() > self.visible_height as usize)
1159 }
1160
1161 fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
1163 let indicator = self.generate_footer();
1164 execute!(
1165 stderr,
1166 MoveToColumn(0),
1167 Print(self.config.footer.paint(&indicator)),
1168 Clear(ClearType::UntilNewLine),
1169 )
1170 }
1171
1172 fn row_prefix_width(&self) -> usize {
1174 match self.mode {
1175 SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
1178 }
1179
1180 fn table_column_separator(&self) -> String {
1182 format!(" {} ", self.config.table_column_separator)
1183 }
1184
1185 fn table_column_separator_width(&self) -> usize {
1187 UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
1188 }
1189
1190 fn calculate_visible_columns(&self) -> (usize, bool) {
1194 if let Some(cached) = self.visible_columns_cache {
1196 return cached;
1197 }
1198
1199 let Some(layout) = &self.table_layout else {
1201 return (0, false);
1202 };
1203
1204 Self::calculate_visible_columns_for_layout(
1205 layout,
1206 self.horizontal_offset,
1207 self.term_width as usize,
1208 self.row_prefix_width(),
1209 self.table_column_separator_width(),
1210 )
1211 }
1212
1213 fn calculate_visible_columns_for_layout(
1215 layout: &TableLayout,
1216 horizontal_offset: usize,
1217 term_width: usize,
1218 prefix_width: usize,
1219 separator_width: usize,
1220 ) -> (usize, bool) {
1221 let scroll_indicator_width = if horizontal_offset > 0 {
1223 1 + separator_width
1224 } else {
1225 0
1226 };
1227 let available = term_width
1228 .saturating_sub(prefix_width)
1229 .saturating_sub(scroll_indicator_width);
1230
1231 let mut used_width = 0;
1232 let mut cols_fit = 0;
1233
1234 for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1235 let sep_width = if i > horizontal_offset {
1237 separator_width
1238 } else {
1239 0
1240 };
1241 let needed = col_width + sep_width;
1242
1243 let reserve_right = if i + 1 < layout.col_widths.len() {
1245 separator_width + 1
1246 } else {
1247 0
1248 };
1249
1250 if used_width + needed + reserve_right <= available {
1251 used_width += needed;
1252 cols_fit += 1;
1253 } else {
1254 break;
1255 }
1256 }
1257
1258 let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1259 (cols_fit.max(1), has_more_right) }
1261
1262 fn update_table_layout(&mut self) {
1265 let prefix_width = self.row_prefix_width();
1266 let term_width = self.term_width as usize;
1267 let horizontal_offset = self.horizontal_offset;
1268 let separator_width = self.table_column_separator_width();
1269
1270 if let Some(layout) = &mut self.table_layout {
1271 let result = Self::calculate_visible_columns_for_layout(
1272 layout,
1273 horizontal_offset,
1274 term_width,
1275 prefix_width,
1276 separator_width,
1277 );
1278 layout.truncated_cols = result.0;
1279 self.visible_columns_cache = Some(result);
1280 } else {
1281 self.visible_columns_cache = Some((0, false));
1282 }
1283 }
1284
1285 fn fuzzy_header_lines(&self) -> u16 {
1287 let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1288 if self.config.show_separator {
1289 header_lines += 1;
1290 }
1291 if self.is_table_mode() {
1292 header_lines += 2;
1293 }
1294 header_lines
1295 }
1296
1297 fn fuzzy_filter_row(&self) -> u16 {
1299 if self.prompt.is_some() { 1 } else { 0 }
1300 }
1301
1302 fn update_term_size(&mut self, width: u16, height: u16) {
1304 let new_width = width.saturating_sub(1);
1306 let width_changed = self.term_width != new_width;
1307 self.term_width = new_width;
1308
1309 if width_changed {
1311 self.width_changed = true;
1312 }
1313
1314 if width_changed && self.config.show_separator {
1316 self.generate_separator_line();
1317 }
1318
1319 if width_changed {
1321 self.update_table_layout();
1322 }
1323
1324 let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1326 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1327 reserved += 1; if self.config.show_separator {
1329 reserved += 1; }
1331 }
1332 if self.is_table_mode() {
1333 reserved += 2; }
1335 if self.config.show_footer {
1336 reserved += 1; }
1338 self.visible_height = height.saturating_sub(reserved).max(1);
1339 }
1340
1341 fn run(&mut self) -> io::Result<InteractMode> {
1342 let mut stderr = io::stderr();
1343
1344 enable_raw_mode()?;
1345 scopeguard::defer! {
1346 let _ = disable_raw_mode();
1347 }
1348
1349 if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1351 execute!(stderr, Hide)?;
1352 }
1353 scopeguard::defer! {
1354 let _ = execute!(io::stderr(), Show);
1355 }
1356
1357 let (term_width, term_height) = terminal::size()?;
1359 self.update_term_size(term_width, term_height);
1360
1361 self.render(&mut stderr)?;
1362
1363 loop {
1364 if event::poll(std::time::Duration::from_millis(100))? {
1365 match event::read()? {
1366 Event::Key(key_event) => {
1367 match self.handle_key(key_event) {
1368 KeyAction::Continue => {}
1369 KeyAction::Cancel => {
1370 self.clear_display(&mut stderr)?;
1371 return Ok(match self.mode {
1372 SelectMode::Multi => InteractMode::Multi(None),
1373 _ => InteractMode::Single(None),
1374 });
1375 }
1376 KeyAction::Confirm => {
1377 self.clear_display(&mut stderr)?;
1378 return Ok(self.get_result());
1379 }
1380 }
1381 self.render(&mut stderr)?;
1382 }
1383 Event::Resize(width, height) => {
1384 self.clear_display(&mut stderr)?;
1386 self.update_term_size(width, height);
1387 self.first_render = true;
1389 self.render(&mut stderr)?;
1390 }
1391 _ => {}
1392 }
1393 }
1394 }
1395 }
1396
1397 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1398 if key.kind == KeyEventKind::Release {
1402 return KeyAction::Continue;
1403 }
1404
1405 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1407 return KeyAction::Cancel;
1408 }
1409
1410 match self.mode {
1411 SelectMode::Single => self.handle_single_key(key),
1412 SelectMode::Multi => self.handle_multi_key(key),
1413 SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1414 SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1415 }
1416 }
1417
1418 fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1419 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1420
1421 match key.code {
1422 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1423 KeyCode::Enter => KeyAction::Confirm,
1424 KeyCode::Char('p' | 'P') if ctrl => {
1425 self.navigate_up();
1426 KeyAction::Continue
1427 }
1428 KeyCode::Up | KeyCode::Char('k') => {
1429 self.navigate_up();
1430 KeyAction::Continue
1431 }
1432 KeyCode::Char('n' | 'N') if ctrl => {
1433 self.navigate_down();
1434 KeyAction::Continue
1435 }
1436 KeyCode::Down | KeyCode::Char('j') => {
1437 self.navigate_down();
1438 KeyAction::Continue
1439 }
1440 KeyCode::Left | KeyCode::Char('h') => {
1441 self.scroll_columns_left();
1442 KeyAction::Continue
1443 }
1444 KeyCode::Right | KeyCode::Char('l') => {
1445 self.scroll_columns_right();
1446 KeyAction::Continue
1447 }
1448 KeyCode::Home => {
1449 self.navigate_home();
1450 KeyAction::Continue
1451 }
1452 KeyCode::End => {
1453 self.navigate_end();
1454 KeyAction::Continue
1455 }
1456 KeyCode::PageUp => {
1457 self.navigate_page_up();
1458 KeyAction::Continue
1459 }
1460 KeyCode::PageDown => {
1461 self.navigate_page_down();
1462 KeyAction::Continue
1463 }
1464 KeyCode::Tab => {
1465 self.navigate_down();
1466 KeyAction::Continue
1467 }
1468 KeyCode::BackTab => {
1469 self.navigate_up();
1470 KeyAction::Continue
1471 }
1472 _ => KeyAction::Continue,
1473 }
1474 }
1475
1476 fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1477 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1478
1479 match key.code {
1480 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1481 KeyCode::Enter => KeyAction::Confirm,
1482 KeyCode::Char('r' | 'R') if ctrl => {
1484 self.refine_list();
1485 KeyAction::Continue
1486 }
1487 KeyCode::Char('p' | 'P') if ctrl => {
1488 self.navigate_up();
1489 KeyAction::Continue
1490 }
1491 KeyCode::Up | KeyCode::Char('k') => {
1492 self.navigate_up();
1493 KeyAction::Continue
1494 }
1495 KeyCode::Char('n' | 'N') if ctrl => {
1496 self.navigate_down();
1497 KeyAction::Continue
1498 }
1499 KeyCode::Down | KeyCode::Char('j') => {
1500 self.navigate_down();
1501 KeyAction::Continue
1502 }
1503 KeyCode::Left | KeyCode::Char('h') => {
1504 self.scroll_columns_left();
1505 KeyAction::Continue
1506 }
1507 KeyCode::Right | KeyCode::Char('l') => {
1508 self.scroll_columns_right();
1509 KeyAction::Continue
1510 }
1511 KeyCode::Char(' ') => {
1512 self.toggle_current();
1513 KeyAction::Continue
1514 }
1515 KeyCode::Char('a') => {
1516 self.toggle_all();
1517 KeyAction::Continue
1518 }
1519 KeyCode::Home => {
1520 self.navigate_home();
1521 KeyAction::Continue
1522 }
1523 KeyCode::End => {
1524 self.navigate_end();
1525 KeyAction::Continue
1526 }
1527 KeyCode::PageUp => {
1528 self.navigate_page_up();
1529 KeyAction::Continue
1530 }
1531 KeyCode::PageDown => {
1532 self.navigate_page_down();
1533 KeyAction::Continue
1534 }
1535 KeyCode::Tab => {
1536 self.toggle_current();
1537 self.navigate_down();
1538 KeyAction::Continue
1539 }
1540 KeyCode::BackTab => {
1541 self.toggle_current();
1542 self.navigate_up();
1543 KeyAction::Continue
1544 }
1545 _ => KeyAction::Continue,
1546 }
1547 }
1548
1549 fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1550 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1551 let alt = key.modifiers.contains(KeyModifiers::ALT);
1552 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1553
1554 match key.code {
1555 KeyCode::Esc => KeyAction::Cancel,
1556 KeyCode::Enter => KeyAction::Confirm,
1557
1558 KeyCode::Tab | KeyCode::Char('\t') => {
1560 self.navigate_down();
1561 KeyAction::Continue
1562 }
1563 KeyCode::BackTab => {
1564 self.navigate_up();
1565 KeyAction::Continue
1566 }
1567
1568 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1570 self.navigate_up();
1571 KeyAction::Continue
1572 }
1573 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1574 self.navigate_down();
1575 KeyAction::Continue
1576 }
1577 KeyCode::Up => {
1578 self.navigate_up();
1579 KeyAction::Continue
1580 }
1581 KeyCode::Down => {
1582 self.navigate_down();
1583 KeyAction::Continue
1584 }
1585
1586 KeyCode::Left if shift => {
1588 self.scroll_columns_left();
1589 KeyAction::Continue
1590 }
1591 KeyCode::Right if shift => {
1592 self.scroll_columns_right();
1593 KeyAction::Continue
1594 }
1595
1596 KeyCode::Char('a' | 'A') if ctrl => {
1598 self.filter_cursor = 0;
1600 self.filter_text_changed = true;
1601 KeyAction::Continue
1602 }
1603 KeyCode::Char('e' | 'E') if ctrl => {
1604 self.filter_cursor = self.filter_text.len();
1606 self.filter_text_changed = true;
1607 KeyAction::Continue
1608 }
1609 KeyCode::Char('b' | 'B') if ctrl => {
1610 self.move_filter_cursor_left();
1612 self.filter_text_changed = true;
1613 KeyAction::Continue
1614 }
1615 KeyCode::Char('f' | 'F') if ctrl => {
1616 self.move_filter_cursor_right();
1618 self.filter_text_changed = true;
1619 KeyAction::Continue
1620 }
1621 KeyCode::Char('b' | 'B') if alt => {
1622 self.move_filter_cursor_word_left();
1624 self.filter_text_changed = true;
1625 KeyAction::Continue
1626 }
1627 KeyCode::Char('f' | 'F') if alt => {
1628 self.move_filter_cursor_word_right();
1630 self.filter_text_changed = true;
1631 KeyAction::Continue
1632 }
1633 KeyCode::Char('c' | 'C') if alt => {
1635 self.toggle_case_sensitivity();
1637 KeyAction::Continue
1638 }
1639 KeyCode::Char('p' | 'P') if alt => {
1640 self.toggle_per_column();
1642 KeyAction::Continue
1643 }
1644 KeyCode::Left if ctrl || alt => {
1645 self.move_filter_cursor_word_left();
1647 self.filter_text_changed = true;
1648 KeyAction::Continue
1649 }
1650 KeyCode::Right if ctrl || alt => {
1651 self.move_filter_cursor_word_right();
1653 self.filter_text_changed = true;
1654 KeyAction::Continue
1655 }
1656 KeyCode::Left => {
1657 self.move_filter_cursor_left();
1658 self.filter_text_changed = true;
1659 KeyAction::Continue
1660 }
1661 KeyCode::Right => {
1662 self.move_filter_cursor_right();
1663 self.filter_text_changed = true;
1664 KeyAction::Continue
1665 }
1666
1667 KeyCode::Char('u' | 'U') if ctrl => {
1669 self.filter_text.drain(..self.filter_cursor);
1671 self.filter_cursor = 0;
1672 self.update_filter();
1673 KeyAction::Continue
1674 }
1675 KeyCode::Char('k' | 'K') if ctrl => {
1676 self.filter_text.truncate(self.filter_cursor);
1678 self.update_filter();
1679 KeyAction::Continue
1680 }
1681 KeyCode::Char('d' | 'D') if ctrl => {
1682 if self.filter_cursor < self.filter_text.len() {
1684 self.filter_text.remove(self.filter_cursor);
1685 self.update_filter();
1686 }
1687 KeyAction::Continue
1688 }
1689 KeyCode::Delete => {
1690 if self.filter_cursor < self.filter_text.len() {
1692 self.filter_text.remove(self.filter_cursor);
1693 self.update_filter();
1694 }
1695 KeyAction::Continue
1696 }
1697 KeyCode::Char('d' | 'D') if alt => {
1698 self.delete_word_forwards();
1700 self.update_filter();
1701 KeyAction::Continue
1702 }
1703 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1705 self.delete_word_backwards();
1706 self.update_filter();
1707 KeyAction::Continue
1708 }
1709 KeyCode::Backspace if alt => {
1711 self.delete_word_backwards();
1712 self.update_filter();
1713 KeyAction::Continue
1714 }
1715 KeyCode::Backspace => {
1716 if self.filter_cursor > 0 {
1718 let mut new_pos = self.filter_cursor - 1;
1720 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1721 new_pos -= 1;
1722 }
1723 self.filter_cursor = new_pos;
1724 self.filter_text.remove(self.filter_cursor);
1725 self.update_filter();
1726 }
1727 KeyAction::Continue
1728 }
1729 KeyCode::Char('t' | 'T') if ctrl => {
1731 let old_text = self.filter_text.clone();
1732 self.transpose_chars();
1733 if self.filter_text != old_text {
1734 self.update_filter();
1735 }
1736 KeyAction::Continue
1737 }
1738
1739 KeyCode::Char(c) => {
1741 self.filter_text.insert(self.filter_cursor, c);
1742 self.filter_cursor += c.len_utf8();
1743 self.update_filter();
1744 KeyAction::Continue
1745 }
1746
1747 KeyCode::Home => {
1749 self.navigate_home();
1750 KeyAction::Continue
1751 }
1752 KeyCode::End => {
1753 self.navigate_end();
1754 KeyAction::Continue
1755 }
1756 KeyCode::PageUp => {
1757 self.navigate_page_up();
1758 KeyAction::Continue
1759 }
1760 KeyCode::PageDown => {
1761 self.navigate_page_down();
1762 KeyAction::Continue
1763 }
1764 _ => KeyAction::Continue,
1765 }
1766 }
1767
1768 fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1769 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1770 let alt = key.modifiers.contains(KeyModifiers::ALT);
1771 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1772
1773 match key.code {
1774 KeyCode::Esc => KeyAction::Cancel,
1775 KeyCode::Enter => KeyAction::Confirm,
1776
1777 KeyCode::Char('r' | 'R') if ctrl => {
1779 self.refine_list();
1780 KeyAction::Continue
1781 }
1782
1783 KeyCode::Tab | KeyCode::Char('\t') => {
1786 self.toggle_current_fuzzy();
1787 self.navigate_down();
1788 KeyAction::Continue
1789 }
1790
1791 KeyCode::BackTab => {
1793 self.toggle_current_fuzzy();
1794 self.navigate_up();
1795 KeyAction::Continue
1796 }
1797
1798 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1800 self.navigate_up();
1801 KeyAction::Continue
1802 }
1803 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1804 self.navigate_down();
1805 KeyAction::Continue
1806 }
1807 KeyCode::Up => {
1808 self.navigate_up();
1809 KeyAction::Continue
1810 }
1811 KeyCode::Down => {
1812 self.navigate_down();
1813 KeyAction::Continue
1814 }
1815
1816 KeyCode::Left if shift => {
1818 self.scroll_columns_left();
1819 KeyAction::Continue
1820 }
1821 KeyCode::Right if shift => {
1822 self.scroll_columns_right();
1823 KeyAction::Continue
1824 }
1825
1826 KeyCode::Char('a' | 'A') if ctrl => {
1828 self.filter_cursor = 0;
1829 self.filter_text_changed = true;
1830 KeyAction::Continue
1831 }
1832 KeyCode::Char('e' | 'E') if ctrl => {
1833 self.filter_cursor = self.filter_text.len();
1834 self.filter_text_changed = true;
1835 KeyAction::Continue
1836 }
1837 KeyCode::Char('b' | 'B') if ctrl => {
1838 self.move_filter_cursor_left();
1839 self.filter_text_changed = true;
1840 KeyAction::Continue
1841 }
1842 KeyCode::Char('f' | 'F') if ctrl => {
1843 self.move_filter_cursor_right();
1844 self.filter_text_changed = true;
1845 KeyAction::Continue
1846 }
1847 KeyCode::Char('b' | 'B') if alt => {
1848 self.move_filter_cursor_word_left();
1849 self.filter_text_changed = true;
1850 KeyAction::Continue
1851 }
1852 KeyCode::Char('f' | 'F') if alt => {
1853 self.move_filter_cursor_word_right();
1854 self.filter_text_changed = true;
1855 KeyAction::Continue
1856 }
1857 KeyCode::Char('c' | 'C') if alt => {
1859 self.toggle_case_sensitivity();
1861 KeyAction::Continue
1862 }
1863 KeyCode::Char('p' | 'P') if alt => {
1864 self.toggle_per_column();
1866 KeyAction::Continue
1867 }
1868 KeyCode::Left if ctrl || alt => {
1869 self.move_filter_cursor_word_left();
1870 self.filter_text_changed = true;
1871 KeyAction::Continue
1872 }
1873 KeyCode::Right if ctrl || alt => {
1874 self.move_filter_cursor_word_right();
1875 self.filter_text_changed = true;
1876 KeyAction::Continue
1877 }
1878 KeyCode::Left => {
1879 self.move_filter_cursor_left();
1880 self.filter_text_changed = true;
1881 KeyAction::Continue
1882 }
1883 KeyCode::Right => {
1884 self.move_filter_cursor_right();
1885 self.filter_text_changed = true;
1886 KeyAction::Continue
1887 }
1888
1889 KeyCode::Char('u' | 'U') if ctrl => {
1891 self.filter_text.drain(..self.filter_cursor);
1892 self.filter_cursor = 0;
1893 self.update_filter();
1894 KeyAction::Continue
1895 }
1896 KeyCode::Char('k' | 'K') if ctrl => {
1897 self.filter_text.truncate(self.filter_cursor);
1898 self.update_filter();
1899 KeyAction::Continue
1900 }
1901 KeyCode::Char('d' | 'D') if ctrl => {
1902 if self.filter_cursor < self.filter_text.len() {
1903 self.filter_text.remove(self.filter_cursor);
1904 self.update_filter();
1905 }
1906 KeyAction::Continue
1907 }
1908 KeyCode::Delete => {
1909 if self.filter_cursor < self.filter_text.len() {
1910 self.filter_text.remove(self.filter_cursor);
1911 self.update_filter();
1912 }
1913 KeyAction::Continue
1914 }
1915 KeyCode::Char('d' | 'D') if alt => {
1916 self.delete_word_forwards();
1917 self.update_filter();
1918 KeyAction::Continue
1919 }
1920 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1921 self.delete_word_backwards();
1922 self.update_filter();
1923 KeyAction::Continue
1924 }
1925 KeyCode::Backspace if alt => {
1926 self.delete_word_backwards();
1927 self.update_filter();
1928 KeyAction::Continue
1929 }
1930 KeyCode::Backspace => {
1931 if self.filter_cursor > 0 {
1932 let mut new_pos = self.filter_cursor - 1;
1933 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1934 new_pos -= 1;
1935 }
1936 self.filter_cursor = new_pos;
1937 self.filter_text.remove(self.filter_cursor);
1938 self.update_filter();
1939 }
1940 KeyAction::Continue
1941 }
1942 KeyCode::Char('t' | 'T') if ctrl => {
1943 let old_text = self.filter_text.clone();
1944 self.transpose_chars();
1945 if self.filter_text != old_text {
1946 self.update_filter();
1947 }
1948 KeyAction::Continue
1949 }
1950
1951 KeyCode::Char('a' | 'A') if alt => {
1953 self.toggle_all_fuzzy();
1954 KeyAction::Continue
1955 }
1956
1957 KeyCode::Char(c) => {
1959 self.filter_text.insert(self.filter_cursor, c);
1960 self.filter_cursor += c.len_utf8();
1961 self.update_filter();
1962 KeyAction::Continue
1963 }
1964
1965 KeyCode::Home => {
1967 self.navigate_home();
1968 KeyAction::Continue
1969 }
1970 KeyCode::End => {
1971 self.navigate_end();
1972 KeyAction::Continue
1973 }
1974 KeyCode::PageUp => {
1975 self.navigate_page_up();
1976 KeyAction::Continue
1977 }
1978 KeyCode::PageDown => {
1979 self.navigate_page_down();
1980 KeyAction::Continue
1981 }
1982 _ => KeyAction::Continue,
1983 }
1984 }
1985
1986 fn navigate_up(&mut self) {
1988 let list_len = self.current_list_len();
1989 if self.cursor > 0 {
1990 self.cursor -= 1;
1991 self.adjust_scroll_up();
1992 } else if list_len > 0 {
1993 if self.pending_stream.is_some() {
1995 self.drain_pending_stream();
1996 if !self.filter_text.is_empty() {
1997 self.update_filter();
1998 } else if !self.refined {
1999 self.filtered_indices = (0..self.items.len()).collect();
2000 }
2001 }
2002 let list_len = self.current_list_len();
2003 self.cursor = list_len.saturating_sub(1);
2004 self.adjust_scroll_down();
2005 }
2006 }
2007
2008 fn navigate_down(&mut self) {
2010 self.maybe_load_more();
2011
2012 let list_len = self.current_list_len();
2013 if self.cursor + 1 < list_len {
2014 self.cursor += 1;
2015 self.adjust_scroll_down();
2016 } else {
2017 if self.pending_stream.is_some() {
2019 self.maybe_load_more();
2020 let list_len = self.current_list_len();
2021 if self.cursor + 1 < list_len {
2022 self.cursor += 1;
2023 self.adjust_scroll_down();
2024 return;
2025 }
2026 }
2027
2028 self.cursor = 0;
2030 self.scroll_offset = 0;
2031 }
2032 }
2033
2034 fn adjust_scroll_down(&mut self) {
2035 let max_visible = self.scroll_offset + self.visible_height as usize;
2036 if self.cursor >= max_visible {
2037 self.scroll_offset = self.cursor - self.visible_height as usize + 1;
2038 }
2039 }
2040
2041 fn adjust_scroll_up(&mut self) {
2042 if self.cursor < self.scroll_offset {
2043 self.scroll_offset = self.cursor;
2044 }
2045 }
2046
2047 fn current_list_len(&self) -> usize {
2049 match self.mode {
2050 SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
2051 SelectMode::Multi if self.refined => self.filtered_indices.len(),
2052 _ => self.items.len(),
2053 }
2054 }
2055
2056 fn navigate_home(&mut self) {
2058 self.cursor = 0;
2059 self.scroll_offset = 0;
2060 }
2061
2062 fn navigate_end(&mut self) {
2064 if self.pending_stream.is_some() {
2066 self.drain_pending_stream();
2067 if !self.filter_text.is_empty() {
2068 self.update_filter();
2069 } else if !self.refined {
2070 self.filtered_indices = (0..self.items.len()).collect();
2071 }
2072 }
2073 self.cursor = self.current_list_len().saturating_sub(1);
2074 self.adjust_scroll_down();
2075 }
2076
2077 fn navigate_page_up(&mut self) {
2079 let page_top = self.scroll_offset;
2080 if self.cursor == page_top {
2081 self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
2083 self.adjust_scroll_up();
2084 } else {
2085 self.cursor = page_top;
2087 }
2088 }
2089
2090 fn navigate_page_down(&mut self) {
2092 self.maybe_load_more();
2093
2094 let list_len = self.current_list_len();
2095 let page_bottom =
2096 (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
2097 if self.cursor == page_bottom {
2098 self.cursor =
2100 (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
2101 self.adjust_scroll_down();
2102 } else {
2103 self.cursor = page_bottom;
2105 }
2106
2107 self.maybe_load_more();
2108 }
2109
2110 fn scroll_columns_left(&mut self) -> bool {
2112 if !self.is_table_mode() || self.horizontal_offset == 0 {
2113 return false;
2114 }
2115 self.horizontal_offset -= 1;
2116 self.horizontal_scroll_changed = true;
2117 self.update_table_layout();
2118 true
2119 }
2120
2121 fn scroll_columns_right(&mut self) -> bool {
2123 let Some(layout) = &self.table_layout else {
2124 return false;
2125 };
2126 let (cols_visible, has_more_right) = self.calculate_visible_columns();
2127 if !has_more_right {
2128 return false;
2129 }
2130 if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
2132 return false;
2133 }
2134 self.horizontal_offset += 1;
2135 self.horizontal_scroll_changed = true;
2136 self.update_table_layout();
2137 true
2138 }
2139
2140 fn toggle_current(&mut self) {
2141 if self.refined && self.filtered_indices.is_empty() {
2143 return;
2144 }
2145 let real_idx = if self.refined {
2147 self.filtered_indices[self.cursor]
2148 } else {
2149 self.cursor
2150 };
2151 self.toggle_index(real_idx);
2152 }
2153
2154 fn toggle_index(&mut self, real_idx: usize) {
2156 if self.selected.contains(&real_idx) {
2157 self.selected.remove(&real_idx);
2158 } else {
2159 self.selected.insert(real_idx);
2160 }
2161 self.toggled_item = Some(self.cursor);
2162 }
2163
2164 fn toggle_current_fuzzy(&mut self) -> bool {
2167 if self.filtered_indices.is_empty() {
2168 return false;
2169 }
2170 let real_idx = self.filtered_indices[self.cursor];
2171 self.toggle_index(real_idx);
2172 true
2173 }
2174
2175 fn toggle_all(&mut self) {
2176 let all_selected = if self.refined {
2178 self.filtered_indices
2179 .iter()
2180 .all(|i| self.selected.contains(i))
2181 } else {
2182 (0..self.items.len()).all(|i| self.selected.contains(&i))
2183 };
2184
2185 if all_selected {
2186 if self.refined {
2188 for i in &self.filtered_indices {
2189 self.selected.remove(i);
2190 }
2191 } else {
2192 self.selected.clear();
2193 }
2194 } else {
2195 if self.refined {
2197 self.selected.extend(self.filtered_indices.iter().copied());
2198 } else {
2199 self.selected.extend(0..self.items.len());
2200 }
2201 }
2202 self.toggled_all = true;
2203 }
2204
2205 fn toggle_all_fuzzy(&mut self) {
2207 if self.filtered_indices.is_empty() {
2208 return;
2209 }
2210
2211 let all_selected = self
2213 .filtered_indices
2214 .iter()
2215 .all(|i| self.selected.contains(i));
2216
2217 if all_selected {
2218 for i in &self.filtered_indices {
2220 self.selected.remove(i);
2221 }
2222 } else {
2223 self.selected.extend(self.filtered_indices.iter().copied());
2225 }
2226 self.toggled_all = true;
2227 }
2228
2229 fn refine_list(&mut self) {
2232 if self.selected.is_empty() {
2233 return;
2234 }
2235
2236 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2238 indices.sort();
2239
2240 self.filtered_indices = indices.clone();
2243 self.refined_base_indices = indices;
2244
2245 self.cursor = 0;
2247 self.scroll_offset = 0;
2248
2249 if self.mode == SelectMode::FuzzyMulti {
2254 self.filter_text.clear();
2255 self.filter_cursor = 0;
2256 self.filter_text_changed = true;
2257 }
2258
2259 self.refined = true;
2261
2262 self.first_render = true;
2264 }
2265
2266 fn move_filter_cursor_left(&mut self) {
2268 if self.filter_cursor > 0 {
2269 let mut new_pos = self.filter_cursor - 1;
2271 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2272 new_pos -= 1;
2273 }
2274 self.filter_cursor = new_pos;
2275 }
2276 }
2277
2278 fn move_filter_cursor_right(&mut self) {
2279 if self.filter_cursor < self.filter_text.len() {
2280 let mut new_pos = self.filter_cursor + 1;
2282 while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2283 new_pos += 1;
2284 }
2285 self.filter_cursor = new_pos;
2286 }
2287 }
2288
2289 fn move_filter_cursor_word_left(&mut self) {
2290 if self.filter_cursor == 0 {
2291 return;
2292 }
2293 let bytes = self.filter_text.as_bytes();
2294 let mut pos = self.filter_cursor;
2295 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2297 pos -= 1;
2298 }
2299 while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2301 pos -= 1;
2302 }
2303 self.filter_cursor = pos;
2304 }
2305
2306 fn move_filter_cursor_word_right(&mut self) {
2307 let len = self.filter_text.len();
2308 if self.filter_cursor >= len {
2309 return;
2310 }
2311 let bytes = self.filter_text.as_bytes();
2312 let mut pos = self.filter_cursor;
2313 while pos < len && !bytes[pos].is_ascii_whitespace() {
2315 pos += 1;
2316 }
2317 while pos < len && bytes[pos].is_ascii_whitespace() {
2319 pos += 1;
2320 }
2321 self.filter_cursor = pos;
2322 }
2323
2324 fn delete_word_backwards(&mut self) {
2325 if self.filter_cursor == 0 {
2326 return;
2327 }
2328 let start = self.filter_cursor;
2329 while self.filter_cursor > 0
2331 && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2332 {
2333 self.filter_cursor -= 1;
2334 }
2335 while self.filter_cursor > 0
2337 && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2338 {
2339 self.filter_cursor -= 1;
2340 }
2341 self.filter_text.drain(self.filter_cursor..start);
2342 }
2343
2344 fn delete_word_forwards(&mut self) {
2345 let len = self.filter_text.len();
2346 if self.filter_cursor >= len {
2347 return;
2348 }
2349 let start = self.filter_cursor;
2350 let bytes = self.filter_text.as_bytes();
2351 let mut end = start;
2352 while end < len && !bytes[end].is_ascii_whitespace() {
2354 end += 1;
2355 }
2356 while end < len && bytes[end].is_ascii_whitespace() {
2358 end += 1;
2359 }
2360 self.filter_text.drain(start..end);
2361 }
2362
2363 fn transpose_chars(&mut self) {
2364 let len = self.filter_text.len();
2368 if len < 2 {
2369 return;
2370 }
2371
2372 if self.filter_cursor == 0 {
2374 return;
2375 }
2376
2377 let pos = if self.filter_cursor >= len {
2380 len - 1
2381 } else {
2382 self.filter_cursor
2383 };
2384
2385 if pos == 0 {
2386 return;
2387 }
2388
2389 if self.filter_text.is_char_boundary(pos - 1)
2392 && self.filter_text.is_char_boundary(pos)
2393 && pos < len
2394 && self.filter_text.is_char_boundary(pos + 1)
2395 {
2396 let bytes = self.filter_text.as_bytes();
2398 if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2399 let bytes = unsafe { self.filter_text.as_bytes_mut() };
2401 bytes.swap(pos - 1, pos);
2402
2403 if self.filter_cursor < len {
2405 self.filter_cursor += 1;
2406 }
2407 }
2408 }
2409 }
2410
2411 fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
2413 item.cells.as_ref().and_then(|cells| {
2414 cells
2415 .iter()
2416 .filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
2417 .max()
2418 })
2419 }
2420
2421 fn score_item(&self, item: &SelectItem) -> Option<i64> {
2423 if self.per_column && item.cells.is_some() {
2424 self.score_per_column(item)
2425 } else {
2426 self.matcher.fuzzy_match(&item.name, &self.filter_text)
2427 }
2428 }
2429
2430 fn update_filter(&mut self) {
2431 if self.pending_stream.is_some() && !self.filter_text.is_empty() {
2434 self.drain_pending_stream();
2435 }
2436
2437 let old_indices = std::mem::take(&mut self.filtered_indices);
2438
2439 let use_refined = self.refined && !self.refined_base_indices.is_empty();
2441
2442 if self.filter_text.is_empty() {
2443 self.filtered_indices = if use_refined {
2445 self.refined_base_indices.clone()
2446 } else {
2447 (0..self.items.len()).collect()
2448 };
2449 } else {
2450 let mut scored: Vec<(usize, i64)> = if use_refined {
2452 self.refined_base_indices
2453 .iter()
2454 .filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
2455 .collect()
2456 } else {
2457 (0..self.items.len())
2458 .filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
2459 .collect()
2460 };
2461 scored.sort_by(|a, b| b.1.cmp(&a.1));
2463 self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2464 }
2465
2466 self.results_changed = old_indices != self.filtered_indices;
2468 self.filter_text_changed = true;
2469
2470 if self.results_changed {
2472 self.cursor = 0;
2473 self.scroll_offset = 0;
2474 }
2475
2476 if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2478 {
2479 self.auto_scroll_to_match_column();
2480 }
2481 }
2482
2483 fn auto_scroll_to_match_column(&mut self) {
2485 let Some(layout) = &self.table_layout else {
2486 return;
2487 };
2488
2489 let first_idx = self.filtered_indices[0];
2491 let item = &self.items[first_idx];
2492 let Some(cells) = &item.cells else {
2493 return;
2494 };
2495
2496 let mut first_match_col: Option<usize> = None;
2498 for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2499 if self.per_column {
2500 if self
2502 .matcher
2503 .fuzzy_match(cell_text, &self.filter_text)
2504 .is_some()
2505 {
2506 first_match_col = Some(col_idx);
2507 break;
2508 }
2509 } else {
2510 let cell_start: usize = cells[..col_idx]
2513 .iter()
2514 .map(|(s, _)| s.chars().count() + 1) .sum();
2516 let cell_char_count = cell_text.chars().count();
2517
2518 if let Some((_, indices)) =
2519 self.matcher.fuzzy_indices(&item.name, &self.filter_text)
2520 {
2521 if indices
2523 .iter()
2524 .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
2525 {
2526 first_match_col = Some(col_idx);
2527 break;
2528 }
2529 }
2530 }
2531 }
2532
2533 if let Some(match_col) = first_match_col {
2535 let (cols_visible, _) = self.calculate_visible_columns();
2536 let visible_start = self.horizontal_offset;
2537 let visible_end = self.horizontal_offset + cols_visible;
2538
2539 if match_col < visible_start {
2540 self.horizontal_offset = match_col;
2542 self.horizontal_scroll_changed = true;
2543 self.update_table_layout();
2544 } else if match_col >= visible_end {
2545 self.horizontal_offset = match_col;
2548 let max_offset = layout.col_widths.len().saturating_sub(1);
2550 self.horizontal_offset = self.horizontal_offset.min(max_offset);
2551 self.horizontal_scroll_changed = true;
2552 self.update_table_layout();
2553 }
2554 }
2555 }
2556
2557 fn get_result(&self) -> InteractMode {
2558 match self.mode {
2559 SelectMode::Single => InteractMode::Single(Some(self.cursor)),
2560 SelectMode::Multi => {
2561 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2562 indices.sort();
2563 InteractMode::Multi(Some(indices))
2564 }
2565 SelectMode::Fuzzy => {
2566 if self.filtered_indices.is_empty() {
2567 InteractMode::Single(None)
2568 } else {
2569 InteractMode::Single(Some(self.filtered_indices[self.cursor]))
2570 }
2571 }
2572 SelectMode::FuzzyMulti => {
2573 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2576 indices.sort();
2577 InteractMode::Multi(Some(indices))
2578 }
2579 }
2580 }
2581
2582 fn can_do_multi_toggle_only_update(&self) -> bool {
2585 if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
2586 return false;
2587 }
2588 if self.cursor != self.prev_cursor {
2591 return false;
2592 }
2593 if let Some(toggled) = self.toggled_item {
2594 let visible_start = self.scroll_offset;
2596 let visible_end = self.scroll_offset + self.visible_height as usize;
2597 toggled >= visible_start && toggled < visible_end
2598 } else {
2599 false
2600 }
2601 }
2602
2603 fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
2606 if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
2607 return false;
2608 }
2609 if self.scroll_offset != self.prev_scroll_offset {
2610 return false; }
2612 if self.filter_text_changed || self.results_changed {
2613 return false; }
2615 if let Some(toggled) = self.toggled_item {
2616 let visible_start = self.scroll_offset;
2618 let visible_end = self.scroll_offset + self.visible_height as usize;
2619 let toggled_visible = toggled >= visible_start && toggled < visible_end;
2620 let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
2621 toggled_visible && cursor_visible
2622 } else {
2623 false
2624 }
2625 }
2626
2627 fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
2630 !self.first_render
2631 && !self.width_changed
2632 && self.mode == SelectMode::FuzzyMulti
2633 && self.toggled_all
2634 && !self.filter_text_changed
2635 && !self.results_changed
2636 && self.scroll_offset == self.prev_scroll_offset
2637 && !self.horizontal_scroll_changed
2638 }
2639
2640 fn can_do_multi_toggle_all_update(&self) -> bool {
2643 !self.first_render
2644 && !self.width_changed
2645 && self.mode == SelectMode::Multi
2646 && self.toggled_all
2647 }
2648
2649 fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2651 let toggled = self.toggled_item.expect("toggled_item must be Some");
2652 execute!(stderr, BeginSynchronizedUpdate)?;
2653
2654 let header_lines = self.fuzzy_header_lines();
2656
2657 let toggled_display_row = (toggled - self.scroll_offset) as u16;
2658 let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
2659
2660 let toggled_item_row = header_lines + toggled_display_row;
2661 let cursor_item_row = header_lines + cursor_display_row;
2662
2663 let filter_row = self.fuzzy_filter_row();
2665
2666 let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2668 execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2669
2670 let toggled_real_idx = self.filtered_indices[toggled];
2672 let toggled_item = &self.items[toggled_real_idx];
2673 let toggled_checked = self.selected.contains(&toggled_real_idx);
2674 if self.is_table_mode() {
2675 self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
2676 } else {
2677 self.render_fuzzy_multi_item_inline(
2678 stderr,
2679 &toggled_item.name,
2680 toggled_checked,
2681 false,
2682 )?;
2683 }
2684
2685 if cursor_item_row > toggled_item_row {
2687 let lines_down = cursor_item_row - toggled_item_row;
2688 execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
2689 } else if cursor_item_row < toggled_item_row {
2690 let lines_up = toggled_item_row - cursor_item_row;
2691 execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
2692 }
2693
2694 let cursor_real_idx = self.filtered_indices[self.cursor];
2695 let cursor_item = &self.items[cursor_real_idx];
2696 let cursor_checked = self.selected.contains(&cursor_real_idx);
2697 if self.is_table_mode() {
2698 self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
2699 } else {
2700 self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
2701 }
2702
2703 if self.has_footer() {
2705 let total_count = self.current_list_len();
2707 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2708 let visible_count = (end - self.scroll_offset) as u16;
2709 let footer_row = header_lines + visible_count;
2710
2711 let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2713 execute!(stderr, MoveDown(down_to_footer))?;
2714
2715 self.render_footer_inline(stderr)?;
2717
2718 let up_to_filter = footer_row.saturating_sub(filter_row);
2720 execute!(stderr, MoveUp(up_to_filter))?;
2721 } else {
2722 let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2724 execute!(stderr, MoveUp(up_to_filter))?;
2725 }
2726
2727 self.position_fuzzy_cursor(stderr)?;
2729
2730 self.prev_cursor = self.cursor;
2732 self.toggled_item = None;
2733
2734 execute!(stderr, EndSynchronizedUpdate)?;
2735 stderr.flush()
2736 }
2737
2738 fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2740 let toggled = self.toggled_item.expect("toggled_item must be Some");
2741 execute!(stderr, BeginSynchronizedUpdate)?;
2742
2743 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2744 if self.is_table_mode() {
2745 header_lines += 2; }
2747
2748 let display_row = (toggled - self.scroll_offset) as u16;
2750
2751 let items_rendered = self.rendered_lines - header_lines as usize;
2753
2754 let lines_up = (items_rendered as u16)
2757 .saturating_sub(1)
2758 .saturating_sub(display_row);
2759 execute!(stderr, MoveUp(lines_up))?;
2760
2761 execute!(stderr, MoveToColumn(2))?;
2763
2764 let checkbox = if self.selected.contains(&toggled) {
2766 "[x]"
2767 } else {
2768 "[ ]"
2769 };
2770 execute!(stderr, Print(checkbox))?;
2771
2772 execute!(stderr, MoveDown(lines_up))?;
2774
2775 if self.has_footer() {
2777 self.render_footer_inline(stderr)?;
2778 }
2779
2780 self.toggled_item = None;
2782
2783 execute!(stderr, EndSynchronizedUpdate)?;
2784 stderr.flush()
2785 }
2786
2787 fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2789 execute!(stderr, BeginSynchronizedUpdate)?;
2790
2791 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2792 if self.is_table_mode() {
2793 header_lines += 2; }
2795
2796 let items_rendered = self.rendered_lines - header_lines as usize;
2798
2799 let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
2801 let visible_count = visible_end - self.scroll_offset;
2802
2803 execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2806
2807 for i in 0..visible_count {
2809 let item_idx = self.scroll_offset + i;
2810 let checkbox = if self.selected.contains(&item_idx) {
2811 "[x]"
2812 } else {
2813 "[ ]"
2814 };
2815 execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2817 if i + 1 < visible_count {
2818 execute!(stderr, MoveDown(1))?;
2819 }
2820 }
2821
2822 let remaining = items_rendered as u16 - visible_count as u16;
2824 if remaining > 0 {
2825 execute!(stderr, MoveDown(remaining))?;
2826 }
2827
2828 if self.has_footer() {
2830 self.render_footer_inline(stderr)?;
2831 }
2832
2833 self.toggled_all = false;
2835
2836 execute!(stderr, EndSynchronizedUpdate)?;
2837 stderr.flush()
2838 }
2839
2840 fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2842 execute!(stderr, BeginSynchronizedUpdate)?;
2843
2844 let header_lines = self.fuzzy_header_lines();
2846
2847 let total_count = self.current_list_len();
2848 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2849 let visible_count = end.saturating_sub(self.scroll_offset);
2850
2851 let filter_row = self.fuzzy_filter_row();
2853
2854 let down_to_first = header_lines.saturating_sub(filter_row);
2856 execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
2857
2858 for (i, idx) in (self.scroll_offset..end).enumerate() {
2859 let real_idx = self.filtered_indices[idx];
2860 let item = &self.items[real_idx];
2861 let checked = self.selected.contains(&real_idx);
2862 let active = idx == self.cursor;
2863
2864 if self.is_table_mode() {
2865 self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
2866 } else {
2867 self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
2868 }
2869
2870 if i + 1 < visible_count {
2871 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2872 }
2873 }
2874
2875 if self.has_footer() {
2877 let footer_row = header_lines + visible_count as u16;
2878 let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
2879 let down_to_footer = footer_row.saturating_sub(last_item_row);
2880 execute!(stderr, MoveDown(down_to_footer))?;
2881 self.render_footer_inline(stderr)?;
2882 let up_to_filter = footer_row.saturating_sub(filter_row);
2883 execute!(stderr, MoveUp(up_to_filter))?;
2884 } else {
2885 let up_to_filter =
2886 (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
2887 execute!(stderr, MoveUp(up_to_filter))?;
2888 }
2889
2890 self.position_fuzzy_cursor(stderr)?;
2892
2893 self.toggled_all = false;
2895
2896 execute!(stderr, EndSynchronizedUpdate)?;
2897 stderr.flush()
2898 }
2899
2900 #[allow(clippy::collapsible_if)]
2901 fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2902 self.maybe_load_more();
2903
2904 if self.can_do_fuzzy_multi_toggle_all_update() {
2906 return self.render_fuzzy_multi_toggle_all_update(stderr);
2907 }
2908
2909 if self.can_do_multi_toggle_all_update() {
2911 return self.render_multi_toggle_all(stderr);
2912 }
2913
2914 if self.can_do_multi_toggle_only_update() {
2916 return self.render_multi_toggle_only(stderr);
2917 }
2918
2919 if self.can_do_fuzzy_multi_toggle_update() {
2921 return self.render_fuzzy_multi_toggle_update(stderr);
2922 }
2923
2924 if !self.first_render
2931 && !self.width_changed
2932 && self.cursor == self.prev_cursor
2933 && self.scroll_offset == self.prev_scroll_offset
2934 && !self.results_changed
2935 && !self.filter_text_changed
2936 && !self.horizontal_scroll_changed
2937 && !self.settings_changed
2938 && !self.toggled_all
2939 {
2940 return Ok(());
2941 }
2942
2943 execute!(stderr, BeginSynchronizedUpdate)?;
2944
2945 let total_count = self.current_list_len();
2947 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2948 let has_scroll_indicator = self.has_footer();
2950 let items_to_render = end - self.scroll_offset;
2951
2952 let mut lines_needed: usize = 0;
2954 if self.prompt.is_some() {
2955 lines_needed += 1;
2956 }
2957 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2958 lines_needed += 1; if self.config.show_separator {
2960 lines_needed += 1;
2961 }
2962 }
2963 if self.is_table_mode() {
2964 lines_needed += 2; }
2966 lines_needed += items_to_render;
2967 if has_scroll_indicator {
2968 lines_needed += 1;
2969 }
2970
2971 if self.first_render && lines_needed > 1 {
2973 for _ in 0..(lines_needed - 1) {
2974 execute!(stderr, Print("\n"))?;
2975 }
2976 execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
2977 }
2978
2979 if self.fuzzy_cursor_offset > 0 {
2981 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
2982 self.fuzzy_cursor_offset = 0;
2983 }
2984
2985 if self.rendered_lines > 1 {
2988 execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
2989 }
2990 execute!(stderr, MoveToColumn(0))?;
2991
2992 let mut lines_rendered: usize = 0;
2993
2994 if self.first_render {
2996 if let Some(prompt) = self.prompt {
2997 execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
2998 }
2999 }
3000 if self.prompt.is_some() {
3001 lines_rendered += 1;
3002 if lines_rendered < lines_needed {
3003 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3004 }
3005 }
3006
3007 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3009 execute!(
3010 stderr,
3011 Print(self.prompt_marker()),
3012 Print(&self.filter_text),
3013 Clear(ClearType::UntilNewLine),
3014 )?;
3015 lines_rendered += 1;
3016 if lines_rendered < lines_needed {
3017 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3018 }
3019
3020 if self.config.show_separator {
3022 execute!(
3023 stderr,
3024 Print(self.config.separator.paint(&self.separator_line)),
3025 Clear(ClearType::UntilNewLine),
3026 )?;
3027 lines_rendered += 1;
3028 if lines_rendered < lines_needed {
3029 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3030 }
3031 }
3032 }
3033
3034 if self.is_table_mode() {
3037 let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
3038 if need_header_redraw {
3039 self.render_table_header(stderr)?;
3040 }
3041 lines_rendered += 1;
3042 if lines_rendered < lines_needed {
3043 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3044 }
3045 if need_header_redraw {
3046 self.render_table_header_separator(stderr)?;
3047 }
3048 lines_rendered += 1;
3049 if lines_rendered < lines_needed {
3050 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3051 }
3052 }
3053
3054 for idx in self.scroll_offset..end {
3056 let is_active = idx == self.cursor;
3057 let is_last_line = lines_rendered + 1 == lines_needed;
3058
3059 if self.is_table_mode() {
3060 match self.mode {
3062 SelectMode::Single => {
3063 let item = &self.items[idx];
3064 self.render_table_row_single(stderr, item, is_active)?;
3065 }
3066 SelectMode::Multi => {
3067 let real_idx = if self.refined {
3068 self.filtered_indices[idx]
3069 } else {
3070 idx
3071 };
3072 let item = &self.items[real_idx];
3073 let is_checked = self.selected.contains(&real_idx);
3074 self.render_table_row_multi(stderr, item, is_checked, is_active)?;
3075 }
3076 SelectMode::Fuzzy => {
3077 let real_idx = self.filtered_indices[idx];
3078 let item = &self.items[real_idx];
3079 self.render_table_row_fuzzy(stderr, item, is_active)?;
3080 }
3081 SelectMode::FuzzyMulti => {
3082 let real_idx = self.filtered_indices[idx];
3083 let item = &self.items[real_idx];
3084 let is_checked = self.selected.contains(&real_idx);
3085 self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
3086 }
3087 }
3088 } else {
3089 match self.mode {
3091 SelectMode::Single => {
3092 let item = &self.items[idx];
3093 self.render_single_item_inline(stderr, &item.name, is_active)?;
3094 }
3095 SelectMode::Multi => {
3096 let real_idx = if self.refined {
3097 self.filtered_indices[idx]
3098 } else {
3099 idx
3100 };
3101 let item = &self.items[real_idx];
3102 let is_checked = self.selected.contains(&real_idx);
3103 self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
3104 }
3105 SelectMode::Fuzzy => {
3106 let real_idx = self.filtered_indices[idx];
3107 let item = &self.items[real_idx];
3108 self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
3109 }
3110 SelectMode::FuzzyMulti => {
3111 let real_idx = self.filtered_indices[idx];
3112 let item = &self.items[real_idx];
3113 let is_checked = self.selected.contains(&real_idx);
3114 self.render_fuzzy_multi_item_inline(
3115 stderr, &item.name, is_checked, is_active,
3116 )?;
3117 }
3118 }
3119 }
3120 lines_rendered += 1;
3121 if !is_last_line {
3122 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3123 }
3124 }
3125
3126 if has_scroll_indicator {
3128 let indicator = self.generate_footer();
3129 execute!(
3130 stderr,
3131 Print(self.config.footer.paint(&indicator)),
3132 Clear(ClearType::UntilNewLine),
3133 )?;
3134 lines_rendered += 1;
3135 }
3136
3137 if lines_rendered < self.rendered_lines {
3140 let extra_lines = self.rendered_lines - lines_rendered;
3141 for _ in 0..extra_lines {
3142 execute!(
3143 stderr,
3144 MoveDown(1),
3145 MoveToColumn(0),
3146 Clear(ClearType::CurrentLine)
3147 )?;
3148 }
3149 execute!(stderr, MoveUp(extra_lines as u16))?;
3151 }
3152
3153 self.rendered_lines = lines_rendered;
3155 self.prev_cursor = self.cursor;
3156 self.prev_scroll_offset = self.scroll_offset;
3157 self.first_render = false;
3158 self.filter_text_changed = false;
3159 self.results_changed = false;
3160 self.horizontal_scroll_changed = false;
3161 self.width_changed = false;
3162 self.toggled_item = None;
3163 self.toggled_all = false;
3164 self.settings_changed = false;
3165
3166 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3168 let filter_row = self.fuzzy_filter_row() as usize;
3170 self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3171 if self.fuzzy_cursor_offset > 0 {
3172 execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3173 }
3174 self.position_fuzzy_cursor(stderr)?;
3176 }
3177
3178 execute!(stderr, EndSynchronizedUpdate)?;
3179 stderr.flush()
3180 }
3181
3182 fn render_single_item_inline(
3183 &self,
3184 stderr: &mut Stderr,
3185 text: &str,
3186 active: bool,
3187 ) -> io::Result<()> {
3188 let prefix = if active { self.selected_marker() } else { " " };
3189 let prefix_width = 2;
3190
3191 execute!(stderr, Print(prefix))?;
3192 self.render_truncated_text(stderr, text, prefix_width)?;
3193 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3194 Ok(())
3195 }
3196
3197 fn render_multi_item_inline(
3198 &self,
3199 stderr: &mut Stderr,
3200 text: &str,
3201 checked: bool,
3202 active: bool,
3203 ) -> io::Result<()> {
3204 let cursor = if active { self.selected_marker() } else { " " };
3205 let checkbox = if checked { "[x] " } else { "[ ] " };
3206 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3209 self.render_truncated_text(stderr, text, prefix_width)?;
3210 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3211 Ok(())
3212 }
3213
3214 fn render_fuzzy_item_inline(
3215 &self,
3216 stderr: &mut Stderr,
3217 text: &str,
3218 active: bool,
3219 ) -> io::Result<()> {
3220 let prefix = if active { self.selected_marker() } else { " " };
3221 let prefix_width = 2;
3222 execute!(stderr, Print(prefix))?;
3223
3224 if self.filter_text.is_empty() {
3225 self.render_truncated_text(stderr, text, prefix_width)?;
3226 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3227 {
3228 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3229 } else {
3230 self.render_truncated_text(stderr, text, prefix_width)?;
3231 }
3232 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3233 Ok(())
3234 }
3235
3236 fn render_fuzzy_multi_item_inline(
3237 &self,
3238 stderr: &mut Stderr,
3239 text: &str,
3240 checked: bool,
3241 active: bool,
3242 ) -> io::Result<()> {
3243 let cursor = if active { self.selected_marker() } else { " " };
3244 let checkbox = if checked { "[x] " } else { "[ ] " };
3245 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3247
3248 if self.filter_text.is_empty() {
3249 self.render_truncated_text(stderr, text, prefix_width)?;
3250 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3251 {
3252 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3253 } else {
3254 self.render_truncated_text(stderr, text, prefix_width)?;
3255 }
3256 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3257 Ok(())
3258 }
3259
3260 fn render_truncated_text(
3262 &self,
3263 stderr: &mut Stderr,
3264 text: &str,
3265 prefix_width: usize,
3266 ) -> io::Result<()> {
3267 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3268 let text_width = UnicodeWidthStr::width(text);
3269
3270 if text_width <= available_width {
3271 execute!(stderr, Print(text))?;
3273 } else if available_width <= 1 {
3274 execute!(stderr, Print("…"))?;
3276 } else {
3277 let target_width = available_width - 1;
3279 let mut current_width = 0;
3280 let mut end_pos = 0;
3281
3282 for (byte_pos, c) in text.char_indices() {
3283 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3284 if current_width + char_width > target_width {
3285 break;
3286 }
3287 end_pos = byte_pos + c.len_utf8();
3288 current_width += char_width;
3289 }
3290 execute!(stderr, Print(&text[..end_pos]))?;
3291 execute!(stderr, Print("…"))?;
3292 }
3293 Ok(())
3294 }
3295
3296 fn render_truncated_fuzzy_text(
3299 &self,
3300 stderr: &mut Stderr,
3301 text: &str,
3302 match_indices: &[usize],
3303 prefix_width: usize,
3304 ) -> io::Result<()> {
3305 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3306 let text_width = UnicodeWidthStr::width(text);
3307
3308 let mut char_buf = [0u8; 4];
3310
3311 if text_width <= available_width {
3312 let mut match_iter = match_indices.iter().peekable();
3315 for (idx, c) in text.chars().enumerate() {
3316 while match_iter.peek().is_some_and(|&&i| i < idx) {
3318 match_iter.next();
3319 }
3320 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3321 if is_match {
3322 let s = c.encode_utf8(&mut char_buf);
3323 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3324 } else {
3325 execute!(stderr, Print(c))?;
3326 }
3327 }
3328 } else if available_width <= 1 {
3329 let has_any_matches = !match_indices.is_empty();
3331 if has_any_matches {
3332 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3333 } else {
3334 execute!(stderr, Print("…"))?;
3335 }
3336 } else {
3337 let target_width = available_width - 1;
3339 let mut current_width = 0;
3340 let mut chars_to_render: usize = 0;
3341
3342 for c in text.chars() {
3343 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3344 if current_width + char_width > target_width {
3345 break;
3346 }
3347 current_width += char_width;
3348 chars_to_render += 1;
3349 }
3350
3351 let mut match_iter = match_indices.iter().peekable();
3353 for (idx, c) in text.chars().enumerate() {
3354 if idx >= chars_to_render {
3355 break;
3356 }
3357 while match_iter.peek().is_some_and(|&&i| i < idx) {
3358 match_iter.next();
3359 }
3360 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3361 if is_match {
3362 let s = c.encode_utf8(&mut char_buf);
3363 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3364 } else {
3365 execute!(stderr, Print(c))?;
3366 }
3367 }
3368
3369 let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
3371
3372 if has_hidden_matches {
3373 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3374 } else {
3375 execute!(stderr, Print("…"))?;
3376 }
3377 }
3378 Ok(())
3379 }
3380
3381 fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3383 let Some(layout) = &self.table_layout else {
3384 return Ok(());
3385 };
3386
3387 let prefix_width = self.row_prefix_width();
3388 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3389 let has_more_left = self.horizontal_offset > 0;
3390
3391 execute!(stderr, Print(" ".repeat(prefix_width)))?;
3393
3394 if has_more_left {
3396 let sep = self.table_column_separator();
3397 execute!(
3398 stderr,
3399 Print(self.config.table_separator.paint("…")),
3400 Print(self.config.table_separator.paint(&sep))
3401 )?;
3402 }
3403
3404 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3406 for (i, col_idx) in visible_range.enumerate() {
3407 if col_idx >= layout.columns.len() {
3408 break;
3409 }
3410
3411 if i > 0 {
3413 let sep = self.table_column_separator();
3414 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3415 }
3416
3417 let header = &layout.columns[col_idx];
3419 let col_width = layout.col_widths[col_idx];
3420 let header_width = header.width();
3421 let padding = col_width.saturating_sub(header_width);
3422 let left_pad = padding / 2;
3423 let right_pad = padding - left_pad;
3424 let header_padded = format!(
3425 "{}{}{}",
3426 " ".repeat(left_pad),
3427 header,
3428 " ".repeat(right_pad)
3429 );
3430 execute!(
3431 stderr,
3432 Print(self.config.table_header.paint(&header_padded))
3433 )?;
3434 }
3435
3436 if has_more_right {
3438 let sep = self.table_column_separator();
3439 execute!(
3440 stderr,
3441 Print(self.config.table_separator.paint(&sep)),
3442 Print(self.config.table_separator.paint("…"))
3443 )?;
3444 }
3445
3446 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3447 Ok(())
3448 }
3449
3450 fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3452 let Some(layout) = &self.table_layout else {
3453 return Ok(());
3454 };
3455
3456 let prefix_width = self.row_prefix_width();
3457 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3458 let has_more_left = self.horizontal_offset > 0;
3459
3460 let h_char = self.config.table_header_separator;
3461 let int_char = self.config.table_header_intersection;
3462
3463 let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3465 execute!(
3466 stderr,
3467 Print(self.config.table_separator.paint(&prefix_line))
3468 )?;
3469
3470 if has_more_left {
3473 let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3474 execute!(
3475 stderr,
3476 Print(self.config.table_separator.paint(&left_indicator))
3477 )?;
3478 }
3479
3480 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3482 for (i, col_idx) in visible_range.enumerate() {
3483 if col_idx >= layout.col_widths.len() {
3484 break;
3485 }
3486
3487 if i > 0 {
3489 let intersection = format!("{}{}{}", h_char, int_char, h_char);
3490 execute!(
3491 stderr,
3492 Print(self.config.table_separator.paint(&intersection))
3493 )?;
3494 }
3495
3496 let col_width = layout.col_widths[col_idx];
3498 let line: String = std::iter::repeat_n(h_char, col_width).collect();
3499 execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
3500 }
3501
3502 if has_more_right {
3505 let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
3506 execute!(
3507 stderr,
3508 Print(self.config.table_separator.paint(&right_indicator))
3509 )?;
3510 }
3511
3512 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3513 Ok(())
3514 }
3515
3516 fn render_table_row_single(
3518 &self,
3519 stderr: &mut Stderr,
3520 item: &SelectItem,
3521 active: bool,
3522 ) -> io::Result<()> {
3523 let prefix = if active { self.selected_marker() } else { " " };
3524 execute!(stderr, Print(prefix))?;
3525 self.render_table_cells(stderr, item, None)?;
3526 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3527 Ok(())
3528 }
3529
3530 fn render_table_row_multi(
3532 &self,
3533 stderr: &mut Stderr,
3534 item: &SelectItem,
3535 checked: bool,
3536 active: bool,
3537 ) -> io::Result<()> {
3538 let cursor = if active { self.selected_marker() } else { " " };
3539 let checkbox = if checked { "[x] " } else { "[ ] " };
3540 execute!(stderr, Print(cursor), Print(checkbox))?;
3541 self.render_table_cells(stderr, item, None)?;
3542 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3543 Ok(())
3544 }
3545
3546 fn render_table_row_fuzzy(
3548 &self,
3549 stderr: &mut Stderr,
3550 item: &SelectItem,
3551 active: bool,
3552 ) -> io::Result<()> {
3553 let prefix = if active { self.selected_marker() } else { " " };
3554 execute!(stderr, Print(prefix))?;
3555
3556 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3558 self.matcher
3559 .fuzzy_indices(&item.name, &self.filter_text)
3560 .map(|(_, indices)| indices)
3561 } else {
3562 None
3563 };
3564
3565 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3566 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3567 Ok(())
3568 }
3569
3570 fn render_table_row_fuzzy_multi(
3572 &self,
3573 stderr: &mut Stderr,
3574 item: &SelectItem,
3575 checked: bool,
3576 active: bool,
3577 ) -> io::Result<()> {
3578 let cursor = if active { self.selected_marker() } else { " " };
3579 let checkbox = if checked { "[x] " } else { "[ ] " };
3580 execute!(stderr, Print(cursor), Print(checkbox))?;
3581
3582 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3584 self.matcher
3585 .fuzzy_indices(&item.name, &self.filter_text)
3586 .map(|(_, indices)| indices)
3587 } else {
3588 None
3589 };
3590
3591 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3592 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3593 Ok(())
3594 }
3595
3596 fn render_table_cells(
3598 &self,
3599 stderr: &mut Stderr,
3600 item: &SelectItem,
3601 match_indices: Option<&[usize]>,
3602 ) -> io::Result<()> {
3603 let Some(layout) = &self.table_layout else {
3604 return Ok(());
3605 };
3606 let Some(cells) = &item.cells else {
3607 return Ok(());
3608 };
3609
3610 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3611 let has_more_left = self.horizontal_offset > 0;
3612
3613 let mut matches_in_hidden_left = false;
3615 let mut matches_in_hidden_right = false;
3616
3617 let per_column_matches: Vec<Option<Vec<usize>>> =
3619 if self.per_column && !self.filter_text.is_empty() {
3620 cells
3621 .iter()
3622 .map(|(cell_text, _)| {
3623 self.matcher
3624 .fuzzy_indices(cell_text, &self.filter_text)
3625 .map(|(_, indices)| indices)
3626 })
3627 .collect()
3628 } else {
3629 vec![]
3630 };
3631
3632 let cell_offsets: Vec<usize> = if match_indices.is_some() {
3635 let mut offsets = Vec::with_capacity(cells.len());
3636 let mut offset = 0;
3637 for (i, (cell_text, _)) in cells.iter().enumerate() {
3638 offsets.push(offset);
3639 offset += cell_text.chars().count();
3640 if i + 1 < cells.len() {
3641 offset += 1; }
3643 }
3644 offsets
3645 } else {
3646 vec![]
3647 };
3648
3649 if self.per_column && !self.filter_text.is_empty() {
3651 for col_idx in 0..self.horizontal_offset {
3652 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3653 matches_in_hidden_left = true;
3654 break;
3655 }
3656 }
3657 } else if let Some(indices) = match_indices {
3658 for col_idx in 0..self.horizontal_offset {
3659 if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
3660 let cell_start = cell_offsets[col_idx];
3661 let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3663 matches_in_hidden_left = true;
3664 break;
3665 }
3666 }
3667 }
3668 }
3669
3670 if has_more_left {
3672 let sep = self.table_column_separator();
3673 if matches_in_hidden_left {
3674 execute!(
3675 stderr,
3676 Print(self.config.match_text.paint("…")),
3677 Print(self.config.table_separator.paint(&sep))
3678 )?;
3679 } else {
3680 execute!(
3681 stderr,
3682 Print(self.config.table_separator.paint("…")),
3683 Print(self.config.table_separator.paint(&sep))
3684 )?;
3685 }
3686 }
3687
3688 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3690 for (i, col_idx) in visible_range.enumerate() {
3691 if col_idx >= cells.len() {
3692 break;
3693 }
3694
3695 if i > 0 {
3697 let sep = self.table_column_separator();
3698 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3699 }
3700
3701 let (cell_text, cell_style) = &cells[col_idx];
3702 let col_width = layout.col_widths[col_idx];
3703
3704 let cell_matches: Option<Vec<usize>> =
3706 if self.per_column && !self.filter_text.is_empty() {
3707 per_column_matches.get(col_idx).cloned().flatten()
3709 } else if let Some(indices) = match_indices {
3710 if col_idx < cell_offsets.len() {
3712 let cell_start = cell_offsets[col_idx];
3713 let cell_char_count = cell_text.chars().count();
3715 let relative_indices: Vec<usize> = indices
3716 .iter()
3717 .filter_map(|&idx| {
3718 if idx >= cell_start && idx < cell_start + cell_char_count {
3719 Some(idx - cell_start)
3720 } else {
3721 None
3722 }
3723 })
3724 .collect();
3725 if relative_indices.is_empty() {
3726 None
3727 } else {
3728 Some(relative_indices)
3729 }
3730 } else {
3731 None
3732 }
3733 } else {
3734 None
3735 };
3736
3737 self.render_table_cell(
3739 stderr,
3740 cell_text,
3741 cell_style,
3742 col_width,
3743 cell_matches.as_deref(),
3744 )?;
3745 }
3746
3747 if self.per_column && !self.filter_text.is_empty() {
3749 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3750 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3751 matches_in_hidden_right = true;
3752 break;
3753 }
3754 }
3755 } else if let Some(indices) = match_indices {
3756 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3757 if col_idx < cell_offsets.len() {
3758 let cell_start = cell_offsets[col_idx];
3759 let cell_end = if col_idx + 1 < cell_offsets.len() {
3760 cell_offsets[col_idx + 1].saturating_sub(1)
3761 } else {
3762 item.name.chars().count()
3763 };
3764 if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3765 matches_in_hidden_right = true;
3766 break;
3767 }
3768 }
3769 }
3770 }
3771
3772 if has_more_right {
3774 let sep = self.table_column_separator();
3775 if matches_in_hidden_right {
3776 execute!(
3777 stderr,
3778 Print(self.config.table_separator.paint(&sep)),
3779 Print(self.config.match_text.paint("…"))
3780 )?;
3781 } else {
3782 execute!(
3783 stderr,
3784 Print(self.config.table_separator.paint(&sep)),
3785 Print(self.config.table_separator.paint("…"))
3786 )?;
3787 }
3788 }
3789
3790 Ok(())
3791 }
3792
3793 fn render_table_cell(
3795 &self,
3796 stderr: &mut Stderr,
3797 cell: &str,
3798 cell_style: &TextStyle,
3799 col_width: usize,
3800 match_indices: Option<&[usize]>,
3801 ) -> io::Result<()> {
3802 let cell_width = cell.width();
3803 let padding_needed = col_width.saturating_sub(cell_width);
3804
3805 let (left_pad, right_pad) = match cell_style.alignment {
3807 Alignment::Left => (0, padding_needed),
3808 Alignment::Right => (padding_needed, 0),
3809 Alignment::Center => {
3810 let left = padding_needed / 2;
3811 (left, padding_needed - left)
3812 }
3813 };
3814
3815 if left_pad > 0 {
3817 execute!(stderr, Print(" ".repeat(left_pad)))?;
3818 }
3819
3820 if let Some(indices) = match_indices {
3821 let mut char_buf = [0u8; 4];
3823 let mut match_iter = indices.iter().peekable();
3824
3825 for (idx, c) in cell.chars().enumerate() {
3826 while match_iter.peek().is_some_and(|&&i| i < idx) {
3827 match_iter.next();
3828 }
3829 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3830 if is_match {
3831 let s = c.encode_utf8(&mut char_buf);
3832 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3833 } else {
3834 let s = c.encode_utf8(&mut char_buf);
3836 if let Some(color) = cell_style.color_style {
3837 execute!(stderr, Print(color.paint(&*s)))?;
3838 } else {
3839 execute!(stderr, Print(&*s))?;
3840 }
3841 }
3842 }
3843 } else {
3844 if let Some(color) = cell_style.color_style {
3846 execute!(stderr, Print(color.paint(cell)))?;
3847 } else {
3848 execute!(stderr, Print(cell))?;
3849 }
3850 }
3851
3852 if right_pad > 0 {
3854 execute!(stderr, Print(" ".repeat(right_pad)))?;
3855 }
3856
3857 Ok(())
3858 }
3859
3860 fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3861 if self.fuzzy_cursor_offset > 0 {
3863 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3864 self.fuzzy_cursor_offset = 0;
3865 }
3866
3867 if self.rendered_lines > 0 {
3868 execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3872 for _ in 1..self.rendered_lines {
3874 execute!(
3875 stderr,
3876 MoveUp(1),
3877 MoveToColumn(0),
3878 Clear(ClearType::CurrentLine)
3879 )?;
3880 }
3881 }
3883 self.rendered_lines = 0;
3884 stderr.flush()
3885 }
3886}
3887
3888enum KeyAction {
3889 Continue,
3890 Cancel,
3891 Confirm,
3892}
3893
3894#[cfg(test)]
3895mod test {
3896 use super::*;
3897
3898 fn make_widget(items: &[&str]) -> SelectWidget<'static> {
3899 let options: Vec<SelectItem> = items
3900 .iter()
3901 .map(|s| SelectItem {
3902 name: s.to_string(),
3903 cells: None,
3904 value: nu_protocol::Value::nothing(nu_protocol::Span::test_data()),
3905 })
3906 .collect();
3907
3908 SelectWidget::new(
3909 SelectMode::Single,
3910 None,
3911 options,
3912 InputListConfig::default(),
3913 None,
3914 false,
3915 StreamState {
3916 pending_stream: None,
3917 item_generator: None,
3918 },
3919 )
3920 }
3921
3922 #[test]
3923 fn wrap_up_and_down_cycles() {
3924 let mut w = make_widget(&["A", "B", "C"]);
3925 w.navigate_up();
3927 assert_eq!(w.cursor, 2);
3928 w.navigate_up();
3929 assert_eq!(w.cursor, 1);
3930 w.navigate_up();
3931 assert_eq!(w.cursor, 0);
3932
3933 w.navigate_down();
3935 assert_eq!(w.cursor, 1);
3936 w.navigate_down();
3937 assert_eq!(w.cursor, 2);
3938 w.navigate_down();
3939 assert_eq!(w.cursor, 0);
3940 }
3941
3942 #[test]
3943 fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
3944 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3945 w.first_render = false;
3946 w.prev_cursor = 0;
3947 w.prev_scroll_offset = 0;
3948 w.cursor = 0;
3949 w.scroll_offset = 0;
3950
3951 let mut stderr = io::stderr();
3952
3953 for _ in 0..7 {
3954 w.navigate_down();
3955 w.render(&mut stderr)?;
3956 assert_eq!(w.scroll_offset, 0);
3957 }
3958
3959 Ok(())
3960 }
3961
3962 #[test]
3963 fn up_arrow_sequence_state_and_render() -> io::Result<()> {
3964 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3965 w.first_render = false;
3966 w.prev_cursor = 0;
3967 w.prev_scroll_offset = 0;
3968 w.cursor = 0;
3969 w.scroll_offset = 0;
3970
3971 let mut stderr = io::stderr();
3972
3973 w.render(&mut stderr)?;
3974 assert_eq!(w.cursor, 0);
3975
3976 w.navigate_up();
3977 w.render(&mut stderr)?;
3978 assert_eq!(w.cursor, 2);
3979
3980 w.navigate_up();
3981 w.render(&mut stderr)?;
3982 assert_eq!(w.cursor, 1);
3983
3984 Ok(())
3985 }
3986
3987 #[test]
3988 fn test_examples() -> nu_test_support::Result {
3989 nu_test_support::test().examples(InputList)
3990 }
3991}