1use crossterm::{
2 cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
3 event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
4 execute,
5 style::Print,
6 terminal::{
7 self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, disable_raw_mode,
8 enable_raw_mode,
9 },
10};
11use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
12use nu_ansi_term::{Style, ansi::RESET};
13use nu_color_config::{Alignment, StyleComputer, TextStyle};
14use nu_engine::{ClosureEval, command_prelude::*, get_columns};
15use nu_protocol::engine::Closure;
16use nu_protocol::{TableMode, shell_error::io::IoError};
17use nu_table::common::nu_value_to_string;
18use std::{
19 collections::HashSet,
20 io::{self, Stderr, Write},
21};
22use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25enum CaseSensitivity {
26 #[default]
27 Smart,
28 CaseSensitive,
29 CaseInsensitive,
30}
31
32#[derive(Debug, Clone)]
33struct InputListConfig {
34 match_text: Style, footer: Style, separator: Style, prompt_marker: Style, selected_marker: Style, table_header: Style, table_separator: Style, show_footer: bool, separator_char: String, show_separator: bool, prompt_marker_text: String, selected_marker_char: char, table_column_separator: char, table_header_separator: char, table_header_intersection: char, case_sensitivity: CaseSensitivity, }
51
52const DEFAULT_PROMPT_MARKER: &str = "> ";
53const DEFAULT_SELECTED_MARKER: char = '>';
54
55const DEFAULT_TABLE_COLUMN_SEPARATOR: char = '│';
56
57fn table_mode_to_separator(mode: TableMode) -> char {
59 match mode {
60 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql | TableMode::Markdown => '|',
62 TableMode::AsciiRounded => '|',
63 TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => '│',
65 TableMode::Reinforced | TableMode::Light => '│',
66 TableMode::Heavy => '┃',
68 TableMode::Double | TableMode::CompactDouble => '║',
70 TableMode::WithLove => '❤',
72 TableMode::Dots => ':',
73 TableMode::Restructured | TableMode::None => ' ',
75 }
76}
77
78fn table_mode_to_header_separator(mode: TableMode) -> (char, char) {
80 match mode {
81 TableMode::Basic | TableMode::BasicCompact | TableMode::Psql => ('-', '+'),
83 TableMode::AsciiRounded => ('-', '+'),
84 TableMode::Markdown => ('-', '|'),
85 TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => ('─', '┼'),
87 TableMode::Reinforced => ('─', '┼'),
88 TableMode::Light => ('─', '─'), TableMode::Heavy => ('━', '╋'),
91 TableMode::Double | TableMode::CompactDouble => ('═', '╬'),
93 TableMode::WithLove => ('❤', '❤'),
95 TableMode::Dots => ('.', ':'),
96 TableMode::Restructured | TableMode::None => (' ', ' '),
98 }
99}
100
101impl Default for InputListConfig {
102 fn default() -> Self {
103 Self {
104 match_text: Style::new().fg(nu_ansi_term::Color::Yellow),
105 footer: Style::new().fg(nu_ansi_term::Color::DarkGray),
106 separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
107 prompt_marker: Style::new().fg(nu_ansi_term::Color::Green),
108 selected_marker: Style::new().fg(nu_ansi_term::Color::Green),
109 table_header: Style::new().bold(),
110 table_separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
111 show_footer: true,
112 separator_char: "─".to_string(),
113 show_separator: true,
114 prompt_marker_text: DEFAULT_PROMPT_MARKER.to_string(),
115 selected_marker_char: DEFAULT_SELECTED_MARKER,
116 table_column_separator: DEFAULT_TABLE_COLUMN_SEPARATOR,
117 table_header_separator: '─',
118 table_header_intersection: '┼',
119 case_sensitivity: CaseSensitivity::default(),
120 }
121 }
122}
123
124impl InputListConfig {
125 fn from_nu_config(config: &nu_protocol::Config, style_computer: &StyleComputer) -> Self {
126 let mut ret = Self::default();
127
128 let color_config_header =
130 style_computer.compute("header", &Value::string("", Span::unknown()));
131 let color_config_separator =
132 style_computer.compute("separator", &Value::nothing(Span::unknown()));
133 let color_config_search_result =
134 style_computer.compute("search_result", &Value::string("", Span::unknown()));
135 let color_config_hints = style_computer.compute("hints", &Value::nothing(Span::unknown()));
136 let color_config_row_index =
137 style_computer.compute("row_index", &Value::string("", Span::unknown()));
138
139 ret.table_header = color_config_header;
140 ret.table_separator = color_config_separator;
141 ret.separator = color_config_separator;
142 ret.match_text = color_config_search_result;
143 ret.footer = color_config_hints;
144 ret.prompt_marker = color_config_row_index;
145 ret.selected_marker = color_config_row_index;
146
147 ret.table_column_separator = table_mode_to_separator(config.table.mode);
149 let (header_sep, header_int) = table_mode_to_header_separator(config.table.mode);
150 ret.table_header_separator = header_sep;
151 ret.table_header_intersection = header_int;
152
153 ret
154 }
155}
156
157enum InteractMode {
158 Single(Option<usize>),
159 Multi(Option<Vec<usize>>),
160}
161
162struct SelectItem {
163 name: String, cells: Option<Vec<(String, TextStyle)>>, value: Value, }
167
168struct TableLayout {
170 columns: Vec<String>, col_widths: Vec<usize>, truncated_cols: usize, }
174
175#[derive(Clone)]
176pub struct InputList;
177
178const INTERACT_ERROR: &str = "Interact error, could not process options";
179
180impl Command for InputList {
181 fn name(&self) -> &str {
182 "input list"
183 }
184
185 fn signature(&self) -> Signature {
186 Signature::build("input list")
187 .input_output_types(vec![
188 (Type::List(Box::new(Type::Any)), Type::Any),
189 (Type::Range, Type::Int),
190 ])
191 .optional("prompt", SyntaxShape::String, "The prompt to display.")
192 .switch(
193 "multi",
194 "Use multiple results, you can press a to toggle all, Ctrl+R to refine.",
195 Some('m'),
196 )
197 .switch("fuzzy", "Use a fuzzy select.", Some('f'))
198 .switch("index", "Returns list indexes.", Some('i'))
199 .switch(
200 "no-footer",
201 "Hide the footer showing item count and selection count.",
202 Some('n'),
203 )
204 .switch(
205 "no-separator",
206 "Hide the separator line between the search box and results.",
207 None,
208 )
209 .named(
210 "case-sensitive",
211 SyntaxShape::OneOf(vec![SyntaxShape::Boolean, SyntaxShape::String]),
212 "Case sensitivity for fuzzy matching: true, false, or 'smart' (case-insensitive unless query has uppercase)",
213 Some('s'),
214 )
215 .named(
216 "display",
217 SyntaxShape::OneOf(vec![
218 SyntaxShape::CellPath,
219 SyntaxShape::Closure(Some(vec![SyntaxShape::Any])),
220 ]),
221 "Field or closure to generate display value for search (returns original value when selected)",
222 Some('d'),
223 )
224 .switch(
225 "no-table",
226 "Disable table rendering for table input (show as single lines).",
227 Some('t'),
228 )
229 .switch(
230 "per-column",
231 "Match filter text against each column independently (table mode only).",
232 Some('c'),
233 )
234 .allow_variants_without_examples(true)
235 .category(Category::Platform)
236 }
237
238 fn description(&self) -> &str {
239 "Display an interactive list for user selection."
240 }
241
242 fn extra_description(&self) -> &str {
243 r#"Presents an interactive list in the terminal for selecting items.
244
245Four modes are available:
246- Single (default): Select one item with arrow keys, confirm with Enter
247- Multi (--multi): Select multiple items with Space, toggle all with 'a'
248- Fuzzy (--fuzzy): Type to filter, matches are highlighted
249- Fuzzy Multi (--fuzzy --multi): Type to filter AND select multiple items with Tab, toggle all with Alt+A
250
251Multi mode features:
252- The footer always shows the selection count (e.g., "[1-5 of 10, 3 selected]")
253- Use Ctrl+R to "refine" the list: narrow down to only selected items, keeping them
254 selected so you can deselect the ones you don't want. Can be used multiple times.
255
256Table rendering:
257When piping a table (list of records), items are displayed with aligned columns.
258Use Left/Right arrows (or h/l) to scroll horizontally when columns exceed terminal width.
259In fuzzy mode, use Shift+Left/Right for horizontal scrolling.
260Ellipsis (…) shows when more columns are available in each direction.
261In fuzzy mode, the ellipsis is highlighted when matches exist in hidden columns.
262Use --no-table to disable table rendering and show records as single lines.
263Use --per-column to match filter text against each column independently (best match wins).
264This prevents false positives from matches spanning column boundaries.
265Use --display to specify a column or closure for display/search text (disables table mode).
266The --display flag accepts either a cell path (e.g., -d name) or a closure (e.g., -d {|it| $it.name}).
267The closure receives each item and should return the string to display and search on.
268The original value is always returned when selected, regardless of what --display shows.
269
270Keyboard shortcuts:
271- Up/Down, j/k: Navigate items
272- Left/Right, h/l: Scroll columns horizontally (table mode, single/multi)
273- Shift+Left/Right: Scroll columns horizontally (fuzzy mode)
274- Home/End: Jump to first/last item
275- PageUp/PageDown: Navigate by page
276- Space: Toggle selection (multi mode)
277- Tab: Toggle selection and move down (fuzzy multi mode)
278- Shift+Tab: Toggle selection and move up (fuzzy multi mode)
279- a: Toggle all items (multi mode), Alt+A in fuzzy multi mode
280- Ctrl+R: Refine list to only selected items (multi modes)
281- Alt+C: Cycle case sensitivity (smart -> CASE -> nocase) in fuzzy modes
282- Alt+P: Toggle per-column matching in fuzzy table mode
283- Enter: Confirm selection
284- Esc: Cancel (all modes)
285- q: Cancel (single/multi modes only)
286- Ctrl+C: Cancel (all modes)
287
288Fuzzy mode supports readline-style editing:
289- Ctrl+A/E: Beginning/end of line
290- Ctrl+B/F, Left/Right: Move cursor
291- Alt+B/F: Move by word
292- Ctrl+U/K: Kill to beginning/end of line
293- Ctrl+W, Alt+Backspace: Delete previous word
294- Ctrl+D, Delete: Delete character at cursor
295
296Styling (inherited from $env.config.color_config):
297- search_result: Match highlighting in fuzzy mode
298- hints: Footer text
299- separator: Separator line and table column separators
300- row_index: Prompt marker and selection marker
301- header: Table column headers
302- Table column characters inherit from $env.config.table.mode
303
304Use --no-footer and --no-separator to hide the footer and separator line."#
305 }
306
307 fn search_terms(&self) -> Vec<&str> {
308 vec![
309 "prompt", "ask", "menu", "select", "pick", "choose", "fzf", "fuzzy",
310 ]
311 }
312
313 fn run(
314 &self,
315 engine_state: &EngineState,
316 stack: &mut Stack,
317 call: &Call,
318 input: PipelineData,
319 ) -> Result<PipelineData, ShellError> {
320 let head = call.head;
321 let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
322 let multi = call.has_flag(engine_state, stack, "multi")?;
323 let fuzzy = call.has_flag(engine_state, stack, "fuzzy")?;
324 let index = call.has_flag(engine_state, stack, "index")?;
325 let display_flag: Option<Value> = call.get_flag(engine_state, stack, "display")?;
326 let no_footer = call.has_flag(engine_state, stack, "no-footer")?;
327 let no_separator = call.has_flag(engine_state, stack, "no-separator")?;
328 let case_sensitive: Option<Value> = call.get_flag(engine_state, stack, "case-sensitive")?;
329 let no_table = call.has_flag(engine_state, stack, "no-table")?;
330 let per_column = call.has_flag(engine_state, stack, "per-column")?;
331 let config = stack.get_config(engine_state);
332 let style_computer = StyleComputer::from_config(engine_state, stack);
333 let mut input_list_config = InputListConfig::from_nu_config(&config, &style_computer);
334 if no_footer {
335 input_list_config.show_footer = false;
336 }
337 if no_separator {
338 input_list_config.show_separator = false;
339 }
340 if let Some(cs) = case_sensitive {
341 input_list_config.case_sensitivity = match &cs {
342 Value::Bool { val: true, .. } => CaseSensitivity::CaseSensitive,
343 Value::Bool { val: false, .. } => CaseSensitivity::CaseInsensitive,
344 Value::String { val, .. } if val == "smart" => CaseSensitivity::Smart,
345 Value::String { val, .. } if val == "true" => CaseSensitivity::CaseSensitive,
346 Value::String { val, .. } if val == "false" => CaseSensitivity::CaseInsensitive,
347 _ => {
348 return Err(ShellError::InvalidValue {
349 valid: "true, false, or 'smart'".to_string(),
350 actual: cs.to_abbreviated_string(&config),
351 span: cs.span(),
352 });
353 }
354 };
355 }
356
357 let values: Vec<Value> = match input {
359 PipelineData::Value(Value::Range { .. }, ..)
360 | PipelineData::Value(Value::List { .. }, ..)
361 | PipelineData::ListStream { .. } => input.into_iter().collect(),
362 _ => {
363 return Err(ShellError::TypeMismatch {
364 err_message: "expected a list, a table, or a range".to_string(),
365 span: head,
366 });
367 }
368 };
369
370 let columns = if display_flag.is_none() && !no_table {
372 get_columns(&values)
373 } else {
374 vec![]
375 };
376 let is_table_mode = !columns.is_empty();
377
378 let options: Vec<SelectItem> = if is_table_mode {
381 values
382 .into_iter()
383 .map(|val| {
384 let cells: Vec<(String, TextStyle)> = columns
385 .iter()
386 .map(|col| {
387 if let Value::Record { val: record, .. } = &val {
388 record
389 .get(col)
390 .map(|v| nu_value_to_string(v, &config, &style_computer))
391 .unwrap_or_else(|| (String::new(), TextStyle::default()))
392 } else {
393 (String::new(), TextStyle::default())
394 }
395 })
396 .collect();
397 let name = cells
399 .iter()
400 .map(|(s, _)| s.as_str())
401 .collect::<Vec<_>>()
402 .join(" ");
403 SelectItem {
404 name,
405 cells: Some(cells),
406 value: val,
407 }
408 })
409 .collect()
410 } else {
411 match &display_flag {
413 Some(Value::CellPath { val: cellpath, .. }) => values
414 .into_iter()
415 .map(|val| {
416 let display_value = val
417 .follow_cell_path(&cellpath.members)
418 .map(|v| v.to_expanded_string(", ", &config))
419 .unwrap_or_else(|_| val.to_expanded_string(", ", &config));
420 SelectItem {
421 name: display_value,
422 cells: None,
423 value: val,
424 }
425 })
426 .collect(),
427 Some(Value::Closure { val: closure, .. }) => {
428 let mut closure_eval =
429 ClosureEval::new(engine_state, stack, Closure::clone(closure));
430 let mut options = Vec::with_capacity(values.len());
431 for val in values {
432 let display_value = closure_eval
433 .run_with_value(val.clone())
434 .and_then(|data| data.into_value(head))
435 .map(|v| v.to_expanded_string(", ", &config))
436 .unwrap_or_else(|_| val.to_expanded_string(", ", &config));
437 options.push(SelectItem {
438 name: display_value,
439 cells: None,
440 value: val,
441 });
442 }
443 options
444 }
445 None => values
446 .into_iter()
447 .map(|val| {
448 let display_value = val.to_expanded_string(", ", &config);
449 SelectItem {
450 name: display_value,
451 cells: None,
452 value: val,
453 }
454 })
455 .collect(),
456 _ => {
457 return Err(ShellError::TypeMismatch {
458 err_message: "expected a cell path or closure for --display".to_string(),
459 span: display_flag.as_ref().map(|v| v.span()).unwrap_or(head),
460 });
461 }
462 }
463 };
464
465 let table_layout = if is_table_mode {
467 Some(Self::calculate_table_layout(&columns, &options))
468 } else {
469 None
470 };
471
472 if options.is_empty() {
473 return Err(ShellError::TypeMismatch {
474 err_message: "expected a list or table, it can also be a problem with the inner type of your list.".to_string(),
475 span: head,
476 });
477 }
478
479 let mode = if multi && fuzzy {
480 SelectMode::FuzzyMulti
481 } else if multi {
482 SelectMode::Multi
483 } else if fuzzy {
484 SelectMode::Fuzzy
485 } else {
486 SelectMode::Single
487 };
488
489 let mut widget = SelectWidget::new(
490 mode,
491 prompt.as_deref(),
492 &options,
493 input_list_config,
494 table_layout,
495 per_column,
496 );
497 let answer = widget.run().map_err(|err| {
498 IoError::new_with_additional_context(err, call.head, None, INTERACT_ERROR)
499 })?;
500
501 Ok(match answer {
502 InteractMode::Multi(res) => {
503 if index {
504 match res {
505 Some(opts) => Value::list(
506 opts.into_iter()
507 .map(|s| Value::int(s as i64, head))
508 .collect(),
509 head,
510 ),
511 None => Value::nothing(head),
512 }
513 } else {
514 match res {
515 Some(opts) => Value::list(
516 opts.iter().map(|s| options[*s].value.clone()).collect(),
517 head,
518 ),
519 None => Value::nothing(head),
520 }
521 }
522 }
523 InteractMode::Single(res) => {
524 if index {
525 match res {
526 Some(opt) => Value::int(opt as i64, head),
527 None => Value::nothing(head),
528 }
529 } else {
530 match res {
531 Some(opt) => options[opt].value.clone(),
532 None => Value::nothing(head),
533 }
534 }
535 }
536 }
537 .into_pipeline_data())
538 }
539
540 fn examples(&self) -> Vec<Example<'_>> {
541 vec![
542 Example {
543 description: "Return a single value from a list.",
544 example: r#"[1 2 3 4 5] | input list 'Rate it'"#,
545 result: None,
546 },
547 Example {
548 description: "Return multiple values from a list.",
549 example: r#"[Banana Kiwi Pear Peach Strawberry] | input list --multi 'Add fruits to the basket'"#,
550 result: None,
551 },
552 Example {
553 description: "Return a single record from a table with fuzzy search.",
554 example: r#"ls | input list --fuzzy 'Select the target'"#,
555 result: None,
556 },
557 Example {
558 description: "Choose an item from a range.",
559 example: r#"1..10 | input list"#,
560 result: None,
561 },
562 Example {
563 description: "Return the index of a selected item.",
564 example: r#"[Banana Kiwi Pear Peach Strawberry] | input list --index"#,
565 result: None,
566 },
567 Example {
568 description: "Choose an item from a table using a column as display value.",
569 example: r#"[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d name"#,
570 result: None,
571 },
572 Example {
573 description: "Choose an item using a closure to generate display text",
574 example: r#"[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d {|it| $"($it.name): $($it.price)"}"#,
575 result: None,
576 },
577 Example {
578 description: "Fuzzy search with case-sensitive matching",
579 example: r#"[abc ABC aBc] | input list --fuzzy --case-sensitive true"#,
580 result: None,
581 },
582 Example {
583 description: "Fuzzy search without the footer showing item count",
584 example: r#"ls | input list --fuzzy --no-footer"#,
585 result: None,
586 },
587 Example {
588 description: "Fuzzy search without the separator line",
589 example: r#"ls | input list --fuzzy --no-separator"#,
590 result: None,
591 },
592 Example {
593 description: "Fuzzy search with custom match highlighting color",
594 example: r#"$env.config.color_config.search_result = "red"; ls | input list --fuzzy"#,
595 result: None,
596 },
597 Example {
598 description: "Display a table with column rendering",
599 example: r#"[[name size]; [file1.txt "1.2 KB"] [file2.txt "3.4 KB"]] | input list"#,
600 result: None,
601 },
602 Example {
603 description: "Display a table as single lines (no table rendering)",
604 example: r#"ls | input list --no-table"#,
605 result: None,
606 },
607 Example {
608 description: "Fuzzy search with multiple selection (use Tab to toggle)",
609 example: r#"ls | input list --fuzzy --multi"#,
610 result: None,
611 },
612 ]
613 }
614}
615
616impl InputList {
617 fn calculate_table_layout(columns: &[String], options: &[SelectItem]) -> TableLayout {
619 let mut col_widths: Vec<usize> = columns.iter().map(|c| c.width()).collect();
620
621 for item in options {
623 if let Some(cells) = &item.cells {
624 for (i, (cell_text, _)) in cells.iter().enumerate() {
625 if i < col_widths.len() {
626 col_widths[i] = col_widths[i].max(cell_text.width());
627 }
628 }
629 }
630 }
631
632 TableLayout {
633 columns: columns.to_vec(),
634 col_widths,
635 truncated_cols: 0, }
637 }
638}
639
640#[derive(Clone, Copy, PartialEq, Eq)]
641enum SelectMode {
642 Single,
643 Multi,
644 Fuzzy,
645 FuzzyMulti,
646}
647
648struct SelectWidget<'a> {
649 mode: SelectMode,
650 prompt: Option<&'a str>,
651 items: &'a [SelectItem],
652 cursor: usize,
653 selected: HashSet<usize>,
654 filter_text: String,
655 filtered_indices: Vec<usize>,
656 scroll_offset: usize,
657 visible_height: u16,
658 matcher: SkimMatcherV2,
659 rendered_lines: usize,
660 prev_cursor: usize,
662 prev_scroll_offset: usize,
664 first_render: bool,
666 fuzzy_cursor_offset: usize,
668 results_changed: bool,
670 filter_text_changed: bool,
672 toggled_item: Option<usize>,
674 toggled_all: bool,
676 filter_cursor: usize,
678 config: InputListConfig,
680 term_width: u16,
682 separator_line: String,
684 table_layout: Option<TableLayout>,
686 horizontal_offset: usize,
688 horizontal_scroll_changed: bool,
690 width_changed: bool,
692 refined: bool,
694 refined_base_indices: Vec<usize>,
696 per_column: bool,
698 settings_changed: bool,
700 selected_marker_cached: String,
702 visible_columns_cache: Option<(usize, bool)>,
705}
706
707impl<'a> SelectWidget<'a> {
708 fn new(
709 mode: SelectMode,
710 prompt: Option<&'a str>,
711 items: &'a [SelectItem],
712 config: InputListConfig,
713 table_layout: Option<TableLayout>,
714 per_column: bool,
715 ) -> Self {
716 let filtered_indices: Vec<usize> = (0..items.len()).collect();
717 let matcher = match config.case_sensitivity {
718 CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
719 CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
720 CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
721 };
722 let selected_marker_cached = format!(
724 "{} ",
725 config
726 .selected_marker
727 .paint(config.selected_marker_char.to_string())
728 );
729 Self {
730 mode,
731 prompt,
732 items,
733 cursor: 0,
734 selected: HashSet::new(),
735 filter_text: String::new(),
736 filtered_indices,
737 scroll_offset: 0,
738 visible_height: 10,
739 matcher,
740 rendered_lines: 0,
741 prev_cursor: 0,
742 prev_scroll_offset: 0,
743 first_render: true,
744 fuzzy_cursor_offset: 0,
745 results_changed: true,
746 filter_text_changed: false,
747 toggled_item: None,
748 toggled_all: false,
749 filter_cursor: 0,
750 config,
751 term_width: 0,
752 separator_line: String::new(),
753 table_layout,
754 horizontal_offset: 0,
755 horizontal_scroll_changed: false,
756 width_changed: false,
757 refined: false,
758 refined_base_indices: Vec::new(),
759 per_column,
760 settings_changed: false,
761 selected_marker_cached,
762 visible_columns_cache: None,
763 }
764 }
765
766 fn generate_separator_line(&mut self) {
768 let sep_width = self.config.separator_char.width();
769 let repeat_count = if sep_width > 0 {
770 self.term_width as usize / sep_width
771 } else {
772 self.term_width as usize
773 };
774 self.separator_line = self.config.separator_char.repeat(repeat_count);
775 }
776
777 fn prompt_marker(&self) -> String {
779 self.config
780 .prompt_marker
781 .paint(&self.config.prompt_marker_text)
782 .to_string()
783 }
784
785 fn prompt_marker_width(&self) -> usize {
787 self.config.prompt_marker_text.width()
788 }
789
790 fn position_fuzzy_cursor(&self, stderr: &mut Stderr) -> io::Result<()> {
792 let text_before_cursor = &self.filter_text[..self.filter_cursor];
793 let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
794 execute!(stderr, MoveToColumn(cursor_col as u16))
795 }
796
797 fn selected_marker(&self) -> &str {
799 &self.selected_marker_cached
800 }
801
802 fn is_table_mode(&self) -> bool {
804 self.table_layout.is_some()
805 }
806
807 fn is_multi_mode(&self) -> bool {
809 self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
810 }
811
812 fn is_fuzzy_mode(&self) -> bool {
814 self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
815 }
816
817 fn toggle_case_sensitivity(&mut self) {
819 self.config.case_sensitivity = match self.config.case_sensitivity {
820 CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
821 CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
822 CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
823 };
824 self.rebuild_matcher();
825 if !self.filter_text.is_empty() {
827 self.update_filter();
828 }
829 self.settings_changed = true;
830 }
831
832 fn toggle_per_column(&mut self) {
834 if self.is_table_mode() {
835 self.per_column = !self.per_column;
836 if !self.filter_text.is_empty() {
838 self.update_filter();
839 }
840 self.settings_changed = true;
841 }
842 }
843
844 fn rebuild_matcher(&mut self) {
846 self.matcher = match self.config.case_sensitivity {
847 CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
848 CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
849 CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
850 };
851 }
852
853 fn settings_indicator(&self) -> String {
856 if !self.is_fuzzy_mode() {
857 return String::new();
858 }
859
860 let case_str = match self.config.case_sensitivity {
861 CaseSensitivity::Smart => "smart",
862 CaseSensitivity::CaseSensitive => "CASE",
863 CaseSensitivity::CaseInsensitive => "nocase",
864 };
865
866 if self.is_table_mode() && self.per_column {
867 format!(" [{} col]", case_str)
868 } else {
869 format!(" [{}]", case_str)
870 }
871 }
872
873 fn generate_footer(&self) -> String {
875 let total_count = self.current_list_len();
876 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
877 let settings = self.settings_indicator();
878
879 let position_part = if self.is_multi_mode() {
880 format!(
881 "[{}-{} of {}, {} selected]",
882 self.scroll_offset + 1,
883 end.min(total_count),
884 total_count,
885 self.selected.len()
886 )
887 } else {
888 format!(
889 "[{}-{} of {}]",
890 self.scroll_offset + 1,
891 end.min(total_count),
892 total_count
893 )
894 };
895
896 let full_footer = format!("{}{}", position_part, settings);
897
898 let max_width = self.term_width as usize;
900 if full_footer.width() <= max_width {
901 full_footer
902 } else if max_width <= 3 {
903 "…".to_string()
905 } else {
906 if position_part.width() <= max_width {
908 let remaining = max_width - position_part.width();
910 if remaining <= 4 {
911 position_part
913 } else {
914 let target_width = remaining - 2; let mut current_width = 0;
917 let mut end_pos = 0;
918
919 for (byte_pos, c) in settings.char_indices().skip(2) {
921 if c == ']' {
922 break;
923 }
924 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
925 if current_width + char_width > target_width {
926 break;
927 }
928 end_pos = byte_pos + c.len_utf8();
929 current_width += char_width;
930 }
931 if end_pos > 2 {
932 format!("{} [{}…]", position_part, &settings[2..end_pos])
933 } else {
934 position_part
935 }
936 }
937 } else {
938 let target_width = max_width - 2; let mut current_width = 0;
941 let mut end_pos = 0;
942
943 for (byte_pos, c) in position_part.char_indices() {
944 if c == ']' {
945 break;
946 }
947 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
948 if current_width + char_width > target_width {
949 break;
950 }
951 end_pos = byte_pos + c.len_utf8();
952 current_width += char_width;
953 }
954 format!("{}…]", &position_part[..end_pos])
955 }
956 }
957 }
958
959 fn has_footer(&self) -> bool {
963 self.config.show_footer
964 && (self.is_fuzzy_mode()
965 || self.is_multi_mode()
966 || self.current_list_len() > self.visible_height as usize)
967 }
968
969 fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
971 let indicator = self.generate_footer();
972 execute!(
973 stderr,
974 MoveToColumn(0),
975 Print(self.config.footer.paint(&indicator)),
976 Clear(ClearType::UntilNewLine),
977 )
978 }
979
980 fn row_prefix_width(&self) -> usize {
982 match self.mode {
983 SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
986 }
987
988 fn table_column_separator(&self) -> String {
990 format!(" {} ", self.config.table_column_separator)
991 }
992
993 fn table_column_separator_width(&self) -> usize {
995 UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
996 }
997
998 fn calculate_visible_columns(&self) -> (usize, bool) {
1002 if let Some(cached) = self.visible_columns_cache {
1004 return cached;
1005 }
1006
1007 let Some(layout) = &self.table_layout else {
1009 return (0, false);
1010 };
1011
1012 Self::calculate_visible_columns_for_layout(
1013 layout,
1014 self.horizontal_offset,
1015 self.term_width as usize,
1016 self.row_prefix_width(),
1017 self.table_column_separator_width(),
1018 )
1019 }
1020
1021 fn calculate_visible_columns_for_layout(
1023 layout: &TableLayout,
1024 horizontal_offset: usize,
1025 term_width: usize,
1026 prefix_width: usize,
1027 separator_width: usize,
1028 ) -> (usize, bool) {
1029 let scroll_indicator_width = if horizontal_offset > 0 {
1031 1 + separator_width
1032 } else {
1033 0
1034 };
1035 let available = term_width
1036 .saturating_sub(prefix_width)
1037 .saturating_sub(scroll_indicator_width);
1038
1039 let mut used_width = 0;
1040 let mut cols_fit = 0;
1041
1042 for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
1043 let sep_width = if i > horizontal_offset {
1045 separator_width
1046 } else {
1047 0
1048 };
1049 let needed = col_width + sep_width;
1050
1051 let reserve_right = if i + 1 < layout.col_widths.len() {
1053 separator_width + 1
1054 } else {
1055 0
1056 };
1057
1058 if used_width + needed + reserve_right <= available {
1059 used_width += needed;
1060 cols_fit += 1;
1061 } else {
1062 break;
1063 }
1064 }
1065
1066 let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
1067 (cols_fit.max(1), has_more_right) }
1069
1070 fn update_table_layout(&mut self) {
1073 let prefix_width = self.row_prefix_width();
1074 let term_width = self.term_width as usize;
1075 let horizontal_offset = self.horizontal_offset;
1076 let separator_width = self.table_column_separator_width();
1077
1078 if let Some(layout) = &mut self.table_layout {
1079 let result = Self::calculate_visible_columns_for_layout(
1080 layout,
1081 horizontal_offset,
1082 term_width,
1083 prefix_width,
1084 separator_width,
1085 );
1086 layout.truncated_cols = result.0;
1087 self.visible_columns_cache = Some(result);
1088 } else {
1089 self.visible_columns_cache = Some((0, false));
1090 }
1091 }
1092
1093 fn fuzzy_header_lines(&self) -> u16 {
1095 let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
1096 if self.config.show_separator {
1097 header_lines += 1;
1098 }
1099 if self.is_table_mode() {
1100 header_lines += 2;
1101 }
1102 header_lines
1103 }
1104
1105 fn fuzzy_filter_row(&self) -> u16 {
1107 if self.prompt.is_some() { 1 } else { 0 }
1108 }
1109
1110 fn update_term_size(&mut self, width: u16, height: u16) {
1112 let new_width = width.saturating_sub(1);
1114 let width_changed = self.term_width != new_width;
1115 self.term_width = new_width;
1116
1117 if width_changed {
1119 self.width_changed = true;
1120 }
1121
1122 if width_changed && self.config.show_separator {
1124 self.generate_separator_line();
1125 }
1126
1127 if width_changed {
1129 self.update_table_layout();
1130 }
1131
1132 let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
1134 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
1135 reserved += 1; if self.config.show_separator {
1137 reserved += 1; }
1139 }
1140 if self.is_table_mode() {
1141 reserved += 2; }
1143 if self.config.show_footer {
1144 reserved += 1; }
1146 self.visible_height = height.saturating_sub(reserved).max(1);
1147 }
1148
1149 fn run(&mut self) -> io::Result<InteractMode> {
1150 let mut stderr = io::stderr();
1151
1152 enable_raw_mode()?;
1153 scopeguard::defer! {
1154 let _ = disable_raw_mode();
1155 }
1156
1157 if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
1159 execute!(stderr, Hide)?;
1160 }
1161 scopeguard::defer! {
1162 let _ = execute!(io::stderr(), Show);
1163 }
1164
1165 let (term_width, term_height) = terminal::size()?;
1167 self.update_term_size(term_width, term_height);
1168
1169 self.render(&mut stderr)?;
1170
1171 loop {
1172 if event::poll(std::time::Duration::from_millis(100))? {
1173 match event::read()? {
1174 Event::Key(key_event) => {
1175 match self.handle_key(key_event) {
1176 KeyAction::Continue => {}
1177 KeyAction::Cancel => {
1178 self.clear_display(&mut stderr)?;
1179 return Ok(match self.mode {
1180 SelectMode::Multi => InteractMode::Multi(None),
1181 _ => InteractMode::Single(None),
1182 });
1183 }
1184 KeyAction::Confirm => {
1185 self.clear_display(&mut stderr)?;
1186 return Ok(self.get_result());
1187 }
1188 }
1189 self.render(&mut stderr)?;
1190 }
1191 Event::Resize(width, height) => {
1192 self.clear_display(&mut stderr)?;
1194 self.update_term_size(width, height);
1195 self.first_render = true;
1197 self.render(&mut stderr)?;
1198 }
1199 _ => {}
1200 }
1201 }
1202 }
1203 }
1204
1205 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
1206 if key.kind == KeyEventKind::Release {
1210 return KeyAction::Continue;
1211 }
1212
1213 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1215 return KeyAction::Cancel;
1216 }
1217
1218 match self.mode {
1219 SelectMode::Single => self.handle_single_key(key),
1220 SelectMode::Multi => self.handle_multi_key(key),
1221 SelectMode::Fuzzy => self.handle_fuzzy_key(key),
1222 SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
1223 }
1224 }
1225
1226 fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
1227 match key.code {
1228 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1229 KeyCode::Enter => KeyAction::Confirm,
1230 KeyCode::Up | KeyCode::Char('k') => {
1231 self.navigate_up();
1232 KeyAction::Continue
1233 }
1234 KeyCode::Down | KeyCode::Char('j') => {
1235 self.navigate_down();
1236 KeyAction::Continue
1237 }
1238 KeyCode::Left | KeyCode::Char('h') => {
1239 self.scroll_columns_left();
1240 KeyAction::Continue
1241 }
1242 KeyCode::Right | KeyCode::Char('l') => {
1243 self.scroll_columns_right();
1244 KeyAction::Continue
1245 }
1246 KeyCode::Home => {
1247 self.navigate_home();
1248 KeyAction::Continue
1249 }
1250 KeyCode::End => {
1251 self.navigate_end();
1252 KeyAction::Continue
1253 }
1254 KeyCode::PageUp => {
1255 self.navigate_page_up();
1256 KeyAction::Continue
1257 }
1258 KeyCode::PageDown => {
1259 self.navigate_page_down();
1260 KeyAction::Continue
1261 }
1262 _ => KeyAction::Continue,
1263 }
1264 }
1265
1266 fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1267 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1268
1269 match key.code {
1270 KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
1271 KeyCode::Enter => KeyAction::Confirm,
1272 KeyCode::Char('r' | 'R') if ctrl => {
1274 self.refine_list();
1275 KeyAction::Continue
1276 }
1277 KeyCode::Up | KeyCode::Char('k') => {
1278 self.navigate_up();
1279 KeyAction::Continue
1280 }
1281 KeyCode::Down | KeyCode::Char('j') => {
1282 self.navigate_down();
1283 KeyAction::Continue
1284 }
1285 KeyCode::Left | KeyCode::Char('h') => {
1286 self.scroll_columns_left();
1287 KeyAction::Continue
1288 }
1289 KeyCode::Right | KeyCode::Char('l') => {
1290 self.scroll_columns_right();
1291 KeyAction::Continue
1292 }
1293 KeyCode::Char(' ') => {
1294 self.toggle_current();
1295 KeyAction::Continue
1296 }
1297 KeyCode::Char('a') => {
1298 self.toggle_all();
1299 KeyAction::Continue
1300 }
1301 KeyCode::Home => {
1302 self.navigate_home();
1303 KeyAction::Continue
1304 }
1305 KeyCode::End => {
1306 self.navigate_end();
1307 KeyAction::Continue
1308 }
1309 KeyCode::PageUp => {
1310 self.navigate_page_up();
1311 KeyAction::Continue
1312 }
1313 KeyCode::PageDown => {
1314 self.navigate_page_down();
1315 KeyAction::Continue
1316 }
1317 _ => KeyAction::Continue,
1318 }
1319 }
1320
1321 fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
1322 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1323 let alt = key.modifiers.contains(KeyModifiers::ALT);
1324 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1325
1326 match key.code {
1327 KeyCode::Esc => KeyAction::Cancel,
1328 KeyCode::Enter => KeyAction::Confirm,
1329
1330 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1332 self.navigate_up();
1333 KeyAction::Continue
1334 }
1335 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1336 self.navigate_down();
1337 KeyAction::Continue
1338 }
1339 KeyCode::Up => {
1340 self.navigate_up();
1341 KeyAction::Continue
1342 }
1343 KeyCode::Down => {
1344 self.navigate_down();
1345 KeyAction::Continue
1346 }
1347
1348 KeyCode::Left if shift => {
1350 self.scroll_columns_left();
1351 KeyAction::Continue
1352 }
1353 KeyCode::Right if shift => {
1354 self.scroll_columns_right();
1355 KeyAction::Continue
1356 }
1357
1358 KeyCode::Char('a' | 'A') if ctrl => {
1360 self.filter_cursor = 0;
1362 KeyAction::Continue
1363 }
1364 KeyCode::Char('e' | 'E') if ctrl => {
1365 self.filter_cursor = self.filter_text.len();
1367 KeyAction::Continue
1368 }
1369 KeyCode::Char('b' | 'B') if ctrl => {
1370 self.move_filter_cursor_left();
1372 KeyAction::Continue
1373 }
1374 KeyCode::Char('f' | 'F') if ctrl => {
1375 self.move_filter_cursor_right();
1377 KeyAction::Continue
1378 }
1379 KeyCode::Char('b' | 'B') if alt => {
1380 self.move_filter_cursor_word_left();
1382 KeyAction::Continue
1383 }
1384 KeyCode::Char('f' | 'F') if alt => {
1385 self.move_filter_cursor_word_right();
1387 KeyAction::Continue
1388 }
1389 KeyCode::Char('c' | 'C') if alt => {
1391 self.toggle_case_sensitivity();
1393 KeyAction::Continue
1394 }
1395 KeyCode::Char('p' | 'P') if alt => {
1396 self.toggle_per_column();
1398 KeyAction::Continue
1399 }
1400 KeyCode::Left if ctrl || alt => {
1401 self.move_filter_cursor_word_left();
1403 KeyAction::Continue
1404 }
1405 KeyCode::Right if ctrl || alt => {
1406 self.move_filter_cursor_word_right();
1408 KeyAction::Continue
1409 }
1410 KeyCode::Left => {
1411 self.move_filter_cursor_left();
1412 KeyAction::Continue
1413 }
1414 KeyCode::Right => {
1415 self.move_filter_cursor_right();
1416 KeyAction::Continue
1417 }
1418
1419 KeyCode::Char('u' | 'U') if ctrl => {
1421 self.filter_text.drain(..self.filter_cursor);
1423 self.filter_cursor = 0;
1424 self.update_filter();
1425 KeyAction::Continue
1426 }
1427 KeyCode::Char('k' | 'K') if ctrl => {
1428 self.filter_text.truncate(self.filter_cursor);
1430 self.update_filter();
1431 KeyAction::Continue
1432 }
1433 KeyCode::Char('d' | 'D') if ctrl => {
1434 if self.filter_cursor < self.filter_text.len() {
1436 self.filter_text.remove(self.filter_cursor);
1437 self.update_filter();
1438 }
1439 KeyAction::Continue
1440 }
1441 KeyCode::Delete => {
1442 if self.filter_cursor < self.filter_text.len() {
1444 self.filter_text.remove(self.filter_cursor);
1445 self.update_filter();
1446 }
1447 KeyAction::Continue
1448 }
1449 KeyCode::Char('d' | 'D') if alt => {
1450 self.delete_word_forwards();
1452 self.update_filter();
1453 KeyAction::Continue
1454 }
1455 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1457 self.delete_word_backwards();
1458 self.update_filter();
1459 KeyAction::Continue
1460 }
1461 KeyCode::Backspace if alt => {
1463 self.delete_word_backwards();
1464 self.update_filter();
1465 KeyAction::Continue
1466 }
1467 KeyCode::Backspace => {
1468 if self.filter_cursor > 0 {
1470 let mut new_pos = self.filter_cursor - 1;
1472 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1473 new_pos -= 1;
1474 }
1475 self.filter_cursor = new_pos;
1476 self.filter_text.remove(self.filter_cursor);
1477 self.update_filter();
1478 }
1479 KeyAction::Continue
1480 }
1481 KeyCode::Char('t' | 'T') if ctrl => {
1483 let old_text = self.filter_text.clone();
1484 self.transpose_chars();
1485 if self.filter_text != old_text {
1486 self.update_filter();
1487 }
1488 KeyAction::Continue
1489 }
1490
1491 KeyCode::Char(c) => {
1493 self.filter_text.insert(self.filter_cursor, c);
1494 self.filter_cursor += c.len_utf8();
1495 self.update_filter();
1496 KeyAction::Continue
1497 }
1498
1499 KeyCode::Home => {
1501 self.navigate_home();
1502 KeyAction::Continue
1503 }
1504 KeyCode::End => {
1505 self.navigate_end();
1506 KeyAction::Continue
1507 }
1508 KeyCode::PageUp => {
1509 self.navigate_page_up();
1510 KeyAction::Continue
1511 }
1512 KeyCode::PageDown => {
1513 self.navigate_page_down();
1514 KeyAction::Continue
1515 }
1516 _ => KeyAction::Continue,
1517 }
1518 }
1519
1520 fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
1521 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1522 let alt = key.modifiers.contains(KeyModifiers::ALT);
1523 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1524
1525 match key.code {
1526 KeyCode::Esc => KeyAction::Cancel,
1527 KeyCode::Enter => KeyAction::Confirm,
1528
1529 KeyCode::Char('r' | 'R') if ctrl => {
1531 self.refine_list();
1532 KeyAction::Continue
1533 }
1534
1535 KeyCode::Tab | KeyCode::Char('\t') => {
1538 self.toggle_current_fuzzy();
1539 self.navigate_down();
1540 KeyAction::Continue
1541 }
1542
1543 KeyCode::BackTab => {
1545 self.toggle_current_fuzzy();
1546 self.navigate_up();
1547 KeyAction::Continue
1548 }
1549
1550 KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
1552 self.navigate_up();
1553 KeyAction::Continue
1554 }
1555 KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
1556 self.navigate_down();
1557 KeyAction::Continue
1558 }
1559 KeyCode::Up => {
1560 self.navigate_up();
1561 KeyAction::Continue
1562 }
1563 KeyCode::Down => {
1564 self.navigate_down();
1565 KeyAction::Continue
1566 }
1567
1568 KeyCode::Left if shift => {
1570 self.scroll_columns_left();
1571 KeyAction::Continue
1572 }
1573 KeyCode::Right if shift => {
1574 self.scroll_columns_right();
1575 KeyAction::Continue
1576 }
1577
1578 KeyCode::Char('a' | 'A') if ctrl => {
1580 self.filter_cursor = 0;
1581 KeyAction::Continue
1582 }
1583 KeyCode::Char('e' | 'E') if ctrl => {
1584 self.filter_cursor = self.filter_text.len();
1585 KeyAction::Continue
1586 }
1587 KeyCode::Char('b' | 'B') if ctrl => {
1588 self.move_filter_cursor_left();
1589 KeyAction::Continue
1590 }
1591 KeyCode::Char('f' | 'F') if ctrl => {
1592 self.move_filter_cursor_right();
1593 KeyAction::Continue
1594 }
1595 KeyCode::Char('b' | 'B') if alt => {
1596 self.move_filter_cursor_word_left();
1597 KeyAction::Continue
1598 }
1599 KeyCode::Char('f' | 'F') if alt => {
1600 self.move_filter_cursor_word_right();
1601 KeyAction::Continue
1602 }
1603 KeyCode::Char('c' | 'C') if alt => {
1605 self.toggle_case_sensitivity();
1607 KeyAction::Continue
1608 }
1609 KeyCode::Char('p' | 'P') if alt => {
1610 self.toggle_per_column();
1612 KeyAction::Continue
1613 }
1614 KeyCode::Left if ctrl || alt => {
1615 self.move_filter_cursor_word_left();
1616 KeyAction::Continue
1617 }
1618 KeyCode::Right if ctrl || alt => {
1619 self.move_filter_cursor_word_right();
1620 KeyAction::Continue
1621 }
1622 KeyCode::Left => {
1623 self.move_filter_cursor_left();
1624 KeyAction::Continue
1625 }
1626 KeyCode::Right => {
1627 self.move_filter_cursor_right();
1628 KeyAction::Continue
1629 }
1630
1631 KeyCode::Char('u' | 'U') if ctrl => {
1633 self.filter_text.drain(..self.filter_cursor);
1634 self.filter_cursor = 0;
1635 self.update_filter();
1636 KeyAction::Continue
1637 }
1638 KeyCode::Char('k' | 'K') if ctrl => {
1639 self.filter_text.truncate(self.filter_cursor);
1640 self.update_filter();
1641 KeyAction::Continue
1642 }
1643 KeyCode::Char('d' | 'D') if ctrl => {
1644 if self.filter_cursor < self.filter_text.len() {
1645 self.filter_text.remove(self.filter_cursor);
1646 self.update_filter();
1647 }
1648 KeyAction::Continue
1649 }
1650 KeyCode::Delete => {
1651 if self.filter_cursor < self.filter_text.len() {
1652 self.filter_text.remove(self.filter_cursor);
1653 self.update_filter();
1654 }
1655 KeyAction::Continue
1656 }
1657 KeyCode::Char('d' | 'D') if alt => {
1658 self.delete_word_forwards();
1659 self.update_filter();
1660 KeyAction::Continue
1661 }
1662 KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
1663 self.delete_word_backwards();
1664 self.update_filter();
1665 KeyAction::Continue
1666 }
1667 KeyCode::Backspace if alt => {
1668 self.delete_word_backwards();
1669 self.update_filter();
1670 KeyAction::Continue
1671 }
1672 KeyCode::Backspace => {
1673 if self.filter_cursor > 0 {
1674 let mut new_pos = self.filter_cursor - 1;
1675 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1676 new_pos -= 1;
1677 }
1678 self.filter_cursor = new_pos;
1679 self.filter_text.remove(self.filter_cursor);
1680 self.update_filter();
1681 }
1682 KeyAction::Continue
1683 }
1684 KeyCode::Char('t' | 'T') if ctrl => {
1685 let old_text = self.filter_text.clone();
1686 self.transpose_chars();
1687 if self.filter_text != old_text {
1688 self.update_filter();
1689 }
1690 KeyAction::Continue
1691 }
1692
1693 KeyCode::Char('a' | 'A') if alt => {
1695 self.toggle_all_fuzzy();
1696 KeyAction::Continue
1697 }
1698
1699 KeyCode::Char(c) => {
1701 self.filter_text.insert(self.filter_cursor, c);
1702 self.filter_cursor += c.len_utf8();
1703 self.update_filter();
1704 KeyAction::Continue
1705 }
1706
1707 KeyCode::Home => {
1709 self.navigate_home();
1710 KeyAction::Continue
1711 }
1712 KeyCode::End => {
1713 self.navigate_end();
1714 KeyAction::Continue
1715 }
1716 KeyCode::PageUp => {
1717 self.navigate_page_up();
1718 KeyAction::Continue
1719 }
1720 KeyCode::PageDown => {
1721 self.navigate_page_down();
1722 KeyAction::Continue
1723 }
1724 _ => KeyAction::Continue,
1725 }
1726 }
1727
1728 fn navigate_up(&mut self) {
1730 let list_len = self.current_list_len();
1731 if self.cursor > 0 {
1732 self.cursor -= 1;
1733 self.adjust_scroll_up();
1734 } else if list_len > 0 {
1735 self.cursor = list_len - 1;
1737 self.adjust_scroll_down();
1738 }
1739 }
1740
1741 fn navigate_down(&mut self) {
1743 let list_len = self.current_list_len();
1744 if self.cursor + 1 < list_len {
1745 self.cursor += 1;
1746 self.adjust_scroll_down();
1747 } else {
1748 self.cursor = 0;
1750 self.scroll_offset = 0;
1751 }
1752 }
1753
1754 fn adjust_scroll_down(&mut self) {
1755 let max_visible = self.scroll_offset + self.visible_height as usize;
1756 if self.cursor >= max_visible {
1757 self.scroll_offset = self.cursor - self.visible_height as usize + 1;
1758 }
1759 }
1760
1761 fn adjust_scroll_up(&mut self) {
1762 if self.cursor < self.scroll_offset {
1763 self.scroll_offset = self.cursor;
1764 }
1765 }
1766
1767 fn current_list_len(&self) -> usize {
1769 match self.mode {
1770 SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
1771 SelectMode::Multi if self.refined => self.filtered_indices.len(),
1772 _ => self.items.len(),
1773 }
1774 }
1775
1776 fn navigate_home(&mut self) {
1778 self.cursor = 0;
1779 self.scroll_offset = 0;
1780 }
1781
1782 fn navigate_end(&mut self) {
1784 self.cursor = self.current_list_len().saturating_sub(1);
1785 self.adjust_scroll_down();
1786 }
1787
1788 fn navigate_page_up(&mut self) {
1790 let page_top = self.scroll_offset;
1791 if self.cursor == page_top {
1792 self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
1794 self.adjust_scroll_up();
1795 } else {
1796 self.cursor = page_top;
1798 }
1799 }
1800
1801 fn navigate_page_down(&mut self) {
1803 let list_len = self.current_list_len();
1804 let page_bottom =
1805 (self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
1806 if self.cursor == page_bottom {
1807 self.cursor =
1809 (self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
1810 self.adjust_scroll_down();
1811 } else {
1812 self.cursor = page_bottom;
1814 }
1815 }
1816
1817 fn scroll_columns_left(&mut self) -> bool {
1819 if !self.is_table_mode() || self.horizontal_offset == 0 {
1820 return false;
1821 }
1822 self.horizontal_offset -= 1;
1823 self.horizontal_scroll_changed = true;
1824 self.update_table_layout();
1825 true
1826 }
1827
1828 fn scroll_columns_right(&mut self) -> bool {
1830 let Some(layout) = &self.table_layout else {
1831 return false;
1832 };
1833 let (cols_visible, has_more_right) = self.calculate_visible_columns();
1834 if !has_more_right {
1835 return false;
1836 }
1837 if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
1839 return false;
1840 }
1841 self.horizontal_offset += 1;
1842 self.horizontal_scroll_changed = true;
1843 self.update_table_layout();
1844 true
1845 }
1846
1847 fn toggle_current(&mut self) {
1848 if self.refined && self.filtered_indices.is_empty() {
1850 return;
1851 }
1852 let real_idx = if self.refined {
1854 self.filtered_indices[self.cursor]
1855 } else {
1856 self.cursor
1857 };
1858 self.toggle_index(real_idx);
1859 }
1860
1861 fn toggle_index(&mut self, real_idx: usize) {
1863 if self.selected.contains(&real_idx) {
1864 self.selected.remove(&real_idx);
1865 } else {
1866 self.selected.insert(real_idx);
1867 }
1868 self.toggled_item = Some(self.cursor);
1869 }
1870
1871 fn toggle_current_fuzzy(&mut self) -> bool {
1874 if self.filtered_indices.is_empty() {
1875 return false;
1876 }
1877 let real_idx = self.filtered_indices[self.cursor];
1878 self.toggle_index(real_idx);
1879 true
1880 }
1881
1882 fn toggle_all(&mut self) {
1883 let all_selected = if self.refined {
1885 self.filtered_indices
1886 .iter()
1887 .all(|i| self.selected.contains(i))
1888 } else {
1889 (0..self.items.len()).all(|i| self.selected.contains(&i))
1890 };
1891
1892 if all_selected {
1893 if self.refined {
1895 for i in &self.filtered_indices {
1896 self.selected.remove(i);
1897 }
1898 } else {
1899 self.selected.clear();
1900 }
1901 } else {
1902 if self.refined {
1904 self.selected.extend(self.filtered_indices.iter().copied());
1905 } else {
1906 self.selected.extend(0..self.items.len());
1907 }
1908 }
1909 self.toggled_all = true;
1910 }
1911
1912 fn toggle_all_fuzzy(&mut self) {
1914 if self.filtered_indices.is_empty() {
1915 return;
1916 }
1917
1918 let all_selected = self
1920 .filtered_indices
1921 .iter()
1922 .all(|i| self.selected.contains(i));
1923
1924 if all_selected {
1925 for i in &self.filtered_indices {
1927 self.selected.remove(i);
1928 }
1929 } else {
1930 self.selected.extend(self.filtered_indices.iter().copied());
1932 }
1933 self.toggled_all = true;
1934 }
1935
1936 fn refine_list(&mut self) {
1939 if self.selected.is_empty() {
1940 return;
1941 }
1942
1943 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1945 indices.sort();
1946
1947 self.filtered_indices = indices.clone();
1950 self.refined_base_indices = indices;
1951
1952 self.cursor = 0;
1954 self.scroll_offset = 0;
1955
1956 if self.mode == SelectMode::FuzzyMulti {
1961 self.filter_text.clear();
1962 self.filter_cursor = 0;
1963 self.filter_text_changed = true;
1964 }
1965
1966 self.refined = true;
1968
1969 self.first_render = true;
1971 }
1972
1973 fn move_filter_cursor_left(&mut self) {
1975 if self.filter_cursor > 0 {
1976 let mut new_pos = self.filter_cursor - 1;
1978 while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
1979 new_pos -= 1;
1980 }
1981 self.filter_cursor = new_pos;
1982 }
1983 }
1984
1985 fn move_filter_cursor_right(&mut self) {
1986 if self.filter_cursor < self.filter_text.len() {
1987 let mut new_pos = self.filter_cursor + 1;
1989 while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
1990 new_pos += 1;
1991 }
1992 self.filter_cursor = new_pos;
1993 }
1994 }
1995
1996 fn move_filter_cursor_word_left(&mut self) {
1997 if self.filter_cursor == 0 {
1998 return;
1999 }
2000 let bytes = self.filter_text.as_bytes();
2001 let mut pos = self.filter_cursor;
2002 while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
2004 pos -= 1;
2005 }
2006 while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
2008 pos -= 1;
2009 }
2010 self.filter_cursor = pos;
2011 }
2012
2013 fn move_filter_cursor_word_right(&mut self) {
2014 let len = self.filter_text.len();
2015 if self.filter_cursor >= len {
2016 return;
2017 }
2018 let bytes = self.filter_text.as_bytes();
2019 let mut pos = self.filter_cursor;
2020 while pos < len && !bytes[pos].is_ascii_whitespace() {
2022 pos += 1;
2023 }
2024 while pos < len && bytes[pos].is_ascii_whitespace() {
2026 pos += 1;
2027 }
2028 self.filter_cursor = pos;
2029 }
2030
2031 fn delete_word_backwards(&mut self) {
2032 if self.filter_cursor == 0 {
2033 return;
2034 }
2035 let start = self.filter_cursor;
2036 while self.filter_cursor > 0
2038 && self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2039 {
2040 self.filter_cursor -= 1;
2041 }
2042 while self.filter_cursor > 0
2044 && !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
2045 {
2046 self.filter_cursor -= 1;
2047 }
2048 self.filter_text.drain(self.filter_cursor..start);
2049 }
2050
2051 fn delete_word_forwards(&mut self) {
2052 let len = self.filter_text.len();
2053 if self.filter_cursor >= len {
2054 return;
2055 }
2056 let start = self.filter_cursor;
2057 let bytes = self.filter_text.as_bytes();
2058 let mut end = start;
2059 while end < len && !bytes[end].is_ascii_whitespace() {
2061 end += 1;
2062 }
2063 while end < len && bytes[end].is_ascii_whitespace() {
2065 end += 1;
2066 }
2067 self.filter_text.drain(start..end);
2068 }
2069
2070 fn transpose_chars(&mut self) {
2071 let len = self.filter_text.len();
2075 if len < 2 {
2076 return;
2077 }
2078
2079 if self.filter_cursor == 0 {
2081 return;
2082 }
2083
2084 let pos = if self.filter_cursor >= len {
2087 len - 1
2088 } else {
2089 self.filter_cursor
2090 };
2091
2092 if pos == 0 {
2093 return;
2094 }
2095
2096 if self.filter_text.is_char_boundary(pos - 1)
2099 && self.filter_text.is_char_boundary(pos)
2100 && pos < len
2101 && self.filter_text.is_char_boundary(pos + 1)
2102 {
2103 let bytes = self.filter_text.as_bytes();
2105 if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
2106 let bytes = unsafe { self.filter_text.as_bytes_mut() };
2108 bytes.swap(pos - 1, pos);
2109
2110 if self.filter_cursor < len {
2112 self.filter_cursor += 1;
2113 }
2114 }
2115 }
2116 }
2117
2118 fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
2120 item.cells.as_ref().and_then(|cells| {
2121 cells
2122 .iter()
2123 .filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
2124 .max()
2125 })
2126 }
2127
2128 fn score_item(&self, item: &SelectItem) -> Option<i64> {
2130 if self.per_column && item.cells.is_some() {
2131 self.score_per_column(item)
2132 } else {
2133 self.matcher.fuzzy_match(&item.name, &self.filter_text)
2134 }
2135 }
2136
2137 fn update_filter(&mut self) {
2138 let old_indices = std::mem::take(&mut self.filtered_indices);
2139
2140 let use_refined = self.refined && !self.refined_base_indices.is_empty();
2142
2143 if self.filter_text.is_empty() {
2144 self.filtered_indices = if use_refined {
2146 self.refined_base_indices.clone()
2147 } else {
2148 (0..self.items.len()).collect()
2149 };
2150 } else {
2151 let mut scored: Vec<(usize, i64)> = if use_refined {
2153 self.refined_base_indices
2154 .iter()
2155 .filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
2156 .collect()
2157 } else {
2158 (0..self.items.len())
2159 .filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
2160 .collect()
2161 };
2162 scored.sort_by(|a, b| b.1.cmp(&a.1));
2164 self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
2165 }
2166
2167 self.results_changed = old_indices != self.filtered_indices;
2169 self.filter_text_changed = true;
2170
2171 if self.results_changed {
2173 self.cursor = 0;
2174 self.scroll_offset = 0;
2175 }
2176
2177 if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
2179 {
2180 self.auto_scroll_to_match_column();
2181 }
2182 }
2183
2184 fn auto_scroll_to_match_column(&mut self) {
2186 let Some(layout) = &self.table_layout else {
2187 return;
2188 };
2189
2190 let first_idx = self.filtered_indices[0];
2192 let item = &self.items[first_idx];
2193 let Some(cells) = &item.cells else {
2194 return;
2195 };
2196
2197 let mut first_match_col: Option<usize> = None;
2199 for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
2200 if self.per_column {
2201 if self
2203 .matcher
2204 .fuzzy_match(cell_text, &self.filter_text)
2205 .is_some()
2206 {
2207 first_match_col = Some(col_idx);
2208 break;
2209 }
2210 } else {
2211 let cell_start: usize = cells[..col_idx]
2214 .iter()
2215 .map(|(s, _)| s.chars().count() + 1) .sum();
2217 let cell_char_count = cell_text.chars().count();
2218
2219 if let Some((_, indices)) =
2220 self.matcher.fuzzy_indices(&item.name, &self.filter_text)
2221 {
2222 if indices
2224 .iter()
2225 .any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
2226 {
2227 first_match_col = Some(col_idx);
2228 break;
2229 }
2230 }
2231 }
2232 }
2233
2234 if let Some(match_col) = first_match_col {
2236 let (cols_visible, _) = self.calculate_visible_columns();
2237 let visible_start = self.horizontal_offset;
2238 let visible_end = self.horizontal_offset + cols_visible;
2239
2240 if match_col < visible_start {
2241 self.horizontal_offset = match_col;
2243 self.horizontal_scroll_changed = true;
2244 self.update_table_layout();
2245 } else if match_col >= visible_end {
2246 self.horizontal_offset = match_col;
2249 let max_offset = layout.col_widths.len().saturating_sub(1);
2251 self.horizontal_offset = self.horizontal_offset.min(max_offset);
2252 self.horizontal_scroll_changed = true;
2253 self.update_table_layout();
2254 }
2255 }
2256 }
2257
2258 fn get_result(&self) -> InteractMode {
2259 match self.mode {
2260 SelectMode::Single => InteractMode::Single(Some(self.cursor)),
2261 SelectMode::Multi => {
2262 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2263 indices.sort();
2264 InteractMode::Multi(Some(indices))
2265 }
2266 SelectMode::Fuzzy => {
2267 if self.filtered_indices.is_empty() {
2268 InteractMode::Single(None)
2269 } else {
2270 InteractMode::Single(Some(self.filtered_indices[self.cursor]))
2271 }
2272 }
2273 SelectMode::FuzzyMulti => {
2274 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
2277 indices.sort();
2278 InteractMode::Multi(Some(indices))
2279 }
2280 }
2281 }
2282
2283 fn can_do_fuzzy_cursor_only_update(&self) -> bool {
2286 !self.first_render
2287 && !self.width_changed
2288 && (self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti)
2289 && !self.filter_text_changed
2290 && !self.results_changed
2291 && self.scroll_offset == self.prev_scroll_offset
2292 && self.cursor != self.prev_cursor
2293 && self.toggled_item.is_none() && !self.toggled_all }
2296
2297 fn can_do_multi_toggle_only_update(&self) -> bool {
2300 if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
2301 return false;
2302 }
2303 if let Some(toggled) = self.toggled_item {
2304 let visible_start = self.scroll_offset;
2306 let visible_end = self.scroll_offset + self.visible_height as usize;
2307 toggled >= visible_start && toggled < visible_end
2308 } else {
2309 false
2310 }
2311 }
2312
2313 fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
2316 if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
2317 return false;
2318 }
2319 if self.scroll_offset != self.prev_scroll_offset {
2320 return false; }
2322 if self.filter_text_changed || self.results_changed {
2323 return false; }
2325 if let Some(toggled) = self.toggled_item {
2326 let visible_start = self.scroll_offset;
2328 let visible_end = self.scroll_offset + self.visible_height as usize;
2329 let toggled_visible = toggled >= visible_start && toggled < visible_end;
2330 let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
2331 toggled_visible && cursor_visible
2332 } else {
2333 false
2334 }
2335 }
2336
2337 fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
2340 !self.first_render
2341 && !self.width_changed
2342 && self.mode == SelectMode::FuzzyMulti
2343 && self.toggled_all
2344 && !self.filter_text_changed
2345 && !self.results_changed
2346 && self.scroll_offset == self.prev_scroll_offset
2347 && !self.horizontal_scroll_changed
2348 }
2349
2350 fn can_do_multi_toggle_all_update(&self) -> bool {
2353 !self.first_render
2354 && !self.width_changed
2355 && self.mode == SelectMode::Multi
2356 && self.toggled_all
2357 }
2358
2359 fn can_do_cursor_only_update(&self) -> bool {
2362 !self.first_render
2363 && !self.width_changed
2364 && (self.mode == SelectMode::Single || self.mode == SelectMode::Multi)
2365 && self.scroll_offset == self.prev_scroll_offset
2366 && self.cursor != self.prev_cursor
2367 && !self.horizontal_scroll_changed
2368 && self.toggled_item.is_none() && !self.toggled_all }
2371
2372 fn render_cursor_only_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2374 execute!(stderr, BeginSynchronizedUpdate)?;
2375
2376 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2378 if self.is_table_mode() {
2379 header_lines += 2; }
2381
2382 let prev_display_row = (self.prev_cursor - self.scroll_offset) as u16;
2384 let curr_display_row = (self.cursor - self.scroll_offset) as u16;
2385
2386 let footer_lines: u16 = if self.config.show_footer
2392 && (self.is_multi_mode() || self.current_list_len() > self.visible_height as usize)
2393 {
2394 1
2395 } else {
2396 0
2397 };
2398 let items_rendered = self.rendered_lines - header_lines as usize - footer_lines as usize;
2399
2400 let last_item_display_row = (items_rendered as u16).saturating_sub(1);
2402
2403 let lines_up_to_prev = last_item_display_row + footer_lines - prev_display_row;
2407 execute!(stderr, MoveUp(lines_up_to_prev), MoveToColumn(0))?;
2408
2409 execute!(stderr, Print(" "))?;
2411
2412 let marker = self.selected_marker();
2414 if curr_display_row > prev_display_row {
2415 let lines_down = curr_display_row - prev_display_row;
2416 execute!(
2417 stderr,
2418 MoveDown(lines_down),
2419 MoveToColumn(0),
2420 Print(&marker)
2421 )?;
2422 } else if curr_display_row < prev_display_row {
2423 let lines_up = prev_display_row - curr_display_row;
2424 execute!(stderr, MoveUp(lines_up), MoveToColumn(0), Print(&marker))?;
2425 } else {
2426 execute!(stderr, MoveToColumn(0), Print(&marker))?;
2428 }
2429
2430 let lines_down_to_end = last_item_display_row + footer_lines - curr_display_row;
2432 execute!(stderr, MoveDown(lines_down_to_end))?;
2433
2434 self.prev_cursor = self.cursor;
2436
2437 execute!(stderr, EndSynchronizedUpdate)?;
2438 stderr.flush()
2439 }
2440
2441 fn render_fuzzy_cursor_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2443 execute!(stderr, BeginSynchronizedUpdate)?;
2444
2445 let header_lines = self.fuzzy_header_lines();
2447
2448 let prev_display_row = (self.prev_cursor - self.scroll_offset) as u16;
2450 let curr_display_row = (self.cursor - self.scroll_offset) as u16;
2451
2452 let prev_item_row = header_lines + prev_display_row;
2459 let curr_item_row = header_lines + curr_display_row;
2460
2461 let filter_row = self.fuzzy_filter_row();
2463
2464 let down_to_prev = prev_item_row.saturating_sub(filter_row);
2466 execute!(stderr, MoveDown(down_to_prev), MoveToColumn(0), Print(" "))?;
2467
2468 let marker = self.selected_marker();
2470 if curr_item_row > prev_item_row {
2471 let lines_down = curr_item_row - prev_item_row;
2472 execute!(
2473 stderr,
2474 MoveDown(lines_down),
2475 MoveToColumn(0),
2476 Print(&marker)
2477 )?;
2478 } else if curr_item_row < prev_item_row {
2479 let lines_up = prev_item_row - curr_item_row;
2480 execute!(stderr, MoveUp(lines_up), MoveToColumn(0), Print(&marker))?;
2481 } else {
2482 execute!(stderr, MoveToColumn(0), Print(&marker))?;
2484 }
2485
2486 let up_to_filter = curr_item_row.saturating_sub(filter_row);
2488 execute!(stderr, MoveUp(up_to_filter))?;
2489
2490 self.position_fuzzy_cursor(stderr)?;
2492
2493 self.prev_cursor = self.cursor;
2495
2496 execute!(stderr, EndSynchronizedUpdate)?;
2497 stderr.flush()
2498 }
2499
2500 fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2502 let toggled = self.toggled_item.expect("toggled_item must be Some");
2503 execute!(stderr, BeginSynchronizedUpdate)?;
2504
2505 let header_lines = self.fuzzy_header_lines();
2507
2508 let toggled_display_row = (toggled - self.scroll_offset) as u16;
2509 let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
2510
2511 let toggled_item_row = header_lines + toggled_display_row;
2512 let cursor_item_row = header_lines + cursor_display_row;
2513
2514 let filter_row = self.fuzzy_filter_row();
2516
2517 let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
2519 execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
2520
2521 let toggled_real_idx = self.filtered_indices[toggled];
2523 let toggled_item = &self.items[toggled_real_idx];
2524 let toggled_checked = self.selected.contains(&toggled_real_idx);
2525 if self.is_table_mode() {
2526 self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
2527 } else {
2528 self.render_fuzzy_multi_item_inline(
2529 stderr,
2530 &toggled_item.name,
2531 toggled_checked,
2532 false,
2533 )?;
2534 }
2535
2536 if cursor_item_row > toggled_item_row {
2538 let lines_down = cursor_item_row - toggled_item_row;
2539 execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
2540 } else if cursor_item_row < toggled_item_row {
2541 let lines_up = toggled_item_row - cursor_item_row;
2542 execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
2543 }
2544
2545 let cursor_real_idx = self.filtered_indices[self.cursor];
2546 let cursor_item = &self.items[cursor_real_idx];
2547 let cursor_checked = self.selected.contains(&cursor_real_idx);
2548 if self.is_table_mode() {
2549 self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
2550 } else {
2551 self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
2552 }
2553
2554 if self.has_footer() {
2556 let total_count = self.current_list_len();
2558 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2559 let visible_count = (end - self.scroll_offset) as u16;
2560 let footer_row = header_lines + visible_count;
2561
2562 let down_to_footer = footer_row.saturating_sub(cursor_item_row);
2564 execute!(stderr, MoveDown(down_to_footer))?;
2565
2566 self.render_footer_inline(stderr)?;
2568
2569 let up_to_filter = footer_row.saturating_sub(filter_row);
2571 execute!(stderr, MoveUp(up_to_filter))?;
2572 } else {
2573 let up_to_filter = cursor_item_row.saturating_sub(filter_row);
2575 execute!(stderr, MoveUp(up_to_filter))?;
2576 }
2577
2578 self.position_fuzzy_cursor(stderr)?;
2580
2581 self.prev_cursor = self.cursor;
2583 self.toggled_item = None;
2584
2585 execute!(stderr, EndSynchronizedUpdate)?;
2586 stderr.flush()
2587 }
2588
2589 fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2591 let toggled = self.toggled_item.expect("toggled_item must be Some");
2592 execute!(stderr, BeginSynchronizedUpdate)?;
2593
2594 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2595 if self.is_table_mode() {
2596 header_lines += 2; }
2598
2599 let display_row = (toggled - self.scroll_offset) as u16;
2601
2602 let items_rendered = self.rendered_lines - header_lines as usize;
2604
2605 let lines_up = (items_rendered as u16)
2608 .saturating_sub(1)
2609 .saturating_sub(display_row);
2610 execute!(stderr, MoveUp(lines_up))?;
2611
2612 execute!(stderr, MoveToColumn(2))?;
2614
2615 let checkbox = if self.selected.contains(&toggled) {
2617 "[x]"
2618 } else {
2619 "[ ]"
2620 };
2621 execute!(stderr, Print(checkbox))?;
2622
2623 execute!(stderr, MoveDown(lines_up))?;
2625
2626 if self.has_footer() {
2628 self.render_footer_inline(stderr)?;
2629 }
2630
2631 self.toggled_item = None;
2633
2634 execute!(stderr, EndSynchronizedUpdate)?;
2635 stderr.flush()
2636 }
2637
2638 fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2640 execute!(stderr, BeginSynchronizedUpdate)?;
2641
2642 let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
2643 if self.is_table_mode() {
2644 header_lines += 2; }
2646
2647 let items_rendered = self.rendered_lines - header_lines as usize;
2649
2650 let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
2652 let visible_count = visible_end - self.scroll_offset;
2653
2654 execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
2657
2658 for i in 0..visible_count {
2660 let item_idx = self.scroll_offset + i;
2661 let checkbox = if self.selected.contains(&item_idx) {
2662 "[x]"
2663 } else {
2664 "[ ]"
2665 };
2666 execute!(stderr, MoveToColumn(2), Print(checkbox))?;
2668 if i + 1 < visible_count {
2669 execute!(stderr, MoveDown(1))?;
2670 }
2671 }
2672
2673 let remaining = items_rendered as u16 - visible_count as u16;
2675 if remaining > 0 {
2676 execute!(stderr, MoveDown(remaining))?;
2677 }
2678
2679 if self.has_footer() {
2681 self.render_footer_inline(stderr)?;
2682 }
2683
2684 self.toggled_all = false;
2686
2687 execute!(stderr, EndSynchronizedUpdate)?;
2688 stderr.flush()
2689 }
2690
2691 fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2693 execute!(stderr, BeginSynchronizedUpdate)?;
2694
2695 let header_lines = self.fuzzy_header_lines();
2697
2698 let total_count = self.current_list_len();
2699 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2700 let visible_count = end.saturating_sub(self.scroll_offset);
2701
2702 let filter_row = self.fuzzy_filter_row();
2704
2705 let down_to_first = header_lines.saturating_sub(filter_row);
2707 execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
2708
2709 for (i, idx) in (self.scroll_offset..end).enumerate() {
2710 let real_idx = self.filtered_indices[idx];
2711 let item = &self.items[real_idx];
2712 let checked = self.selected.contains(&real_idx);
2713 let active = idx == self.cursor;
2714
2715 if self.is_table_mode() {
2716 self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
2717 } else {
2718 self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
2719 }
2720
2721 if i + 1 < visible_count {
2722 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2723 }
2724 }
2725
2726 if self.has_footer() {
2728 let footer_row = header_lines + visible_count as u16;
2729 let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
2730 let down_to_footer = footer_row.saturating_sub(last_item_row);
2731 execute!(stderr, MoveDown(down_to_footer))?;
2732 self.render_footer_inline(stderr)?;
2733 let up_to_filter = footer_row.saturating_sub(filter_row);
2734 execute!(stderr, MoveUp(up_to_filter))?;
2735 } else {
2736 let up_to_filter =
2737 (header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
2738 execute!(stderr, MoveUp(up_to_filter))?;
2739 }
2740
2741 self.position_fuzzy_cursor(stderr)?;
2743
2744 self.toggled_all = false;
2746
2747 execute!(stderr, EndSynchronizedUpdate)?;
2748 stderr.flush()
2749 }
2750
2751 #[allow(clippy::collapsible_if)]
2752 fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
2753 if self.can_do_fuzzy_multi_toggle_all_update() {
2755 return self.render_fuzzy_multi_toggle_all_update(stderr);
2756 }
2757
2758 if self.can_do_multi_toggle_all_update() {
2760 return self.render_multi_toggle_all(stderr);
2761 }
2762
2763 if self.can_do_multi_toggle_only_update() {
2765 return self.render_multi_toggle_only(stderr);
2766 }
2767
2768 if self.can_do_fuzzy_multi_toggle_update() {
2770 return self.render_fuzzy_multi_toggle_update(stderr);
2771 }
2772
2773 if self.can_do_fuzzy_cursor_only_update() {
2775 return self.render_fuzzy_cursor_update(stderr);
2776 }
2777
2778 if self.can_do_cursor_only_update() {
2780 return self.render_cursor_only_update(stderr);
2781 }
2782
2783 if !self.first_render
2785 && !self.width_changed
2786 && self.cursor == self.prev_cursor
2787 && self.scroll_offset == self.prev_scroll_offset
2788 && !self.results_changed
2789 && !self.filter_text_changed
2790 && !self.horizontal_scroll_changed
2791 && !self.settings_changed
2792 && !self.toggled_all
2793 {
2794 return Ok(());
2795 }
2796
2797 execute!(stderr, BeginSynchronizedUpdate)?;
2798
2799 let total_count = self.current_list_len();
2801 let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
2802 let has_scroll_indicator = self.has_footer();
2804 let items_to_render = end - self.scroll_offset;
2805
2806 let mut lines_needed: usize = 0;
2808 if self.prompt.is_some() {
2809 lines_needed += 1;
2810 }
2811 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2812 lines_needed += 1; if self.config.show_separator {
2814 lines_needed += 1;
2815 }
2816 }
2817 if self.is_table_mode() {
2818 lines_needed += 2; }
2820 lines_needed += items_to_render;
2821 if has_scroll_indicator {
2822 lines_needed += 1;
2823 }
2824
2825 if self.first_render && lines_needed > 1 {
2827 for _ in 0..(lines_needed - 1) {
2828 execute!(stderr, Print("\n"))?;
2829 }
2830 execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
2831 }
2832
2833 if self.fuzzy_cursor_offset > 0 {
2835 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
2836 self.fuzzy_cursor_offset = 0;
2837 }
2838
2839 if self.rendered_lines > 1 {
2842 execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
2843 }
2844 execute!(stderr, MoveToColumn(0))?;
2845
2846 let mut lines_rendered: usize = 0;
2847
2848 if self.first_render {
2850 if let Some(prompt) = self.prompt {
2851 execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
2852 }
2853 }
2854 if self.prompt.is_some() {
2855 lines_rendered += 1;
2856 if lines_rendered < lines_needed {
2857 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2858 }
2859 }
2860
2861 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
2863 execute!(
2864 stderr,
2865 Print(self.prompt_marker()),
2866 Print(&self.filter_text),
2867 Clear(ClearType::UntilNewLine),
2868 )?;
2869 lines_rendered += 1;
2870 if lines_rendered < lines_needed {
2871 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2872 }
2873
2874 if self.config.show_separator {
2876 execute!(
2877 stderr,
2878 Print(self.config.separator.paint(&self.separator_line)),
2879 Clear(ClearType::UntilNewLine),
2880 )?;
2881 lines_rendered += 1;
2882 if lines_rendered < lines_needed {
2883 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2884 }
2885 }
2886 }
2887
2888 if self.is_table_mode() {
2891 let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
2892 if need_header_redraw {
2893 self.render_table_header(stderr)?;
2894 }
2895 lines_rendered += 1;
2896 if lines_rendered < lines_needed {
2897 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2898 }
2899 if need_header_redraw {
2900 self.render_table_header_separator(stderr)?;
2901 }
2902 lines_rendered += 1;
2903 if lines_rendered < lines_needed {
2904 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2905 }
2906 }
2907
2908 for idx in self.scroll_offset..end {
2910 let is_active = idx == self.cursor;
2911 let is_last_line = lines_rendered + 1 == lines_needed;
2912
2913 if self.is_table_mode() {
2914 match self.mode {
2916 SelectMode::Single => {
2917 let item = &self.items[idx];
2918 self.render_table_row_single(stderr, item, is_active)?;
2919 }
2920 SelectMode::Multi => {
2921 let real_idx = if self.refined {
2922 self.filtered_indices[idx]
2923 } else {
2924 idx
2925 };
2926 let item = &self.items[real_idx];
2927 let is_checked = self.selected.contains(&real_idx);
2928 self.render_table_row_multi(stderr, item, is_checked, is_active)?;
2929 }
2930 SelectMode::Fuzzy => {
2931 let real_idx = self.filtered_indices[idx];
2932 let item = &self.items[real_idx];
2933 self.render_table_row_fuzzy(stderr, item, is_active)?;
2934 }
2935 SelectMode::FuzzyMulti => {
2936 let real_idx = self.filtered_indices[idx];
2937 let item = &self.items[real_idx];
2938 let is_checked = self.selected.contains(&real_idx);
2939 self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
2940 }
2941 }
2942 } else {
2943 match self.mode {
2945 SelectMode::Single => {
2946 let item = &self.items[idx];
2947 self.render_single_item_inline(stderr, &item.name, is_active)?;
2948 }
2949 SelectMode::Multi => {
2950 let real_idx = if self.refined {
2951 self.filtered_indices[idx]
2952 } else {
2953 idx
2954 };
2955 let item = &self.items[real_idx];
2956 let is_checked = self.selected.contains(&real_idx);
2957 self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
2958 }
2959 SelectMode::Fuzzy => {
2960 let real_idx = self.filtered_indices[idx];
2961 let item = &self.items[real_idx];
2962 self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
2963 }
2964 SelectMode::FuzzyMulti => {
2965 let real_idx = self.filtered_indices[idx];
2966 let item = &self.items[real_idx];
2967 let is_checked = self.selected.contains(&real_idx);
2968 self.render_fuzzy_multi_item_inline(
2969 stderr, &item.name, is_checked, is_active,
2970 )?;
2971 }
2972 }
2973 }
2974 lines_rendered += 1;
2975 if !is_last_line {
2976 execute!(stderr, MoveDown(1), MoveToColumn(0))?;
2977 }
2978 }
2979
2980 if has_scroll_indicator {
2982 let indicator = self.generate_footer();
2983 execute!(
2984 stderr,
2985 Print(self.config.footer.paint(&indicator)),
2986 Clear(ClearType::UntilNewLine),
2987 )?;
2988 lines_rendered += 1;
2989 }
2990
2991 if lines_rendered < self.rendered_lines {
2994 let extra_lines = self.rendered_lines - lines_rendered;
2995 for _ in 0..extra_lines {
2996 execute!(
2997 stderr,
2998 MoveDown(1),
2999 MoveToColumn(0),
3000 Clear(ClearType::CurrentLine)
3001 )?;
3002 }
3003 execute!(stderr, MoveUp(extra_lines as u16))?;
3005 }
3006
3007 self.rendered_lines = lines_rendered;
3009 self.prev_cursor = self.cursor;
3010 self.prev_scroll_offset = self.scroll_offset;
3011 self.first_render = false;
3012 self.filter_text_changed = false;
3013 self.results_changed = false;
3014 self.horizontal_scroll_changed = false;
3015 self.width_changed = false;
3016 self.toggled_item = None;
3017 self.toggled_all = false;
3018 self.settings_changed = false;
3019
3020 if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
3022 let filter_row = self.fuzzy_filter_row() as usize;
3024 self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
3025 if self.fuzzy_cursor_offset > 0 {
3026 execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
3027 }
3028 self.position_fuzzy_cursor(stderr)?;
3030 }
3031
3032 execute!(stderr, EndSynchronizedUpdate)?;
3033 stderr.flush()
3034 }
3035
3036 fn render_single_item_inline(
3037 &self,
3038 stderr: &mut Stderr,
3039 text: &str,
3040 active: bool,
3041 ) -> io::Result<()> {
3042 let prefix = if active { self.selected_marker() } else { " " };
3043 let prefix_width = 2;
3044
3045 execute!(stderr, Print(prefix))?;
3046 self.render_truncated_text(stderr, text, prefix_width)?;
3047 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3048 Ok(())
3049 }
3050
3051 fn render_multi_item_inline(
3052 &self,
3053 stderr: &mut Stderr,
3054 text: &str,
3055 checked: bool,
3056 active: bool,
3057 ) -> io::Result<()> {
3058 let cursor = if active { self.selected_marker() } else { " " };
3059 let checkbox = if checked { "[x] " } else { "[ ] " };
3060 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3063 self.render_truncated_text(stderr, text, prefix_width)?;
3064 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3065 Ok(())
3066 }
3067
3068 fn render_fuzzy_item_inline(
3069 &self,
3070 stderr: &mut Stderr,
3071 text: &str,
3072 active: bool,
3073 ) -> io::Result<()> {
3074 let prefix = if active { self.selected_marker() } else { " " };
3075 let prefix_width = 2;
3076 execute!(stderr, Print(prefix))?;
3077
3078 if self.filter_text.is_empty() {
3079 self.render_truncated_text(stderr, text, prefix_width)?;
3080 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3081 {
3082 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3083 } else {
3084 self.render_truncated_text(stderr, text, prefix_width)?;
3085 }
3086 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3087 Ok(())
3088 }
3089
3090 fn render_fuzzy_multi_item_inline(
3091 &self,
3092 stderr: &mut Stderr,
3093 text: &str,
3094 checked: bool,
3095 active: bool,
3096 ) -> io::Result<()> {
3097 let cursor = if active { self.selected_marker() } else { " " };
3098 let checkbox = if checked { "[x] " } else { "[ ] " };
3099 let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
3101
3102 if self.filter_text.is_empty() {
3103 self.render_truncated_text(stderr, text, prefix_width)?;
3104 } else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
3105 {
3106 self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
3107 } else {
3108 self.render_truncated_text(stderr, text, prefix_width)?;
3109 }
3110 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3111 Ok(())
3112 }
3113
3114 fn render_truncated_text(
3116 &self,
3117 stderr: &mut Stderr,
3118 text: &str,
3119 prefix_width: usize,
3120 ) -> io::Result<()> {
3121 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3122 let text_width = UnicodeWidthStr::width(text);
3123
3124 if text_width <= available_width {
3125 execute!(stderr, Print(text))?;
3127 } else if available_width <= 1 {
3128 execute!(stderr, Print("…"))?;
3130 } else {
3131 let target_width = available_width - 1;
3133 let mut current_width = 0;
3134 let mut end_pos = 0;
3135
3136 for (byte_pos, c) in text.char_indices() {
3137 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3138 if current_width + char_width > target_width {
3139 break;
3140 }
3141 end_pos = byte_pos + c.len_utf8();
3142 current_width += char_width;
3143 }
3144 execute!(stderr, Print(&text[..end_pos]))?;
3145 execute!(stderr, Print("…"))?;
3146 }
3147 Ok(())
3148 }
3149
3150 fn render_truncated_fuzzy_text(
3153 &self,
3154 stderr: &mut Stderr,
3155 text: &str,
3156 match_indices: &[usize],
3157 prefix_width: usize,
3158 ) -> io::Result<()> {
3159 let available_width = (self.term_width as usize).saturating_sub(prefix_width);
3160 let text_width = UnicodeWidthStr::width(text);
3161
3162 let mut char_buf = [0u8; 4];
3164
3165 if text_width <= available_width {
3166 let mut match_iter = match_indices.iter().peekable();
3169 for (idx, c) in text.chars().enumerate() {
3170 while match_iter.peek().is_some_and(|&&i| i < idx) {
3172 match_iter.next();
3173 }
3174 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3175 if is_match {
3176 let s = c.encode_utf8(&mut char_buf);
3177 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3178 } else {
3179 execute!(stderr, Print(c))?;
3180 }
3181 }
3182 } else if available_width <= 1 {
3183 let has_any_matches = !match_indices.is_empty();
3185 if has_any_matches {
3186 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3187 } else {
3188 execute!(stderr, Print("…"))?;
3189 }
3190 } else {
3191 let target_width = available_width - 1;
3193 let mut current_width = 0;
3194 let mut chars_to_render: usize = 0;
3195
3196 for c in text.chars() {
3197 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
3198 if current_width + char_width > target_width {
3199 break;
3200 }
3201 current_width += char_width;
3202 chars_to_render += 1;
3203 }
3204
3205 let mut match_iter = match_indices.iter().peekable();
3207 for (idx, c) in text.chars().enumerate() {
3208 if idx >= chars_to_render {
3209 break;
3210 }
3211 while match_iter.peek().is_some_and(|&&i| i < idx) {
3212 match_iter.next();
3213 }
3214 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3215 if is_match {
3216 let s = c.encode_utf8(&mut char_buf);
3217 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3218 } else {
3219 execute!(stderr, Print(c))?;
3220 }
3221 }
3222
3223 let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
3225
3226 if has_hidden_matches {
3227 execute!(stderr, Print(self.config.match_text.paint("…")))?;
3228 } else {
3229 execute!(stderr, Print("…"))?;
3230 }
3231 }
3232 Ok(())
3233 }
3234
3235 fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
3237 let Some(layout) = &self.table_layout else {
3238 return Ok(());
3239 };
3240
3241 let prefix_width = self.row_prefix_width();
3242 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3243 let has_more_left = self.horizontal_offset > 0;
3244
3245 execute!(stderr, Print(" ".repeat(prefix_width)))?;
3247
3248 if has_more_left {
3250 let sep = self.table_column_separator();
3251 execute!(
3252 stderr,
3253 Print(self.config.table_separator.paint("…")),
3254 Print(self.config.table_separator.paint(&sep))
3255 )?;
3256 }
3257
3258 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3260 for (i, col_idx) in visible_range.enumerate() {
3261 if col_idx >= layout.columns.len() {
3262 break;
3263 }
3264
3265 if i > 0 {
3267 let sep = self.table_column_separator();
3268 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3269 }
3270
3271 let header = &layout.columns[col_idx];
3273 let col_width = layout.col_widths[col_idx];
3274 let header_width = header.width();
3275 let padding = col_width.saturating_sub(header_width);
3276 let left_pad = padding / 2;
3277 let right_pad = padding - left_pad;
3278 let header_padded = format!(
3279 "{}{}{}",
3280 " ".repeat(left_pad),
3281 header,
3282 " ".repeat(right_pad)
3283 );
3284 execute!(
3285 stderr,
3286 Print(self.config.table_header.paint(&header_padded))
3287 )?;
3288 }
3289
3290 if has_more_right {
3292 let sep = self.table_column_separator();
3293 execute!(
3294 stderr,
3295 Print(self.config.table_separator.paint(&sep)),
3296 Print(self.config.table_separator.paint("…"))
3297 )?;
3298 }
3299
3300 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3301 Ok(())
3302 }
3303
3304 fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
3306 let Some(layout) = &self.table_layout else {
3307 return Ok(());
3308 };
3309
3310 let prefix_width = self.row_prefix_width();
3311 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3312 let has_more_left = self.horizontal_offset > 0;
3313
3314 let h_char = self.config.table_header_separator;
3315 let int_char = self.config.table_header_intersection;
3316
3317 let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
3319 execute!(
3320 stderr,
3321 Print(self.config.table_separator.paint(&prefix_line))
3322 )?;
3323
3324 if has_more_left {
3327 let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
3328 execute!(
3329 stderr,
3330 Print(self.config.table_separator.paint(&left_indicator))
3331 )?;
3332 }
3333
3334 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3336 for (i, col_idx) in visible_range.enumerate() {
3337 if col_idx >= layout.col_widths.len() {
3338 break;
3339 }
3340
3341 if i > 0 {
3343 let intersection = format!("{}{}{}", h_char, int_char, h_char);
3344 execute!(
3345 stderr,
3346 Print(self.config.table_separator.paint(&intersection))
3347 )?;
3348 }
3349
3350 let col_width = layout.col_widths[col_idx];
3352 let line: String = std::iter::repeat_n(h_char, col_width).collect();
3353 execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
3354 }
3355
3356 if has_more_right {
3359 let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
3360 execute!(
3361 stderr,
3362 Print(self.config.table_separator.paint(&right_indicator))
3363 )?;
3364 }
3365
3366 execute!(stderr, Clear(ClearType::UntilNewLine))?;
3367 Ok(())
3368 }
3369
3370 fn render_table_row_single(
3372 &self,
3373 stderr: &mut Stderr,
3374 item: &SelectItem,
3375 active: bool,
3376 ) -> io::Result<()> {
3377 let prefix = if active { self.selected_marker() } else { " " };
3378 execute!(stderr, Print(prefix))?;
3379 self.render_table_cells(stderr, item, None)?;
3380 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3381 Ok(())
3382 }
3383
3384 fn render_table_row_multi(
3386 &self,
3387 stderr: &mut Stderr,
3388 item: &SelectItem,
3389 checked: bool,
3390 active: bool,
3391 ) -> io::Result<()> {
3392 let cursor = if active { self.selected_marker() } else { " " };
3393 let checkbox = if checked { "[x] " } else { "[ ] " };
3394 execute!(stderr, Print(cursor), Print(checkbox))?;
3395 self.render_table_cells(stderr, item, None)?;
3396 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3397 Ok(())
3398 }
3399
3400 fn render_table_row_fuzzy(
3402 &self,
3403 stderr: &mut Stderr,
3404 item: &SelectItem,
3405 active: bool,
3406 ) -> io::Result<()> {
3407 let prefix = if active { self.selected_marker() } else { " " };
3408 execute!(stderr, Print(prefix))?;
3409
3410 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3412 self.matcher
3413 .fuzzy_indices(&item.name, &self.filter_text)
3414 .map(|(_, indices)| indices)
3415 } else {
3416 None
3417 };
3418
3419 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3420 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3421 Ok(())
3422 }
3423
3424 fn render_table_row_fuzzy_multi(
3426 &self,
3427 stderr: &mut Stderr,
3428 item: &SelectItem,
3429 checked: bool,
3430 active: bool,
3431 ) -> io::Result<()> {
3432 let cursor = if active { self.selected_marker() } else { " " };
3433 let checkbox = if checked { "[x] " } else { "[ ] " };
3434 execute!(stderr, Print(cursor), Print(checkbox))?;
3435
3436 let match_indices = if !self.filter_text.is_empty() && !self.per_column {
3438 self.matcher
3439 .fuzzy_indices(&item.name, &self.filter_text)
3440 .map(|(_, indices)| indices)
3441 } else {
3442 None
3443 };
3444
3445 self.render_table_cells(stderr, item, match_indices.as_deref())?;
3446 execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
3447 Ok(())
3448 }
3449
3450 fn render_table_cells(
3452 &self,
3453 stderr: &mut Stderr,
3454 item: &SelectItem,
3455 match_indices: Option<&[usize]>,
3456 ) -> io::Result<()> {
3457 let Some(layout) = &self.table_layout else {
3458 return Ok(());
3459 };
3460 let Some(cells) = &item.cells else {
3461 return Ok(());
3462 };
3463
3464 let (cols_visible, has_more_right) = self.calculate_visible_columns();
3465 let has_more_left = self.horizontal_offset > 0;
3466
3467 let mut matches_in_hidden_left = false;
3469 let mut matches_in_hidden_right = false;
3470
3471 let per_column_matches: Vec<Option<Vec<usize>>> =
3473 if self.per_column && !self.filter_text.is_empty() {
3474 cells
3475 .iter()
3476 .map(|(cell_text, _)| {
3477 self.matcher
3478 .fuzzy_indices(cell_text, &self.filter_text)
3479 .map(|(_, indices)| indices)
3480 })
3481 .collect()
3482 } else {
3483 vec![]
3484 };
3485
3486 let cell_offsets: Vec<usize> = if match_indices.is_some() {
3489 let mut offsets = Vec::with_capacity(cells.len());
3490 let mut offset = 0;
3491 for (i, (cell_text, _)) in cells.iter().enumerate() {
3492 offsets.push(offset);
3493 offset += cell_text.chars().count();
3494 if i + 1 < cells.len() {
3495 offset += 1; }
3497 }
3498 offsets
3499 } else {
3500 vec![]
3501 };
3502
3503 if self.per_column && !self.filter_text.is_empty() {
3505 for col_idx in 0..self.horizontal_offset {
3506 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3507 matches_in_hidden_left = true;
3508 break;
3509 }
3510 }
3511 } else if let Some(indices) = match_indices {
3512 for col_idx in 0..self.horizontal_offset {
3513 if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
3514 let cell_start = cell_offsets[col_idx];
3515 let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3517 matches_in_hidden_left = true;
3518 break;
3519 }
3520 }
3521 }
3522 }
3523
3524 if has_more_left {
3526 let sep = self.table_column_separator();
3527 if matches_in_hidden_left {
3528 execute!(
3529 stderr,
3530 Print(self.config.match_text.paint("…")),
3531 Print(self.config.table_separator.paint(&sep))
3532 )?;
3533 } else {
3534 execute!(
3535 stderr,
3536 Print(self.config.table_separator.paint("…")),
3537 Print(self.config.table_separator.paint(&sep))
3538 )?;
3539 }
3540 }
3541
3542 let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
3544 for (i, col_idx) in visible_range.enumerate() {
3545 if col_idx >= cells.len() {
3546 break;
3547 }
3548
3549 if i > 0 {
3551 let sep = self.table_column_separator();
3552 execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
3553 }
3554
3555 let (cell_text, cell_style) = &cells[col_idx];
3556 let col_width = layout.col_widths[col_idx];
3557
3558 let cell_matches: Option<Vec<usize>> =
3560 if self.per_column && !self.filter_text.is_empty() {
3561 per_column_matches.get(col_idx).cloned().flatten()
3563 } else if let Some(indices) = match_indices {
3564 if col_idx < cell_offsets.len() {
3566 let cell_start = cell_offsets[col_idx];
3567 let cell_char_count = cell_text.chars().count();
3569 let relative_indices: Vec<usize> = indices
3570 .iter()
3571 .filter_map(|&idx| {
3572 if idx >= cell_start && idx < cell_start + cell_char_count {
3573 Some(idx - cell_start)
3574 } else {
3575 None
3576 }
3577 })
3578 .collect();
3579 if relative_indices.is_empty() {
3580 None
3581 } else {
3582 Some(relative_indices)
3583 }
3584 } else {
3585 None
3586 }
3587 } else {
3588 None
3589 };
3590
3591 self.render_table_cell(
3593 stderr,
3594 cell_text,
3595 cell_style,
3596 col_width,
3597 cell_matches.as_deref(),
3598 )?;
3599 }
3600
3601 if self.per_column && !self.filter_text.is_empty() {
3603 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3604 if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
3605 matches_in_hidden_right = true;
3606 break;
3607 }
3608 }
3609 } else if let Some(indices) = match_indices {
3610 for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
3611 if col_idx < cell_offsets.len() {
3612 let cell_start = cell_offsets[col_idx];
3613 let cell_end = if col_idx + 1 < cell_offsets.len() {
3614 cell_offsets[col_idx + 1].saturating_sub(1)
3615 } else {
3616 item.name.chars().count()
3617 };
3618 if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
3619 matches_in_hidden_right = true;
3620 break;
3621 }
3622 }
3623 }
3624 }
3625
3626 if has_more_right {
3628 let sep = self.table_column_separator();
3629 if matches_in_hidden_right {
3630 execute!(
3631 stderr,
3632 Print(self.config.table_separator.paint(&sep)),
3633 Print(self.config.match_text.paint("…"))
3634 )?;
3635 } else {
3636 execute!(
3637 stderr,
3638 Print(self.config.table_separator.paint(&sep)),
3639 Print(self.config.table_separator.paint("…"))
3640 )?;
3641 }
3642 }
3643
3644 Ok(())
3645 }
3646
3647 fn render_table_cell(
3649 &self,
3650 stderr: &mut Stderr,
3651 cell: &str,
3652 cell_style: &TextStyle,
3653 col_width: usize,
3654 match_indices: Option<&[usize]>,
3655 ) -> io::Result<()> {
3656 let cell_width = cell.width();
3657 let padding_needed = col_width.saturating_sub(cell_width);
3658
3659 let (left_pad, right_pad) = match cell_style.alignment {
3661 Alignment::Left => (0, padding_needed),
3662 Alignment::Right => (padding_needed, 0),
3663 Alignment::Center => {
3664 let left = padding_needed / 2;
3665 (left, padding_needed - left)
3666 }
3667 };
3668
3669 if left_pad > 0 {
3671 execute!(stderr, Print(" ".repeat(left_pad)))?;
3672 }
3673
3674 if let Some(indices) = match_indices {
3675 let mut char_buf = [0u8; 4];
3677 let mut match_iter = indices.iter().peekable();
3678
3679 for (idx, c) in cell.chars().enumerate() {
3680 while match_iter.peek().is_some_and(|&&i| i < idx) {
3681 match_iter.next();
3682 }
3683 let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
3684 if is_match {
3685 let s = c.encode_utf8(&mut char_buf);
3686 execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
3687 } else {
3688 let s = c.encode_utf8(&mut char_buf);
3690 if let Some(color) = cell_style.color_style {
3691 execute!(stderr, Print(color.paint(&*s)))?;
3692 } else {
3693 execute!(stderr, Print(&*s))?;
3694 }
3695 }
3696 }
3697 } else {
3698 if let Some(color) = cell_style.color_style {
3700 execute!(stderr, Print(color.paint(cell)))?;
3701 } else {
3702 execute!(stderr, Print(cell))?;
3703 }
3704 }
3705
3706 if right_pad > 0 {
3708 execute!(stderr, Print(" ".repeat(right_pad)))?;
3709 }
3710
3711 Ok(())
3712 }
3713
3714 fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
3715 if self.fuzzy_cursor_offset > 0 {
3717 execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
3718 self.fuzzy_cursor_offset = 0;
3719 }
3720
3721 if self.rendered_lines > 0 {
3722 execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
3726 for _ in 1..self.rendered_lines {
3728 execute!(
3729 stderr,
3730 MoveUp(1),
3731 MoveToColumn(0),
3732 Clear(ClearType::CurrentLine)
3733 )?;
3734 }
3735 }
3737 self.rendered_lines = 0;
3738 stderr.flush()
3739 }
3740}
3741
3742enum KeyAction {
3743 Continue,
3744 Cancel,
3745 Confirm,
3746}
3747
3748#[cfg(test)]
3749mod test {
3750 use super::*;
3751
3752 #[test]
3753 fn test_examples() {
3754 use crate::test_examples;
3755
3756 test_examples(InputList {})
3757 }
3758}