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 = (self.term_width as usize)
874 .checked_div(sep_width)
875 .unwrap_or(self.term_width as usize);
876 self.separator_line = self.config.separator_char.repeat(repeat_count);
877 }
878
879 fn prompt_marker(&self) -> String {
881 self.config
882 .prompt_marker
883 .paint(&self.config.prompt_marker_text)
884 .to_string()
885 }
886
887 fn prompt_marker_width(&self) -> usize {
889 self.config.prompt_marker_text.width()
890 }
891
892 fn position_fuzzy_cursor(&self, stderr: &mut Stderr) -> io::Result<()> {
894 let text_before_cursor = &self.filter_text[..self.filter_cursor];
895 let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
896 execute!(stderr, MoveToColumn(cursor_col as u16))
897 }
898
899 fn selected_marker(&self) -> &str {
901 &self.selected_marker_cached
902 }
903
904 fn is_table_mode(&self) -> bool {
906 self.table_layout.is_some()
907 }
908
909 fn is_multi_mode(&self) -> bool {
911 self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
912 }
913
914 fn is_fuzzy_mode(&self) -> bool {
916 self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
917 }
918
919 fn make_select_item(&mut self, value: Value) -> SelectItem {
921 if let Some(r#gen) = self.item_generator.as_mut() {
922 r#gen(value)
923 } else {
924 SelectItem {
928 name: value.to_expanded_string(", ", &Config::default()),
929 cells: None,
930 value,
931 }
932 }
933 }
934
935 fn load_more_items(&mut self, count: usize) {
937 if self.pending_stream.is_none() {
938 return;
939 }
940
941 let mut loaded = 0;
942 while loaded < count {
943 let next = self
944 .pending_stream
945 .as_mut()
946 .and_then(|stream| stream.next_value());
947 if let Some(val) = next {
948 let item = self.make_select_item(val);
949 self.items.push(item);
950 loaded += 1;
951 } else {
952 self.pending_stream = None;
953 break;
954 }
955 }
956
957 if loaded > 0 {
958 if self.is_table_mode()
960 && let Some(layout) = &mut self.table_layout
961 {
962 *layout = InputList::calculate_table_layout(&layout.columns, &self.items);
963 }
964
965 let start_len = self.filtered_indices.len();
966 if self.filter_text.is_empty() && !self.refined {
967 self.filtered_indices.extend(start_len..self.items.len());
968 } else {
969 self.update_filter();
970 }
971 }
972 }
973
974 fn drain_pending_stream(&mut self) {
979 if self.pending_stream.is_none() {
980 return;
981 }
982 while let Some(val) = self.pending_stream.as_mut().and_then(|s| s.next_value()) {
983 let item = self.make_select_item(val);
984 self.items.push(item);
985 }
986 self.pending_stream = None;
987 if self.is_table_mode()
988 && let Some(layout) = &mut self.table_layout
989 {
990 *layout = InputList::calculate_table_layout(&layout.columns, &self.items);
991 }
992 }
993
994 fn maybe_load_more(&mut self) {
996 if self.pending_stream.is_none() {
997 return;
998 }
999
1000 let threshold = self.scroll_offset + self.visible_height as usize + STREAM_PREFETCH_MARGIN;
1002 if threshold >= self.items.len() {
1003 self.load_more_items(STREAM_LOAD_BATCH);
1004 }
1005 }
1006
1007 fn toggle_case_sensitivity(&mut self) {
1009 self.config.case_sensitivity = match self.config.case_sensitivity {
1010 CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
1011 CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
1012 CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
1013 };
1014 self.rebuild_matcher();
1015 if !self.filter_text.is_empty() {
1017 self.update_filter();
1018 }
1019 self.settings_changed = true;
1020 }
1021
1022 fn toggle_per_column(&mut self) {
1024 if self.is_table_mode() {
1025 self.per_column = !self.per_column;
1026 if !self.filter_text.is_empty() {
1028 self.update_filter();
1029 }
1030 self.settings_changed = true;
1031 }
1032 }
1033
1034 fn rebuild_matcher(&mut self) {
1036 self.matcher = match self.config.case_sensitivity {
1037 CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
1038 CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
1039 CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
1040 };
1041 }
1042
1043 fn settings_indicator(&self) -> String {
1046 if !self.is_fuzzy_mode() {
1047 return String::new();
1048 }
1049
1050 let case_str = match self.config.case_sensitivity {
1051 CaseSensitivity::Smart => "smart",
1052 CaseSensitivity::CaseSensitive => "CASE",
1053 CaseSensitivity::CaseInsensitive => "nocase",
1054 };
1055
1056 if self.is_table_mode() && self.per_column {
1057 format!(" [{} col]", case_str)
1058 } else {
1059 format!(" [{}]", case_str)
1060 }
1061 }
1062
1063 fn generate_footer(&self) -> String {
1065 let total_count = self.current_list_len();
1066 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
1067 let settings = self.settings_indicator();
1068
1069 let position_part = if self.is_multi_mode() {
1070 format!(
1071 "[{}-{} of {}, {} selected]",
1072 self.scroll_offset + 1,
1073 end.min(total_count),
1074 total_count,
1075 self.selected.len()
1076 )
1077 } else {
1078 format!(
1079 "[{}-{} of {}]",
1080 self.scroll_offset + 1,
1081 end.min(total_count),
1082 total_count
1083 )
1084 };
1085
1086 let full_footer = format!("{}{}", position_part, settings);
1087
1088 let max_width = self.term_width as usize;
1090 if full_footer.width() <= max_width {
1091 full_footer
1092 } else if max_width <= 3 {
1093 "…".to_string()
1095 } else {
1096 if position_part.width() <= max_width {
1098 let remaining = max_width - position_part.width();
1100 if remaining <= 4 {
1101 position_part
1103 } else {
1104 let target_width = remaining - 2; let mut current_width = 0;
1107 let mut end_pos = 0;
1108
1109 for (byte_pos, c) in settings.char_indices().skip(2) {
1111 if c == ']' {
1112 break;
1113 }
1114 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1115 if current_width + char_width > target_width {
1116 break;
1117 }
1118 end_pos = byte_pos + c.len_utf8();
1119 current_width += char_width;
1120 }
1121 if end_pos > 2 {
1122 format!("{} [{}…]", position_part, &settings[2..end_pos])
1123 } else {
1124 position_part
1125 }
1126 }
1127 } else {
1128 let target_width = max_width - 2; let mut current_width = 0;
1131 let mut end_pos = 0;
1132
1133 for (byte_pos, c) in position_part.char_indices() {
1134 if c == ']' {
1135 break;
1136 }
1137 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
1138 if current_width + char_width > target_width {
1139 break;
1140 }
1141 end_pos = byte_pos + c.len_utf8();
1142 current_width += char_width;
1143 }
1144 format!("{}…]", &position_part[..end_pos])
1145 }
1146 }
1147 }
1148
1149 fn has_footer(&self) -> bool {
1153 self.config.show_footer
1154 && (self.is_fuzzy_mode()
1155 || self.is_multi_mode()
1156 || self.current_list_len() > self.visible_height as usize)
1157 }
1158
1159 fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
1161 let indicator = self.generate_footer();
1162 execute!(
1163 stderr,
1164 MoveToColumn(0),
1165 Print(self.config.footer.paint(&indicator)),
1166 Clear(ClearType::UntilNewLine),
1167 )
1168 }
1169
1170 fn row_prefix_width(&self) -> usize {
1172 match self.mode {
1173 SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
1176 }
1177
1178 fn table_column_separator(&self) -> String {
1180 format!(" {} ", self.config.table_column_separator)
1181 }
1182
1183 fn table_column_separator_width(&self) -> usize {
1185 UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
1186 }
1187
1188 fn calculate_visible_columns(&self) -> (usize, bool) {
1192 if let Some(cached) = self.visible_columns_cache {
1194 return cached;
1195 }
1196
1197 let Some(layout) = &self.table_layout else {
1199 return (0, false);
1200 };
1201
1202 Self::calculate_visible_columns_for_layout(
1203 layout,
1204 self.horizontal_offset,
1205 self.term_width as usize,
1206 self.row_prefix_width(),
1207 self.table_column_separator_width(),
1208 )
1209 }
1210
1211 fn calculate_visible_columns_for_layout(
1213 layout: &TableLayout,
1214 horizontal_offset: usize,
1215 term_width: usize,
1216 prefix_width: usize,
1217 separator_width: usize,
1218 ) -> (usize, bool) {
1219 let scroll_indicator_width = if horizontal_offset > 0 {
1221 1 + separator_width
1222 } else {
1223 0
1224 };
1225 let available = term_width
1226 .saturating_sub(prefix_width)
1227 .saturating_sub(scroll_indicator_width);
1228
1229 let mut used_width = 0;
1230 let mut cols_fit = 0;
1231
1232 for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1233 let sep_width = if i > horizontal_offset {
1235 separator_width
1236 } else {
1237 0
1238 };
1239 let needed = col_width + sep_width;
1240
1241 let reserve_right = if i + 1 < layout.col_widths.len() {
1243 separator_width + 1
1244 } else {
1245 0
1246 };
1247
1248 if used_width + needed + reserve_right <= available {
1249 used_width += needed;
1250 cols_fit += 1;
1251 } else {
1252 break;
1253 }
1254 }
1255
1256 let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1257 (cols_fit.max(1), has_more_right) }
1259
1260 fn update_table_layout(&mut self) {
1263 let prefix_width = self.row_prefix_width();
1264 let term_width = self.term_width as usize;
1265 let horizontal_offset = self.horizontal_offset;
1266 let separator_width = self.table_column_separator_width();
1267
1268 if let Some(layout) = &mut self.table_layout {
1269 let result = Self::calculate_visible_columns_for_layout(
1270 layout,
1271 horizontal_offset,
1272 term_width,
1273 prefix_width,
1274 separator_width,
1275 );
1276 layout.truncated_cols = result.0;
1277 self.visible_columns_cache = Some(result);
1278 } else {
1279 self.visible_columns_cache = Some((0, false));
1280 }
1281 }
1282
1283 fn fuzzy_header_lines(&self) -> u16 {
1285 let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1286 if self.config.show_separator {
1287 header_lines += 1;
1288 }
1289 if self.is_table_mode() {
1290 header_lines += 2;
1291 }
1292 header_lines
1293 }
1294
1295 fn fuzzy_filter_row(&self) -> u16 {
1297 if self.prompt.is_some() { 1 } else { 0 }
1298 }
1299
1300 fn update_term_size(&mut self, width: u16, height: u16) {
1302 let new_width = width.saturating_sub(1);
1304 let width_changed = self.term_width != new_width;
1305 self.term_width = new_width;
1306
1307 if width_changed {
1309 self.width_changed = true;
1310 }
1311
1312 if width_changed && self.config.show_separator {
1314 self.generate_separator_line();
1315 }
1316
1317 if width_changed {
1319 self.update_table_layout();
1320 }
1321
1322 let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1324 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1325 reserved += 1; if self.config.show_separator {
1327 reserved += 1; }
1329 }
1330 if self.is_table_mode() {
1331 reserved += 2; }
1333 if self.config.show_footer {
1334 reserved += 1; }
1336 self.visible_height = height.saturating_sub(reserved).max(1);
1337 }
1338
1339 fn run(&mut self) -> io::Result<InteractMode> {
1340 let mut stderr = io::stderr();
1341
1342 enable_raw_mode()?;
1343 scopeguard::defer! {
1344 let _ = disable_raw_mode();
1345 }
1346
1347 if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1349 execute!(stderr, Hide)?;
1350 }
1351 scopeguard::defer! {
1352 let _ = execute!(io::stderr(), Show);
1353 }
1354
1355 let (term_width, term_height) = terminal::size()?;
1357 self.update_term_size(term_width, term_height);
1358
1359 self.render(&mut stderr)?;
1360
1361 loop {
1362 if event::poll(std::time::Duration::from_millis(100))? {
1363 match event::read()? {
1364 Event::Key(key_event) => {
1365 match self.handle_key(key_event) {
1366 KeyAction::Continue => {}
1367 KeyAction::Cancel => {
1368 self.clear_display(&mut stderr)?;
1369 return Ok(match self.mode {
1370 SelectMode::Multi => InteractMode::Multi(None),
1371 _ => InteractMode::Single(None),
1372 });
1373 }
1374 KeyAction::Confirm => {
1375 self.clear_display(&mut stderr)?;
1376 return Ok(self.get_result());
1377 }
1378 }
1379 self.render(&mut stderr)?;
1380 }
1381 Event::Resize(width, height) => {
1382 self.clear_display(&mut stderr)?;
1384 self.update_term_size(width, height);
1385 self.first_render = true;
1387 self.render(&mut stderr)?;
1388 }
1389 _ => {}
1390 }
1391 }
1392 }
1393 }
1394
1395 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1396 if key.kind == KeyEventKind::Release {
1400 return KeyAction::Continue;
1401 }
1402
1403 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1405 return KeyAction::Cancel;
1406 }
1407
1408 match self.mode {
1409 SelectMode::Single => self.handle_single_key(key),
1410 SelectMode::Multi => self.handle_multi_key(key),
1411 SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1412 SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1413 }
1414 }
1415
1416 fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1417 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1418
1419 match key.code {
1420 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1421 KeyCode::Enter => KeyAction::Confirm,
1422 KeyCode::Char('p' | 'P') if ctrl => {
1423 self.navigate_up();
1424 KeyAction::Continue
1425 }
1426 KeyCode::Up | KeyCode::Char('k') => {
1427 self.navigate_up();
1428 KeyAction::Continue
1429 }
1430 KeyCode::Char('n' | 'N') if ctrl => {
1431 self.navigate_down();
1432 KeyAction::Continue
1433 }
1434 KeyCode::Down | KeyCode::Char('j') => {
1435 self.navigate_down();
1436 KeyAction::Continue
1437 }
1438 KeyCode::Left | KeyCode::Char('h') => {
1439 self.scroll_columns_left();
1440 KeyAction::Continue
1441 }
1442 KeyCode::Right | KeyCode::Char('l') => {
1443 self.scroll_columns_right();
1444 KeyAction::Continue
1445 }
1446 KeyCode::Home => {
1447 self.navigate_home();
1448 KeyAction::Continue
1449 }
1450 KeyCode::End => {
1451 self.navigate_end();
1452 KeyAction::Continue
1453 }
1454 KeyCode::PageUp => {
1455 self.navigate_page_up();
1456 KeyAction::Continue
1457 }
1458 KeyCode::PageDown => {
1459 self.navigate_page_down();
1460 KeyAction::Continue
1461 }
1462 KeyCode::Tab => {
1463 self.navigate_down();
1464 KeyAction::Continue
1465 }
1466 KeyCode::BackTab => {
1467 self.navigate_up();
1468 KeyAction::Continue
1469 }
1470 _ => KeyAction::Continue,
1471 }
1472 }
1473
1474 fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1475 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1476
1477 match key.code {
1478 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1479 KeyCode::Enter => KeyAction::Confirm,
1480 KeyCode::Char('r' | 'R') if ctrl => {
1482 self.refine_list();
1483 KeyAction::Continue
1484 }
1485 KeyCode::Char('p' | 'P') if ctrl => {
1486 self.navigate_up();
1487 KeyAction::Continue
1488 }
1489 KeyCode::Up | KeyCode::Char('k') => {
1490 self.navigate_up();
1491 KeyAction::Continue
1492 }
1493 KeyCode::Char('n' | 'N') if ctrl => {
1494 self.navigate_down();
1495 KeyAction::Continue
1496 }
1497 KeyCode::Down | KeyCode::Char('j') => {
1498 self.navigate_down();
1499 KeyAction::Continue
1500 }
1501 KeyCode::Left | KeyCode::Char('h') => {
1502 self.scroll_columns_left();
1503 KeyAction::Continue
1504 }
1505 KeyCode::Right | KeyCode::Char('l') => {
1506 self.scroll_columns_right();
1507 KeyAction::Continue
1508 }
1509 KeyCode::Char(' ') => {
1510 self.toggle_current();
1511 KeyAction::Continue
1512 }
1513 KeyCode::Char('a') => {
1514 self.toggle_all();
1515 KeyAction::Continue
1516 }
1517 KeyCode::Home => {
1518 self.navigate_home();
1519 KeyAction::Continue
1520 }
1521 KeyCode::End => {
1522 self.navigate_end();
1523 KeyAction::Continue
1524 }
1525 KeyCode::PageUp => {
1526 self.navigate_page_up();
1527 KeyAction::Continue
1528 }
1529 KeyCode::PageDown => {
1530 self.navigate_page_down();
1531 KeyAction::Continue
1532 }
1533 KeyCode::Tab => {
1534 self.toggle_current();
1535 self.navigate_down();
1536 KeyAction::Continue
1537 }
1538 KeyCode::BackTab => {
1539 self.navigate_up();
1540 self.toggle_current();
1541 KeyAction::Continue
1542 }
1543 _ => KeyAction::Continue,
1544 }
1545 }
1546
1547 fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1548 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1549 let alt = key.modifiers.contains(KeyModifiers::ALT);
1550 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1551
1552 match key.code {
1553 KeyCode::Esc => KeyAction::Cancel,
1554 KeyCode::Enter => KeyAction::Confirm,
1555
1556 KeyCode::Tab | KeyCode::Char('\t') => {
1558 self.navigate_down();
1559 KeyAction::Continue
1560 }
1561 KeyCode::BackTab => {
1562 self.navigate_up();
1563 KeyAction::Continue
1564 }
1565
1566 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1568 self.navigate_up();
1569 KeyAction::Continue
1570 }
1571 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1572 self.navigate_down();
1573 KeyAction::Continue
1574 }
1575 KeyCode::Up => {
1576 self.navigate_up();
1577 KeyAction::Continue
1578 }
1579 KeyCode::Down => {
1580 self.navigate_down();
1581 KeyAction::Continue
1582 }
1583
1584 KeyCode::Left if shift => {
1586 self.scroll_columns_left();
1587 KeyAction::Continue
1588 }
1589 KeyCode::Right if shift => {
1590 self.scroll_columns_right();
1591 KeyAction::Continue
1592 }
1593
1594 KeyCode::Char('a' | 'A') if ctrl => {
1596 self.filter_cursor = 0;
1598 self.filter_text_changed = true;
1599 KeyAction::Continue
1600 }
1601 KeyCode::Char('e' | 'E') if ctrl => {
1602 self.filter_cursor = self.filter_text.len();
1604 self.filter_text_changed = true;
1605 KeyAction::Continue
1606 }
1607 KeyCode::Char('b' | 'B') if ctrl => {
1608 self.move_filter_cursor_left();
1610 self.filter_text_changed = true;
1611 KeyAction::Continue
1612 }
1613 KeyCode::Char('f' | 'F') if ctrl => {
1614 self.move_filter_cursor_right();
1616 self.filter_text_changed = true;
1617 KeyAction::Continue
1618 }
1619 KeyCode::Char('b' | 'B') if alt => {
1620 self.move_filter_cursor_word_left();
1622 self.filter_text_changed = true;
1623 KeyAction::Continue
1624 }
1625 KeyCode::Char('f' | 'F') if alt => {
1626 self.move_filter_cursor_word_right();
1628 self.filter_text_changed = true;
1629 KeyAction::Continue
1630 }
1631 KeyCode::Char('c' | 'C') if alt => {
1633 self.toggle_case_sensitivity();
1635 KeyAction::Continue
1636 }
1637 KeyCode::Char('p' | 'P') if alt => {
1638 self.toggle_per_column();
1640 KeyAction::Continue
1641 }
1642 KeyCode::Left if ctrl || alt => {
1643 self.move_filter_cursor_word_left();
1645 self.filter_text_changed = true;
1646 KeyAction::Continue
1647 }
1648 KeyCode::Right if ctrl || alt => {
1649 self.move_filter_cursor_word_right();
1651 self.filter_text_changed = true;
1652 KeyAction::Continue
1653 }
1654 KeyCode::Left => {
1655 self.move_filter_cursor_left();
1656 self.filter_text_changed = true;
1657 KeyAction::Continue
1658 }
1659 KeyCode::Right => {
1660 self.move_filter_cursor_right();
1661 self.filter_text_changed = true;
1662 KeyAction::Continue
1663 }
1664
1665 KeyCode::Char('u' | 'U') if ctrl => {
1667 self.filter_text.drain(..self.filter_cursor);
1669 self.filter_cursor = 0;
1670 self.update_filter();
1671 KeyAction::Continue
1672 }
1673 KeyCode::Char('k' | 'K') if ctrl => {
1674 self.filter_text.truncate(self.filter_cursor);
1676 self.update_filter();
1677 KeyAction::Continue
1678 }
1679 KeyCode::Char('d' | 'D') if ctrl => {
1680 if self.filter_cursor < self.filter_text.len() {
1682 self.filter_text.remove(self.filter_cursor);
1683 self.update_filter();
1684 }
1685 KeyAction::Continue
1686 }
1687 KeyCode::Delete => {
1688 if self.filter_cursor < self.filter_text.len() {
1690 self.filter_text.remove(self.filter_cursor);
1691 self.update_filter();
1692 }
1693 KeyAction::Continue
1694 }
1695 KeyCode::Char('d' | 'D') if alt => {
1696 self.delete_word_forwards();
1698 self.update_filter();
1699 KeyAction::Continue
1700 }
1701 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1703 self.delete_word_backwards();
1704 self.update_filter();
1705 KeyAction::Continue
1706 }
1707 KeyCode::Backspace if alt => {
1709 self.delete_word_backwards();
1710 self.update_filter();
1711 KeyAction::Continue
1712 }
1713 KeyCode::Backspace => {
1714 if self.filter_cursor > 0 {
1716 let mut new_pos = self.filter_cursor - 1;
1718 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1719 new_pos -= 1;
1720 }
1721 self.filter_cursor = new_pos;
1722 self.filter_text.remove(self.filter_cursor);
1723 self.update_filter();
1724 }
1725 KeyAction::Continue
1726 }
1727 KeyCode::Char('t' | 'T') if ctrl => {
1729 let old_text = self.filter_text.clone();
1730 self.transpose_chars();
1731 if self.filter_text != old_text {
1732 self.update_filter();
1733 }
1734 KeyAction::Continue
1735 }
1736
1737 KeyCode::Char(c) => {
1739 self.filter_text.insert(self.filter_cursor, c);
1740 self.filter_cursor += c.len_utf8();
1741 self.update_filter();
1742 KeyAction::Continue
1743 }
1744
1745 KeyCode::Home => {
1747 self.navigate_home();
1748 KeyAction::Continue
1749 }
1750 KeyCode::End => {
1751 self.navigate_end();
1752 KeyAction::Continue
1753 }
1754 KeyCode::PageUp => {
1755 self.navigate_page_up();
1756 KeyAction::Continue
1757 }
1758 KeyCode::PageDown => {
1759 self.navigate_page_down();
1760 KeyAction::Continue
1761 }
1762 _ => KeyAction::Continue,
1763 }
1764 }
1765
1766 fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1767 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1768 let alt = key.modifiers.contains(KeyModifiers::ALT);
1769 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1770
1771 match key.code {
1772 KeyCode::Esc => KeyAction::Cancel,
1773 KeyCode::Enter => KeyAction::Confirm,
1774
1775 KeyCode::Char('r' | 'R') if ctrl => {
1777 self.refine_list();
1778 KeyAction::Continue
1779 }
1780
1781 KeyCode::Tab | KeyCode::Char('\t') => {
1784 self.toggle_current_fuzzy();
1785 self.navigate_down();
1786 KeyAction::Continue
1787 }
1788
1789 KeyCode::BackTab => {
1791 self.navigate_up();
1792 self.toggle_current_fuzzy();
1793 KeyAction::Continue
1794 }
1795
1796 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1798 self.navigate_up();
1799 KeyAction::Continue
1800 }
1801 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1802 self.navigate_down();
1803 KeyAction::Continue
1804 }
1805 KeyCode::Up => {
1806 self.navigate_up();
1807 KeyAction::Continue
1808 }
1809 KeyCode::Down => {
1810 self.navigate_down();
1811 KeyAction::Continue
1812 }
1813
1814 KeyCode::Left if shift => {
1816 self.scroll_columns_left();
1817 KeyAction::Continue
1818 }
1819 KeyCode::Right if shift => {
1820 self.scroll_columns_right();
1821 KeyAction::Continue
1822 }
1823
1824 KeyCode::Char('a' | 'A') if ctrl => {
1826 self.filter_cursor = 0;
1827 self.filter_text_changed = true;
1828 KeyAction::Continue
1829 }
1830 KeyCode::Char('e' | 'E') if ctrl => {
1831 self.filter_cursor = self.filter_text.len();
1832 self.filter_text_changed = true;
1833 KeyAction::Continue
1834 }
1835 KeyCode::Char('b' | 'B') if ctrl => {
1836 self.move_filter_cursor_left();
1837 self.filter_text_changed = true;
1838 KeyAction::Continue
1839 }
1840 KeyCode::Char('f' | 'F') if ctrl => {
1841 self.move_filter_cursor_right();
1842 self.filter_text_changed = true;
1843 KeyAction::Continue
1844 }
1845 KeyCode::Char('b' | 'B') if alt => {
1846 self.move_filter_cursor_word_left();
1847 self.filter_text_changed = true;
1848 KeyAction::Continue
1849 }
1850 KeyCode::Char('f' | 'F') if alt => {
1851 self.move_filter_cursor_word_right();
1852 self.filter_text_changed = true;
1853 KeyAction::Continue
1854 }
1855 KeyCode::Char('c' | 'C') if alt => {
1857 self.toggle_case_sensitivity();
1859 KeyAction::Continue
1860 }
1861 KeyCode::Char('p' | 'P') if alt => {
1862 self.toggle_per_column();
1864 KeyAction::Continue
1865 }
1866 KeyCode::Left if ctrl || alt => {
1867 self.move_filter_cursor_word_left();
1868 self.filter_text_changed = true;
1869 KeyAction::Continue
1870 }
1871 KeyCode::Right if ctrl || alt => {
1872 self.move_filter_cursor_word_right();
1873 self.filter_text_changed = true;
1874 KeyAction::Continue
1875 }
1876 KeyCode::Left => {
1877 self.move_filter_cursor_left();
1878 self.filter_text_changed = true;
1879 KeyAction::Continue
1880 }
1881 KeyCode::Right => {
1882 self.move_filter_cursor_right();
1883 self.filter_text_changed = true;
1884 KeyAction::Continue
1885 }
1886
1887 KeyCode::Char('u' | 'U') if ctrl => {
1889 self.filter_text.drain(..self.filter_cursor);
1890 self.filter_cursor = 0;
1891 self.update_filter();
1892 KeyAction::Continue
1893 }
1894 KeyCode::Char('k' | 'K') if ctrl => {
1895 self.filter_text.truncate(self.filter_cursor);
1896 self.update_filter();
1897 KeyAction::Continue
1898 }
1899 KeyCode::Char('d' | 'D') if ctrl => {
1900 if self.filter_cursor < self.filter_text.len() {
1901 self.filter_text.remove(self.filter_cursor);
1902 self.update_filter();
1903 }
1904 KeyAction::Continue
1905 }
1906 KeyCode::Delete => {
1907 if self.filter_cursor < self.filter_text.len() {
1908 self.filter_text.remove(self.filter_cursor);
1909 self.update_filter();
1910 }
1911 KeyAction::Continue
1912 }
1913 KeyCode::Char('d' | 'D') if alt => {
1914 self.delete_word_forwards();
1915 self.update_filter();
1916 KeyAction::Continue
1917 }
1918 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1919 self.delete_word_backwards();
1920 self.update_filter();
1921 KeyAction::Continue
1922 }
1923 KeyCode::Backspace if alt => {
1924 self.delete_word_backwards();
1925 self.update_filter();
1926 KeyAction::Continue
1927 }
1928 KeyCode::Backspace => {
1929 if self.filter_cursor > 0 {
1930 let mut new_pos = self.filter_cursor - 1;
1931 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1932 new_pos -= 1;
1933 }
1934 self.filter_cursor = new_pos;
1935 self.filter_text.remove(self.filter_cursor);
1936 self.update_filter();
1937 }
1938 KeyAction::Continue
1939 }
1940 KeyCode::Char('t' | 'T') if ctrl => {
1941 let old_text = self.filter_text.clone();
1942 self.transpose_chars();
1943 if self.filter_text != old_text {
1944 self.update_filter();
1945 }
1946 KeyAction::Continue
1947 }
1948
1949 KeyCode::Char('a' | 'A') if alt => {
1951 self.toggle_all_fuzzy();
1952 KeyAction::Continue
1953 }
1954
1955 KeyCode::Char(c) => {
1957 self.filter_text.insert(self.filter_cursor, c);
1958 self.filter_cursor += c.len_utf8();
1959 self.update_filter();
1960 KeyAction::Continue
1961 }
1962
1963 KeyCode::Home => {
1965 self.navigate_home();
1966 KeyAction::Continue
1967 }
1968 KeyCode::End => {
1969 self.navigate_end();
1970 KeyAction::Continue
1971 }
1972 KeyCode::PageUp => {
1973 self.navigate_page_up();
1974 KeyAction::Continue
1975 }
1976 KeyCode::PageDown => {
1977 self.navigate_page_down();
1978 KeyAction::Continue
1979 }
1980 _ => KeyAction::Continue,
1981 }
1982 }
1983
1984 fn navigate_up(&mut self) {
1986 let list_len = self.current_list_len();
1987 if self.cursor > 0 {
1988 self.cursor -= 1;
1989 self.adjust_scroll_up();
1990 } else if list_len > 0 {
1991 if self.pending_stream.is_some() {
1993 self.drain_pending_stream();
1994 if !self.filter_text.is_empty() {
1995 self.update_filter();
1996 } else if !self.refined {
1997 self.filtered_indices = (0..self.items.len()).collect();
1998 }
1999 }
2000 let list_len = self.current_list_len();
2001 self.cursor = list_len.saturating_sub(1);
2002 self.adjust_scroll_down();
2003 }
2004 }
2005
2006 fn navigate_down(&mut self) {
2008 self.maybe_load_more();
2009
2010 let list_len = self.current_list_len();
2011 if self.cursor + 1 < list_len {
2012 self.cursor += 1;
2013 self.adjust_scroll_down();
2014 } else {
2015 if self.pending_stream.is_some() {
2017 self.maybe_load_more();
2018 let list_len = self.current_list_len();
2019 if self.cursor + 1 < list_len {
2020 self.cursor += 1;
2021 self.adjust_scroll_down();
2022 return;
2023 }
2024 }
2025
2026 self.cursor = 0;
2028 self.scroll_offset = 0;
2029 }
2030 }
2031
2032 fn adjust_scroll_down(&mut self) {
2033 let max_visible = self.scroll_offset + self.visible_height as usize;
2034 if self.cursor >= max_visible {
2035 self.scroll_offset = self.cursor - self.visible_height as usize + 1;
2036 }
2037 }
2038
2039 fn adjust_scroll_up(&mut self) {
2040 if self.cursor < self.scroll_offset {
2041 self.scroll_offset = self.cursor;
2042 }
2043 }
2044
2045 fn current_list_len(&self) -> usize {
2047 match self.mode {
2048 SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
2049 SelectMode::Multi if self.refined => self.filtered_indices.len(),
2050 _ => self.items.len(),
2051 }
2052 }
2053
2054 fn navigate_home(&mut self) {
2056 self.cursor = 0;
2057 self.scroll_offset = 0;
2058 }
2059
2060 fn navigate_end(&mut self) {
2062 if self.pending_stream.is_some() {
2064 self.drain_pending_stream();
2065 if !self.filter_text.is_empty() {
2066 self.update_filter();
2067 } else if !self.refined {
2068 self.filtered_indices = (0..self.items.len()).collect();
2069 }
2070 }
2071 self.cursor = self.current_list_len().saturating_sub(1);
2072 self.adjust_scroll_down();
2073 }
2074
2075 fn navigate_page_up(&mut self) {
2077 let page_top = self.scroll_offset;
2078 if self.cursor == page_top {
2079 self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
2081 self.adjust_scroll_up();
2082 } else {
2083 self.cursor = page_top;
2085 }
2086 }
2087
2088 fn navigate_page_down(&mut self) {
2090 self.maybe_load_more();
2091
2092 let list_len = self.current_list_len();
2093 let page_bottom =
2094 (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
2095 if self.cursor == page_bottom {
2096 self.cursor =
2098 (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
2099 self.adjust_scroll_down();
2100 } else {
2101 self.cursor = page_bottom;
2103 }
2104
2105 self.maybe_load_more();
2106 }
2107
2108 fn scroll_columns_left(&mut self) -> bool {
2110 if !self.is_table_mode() || self.horizontal_offset == 0 {
2111 return false;
2112 }
2113 self.horizontal_offset -= 1;
2114 self.horizontal_scroll_changed = true;
2115 self.update_table_layout();
2116 true
2117 }
2118
2119 fn scroll_columns_right(&mut self) -> bool {
2121 let Some(layout) = &self.table_layout else {
2122 return false;
2123 };
2124 let (cols_visible, has_more_right) = self.calculate_visible_columns();
2125 if !has_more_right {
2126 return false;
2127 }
2128 if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
2130 return false;
2131 }
2132 self.horizontal_offset += 1;
2133 self.horizontal_scroll_changed = true;
2134 self.update_table_layout();
2135 true
2136 }
2137
2138 fn toggle_current(&mut self) {
2139 if self.refined && self.filtered_indices.is_empty() {
2141 return;
2142 }
2143 let real_idx = if self.refined {
2145 self.filtered_indices[self.cursor]
2146 } else {
2147 self.cursor
2148 };
2149 self.toggle_index(real_idx);
2150 }
2151
2152 fn toggle_index(&mut self, real_idx: usize) {
2154 if self.selected.contains(&real_idx) {
2155 self.selected.remove(&real_idx);
2156 } else {
2157 self.selected.insert(real_idx);
2158 }
2159 self.toggled_item = Some(self.cursor);
2160 }
2161
2162 fn toggle_current_fuzzy(&mut self) -> bool {
2165 if self.filtered_indices.is_empty() {
2166 return false;
2167 }
2168 let real_idx = self.filtered_indices[self.cursor];
2169 self.toggle_index(real_idx);
2170 true
2171 }
2172
2173 fn toggle_all(&mut self) {
2174 let all_selected = if self.refined {
2176 self.filtered_indices
2177 .iter()
2178 .all(|i| self.selected.contains(i))
2179 } else {
2180 (0..self.items.len()).all(|i| self.selected.contains(&i))
2181 };
2182
2183 if all_selected {
2184 if self.refined {
2186 for i in &self.filtered_indices {
2187 self.selected.remove(i);
2188 }
2189 } else {
2190 self.selected.clear();
2191 }
2192 } else {
2193 if self.refined {
2195 self.selected.extend(self.filtered_indices.iter().copied());
2196 } else {
2197 self.selected.extend(0..self.items.len());
2198 }
2199 }
2200 self.toggled_all = true;
2201 }
2202
2203 fn toggle_all_fuzzy(&mut self) {
2205 if self.filtered_indices.is_empty() {
2206 return;
2207 }
2208
2209 let all_selected = self
2211 .filtered_indices
2212 .iter()
2213 .all(|i| self.selected.contains(i));
2214
2215 if all_selected {
2216 for i in &self.filtered_indices {
2218 self.selected.remove(i);
2219 }
2220 } else {
2221 self.selected.extend(self.filtered_indices.iter().copied());
2223 }
2224 self.toggled_all = true;
2225 }
2226
2227 fn refine_list(&mut self) {
2230 if self.selected.is_empty() {
2231 return;
2232 }
2233
2234 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2236 indices.sort();
2237
2238 self.filtered_indices = indices.clone();
2241 self.refined_base_indices = indices;
2242
2243 self.cursor = 0;
2245 self.scroll_offset = 0;
2246
2247 if self.mode == SelectMode::FuzzyMulti {
2252 self.filter_text.clear();
2253 self.filter_cursor = 0;
2254 self.filter_text_changed = true;
2255 }
2256
2257 self.refined = true;
2259
2260 self.first_render = true;
2262 }
2263
2264 fn move_filter_cursor_left(&mut self) {
2266 if self.filter_cursor > 0 {
2267 let mut new_pos = self.filter_cursor - 1;
2269 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
2270 new_pos -= 1;
2271 }
2272 self.filter_cursor = new_pos;
2273 }
2274 }
2275
2276 fn move_filter_cursor_right(&mut self) {
2277 if self.filter_cursor < self.filter_text.len() {
2278 let mut new_pos = self.filter_cursor + 1;
2280 while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
2281 new_pos += 1;
2282 }
2283 self.filter_cursor = new_pos;
2284 }
2285 }
2286
2287 fn move_filter_cursor_word_left(&mut self) {
2288 if self.filter_cursor == 0 {
2289 return;
2290 }
2291 let bytes = self.filter_text.as_bytes();
2292 let mut pos = self.filter_cursor;
2293 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2295 pos -= 1;
2296 }
2297 while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2299 pos -= 1;
2300 }
2301 self.filter_cursor = pos;
2302 }
2303
2304 fn move_filter_cursor_word_right(&mut self) {
2305 let len = self.filter_text.len();
2306 if self.filter_cursor >= len {
2307 return;
2308 }
2309 let bytes = self.filter_text.as_bytes();
2310 let mut pos = self.filter_cursor;
2311 while pos < len && !bytes[pos].is_ascii_whitespace() {
2313 pos += 1;
2314 }
2315 while pos < len && bytes[pos].is_ascii_whitespace() {
2317 pos += 1;
2318 }
2319 self.filter_cursor = pos;
2320 }
2321
2322 fn delete_word_backwards(&mut self) {
2323 if self.filter_cursor == 0 {
2324 return;
2325 }
2326 let start = self.filter_cursor;
2327 while self.filter_cursor > 0
2329 && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2330 {
2331 self.filter_cursor -= 1;
2332 }
2333 while self.filter_cursor > 0
2335 && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2336 {
2337 self.filter_cursor -= 1;
2338 }
2339 self.filter_text.drain(self.filter_cursor..start);
2340 }
2341
2342 fn delete_word_forwards(&mut self) {
2343 let len = self.filter_text.len();
2344 if self.filter_cursor >= len {
2345 return;
2346 }
2347 let start = self.filter_cursor;
2348 let bytes = self.filter_text.as_bytes();
2349 let mut end = start;
2350 while end < len && !bytes[end].is_ascii_whitespace() {
2352 end += 1;
2353 }
2354 while end < len && bytes[end].is_ascii_whitespace() {
2356 end += 1;
2357 }
2358 self.filter_text.drain(start..end);
2359 }
2360
2361 fn transpose_chars(&mut self) {
2362 let len = self.filter_text.len();
2366 if len < 2 {
2367 return;
2368 }
2369
2370 if self.filter_cursor == 0 {
2372 return;
2373 }
2374
2375 let pos = if self.filter_cursor >= len {
2378 len - 1
2379 } else {
2380 self.filter_cursor
2381 };
2382
2383 if pos == 0 {
2384 return;
2385 }
2386
2387 if self.filter_text.is_char_boundary(pos - 1)
2390 && self.filter_text.is_char_boundary(pos)
2391 && pos < len
2392 && self.filter_text.is_char_boundary(pos + 1)
2393 {
2394 let bytes = self.filter_text.as_bytes();
2396 if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2397 let bytes = unsafe { self.filter_text.as_bytes_mut() };
2399 bytes.swap(pos - 1, pos);
2400
2401 if self.filter_cursor < len {
2403 self.filter_cursor += 1;
2404 }
2405 }
2406 }
2407 }
2408
2409 fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
2411 item.cells.as_ref().and_then(|cells| {
2412 cells
2413 .iter()
2414 .filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
2415 .max()
2416 })
2417 }
2418
2419 fn score_item(&self, item: &SelectItem) -> Option<i64> {
2421 if self.per_column && item.cells.is_some() {
2422 self.score_per_column(item)
2423 } else {
2424 self.matcher.fuzzy_match(&item.name, &self.filter_text)
2425 }
2426 }
2427
2428 fn update_filter(&mut self) {
2429 if self.pending_stream.is_some() && !self.filter_text.is_empty() {
2432 self.drain_pending_stream();
2433 }
2434
2435 let old_indices = std::mem::take(&mut self.filtered_indices);
2436
2437 let use_refined = self.refined && !self.refined_base_indices.is_empty();
2439
2440 if self.filter_text.is_empty() {
2441 self.filtered_indices = if use_refined {
2443 self.refined_base_indices.clone()
2444 } else {
2445 (0..self.items.len()).collect()
2446 };
2447 } else {
2448 let mut scored: Vec<(usize, i64)> = if use_refined {
2450 self.refined_base_indices
2451 .iter()
2452 .filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
2453 .collect()
2454 } else {
2455 (0..self.items.len())
2456 .filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
2457 .collect()
2458 };
2459 scored.sort_by_key(|entry| std::cmp::Reverse(entry.1));
2461 self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2462 }
2463
2464 self.results_changed = old_indices != self.filtered_indices;
2466 self.filter_text_changed = true;
2467
2468 if self.results_changed {
2470 self.cursor = 0;
2471 self.scroll_offset = 0;
2472 }
2473
2474 if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2476 {
2477 self.auto_scroll_to_match_column();
2478 }
2479 }
2480
2481 fn auto_scroll_to_match_column(&mut self) {
2483 let Some(layout) = &self.table_layout else {
2484 return;
2485 };
2486
2487 let first_idx = self.filtered_indices[0];
2489 let item = &self.items[first_idx];
2490 let Some(cells) = &item.cells else {
2491 return;
2492 };
2493
2494 let mut first_match_col: Option<usize> = None;
2496 for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2497 if self.per_column {
2498 if self
2500 .matcher
2501 .fuzzy_match(cell_text, &self.filter_text)
2502 .is_some()
2503 {
2504 first_match_col = Some(col_idx);
2505 break;
2506 }
2507 } else {
2508 let cell_start: usize = cells[..col_idx]
2511 .iter()
2512 .map(|(s, _)| s.chars().count() + 1) .sum();
2514 let cell_char_count = cell_text.chars().count();
2515
2516 if let Some((_, indices)) =
2517 self.matcher.fuzzy_indices(&item.name, &self.filter_text)
2518 {
2519 if indices
2521 .iter()
2522 .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
2523 {
2524 first_match_col = Some(col_idx);
2525 break;
2526 }
2527 }
2528 }
2529 }
2530
2531 if let Some(match_col) = first_match_col {
2533 let (cols_visible, _) = self.calculate_visible_columns();
2534 let visible_start = self.horizontal_offset;
2535 let visible_end = self.horizontal_offset + cols_visible;
2536
2537 if match_col < visible_start {
2538 self.horizontal_offset = match_col;
2540 self.horizontal_scroll_changed = true;
2541 self.update_table_layout();
2542 } else if match_col >= visible_end {
2543 self.horizontal_offset = match_col;
2546 let max_offset = layout.col_widths.len().saturating_sub(1);
2548 self.horizontal_offset = self.horizontal_offset.min(max_offset);
2549 self.horizontal_scroll_changed = true;
2550 self.update_table_layout();
2551 }
2552 }
2553 }
2554
2555 fn get_result(&self) -> InteractMode {
2556 match self.mode {
2557 SelectMode::Single => InteractMode::Single(Some(self.cursor)),
2558 SelectMode::Multi => {
2559 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2560 indices.sort();
2561 InteractMode::Multi(Some(indices))
2562 }
2563 SelectMode::Fuzzy => {
2564 if self.filtered_indices.is_empty() {
2565 InteractMode::Single(None)
2566 } else {
2567 InteractMode::Single(Some(self.filtered_indices[self.cursor]))
2568 }
2569 }
2570 SelectMode::FuzzyMulti => {
2571 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2574 indices.sort();
2575 InteractMode::Multi(Some(indices))
2576 }
2577 }
2578 }
2579
2580 fn can_do_multi_toggle_only_update(&self) -> bool {
2583 if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
2584 return false;
2585 }
2586 if self.cursor != self.prev_cursor {
2589 return false;
2590 }
2591 if let Some(toggled) = self.toggled_item {
2592 let visible_start = self.scroll_offset;
2594 let visible_end = self.scroll_offset + self.visible_height as usize;
2595 toggled >= visible_start && toggled < visible_end
2596 } else {
2597 false
2598 }
2599 }
2600
2601 fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
2604 if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
2605 return false;
2606 }
2607 if self.scroll_offset != self.prev_scroll_offset {
2608 return false; }
2610 if self.filter_text_changed || self.results_changed {
2611 return false; }
2613 if let Some(toggled) = self.toggled_item {
2614 let visible_start = self.scroll_offset;
2616 let visible_end = self.scroll_offset + self.visible_height as usize;
2617 let toggled_visible = toggled >= visible_start && toggled < visible_end;
2618 let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
2619 toggled_visible && cursor_visible
2620 } else {
2621 false
2622 }
2623 }
2624
2625 fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
2628 !self.first_render
2629 && !self.width_changed
2630 && self.mode == SelectMode::FuzzyMulti
2631 && self.toggled_all
2632 && !self.filter_text_changed
2633 && !self.results_changed
2634 && self.scroll_offset == self.prev_scroll_offset
2635 && !self.horizontal_scroll_changed
2636 }
2637
2638 fn can_do_multi_toggle_all_update(&self) -> bool {
2641 !self.first_render
2642 && !self.width_changed
2643 && self.mode == SelectMode::Multi
2644 && self.toggled_all
2645 }
2646
2647 fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2649 let toggled = self.toggled_item.expect("toggled_item must be Some");
2650 execute!(stderr, BeginSynchronizedUpdate)?;
2651
2652 let header_lines = self.fuzzy_header_lines();
2654
2655 let toggled_display_row = (toggled - self.scroll_offset) as u16;
2656 let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
2657
2658 let toggled_item_row = header_lines + toggled_display_row;
2659 let cursor_item_row = header_lines + cursor_display_row;
2660
2661 let filter_row = self.fuzzy_filter_row();
2663
2664 let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2666 execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2667
2668 let toggled_real_idx = self.filtered_indices[toggled];
2670 let toggled_item = &self.items[toggled_real_idx];
2671 let toggled_checked = self.selected.contains(&toggled_real_idx);
2672 if self.is_table_mode() {
2673 self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
2674 } else {
2675 self.render_fuzzy_multi_item_inline(
2676 stderr,
2677 &toggled_item.name,
2678 toggled_checked,
2679 false,
2680 )?;
2681 }
2682
2683 if cursor_item_row > toggled_item_row {
2685 let lines_down = cursor_item_row - toggled_item_row;
2686 execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
2687 } else if cursor_item_row < toggled_item_row {
2688 let lines_up = toggled_item_row - cursor_item_row;
2689 execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
2690 }
2691
2692 let cursor_real_idx = self.filtered_indices[self.cursor];
2693 let cursor_item = &self.items[cursor_real_idx];
2694 let cursor_checked = self.selected.contains(&cursor_real_idx);
2695 if self.is_table_mode() {
2696 self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
2697 } else {
2698 self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
2699 }
2700
2701 if self.has_footer() {
2703 let total_count = self.current_list_len();
2705 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2706 let visible_count = (end - self.scroll_offset) as u16;
2707 let footer_row = header_lines + visible_count;
2708
2709 let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2711 execute!(stderr, MoveDown(down_to_footer))?;
2712
2713 self.render_footer_inline(stderr)?;
2715
2716 let up_to_filter = footer_row.saturating_sub(filter_row);
2718 execute!(stderr, MoveUp(up_to_filter))?;
2719 } else {
2720 let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2722 execute!(stderr, MoveUp(up_to_filter))?;
2723 }
2724
2725 self.position_fuzzy_cursor(stderr)?;
2727
2728 self.prev_cursor = self.cursor;
2730 self.toggled_item = None;
2731
2732 execute!(stderr, EndSynchronizedUpdate)?;
2733 stderr.flush()
2734 }
2735
2736 fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2738 let toggled = self.toggled_item.expect("toggled_item must be Some");
2739 execute!(stderr, BeginSynchronizedUpdate)?;
2740
2741 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2742 if self.is_table_mode() {
2743 header_lines += 2; }
2745
2746 let display_row = (toggled - self.scroll_offset) as u16;
2748
2749 let items_rendered = self.rendered_lines - header_lines as usize;
2751
2752 let lines_up = (items_rendered as u16)
2755 .saturating_sub(1)
2756 .saturating_sub(display_row);
2757 execute!(stderr, MoveUp(lines_up))?;
2758
2759 execute!(stderr, MoveToColumn(2))?;
2761
2762 let checkbox = if self.selected.contains(&toggled) {
2764 "[x]"
2765 } else {
2766 "[ ]"
2767 };
2768 execute!(stderr, Print(checkbox))?;
2769
2770 execute!(stderr, MoveDown(lines_up))?;
2772
2773 if self.has_footer() {
2775 self.render_footer_inline(stderr)?;
2776 }
2777
2778 self.toggled_item = None;
2780
2781 execute!(stderr, EndSynchronizedUpdate)?;
2782 stderr.flush()
2783 }
2784
2785 fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2787 execute!(stderr, BeginSynchronizedUpdate)?;
2788
2789 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2790 if self.is_table_mode() {
2791 header_lines += 2; }
2793
2794 let items_rendered = self.rendered_lines - header_lines as usize;
2796
2797 let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
2799 let visible_count = visible_end - self.scroll_offset;
2800
2801 execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2804
2805 for i in 0..visible_count {
2807 let item_idx = self.scroll_offset + i;
2808 let checkbox = if self.selected.contains(&item_idx) {
2809 "[x]"
2810 } else {
2811 "[ ]"
2812 };
2813 execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2815 if i + 1 < visible_count {
2816 execute!(stderr, MoveDown(1))?;
2817 }
2818 }
2819
2820 let remaining = items_rendered as u16 - visible_count as u16;
2822 if remaining > 0 {
2823 execute!(stderr, MoveDown(remaining))?;
2824 }
2825
2826 if self.has_footer() {
2828 self.render_footer_inline(stderr)?;
2829 }
2830
2831 self.toggled_all = false;
2833
2834 execute!(stderr, EndSynchronizedUpdate)?;
2835 stderr.flush()
2836 }
2837
2838 fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2840 execute!(stderr, BeginSynchronizedUpdate)?;
2841
2842 let header_lines = self.fuzzy_header_lines();
2844
2845 let total_count = self.current_list_len();
2846 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2847 let visible_count = end.saturating_sub(self.scroll_offset);
2848
2849 let filter_row = self.fuzzy_filter_row();
2851
2852 let down_to_first = header_lines.saturating_sub(filter_row);
2854 execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
2855
2856 for (i, idx) in (self.scroll_offset..end).enumerate() {
2857 let real_idx = self.filtered_indices[idx];
2858 let item = &self.items[real_idx];
2859 let checked = self.selected.contains(&real_idx);
2860 let active = idx == self.cursor;
2861
2862 if self.is_table_mode() {
2863 self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
2864 } else {
2865 self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
2866 }
2867
2868 if i + 1 < visible_count {
2869 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2870 }
2871 }
2872
2873 if self.has_footer() {
2875 let footer_row = header_lines + visible_count as u16;
2876 let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
2877 let down_to_footer = footer_row.saturating_sub(last_item_row);
2878 execute!(stderr, MoveDown(down_to_footer))?;
2879 self.render_footer_inline(stderr)?;
2880 let up_to_filter = footer_row.saturating_sub(filter_row);
2881 execute!(stderr, MoveUp(up_to_filter))?;
2882 } else {
2883 let up_to_filter =
2884 (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
2885 execute!(stderr, MoveUp(up_to_filter))?;
2886 }
2887
2888 self.position_fuzzy_cursor(stderr)?;
2890
2891 self.toggled_all = false;
2893
2894 execute!(stderr, EndSynchronizedUpdate)?;
2895 stderr.flush()
2896 }
2897
2898 #[allow(clippy::collapsible_if)]
2899 fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2900 self.maybe_load_more();
2901
2902 if self.can_do_fuzzy_multi_toggle_all_update() {
2904 return self.render_fuzzy_multi_toggle_all_update(stderr);
2905 }
2906
2907 if self.can_do_multi_toggle_all_update() {
2909 return self.render_multi_toggle_all(stderr);
2910 }
2911
2912 if self.can_do_multi_toggle_only_update() {
2914 return self.render_multi_toggle_only(stderr);
2915 }
2916
2917 if self.can_do_fuzzy_multi_toggle_update() {
2919 return self.render_fuzzy_multi_toggle_update(stderr);
2920 }
2921
2922 if !self.first_render
2929 && !self.width_changed
2930 && self.cursor == self.prev_cursor
2931 && self.scroll_offset == self.prev_scroll_offset
2932 && !self.results_changed
2933 && !self.filter_text_changed
2934 && !self.horizontal_scroll_changed
2935 && !self.settings_changed
2936 && !self.toggled_all
2937 {
2938 return Ok(());
2939 }
2940
2941 execute!(stderr, BeginSynchronizedUpdate)?;
2942
2943 let total_count = self.current_list_len();
2945 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2946 let has_scroll_indicator = self.has_footer();
2948 let items_to_render = end - self.scroll_offset;
2949
2950 let mut lines_needed: usize = 0;
2952 if self.prompt.is_some() {
2953 lines_needed += 1;
2954 }
2955 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2956 lines_needed += 1; if self.config.show_separator {
2958 lines_needed += 1;
2959 }
2960 }
2961 if self.is_table_mode() {
2962 lines_needed += 2; }
2964 lines_needed += items_to_render;
2965 if has_scroll_indicator {
2966 lines_needed += 1;
2967 }
2968
2969 if self.first_render && lines_needed > 1 {
2971 for _ in 0..(lines_needed - 1) {
2972 execute!(stderr, Print("\n"))?;
2973 }
2974 execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
2975 }
2976
2977 if self.fuzzy_cursor_offset > 0 {
2979 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
2980 self.fuzzy_cursor_offset = 0;
2981 }
2982
2983 if self.rendered_lines > 1 {
2986 execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
2987 }
2988 execute!(stderr, MoveToColumn(0))?;
2989
2990 let mut lines_rendered: usize = 0;
2991
2992 if self.first_render {
2994 if let Some(prompt) = self.prompt {
2995 execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
2996 }
2997 }
2998 if self.prompt.is_some() {
2999 lines_rendered += 1;
3000 if lines_rendered < lines_needed {
3001 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3002 }
3003 }
3004
3005 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3007 execute!(
3008 stderr,
3009 Print(self.prompt_marker()),
3010 Print(&self.filter_text),
3011 Clear(ClearType::UntilNewLine),
3012 )?;
3013 lines_rendered += 1;
3014 if lines_rendered < lines_needed {
3015 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3016 }
3017
3018 if self.config.show_separator {
3020 execute!(
3021 stderr,
3022 Print(self.config.separator.paint(&self.separator_line)),
3023 Clear(ClearType::UntilNewLine),
3024 )?;
3025 lines_rendered += 1;
3026 if lines_rendered < lines_needed {
3027 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3028 }
3029 }
3030 }
3031
3032 if self.is_table_mode() {
3035 let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
3036 if need_header_redraw {
3037 self.render_table_header(stderr)?;
3038 }
3039 lines_rendered += 1;
3040 if lines_rendered < lines_needed {
3041 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3042 }
3043 if need_header_redraw {
3044 self.render_table_header_separator(stderr)?;
3045 }
3046 lines_rendered += 1;
3047 if lines_rendered < lines_needed {
3048 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3049 }
3050 }
3051
3052 for idx in self.scroll_offset..end {
3054 let is_active = idx == self.cursor;
3055 let is_last_line = lines_rendered + 1 == lines_needed;
3056
3057 if self.is_table_mode() {
3058 match self.mode {
3060 SelectMode::Single => {
3061 let item = &self.items[idx];
3062 self.render_table_row_single(stderr, item, is_active)?;
3063 }
3064 SelectMode::Multi => {
3065 let real_idx = if self.refined {
3066 self.filtered_indices[idx]
3067 } else {
3068 idx
3069 };
3070 let item = &self.items[real_idx];
3071 let is_checked = self.selected.contains(&real_idx);
3072 self.render_table_row_multi(stderr, item, is_checked, is_active)?;
3073 }
3074 SelectMode::Fuzzy => {
3075 let real_idx = self.filtered_indices[idx];
3076 let item = &self.items[real_idx];
3077 self.render_table_row_fuzzy(stderr, item, is_active)?;
3078 }
3079 SelectMode::FuzzyMulti => {
3080 let real_idx = self.filtered_indices[idx];
3081 let item = &self.items[real_idx];
3082 let is_checked = self.selected.contains(&real_idx);
3083 self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
3084 }
3085 }
3086 } else {
3087 match self.mode {
3089 SelectMode::Single => {
3090 let item = &self.items[idx];
3091 self.render_single_item_inline(stderr, &item.name, is_active)?;
3092 }
3093 SelectMode::Multi => {
3094 let real_idx = if self.refined {
3095 self.filtered_indices[idx]
3096 } else {
3097 idx
3098 };
3099 let item = &self.items[real_idx];
3100 let is_checked = self.selected.contains(&real_idx);
3101 self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
3102 }
3103 SelectMode::Fuzzy => {
3104 let real_idx = self.filtered_indices[idx];
3105 let item = &self.items[real_idx];
3106 self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
3107 }
3108 SelectMode::FuzzyMulti => {
3109 let real_idx = self.filtered_indices[idx];
3110 let item = &self.items[real_idx];
3111 let is_checked = self.selected.contains(&real_idx);
3112 self.render_fuzzy_multi_item_inline(
3113 stderr, &item.name, is_checked, is_active,
3114 )?;
3115 }
3116 }
3117 }
3118 lines_rendered += 1;
3119 if !is_last_line {
3120 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
3121 }
3122 }
3123
3124 if has_scroll_indicator {
3126 let indicator = self.generate_footer();
3127 execute!(
3128 stderr,
3129 Print(self.config.footer.paint(&indicator)),
3130 Clear(ClearType::UntilNewLine),
3131 )?;
3132 lines_rendered += 1;
3133 }
3134
3135 if lines_rendered < self.rendered_lines {
3138 let extra_lines = self.rendered_lines - lines_rendered;
3139 for _ in 0..extra_lines {
3140 execute!(
3141 stderr,
3142 MoveDown(1),
3143 MoveToColumn(0),
3144 Clear(ClearType::CurrentLine)
3145 )?;
3146 }
3147 execute!(stderr, MoveUp(extra_lines as u16))?;
3149 }
3150
3151 self.rendered_lines = lines_rendered;
3153 self.prev_cursor = self.cursor;
3154 self.prev_scroll_offset = self.scroll_offset;
3155 self.first_render = false;
3156 self.filter_text_changed = false;
3157 self.results_changed = false;
3158 self.horizontal_scroll_changed = false;
3159 self.width_changed = false;
3160 self.toggled_item = None;
3161 self.toggled_all = false;
3162 self.settings_changed = false;
3163
3164 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3166 let filter_row = self.fuzzy_filter_row() as usize;
3168 self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3169 if self.fuzzy_cursor_offset > 0 {
3170 execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3171 }
3172 self.position_fuzzy_cursor(stderr)?;
3174 }
3175
3176 execute!(stderr, EndSynchronizedUpdate)?;
3177 stderr.flush()
3178 }
3179
3180 fn render_single_item_inline(
3181 &self,
3182 stderr: &mut Stderr,
3183 text: &str,
3184 active: bool,
3185 ) -> io::Result<()> {
3186 let prefix = if active { self.selected_marker() } else { " " };
3187 let prefix_width = 2;
3188
3189 execute!(stderr, Print(prefix))?;
3190 self.render_truncated_text(stderr, text, prefix_width)?;
3191 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3192 Ok(())
3193 }
3194
3195 fn render_multi_item_inline(
3196 &self,
3197 stderr: &mut Stderr,
3198 text: &str,
3199 checked: bool,
3200 active: bool,
3201 ) -> io::Result<()> {
3202 let cursor = if active { self.selected_marker() } else { " " };
3203 let checkbox = if checked { "[x] " } else { "[ ] " };
3204 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3207 self.render_truncated_text(stderr, text, prefix_width)?;
3208 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3209 Ok(())
3210 }
3211
3212 fn render_fuzzy_item_inline(
3213 &self,
3214 stderr: &mut Stderr,
3215 text: &str,
3216 active: bool,
3217 ) -> io::Result<()> {
3218 let prefix = if active { self.selected_marker() } else { " " };
3219 let prefix_width = 2;
3220 execute!(stderr, Print(prefix))?;
3221
3222 if self.filter_text.is_empty() {
3223 self.render_truncated_text(stderr, text, prefix_width)?;
3224 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3225 {
3226 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3227 } else {
3228 self.render_truncated_text(stderr, text, prefix_width)?;
3229 }
3230 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3231 Ok(())
3232 }
3233
3234 fn render_fuzzy_multi_item_inline(
3235 &self,
3236 stderr: &mut Stderr,
3237 text: &str,
3238 checked: bool,
3239 active: bool,
3240 ) -> io::Result<()> {
3241 let cursor = if active { self.selected_marker() } else { " " };
3242 let checkbox = if checked { "[x] " } else { "[ ] " };
3243 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3245
3246 if self.filter_text.is_empty() {
3247 self.render_truncated_text(stderr, text, prefix_width)?;
3248 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3249 {
3250 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3251 } else {
3252 self.render_truncated_text(stderr, text, prefix_width)?;
3253 }
3254 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3255 Ok(())
3256 }
3257
3258 fn render_truncated_text(
3260 &self,
3261 stderr: &mut Stderr,
3262 text: &str,
3263 prefix_width: usize,
3264 ) -> io::Result<()> {
3265 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3266 let text_width = UnicodeWidthStr::width(text);
3267
3268 if text_width <= available_width {
3269 execute!(stderr, Print(text))?;
3271 } else if available_width <= 1 {
3272 execute!(stderr, Print("…"))?;
3274 } else {
3275 let target_width = available_width - 1;
3277 let mut current_width = 0;
3278 let mut end_pos = 0;
3279
3280 for (byte_pos, c) in text.char_indices() {
3281 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3282 if current_width + char_width > target_width {
3283 break;
3284 }
3285 end_pos = byte_pos + c.len_utf8();
3286 current_width += char_width;
3287 }
3288 execute!(stderr, Print(&text[..end_pos]))?;
3289 execute!(stderr, Print("…"))?;
3290 }
3291 Ok(())
3292 }
3293
3294 fn render_truncated_fuzzy_text(
3297 &self,
3298 stderr: &mut Stderr,
3299 text: &str,
3300 match_indices: &[usize],
3301 prefix_width: usize,
3302 ) -> io::Result<()> {
3303 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3304 let text_width = UnicodeWidthStr::width(text);
3305
3306 let mut char_buf = [0u8; 4];
3308
3309 if text_width <= available_width {
3310 let mut match_iter = match_indices.iter().peekable();
3313 for (idx, c) in text.chars().enumerate() {
3314 while match_iter.peek().is_some_and(|&&i| i < idx) {
3316 match_iter.next();
3317 }
3318 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3319 if is_match {
3320 let s = c.encode_utf8(&mut char_buf);
3321 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3322 } else {
3323 execute!(stderr, Print(c))?;
3324 }
3325 }
3326 } else if available_width <= 1 {
3327 let has_any_matches = !match_indices.is_empty();
3329 if has_any_matches {
3330 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3331 } else {
3332 execute!(stderr, Print("…"))?;
3333 }
3334 } else {
3335 let target_width = available_width - 1;
3337 let mut current_width = 0;
3338 let mut chars_to_render: usize = 0;
3339
3340 for c in text.chars() {
3341 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3342 if current_width + char_width > target_width {
3343 break;
3344 }
3345 current_width += char_width;
3346 chars_to_render += 1;
3347 }
3348
3349 let mut match_iter = match_indices.iter().peekable();
3351 for (idx, c) in text.chars().enumerate() {
3352 if idx >= chars_to_render {
3353 break;
3354 }
3355 while match_iter.peek().is_some_and(|&&i| i < idx) {
3356 match_iter.next();
3357 }
3358 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3359 if is_match {
3360 let s = c.encode_utf8(&mut char_buf);
3361 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3362 } else {
3363 execute!(stderr, Print(c))?;
3364 }
3365 }
3366
3367 let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
3369
3370 if has_hidden_matches {
3371 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3372 } else {
3373 execute!(stderr, Print("…"))?;
3374 }
3375 }
3376 Ok(())
3377 }
3378
3379 fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3381 let Some(layout) = &self.table_layout else {
3382 return Ok(());
3383 };
3384
3385 let prefix_width = self.row_prefix_width();
3386 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3387 let has_more_left = self.horizontal_offset > 0;
3388
3389 execute!(stderr, Print(" ".repeat(prefix_width)))?;
3391
3392 if has_more_left {
3394 let sep = self.table_column_separator();
3395 execute!(
3396 stderr,
3397 Print(self.config.table_separator.paint("…")),
3398 Print(self.config.table_separator.paint(&sep))
3399 )?;
3400 }
3401
3402 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3404 for (i, col_idx) in visible_range.enumerate() {
3405 if col_idx >= layout.columns.len() {
3406 break;
3407 }
3408
3409 if i > 0 {
3411 let sep = self.table_column_separator();
3412 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3413 }
3414
3415 let header = &layout.columns[col_idx];
3417 let col_width = layout.col_widths[col_idx];
3418 let header_width = header.width();
3419 let padding = col_width.saturating_sub(header_width);
3420 let left_pad = padding / 2;
3421 let right_pad = padding - left_pad;
3422 let header_padded = format!(
3423 "{}{}{}",
3424 " ".repeat(left_pad),
3425 header,
3426 " ".repeat(right_pad)
3427 );
3428 execute!(
3429 stderr,
3430 Print(self.config.table_header.paint(&header_padded))
3431 )?;
3432 }
3433
3434 if has_more_right {
3436 let sep = self.table_column_separator();
3437 execute!(
3438 stderr,
3439 Print(self.config.table_separator.paint(&sep)),
3440 Print(self.config.table_separator.paint("…"))
3441 )?;
3442 }
3443
3444 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3445 Ok(())
3446 }
3447
3448 fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3450 let Some(layout) = &self.table_layout else {
3451 return Ok(());
3452 };
3453
3454 let prefix_width = self.row_prefix_width();
3455 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3456 let has_more_left = self.horizontal_offset > 0;
3457
3458 let h_char = self.config.table_header_separator;
3459 let int_char = self.config.table_header_intersection;
3460
3461 let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3463 execute!(
3464 stderr,
3465 Print(self.config.table_separator.paint(&prefix_line))
3466 )?;
3467
3468 if has_more_left {
3471 let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3472 execute!(
3473 stderr,
3474 Print(self.config.table_separator.paint(&left_indicator))
3475 )?;
3476 }
3477
3478 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3480 for (i, col_idx) in visible_range.enumerate() {
3481 if col_idx >= layout.col_widths.len() {
3482 break;
3483 }
3484
3485 if i > 0 {
3487 let intersection = format!("{}{}{}", h_char, int_char, h_char);
3488 execute!(
3489 stderr,
3490 Print(self.config.table_separator.paint(&intersection))
3491 )?;
3492 }
3493
3494 let col_width = layout.col_widths[col_idx];
3496 let line: String = std::iter::repeat_n(h_char, col_width).collect();
3497 execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
3498 }
3499
3500 if has_more_right {
3503 let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
3504 execute!(
3505 stderr,
3506 Print(self.config.table_separator.paint(&right_indicator))
3507 )?;
3508 }
3509
3510 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3511 Ok(())
3512 }
3513
3514 fn render_table_row_single(
3516 &self,
3517 stderr: &mut Stderr,
3518 item: &SelectItem,
3519 active: bool,
3520 ) -> io::Result<()> {
3521 let prefix = if active { self.selected_marker() } else { " " };
3522 execute!(stderr, Print(prefix))?;
3523 self.render_table_cells(stderr, item, None)?;
3524 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3525 Ok(())
3526 }
3527
3528 fn render_table_row_multi(
3530 &self,
3531 stderr: &mut Stderr,
3532 item: &SelectItem,
3533 checked: bool,
3534 active: bool,
3535 ) -> io::Result<()> {
3536 let cursor = if active { self.selected_marker() } else { " " };
3537 let checkbox = if checked { "[x] " } else { "[ ] " };
3538 execute!(stderr, Print(cursor), Print(checkbox))?;
3539 self.render_table_cells(stderr, item, None)?;
3540 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3541 Ok(())
3542 }
3543
3544 fn render_table_row_fuzzy(
3546 &self,
3547 stderr: &mut Stderr,
3548 item: &SelectItem,
3549 active: bool,
3550 ) -> io::Result<()> {
3551 let prefix = if active { self.selected_marker() } else { " " };
3552 execute!(stderr, Print(prefix))?;
3553
3554 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3556 self.matcher
3557 .fuzzy_indices(&item.name, &self.filter_text)
3558 .map(|(_, indices)| indices)
3559 } else {
3560 None
3561 };
3562
3563 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3564 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3565 Ok(())
3566 }
3567
3568 fn render_table_row_fuzzy_multi(
3570 &self,
3571 stderr: &mut Stderr,
3572 item: &SelectItem,
3573 checked: bool,
3574 active: bool,
3575 ) -> io::Result<()> {
3576 let cursor = if active { self.selected_marker() } else { " " };
3577 let checkbox = if checked { "[x] " } else { "[ ] " };
3578 execute!(stderr, Print(cursor), Print(checkbox))?;
3579
3580 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3582 self.matcher
3583 .fuzzy_indices(&item.name, &self.filter_text)
3584 .map(|(_, indices)| indices)
3585 } else {
3586 None
3587 };
3588
3589 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3590 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3591 Ok(())
3592 }
3593
3594 fn render_table_cells(
3596 &self,
3597 stderr: &mut Stderr,
3598 item: &SelectItem,
3599 match_indices: Option<&[usize]>,
3600 ) -> io::Result<()> {
3601 let Some(layout) = &self.table_layout else {
3602 return Ok(());
3603 };
3604 let Some(cells) = &item.cells else {
3605 return Ok(());
3606 };
3607
3608 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3609 let has_more_left = self.horizontal_offset > 0;
3610
3611 let mut matches_in_hidden_left = false;
3613 let mut matches_in_hidden_right = false;
3614
3615 let per_column_matches: Vec<Option<Vec<usize>>> =
3617 if self.per_column && !self.filter_text.is_empty() {
3618 cells
3619 .iter()
3620 .map(|(cell_text, _)| {
3621 self.matcher
3622 .fuzzy_indices(cell_text, &self.filter_text)
3623 .map(|(_, indices)| indices)
3624 })
3625 .collect()
3626 } else {
3627 vec![]
3628 };
3629
3630 let cell_offsets: Vec<usize> = if match_indices.is_some() {
3633 let mut offsets = Vec::with_capacity(cells.len());
3634 let mut offset = 0;
3635 for (i, (cell_text, _)) in cells.iter().enumerate() {
3636 offsets.push(offset);
3637 offset += cell_text.chars().count();
3638 if i + 1 < cells.len() {
3639 offset += 1; }
3641 }
3642 offsets
3643 } else {
3644 vec![]
3645 };
3646
3647 if self.per_column && !self.filter_text.is_empty() {
3649 for col_idx in 0..self.horizontal_offset {
3650 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3651 matches_in_hidden_left = true;
3652 break;
3653 }
3654 }
3655 } else if let Some(indices) = match_indices {
3656 for col_idx in 0..self.horizontal_offset {
3657 if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
3658 let cell_start = cell_offsets[col_idx];
3659 let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3661 matches_in_hidden_left = true;
3662 break;
3663 }
3664 }
3665 }
3666 }
3667
3668 if has_more_left {
3670 let sep = self.table_column_separator();
3671 if matches_in_hidden_left {
3672 execute!(
3673 stderr,
3674 Print(self.config.match_text.paint("…")),
3675 Print(self.config.table_separator.paint(&sep))
3676 )?;
3677 } else {
3678 execute!(
3679 stderr,
3680 Print(self.config.table_separator.paint("…")),
3681 Print(self.config.table_separator.paint(&sep))
3682 )?;
3683 }
3684 }
3685
3686 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3688 for (i, col_idx) in visible_range.enumerate() {
3689 if col_idx >= cells.len() {
3690 break;
3691 }
3692
3693 if i > 0 {
3695 let sep = self.table_column_separator();
3696 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3697 }
3698
3699 let (cell_text, cell_style) = &cells[col_idx];
3700 let col_width = layout.col_widths[col_idx];
3701
3702 let cell_matches: Option<Vec<usize>> =
3704 if self.per_column && !self.filter_text.is_empty() {
3705 per_column_matches.get(col_idx).cloned().flatten()
3707 } else if let Some(indices) = match_indices {
3708 if col_idx < cell_offsets.len() {
3710 let cell_start = cell_offsets[col_idx];
3711 let cell_char_count = cell_text.chars().count();
3713 let relative_indices: Vec<usize> = indices
3714 .iter()
3715 .filter_map(|&idx| {
3716 if idx >= cell_start && idx < cell_start + cell_char_count {
3717 Some(idx - cell_start)
3718 } else {
3719 None
3720 }
3721 })
3722 .collect();
3723 if relative_indices.is_empty() {
3724 None
3725 } else {
3726 Some(relative_indices)
3727 }
3728 } else {
3729 None
3730 }
3731 } else {
3732 None
3733 };
3734
3735 self.render_table_cell(
3737 stderr,
3738 cell_text,
3739 cell_style,
3740 col_width,
3741 cell_matches.as_deref(),
3742 )?;
3743 }
3744
3745 if self.per_column && !self.filter_text.is_empty() {
3747 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3748 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3749 matches_in_hidden_right = true;
3750 break;
3751 }
3752 }
3753 } else if let Some(indices) = match_indices {
3754 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3755 if col_idx < cell_offsets.len() {
3756 let cell_start = cell_offsets[col_idx];
3757 let cell_end = if col_idx + 1 < cell_offsets.len() {
3758 cell_offsets[col_idx + 1].saturating_sub(1)
3759 } else {
3760 item.name.chars().count()
3761 };
3762 if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3763 matches_in_hidden_right = true;
3764 break;
3765 }
3766 }
3767 }
3768 }
3769
3770 if has_more_right {
3772 let sep = self.table_column_separator();
3773 if matches_in_hidden_right {
3774 execute!(
3775 stderr,
3776 Print(self.config.table_separator.paint(&sep)),
3777 Print(self.config.match_text.paint("…"))
3778 )?;
3779 } else {
3780 execute!(
3781 stderr,
3782 Print(self.config.table_separator.paint(&sep)),
3783 Print(self.config.table_separator.paint("…"))
3784 )?;
3785 }
3786 }
3787
3788 Ok(())
3789 }
3790
3791 fn render_table_cell(
3793 &self,
3794 stderr: &mut Stderr,
3795 cell: &str,
3796 cell_style: &TextStyle,
3797 col_width: usize,
3798 match_indices: Option<&[usize]>,
3799 ) -> io::Result<()> {
3800 let cell_width = cell.width();
3801 let padding_needed = col_width.saturating_sub(cell_width);
3802
3803 let (left_pad, right_pad) = match cell_style.alignment {
3805 Alignment::Left => (0, padding_needed),
3806 Alignment::Right => (padding_needed, 0),
3807 Alignment::Center => {
3808 let left = padding_needed / 2;
3809 (left, padding_needed - left)
3810 }
3811 };
3812
3813 if left_pad > 0 {
3815 execute!(stderr, Print(" ".repeat(left_pad)))?;
3816 }
3817
3818 if let Some(indices) = match_indices {
3819 let mut char_buf = [0u8; 4];
3821 let mut match_iter = indices.iter().peekable();
3822
3823 for (idx, c) in cell.chars().enumerate() {
3824 while match_iter.peek().is_some_and(|&&i| i < idx) {
3825 match_iter.next();
3826 }
3827 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3828 if is_match {
3829 let s = c.encode_utf8(&mut char_buf);
3830 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3831 } else {
3832 let s = c.encode_utf8(&mut char_buf);
3834 if let Some(color) = cell_style.color_style {
3835 execute!(stderr, Print(color.paint(&*s)))?;
3836 } else {
3837 execute!(stderr, Print(&*s))?;
3838 }
3839 }
3840 }
3841 } else {
3842 if let Some(color) = cell_style.color_style {
3844 execute!(stderr, Print(color.paint(cell)))?;
3845 } else {
3846 execute!(stderr, Print(cell))?;
3847 }
3848 }
3849
3850 if right_pad > 0 {
3852 execute!(stderr, Print(" ".repeat(right_pad)))?;
3853 }
3854
3855 Ok(())
3856 }
3857
3858 fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3859 if self.fuzzy_cursor_offset > 0 {
3861 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3862 self.fuzzy_cursor_offset = 0;
3863 }
3864
3865 if self.rendered_lines > 0 {
3866 execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3870 for _ in 1..self.rendered_lines {
3872 execute!(
3873 stderr,
3874 MoveUp(1),
3875 MoveToColumn(0),
3876 Clear(ClearType::CurrentLine)
3877 )?;
3878 }
3879 }
3881 self.rendered_lines = 0;
3882 stderr.flush()
3883 }
3884}
3885
3886enum KeyAction {
3887 Continue,
3888 Cancel,
3889 Confirm,
3890}
3891
3892#[cfg(test)]
3893mod test {
3894 use super::*;
3895
3896 fn make_widget(items: &[&str]) -> SelectWidget<'static> {
3897 let options: Vec<SelectItem> = items
3898 .iter()
3899 .map(|s| SelectItem {
3900 name: s.to_string(),
3901 cells: None,
3902 value: nu_protocol::Value::nothing(nu_protocol::Span::test_data()),
3903 })
3904 .collect();
3905
3906 SelectWidget::new(
3907 SelectMode::Single,
3908 None,
3909 options,
3910 InputListConfig::default(),
3911 None,
3912 false,
3913 StreamState {
3914 pending_stream: None,
3915 item_generator: None,
3916 },
3917 )
3918 }
3919
3920 #[test]
3921 fn wrap_up_and_down_cycles() {
3922 let mut w = make_widget(&["A", "B", "C"]);
3923 w.navigate_up();
3925 assert_eq!(w.cursor, 2);
3926 w.navigate_up();
3927 assert_eq!(w.cursor, 1);
3928 w.navigate_up();
3929 assert_eq!(w.cursor, 0);
3930
3931 w.navigate_down();
3933 assert_eq!(w.cursor, 1);
3934 w.navigate_down();
3935 assert_eq!(w.cursor, 2);
3936 w.navigate_down();
3937 assert_eq!(w.cursor, 0);
3938 }
3939
3940 #[test]
3941 fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
3942 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3943 w.first_render = false;
3944 w.prev_cursor = 0;
3945 w.prev_scroll_offset = 0;
3946 w.cursor = 0;
3947 w.scroll_offset = 0;
3948
3949 let mut stderr = io::stderr();
3950
3951 for _ in 0..7 {
3952 w.navigate_down();
3953 w.render(&mut stderr)?;
3954 assert_eq!(w.scroll_offset, 0);
3955 }
3956
3957 Ok(())
3958 }
3959
3960 #[test]
3961 fn up_arrow_sequence_state_and_render() -> io::Result<()> {
3962 let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
3963 w.first_render = false;
3964 w.prev_cursor = 0;
3965 w.prev_scroll_offset = 0;
3966 w.cursor = 0;
3967 w.scroll_offset = 0;
3968
3969 let mut stderr = io::stderr();
3970
3971 w.render(&mut stderr)?;
3972 assert_eq!(w.cursor, 0);
3973
3974 w.navigate_up();
3975 w.render(&mut stderr)?;
3976 assert_eq!(w.cursor, 2);
3977
3978 w.navigate_up();
3979 w.render(&mut stderr)?;
3980 assert_eq!(w.cursor, 1);
3981
3982 Ok(())
3983 }
3984
3985 #[test]
3986 fn test_examples() -> nu_test_support::Result {
3987 nu_test_support::test().examples(InputList)
3988 }
3989}