use crossterm::{
cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
style::Print,
terminal::{
self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, disable_raw_mode,
enable_raw_mode,
},
};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use nu_ansi_term::{Style, ansi::RESET};
use nu_color_config::{Alignment, StyleComputer, TextStyle};
use nu_engine::{ClosureEval, command_prelude::*, get_columns};
use nu_protocol::engine::Closure;
use nu_protocol::{TableMode, shell_error::io::IoError};
use nu_table::common::nu_value_to_string;
use std::{
collections::HashSet,
io::{self, Stderr, Write},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum CaseSensitivity {
#[default]
Smart,
CaseSensitive,
CaseInsensitive,
}
#[derive(Debug, Clone)]
struct InputListConfig {
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, }
const DEFAULT_PROMPT_MARKER: &str = "> ";
const DEFAULT_SELECTED_MARKER: char = '>';
const DEFAULT_TABLE_COLUMN_SEPARATOR: char = '│';
fn table_mode_to_separator(mode: TableMode) -> char {
match mode {
TableMode::Basic | TableMode::BasicCompact | TableMode::Psql | TableMode::Markdown => '|',
TableMode::AsciiRounded => '|',
TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => '│',
TableMode::Reinforced | TableMode::Light => '│',
TableMode::Heavy => '┃',
TableMode::Double | TableMode::CompactDouble => '║',
TableMode::WithLove => '❤',
TableMode::Dots => ':',
TableMode::Restructured | TableMode::None => ' ',
}
}
fn table_mode_to_header_separator(mode: TableMode) -> (char, char) {
match mode {
TableMode::Basic | TableMode::BasicCompact | TableMode::Psql => ('-', '+'),
TableMode::AsciiRounded => ('-', '+'),
TableMode::Markdown => ('-', '|'),
TableMode::Thin | TableMode::Rounded | TableMode::Single | TableMode::Compact => ('─', '┼'),
TableMode::Reinforced => ('─', '┼'),
TableMode::Light => ('─', '─'), TableMode::Heavy => ('━', '╋'),
TableMode::Double | TableMode::CompactDouble => ('═', '╬'),
TableMode::WithLove => ('❤', '❤'),
TableMode::Dots => ('.', ':'),
TableMode::Restructured | TableMode::None => (' ', ' '),
}
}
impl Default for InputListConfig {
fn default() -> Self {
Self {
match_text: Style::new().fg(nu_ansi_term::Color::Yellow),
footer: Style::new().fg(nu_ansi_term::Color::DarkGray),
separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
prompt_marker: Style::new().fg(nu_ansi_term::Color::Green),
selected_marker: Style::new().fg(nu_ansi_term::Color::Green),
table_header: Style::new().bold(),
table_separator: Style::new().fg(nu_ansi_term::Color::DarkGray),
show_footer: true,
separator_char: "─".to_string(),
show_separator: true,
prompt_marker_text: DEFAULT_PROMPT_MARKER.to_string(),
selected_marker_char: DEFAULT_SELECTED_MARKER,
table_column_separator: DEFAULT_TABLE_COLUMN_SEPARATOR,
table_header_separator: '─',
table_header_intersection: '┼',
case_sensitivity: CaseSensitivity::default(),
}
}
}
impl InputListConfig {
fn from_nu_config(config: &nu_protocol::Config, style_computer: &StyleComputer) -> Self {
let mut ret = Self::default();
let color_config_header =
style_computer.compute("header", &Value::string("", Span::unknown()));
let color_config_separator =
style_computer.compute("separator", &Value::nothing(Span::unknown()));
let color_config_search_result =
style_computer.compute("search_result", &Value::string("", Span::unknown()));
let color_config_hints = style_computer.compute("hints", &Value::nothing(Span::unknown()));
let color_config_row_index =
style_computer.compute("row_index", &Value::string("", Span::unknown()));
ret.table_header = color_config_header;
ret.table_separator = color_config_separator;
ret.separator = color_config_separator;
ret.match_text = color_config_search_result;
ret.footer = color_config_hints;
ret.prompt_marker = color_config_row_index;
ret.selected_marker = color_config_row_index;
ret.table_column_separator = table_mode_to_separator(config.table.mode);
let (header_sep, header_int) = table_mode_to_header_separator(config.table.mode);
ret.table_header_separator = header_sep;
ret.table_header_intersection = header_int;
ret
}
}
enum InteractMode {
Single(Option<usize>),
Multi(Option<Vec<usize>>),
}
struct SelectItem {
name: String, cells: Option<Vec<(String, TextStyle)>>, value: Value, }
struct TableLayout {
columns: Vec<String>, col_widths: Vec<usize>, truncated_cols: usize, }
#[derive(Clone)]
pub struct InputList;
const INTERACT_ERROR: &str = "Interact error, could not process options";
impl Command for InputList {
fn name(&self) -> &str {
"input list"
}
fn signature(&self) -> Signature {
Signature::build("input list")
.input_output_types(vec![
(Type::List(Box::new(Type::Any)), Type::Any),
(Type::Range, Type::Int),
])
.optional("prompt", SyntaxShape::String, "The prompt to display.")
.switch(
"multi",
"Use multiple results, you can press a to toggle all, Ctrl+R to refine.",
Some('m'),
)
.switch("fuzzy", "Use a fuzzy select.", Some('f'))
.switch("index", "Returns list indexes.", Some('i'))
.switch(
"no-footer",
"Hide the footer showing item count and selection count.",
Some('n'),
)
.switch(
"no-separator",
"Hide the separator line between the search box and results.",
None,
)
.named(
"case-sensitive",
SyntaxShape::OneOf(vec![SyntaxShape::Boolean, SyntaxShape::String]),
"Case sensitivity for fuzzy matching: true, false, or 'smart' (case-insensitive unless query has uppercase)",
Some('s'),
)
.named(
"display",
SyntaxShape::OneOf(vec![
SyntaxShape::CellPath,
SyntaxShape::Closure(Some(vec![SyntaxShape::Any])),
]),
"Field or closure to generate display value for search (returns original value when selected)",
Some('d'),
)
.switch(
"no-table",
"Disable table rendering for table input (show as single lines).",
Some('t'),
)
.switch(
"per-column",
"Match filter text against each column independently (table mode only).",
Some('c'),
)
.allow_variants_without_examples(true)
.category(Category::Platform)
}
fn description(&self) -> &str {
"Display an interactive list for user selection."
}
fn extra_description(&self) -> &str {
r#"Presents an interactive list in the terminal for selecting items.
Four modes are available:
- Single (default): Select one item with arrow keys, confirm with Enter
- Multi (--multi): Select multiple items with Space, toggle all with 'a'
- Fuzzy (--fuzzy): Type to filter, matches are highlighted
- Fuzzy Multi (--fuzzy --multi): Type to filter AND select multiple items with Tab, toggle all with Alt+A
Multi mode features:
- The footer always shows the selection count (e.g., "[1-5 of 10, 3 selected]")
- Use Ctrl+R to "refine" the list: narrow down to only selected items, keeping them
selected so you can deselect the ones you don't want. Can be used multiple times.
Table rendering:
When piping a table (list of records), items are displayed with aligned columns.
Use Left/Right arrows (or h/l) to scroll horizontally when columns exceed terminal width.
In fuzzy mode, use Shift+Left/Right for horizontal scrolling.
Ellipsis (…) shows when more columns are available in each direction.
In fuzzy mode, the ellipsis is highlighted when matches exist in hidden columns.
Use --no-table to disable table rendering and show records as single lines.
Use --per-column to match filter text against each column independently (best match wins).
This prevents false positives from matches spanning column boundaries.
Use --display to specify a column or closure for display/search text (disables table mode).
The --display flag accepts either a cell path (e.g., -d name) or a closure (e.g., -d {|it| $it.name}).
The closure receives each item and should return the string to display and search on.
The original value is always returned when selected, regardless of what --display shows.
Keyboard shortcuts:
- Up/Down, j/k, Ctrl+n/p: Navigate items
- Left/Right, h/l: Scroll columns horizontally (table mode, single/multi)
- Shift+Left/Right: Scroll columns horizontally (fuzzy mode)
- Home/End: Jump to first/last item
- PageUp/PageDown: Navigate by page
- Space: Toggle selection (multi mode)
- Tab: Toggle selection and move down (fuzzy multi mode)
- Shift+Tab: Toggle selection and move up (fuzzy multi mode)
- a: Toggle all items (multi mode), Alt+A in fuzzy multi mode
- Ctrl+R: Refine list to only selected items (multi modes)
- Alt+C: Cycle case sensitivity (smart -> CASE -> nocase) in fuzzy modes
- Alt+P: Toggle per-column matching in fuzzy table mode
- Enter: Confirm selection
- Esc: Cancel (all modes)
- q: Cancel (single/multi modes only)
- Ctrl+C: Cancel (all modes)
Fuzzy mode supports readline-style editing:
- Ctrl+A/E: Beginning/end of line
- Ctrl+B/F, Left/Right: Move cursor
- Alt+B/F: Move by word
- Ctrl+U/K: Kill to beginning/end of line
- Ctrl+W, Alt+Backspace: Delete previous word
- Ctrl+D, Delete: Delete character at cursor
Styling (inherited from $env.config.color_config):
- search_result: Match highlighting in fuzzy mode
- hints: Footer text
- separator: Separator line and table column separators
- row_index: Prompt marker and selection marker
- header: Table column headers
- Table column characters inherit from $env.config.table.mode
Use --no-footer and --no-separator to hide the footer and separator line."#
}
fn search_terms(&self) -> Vec<&str> {
vec![
"prompt", "ask", "menu", "select", "pick", "choose", "fzf", "fuzzy",
]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
let head = call.head;
let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
let multi = call.has_flag(engine_state, stack, "multi")?;
let fuzzy = call.has_flag(engine_state, stack, "fuzzy")?;
let index = call.has_flag(engine_state, stack, "index")?;
let display_flag: Option<Value> = call.get_flag(engine_state, stack, "display")?;
let no_footer = call.has_flag(engine_state, stack, "no-footer")?;
let no_separator = call.has_flag(engine_state, stack, "no-separator")?;
let case_sensitive: Option<Value> = call.get_flag(engine_state, stack, "case-sensitive")?;
let no_table = call.has_flag(engine_state, stack, "no-table")?;
let per_column = call.has_flag(engine_state, stack, "per-column")?;
let config = stack.get_config(engine_state);
let style_computer = StyleComputer::from_config(engine_state, stack);
let mut input_list_config = InputListConfig::from_nu_config(&config, &style_computer);
if no_footer {
input_list_config.show_footer = false;
}
if no_separator {
input_list_config.show_separator = false;
}
if let Some(cs) = case_sensitive {
input_list_config.case_sensitivity = match &cs {
Value::Bool { val: true, .. } => CaseSensitivity::CaseSensitive,
Value::Bool { val: false, .. } => CaseSensitivity::CaseInsensitive,
Value::String { val, .. } if val == "smart" => CaseSensitivity::Smart,
Value::String { val, .. } if val == "true" => CaseSensitivity::CaseSensitive,
Value::String { val, .. } if val == "false" => CaseSensitivity::CaseInsensitive,
_ => {
return Err(ShellError::InvalidValue {
valid: "true, false, or 'smart'".to_string(),
actual: cs.to_abbreviated_string(&config),
span: cs.span(),
});
}
};
}
let values: Vec<Value> = match input {
PipelineData::Value(Value::Range { .. }, ..)
| PipelineData::Value(Value::List { .. }, ..)
| PipelineData::ListStream { .. } => input.into_iter().collect(),
_ => {
return Err(ShellError::TypeMismatch {
err_message: "expected a list, a table, or a range".to_string(),
span: head,
});
}
};
let columns = if display_flag.is_none() && !no_table {
get_columns(&values)
} else {
vec![]
};
let is_table_mode = !columns.is_empty();
let options: Vec<SelectItem> = if is_table_mode {
values
.into_iter()
.map(|val| {
let cells: Vec<(String, TextStyle)> = columns
.iter()
.map(|col| {
if let Value::Record { val: record, .. } = &val {
record
.get(col)
.map(|v| nu_value_to_string(v, &config, &style_computer))
.unwrap_or_else(|| (String::new(), TextStyle::default()))
} else {
(String::new(), TextStyle::default())
}
})
.collect();
let name = cells
.iter()
.map(|(s, _)| s.as_str())
.collect::<Vec<_>>()
.join(" ");
SelectItem {
name,
cells: Some(cells),
value: val,
}
})
.collect()
} else {
match &display_flag {
Some(Value::CellPath { val: cellpath, .. }) => values
.into_iter()
.map(|val| {
let display_value = val
.follow_cell_path(&cellpath.members)
.map(|v| v.to_expanded_string(", ", &config))
.unwrap_or_else(|_| val.to_expanded_string(", ", &config));
SelectItem {
name: display_value,
cells: None,
value: val,
}
})
.collect(),
Some(Value::Closure { val: closure, .. }) => {
let mut closure_eval =
ClosureEval::new(engine_state, stack, Closure::clone(closure));
let mut options = Vec::with_capacity(values.len());
for val in values {
let display_value = closure_eval
.run_with_value(val.clone())
.and_then(|data| data.into_value(head))
.map(|v| v.to_expanded_string(", ", &config))
.unwrap_or_else(|_| val.to_expanded_string(", ", &config));
options.push(SelectItem {
name: display_value,
cells: None,
value: val,
});
}
options
}
None => values
.into_iter()
.map(|val| {
let display_value = val.to_expanded_string(", ", &config);
SelectItem {
name: display_value,
cells: None,
value: val,
}
})
.collect(),
_ => {
return Err(ShellError::TypeMismatch {
err_message: "expected a cell path or closure for --display".to_string(),
span: display_flag.as_ref().map(|v| v.span()).unwrap_or(head),
});
}
}
};
let table_layout = if is_table_mode {
Some(Self::calculate_table_layout(&columns, &options))
} else {
None
};
if options.is_empty() {
return Err(ShellError::TypeMismatch {
err_message: "expected a list or table, it can also be a problem with the inner type of your list.".to_string(),
span: head,
});
}
let mode = if multi && fuzzy {
SelectMode::FuzzyMulti
} else if multi {
SelectMode::Multi
} else if fuzzy {
SelectMode::Fuzzy
} else {
SelectMode::Single
};
let mut widget = SelectWidget::new(
mode,
prompt.as_deref(),
&options,
input_list_config,
table_layout,
per_column,
);
let answer = widget.run().map_err(|err| {
IoError::new_with_additional_context(err, call.head, None, INTERACT_ERROR)
})?;
Ok(match answer {
InteractMode::Multi(res) => {
if index {
match res {
Some(opts) => Value::list(
opts.into_iter()
.map(|s| Value::int(s as i64, head))
.collect(),
head,
),
None => Value::nothing(head),
}
} else {
match res {
Some(opts) => Value::list(
opts.iter().map(|s| options[*s].value.clone()).collect(),
head,
),
None => Value::nothing(head),
}
}
}
InteractMode::Single(res) => {
if index {
match res {
Some(opt) => Value::int(opt as i64, head),
None => Value::nothing(head),
}
} else {
match res {
Some(opt) => options[opt].value.clone(),
None => Value::nothing(head),
}
}
}
}
.into_pipeline_data())
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
description: "Return a single value from a list.",
example: "[1 2 3 4 5] | input list 'Rate it'",
result: None,
},
Example {
description: "Return multiple values from a list.",
example: "[Banana Kiwi Pear Peach Strawberry] | input list --multi 'Add fruits to the basket'",
result: None,
},
Example {
description: "Return a single record from a table with fuzzy search.",
example: "ls | input list --fuzzy 'Select the target'",
result: None,
},
Example {
description: "Choose an item from a range.",
example: "1..10 | input list",
result: None,
},
Example {
description: "Return the index of a selected item.",
example: "[Banana Kiwi Pear Peach Strawberry] | input list --index",
result: None,
},
Example {
description: "Choose an item from a table using a column as display value.",
example: "[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d name",
result: None,
},
Example {
description: "Choose an item using a closure to generate display text",
example: r#"[[name price]; [Banana 12] [Kiwi 4] [Pear 7]] | input list -d {|it| $"($it.name): $($it.price)"}"#,
result: None,
},
Example {
description: "Fuzzy search with case-sensitive matching",
example: "[abc ABC aBc] | input list --fuzzy --case-sensitive true",
result: None,
},
Example {
description: "Fuzzy search without the footer showing item count",
example: "ls | input list --fuzzy --no-footer",
result: None,
},
Example {
description: "Fuzzy search without the separator line",
example: "ls | input list --fuzzy --no-separator",
result: None,
},
Example {
description: "Fuzzy search with custom match highlighting color",
example: r#"$env.config.color_config.search_result = "red"; ls | input list --fuzzy"#,
result: None,
},
Example {
description: "Display a table with column rendering",
example: r#"[[name size]; [file1.txt "1.2 KB"] [file2.txt "3.4 KB"]] | input list"#,
result: None,
},
Example {
description: "Display a table as single lines (no table rendering)",
example: "ls | input list --no-table",
result: None,
},
Example {
description: "Fuzzy search with multiple selection (use Tab to toggle)",
example: "ls | input list --fuzzy --multi",
result: None,
},
]
}
}
impl InputList {
fn calculate_table_layout(columns: &[String], options: &[SelectItem]) -> TableLayout {
let mut col_widths: Vec<usize> = columns.iter().map(|c| c.width()).collect();
for item in options {
if let Some(cells) = &item.cells {
for (i, (cell_text, _)) in cells.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(cell_text.width());
}
}
}
}
TableLayout {
columns: columns.to_vec(),
col_widths,
truncated_cols: 0, }
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SelectMode {
Single,
Multi,
Fuzzy,
FuzzyMulti,
}
struct SelectWidget<'a> {
mode: SelectMode,
prompt: Option<&'a str>,
items: &'a [SelectItem],
cursor: usize,
selected: HashSet<usize>,
filter_text: String,
filtered_indices: Vec<usize>,
scroll_offset: usize,
visible_height: u16,
matcher: SkimMatcherV2,
rendered_lines: usize,
prev_cursor: usize,
prev_scroll_offset: usize,
first_render: bool,
fuzzy_cursor_offset: usize,
results_changed: bool,
filter_text_changed: bool,
toggled_item: Option<usize>,
toggled_all: bool,
filter_cursor: usize,
config: InputListConfig,
term_width: u16,
separator_line: String,
table_layout: Option<TableLayout>,
horizontal_offset: usize,
horizontal_scroll_changed: bool,
width_changed: bool,
refined: bool,
refined_base_indices: Vec<usize>,
per_column: bool,
settings_changed: bool,
selected_marker_cached: String,
visible_columns_cache: Option<(usize, bool)>,
}
impl<'a> SelectWidget<'a> {
fn new(
mode: SelectMode,
prompt: Option<&'a str>,
items: &'a [SelectItem],
config: InputListConfig,
table_layout: Option<TableLayout>,
per_column: bool,
) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let matcher = match config.case_sensitivity {
CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
};
let selected_marker_cached = format!(
"{} ",
config
.selected_marker
.paint(config.selected_marker_char.to_string())
);
Self {
mode,
prompt,
items,
cursor: 0,
selected: HashSet::new(),
filter_text: String::new(),
filtered_indices,
scroll_offset: 0,
visible_height: 10,
matcher,
rendered_lines: 0,
prev_cursor: 0,
prev_scroll_offset: 0,
first_render: true,
fuzzy_cursor_offset: 0,
results_changed: true,
filter_text_changed: false,
toggled_item: None,
toggled_all: false,
filter_cursor: 0,
config,
term_width: 0,
separator_line: String::new(),
table_layout,
horizontal_offset: 0,
horizontal_scroll_changed: false,
width_changed: false,
refined: false,
refined_base_indices: Vec::new(),
per_column,
settings_changed: false,
selected_marker_cached,
visible_columns_cache: None,
}
}
fn generate_separator_line(&mut self) {
let sep_width = self.config.separator_char.width();
let repeat_count = if sep_width > 0 {
self.term_width as usize / sep_width
} else {
self.term_width as usize
};
self.separator_line = self.config.separator_char.repeat(repeat_count);
}
fn prompt_marker(&self) -> String {
self.config
.prompt_marker
.paint(&self.config.prompt_marker_text)
.to_string()
}
fn prompt_marker_width(&self) -> usize {
self.config.prompt_marker_text.width()
}
fn position_fuzzy_cursor(&self, stderr: &mut Stderr) -> io::Result<()> {
let text_before_cursor = &self.filter_text[..self.filter_cursor];
let cursor_col = self.prompt_marker_width() + text_before_cursor.width();
execute!(stderr, MoveToColumn(cursor_col as u16))
}
fn selected_marker(&self) -> &str {
&self.selected_marker_cached
}
fn is_table_mode(&self) -> bool {
self.table_layout.is_some()
}
fn is_multi_mode(&self) -> bool {
self.mode == SelectMode::Multi || self.mode == SelectMode::FuzzyMulti
}
fn is_fuzzy_mode(&self) -> bool {
self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti
}
fn toggle_case_sensitivity(&mut self) {
self.config.case_sensitivity = match self.config.case_sensitivity {
CaseSensitivity::Smart => CaseSensitivity::CaseSensitive,
CaseSensitivity::CaseSensitive => CaseSensitivity::CaseInsensitive,
CaseSensitivity::CaseInsensitive => CaseSensitivity::Smart,
};
self.rebuild_matcher();
if !self.filter_text.is_empty() {
self.update_filter();
}
self.settings_changed = true;
}
fn toggle_per_column(&mut self) {
if self.is_table_mode() {
self.per_column = !self.per_column;
if !self.filter_text.is_empty() {
self.update_filter();
}
self.settings_changed = true;
}
}
fn rebuild_matcher(&mut self) {
self.matcher = match self.config.case_sensitivity {
CaseSensitivity::Smart => SkimMatcherV2::default().smart_case(),
CaseSensitivity::CaseSensitive => SkimMatcherV2::default().respect_case(),
CaseSensitivity::CaseInsensitive => SkimMatcherV2::default().ignore_case(),
};
}
fn settings_indicator(&self) -> String {
if !self.is_fuzzy_mode() {
return String::new();
}
let case_str = match self.config.case_sensitivity {
CaseSensitivity::Smart => "smart",
CaseSensitivity::CaseSensitive => "CASE",
CaseSensitivity::CaseInsensitive => "nocase",
};
if self.is_table_mode() && self.per_column {
format!(" [{} col]", case_str)
} else {
format!(" [{}]", case_str)
}
}
fn generate_footer(&self) -> String {
let total_count = self.current_list_len();
let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
let settings = self.settings_indicator();
let position_part = if self.is_multi_mode() {
format!(
"[{}-{} of {}, {} selected]",
self.scroll_offset + 1,
end.min(total_count),
total_count,
self.selected.len()
)
} else {
format!(
"[{}-{} of {}]",
self.scroll_offset + 1,
end.min(total_count),
total_count
)
};
let full_footer = format!("{}{}", position_part, settings);
let max_width = self.term_width as usize;
if full_footer.width() <= max_width {
full_footer
} else if max_width <= 3 {
"…".to_string()
} else {
if position_part.width() <= max_width {
let remaining = max_width - position_part.width();
if remaining <= 4 {
position_part
} else {
let target_width = remaining - 2; let mut current_width = 0;
let mut end_pos = 0;
for (byte_pos, c) in settings.char_indices().skip(2) {
if c == ']' {
break;
}
let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > target_width {
break;
}
end_pos = byte_pos + c.len_utf8();
current_width += char_width;
}
if end_pos > 2 {
format!("{} [{}…]", position_part, &settings[2..end_pos])
} else {
position_part
}
}
} else {
let target_width = max_width - 2; let mut current_width = 0;
let mut end_pos = 0;
for (byte_pos, c) in position_part.char_indices() {
if c == ']' {
break;
}
let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > target_width {
break;
}
end_pos = byte_pos + c.len_utf8();
current_width += char_width;
}
format!("{}…]", &position_part[..end_pos])
}
}
}
fn has_footer(&self) -> bool {
self.config.show_footer
&& (self.is_fuzzy_mode()
|| self.is_multi_mode()
|| self.current_list_len() > self.visible_height as usize)
}
fn render_footer_inline(&self, stderr: &mut Stderr) -> io::Result<()> {
let indicator = self.generate_footer();
execute!(
stderr,
MoveToColumn(0),
Print(self.config.footer.paint(&indicator)),
Clear(ClearType::UntilNewLine),
)
}
fn row_prefix_width(&self) -> usize {
match self.mode {
SelectMode::Multi | SelectMode::FuzzyMulti => 6, _ => 2, }
}
fn table_column_separator(&self) -> String {
format!(" {} ", self.config.table_column_separator)
}
fn table_column_separator_width(&self) -> usize {
UnicodeWidthChar::width(self.config.table_column_separator).unwrap_or(1) + 2
}
fn calculate_visible_columns(&self) -> (usize, bool) {
if let Some(cached) = self.visible_columns_cache {
return cached;
}
let Some(layout) = &self.table_layout else {
return (0, false);
};
Self::calculate_visible_columns_for_layout(
layout,
self.horizontal_offset,
self.term_width as usize,
self.row_prefix_width(),
self.table_column_separator_width(),
)
}
fn calculate_visible_columns_for_layout(
layout: &TableLayout,
horizontal_offset: usize,
term_width: usize,
prefix_width: usize,
separator_width: usize,
) -> (usize, bool) {
let scroll_indicator_width = if horizontal_offset > 0 {
1 + separator_width
} else {
0
};
let available = term_width
.saturating_sub(prefix_width)
.saturating_sub(scroll_indicator_width);
let mut used_width = 0;
let mut cols_fit = 0;
for (i, &col_width) in layout.col_widths.iter().enumerate().skip(horizontal_offset) {
let sep_width = if i > horizontal_offset {
separator_width
} else {
0
};
let needed = col_width + sep_width;
let reserve_right = if i + 1 < layout.col_widths.len() {
separator_width + 1
} else {
0
};
if used_width + needed + reserve_right <= available {
used_width += needed;
cols_fit += 1;
} else {
break;
}
}
let has_more_right = horizontal_offset + cols_fit < layout.col_widths.len();
(cols_fit.max(1), has_more_right) }
fn update_table_layout(&mut self) {
let prefix_width = self.row_prefix_width();
let term_width = self.term_width as usize;
let horizontal_offset = self.horizontal_offset;
let separator_width = self.table_column_separator_width();
if let Some(layout) = &mut self.table_layout {
let result = Self::calculate_visible_columns_for_layout(
layout,
horizontal_offset,
term_width,
prefix_width,
separator_width,
);
layout.truncated_cols = result.0;
self.visible_columns_cache = Some(result);
} else {
self.visible_columns_cache = Some((0, false));
}
}
fn fuzzy_header_lines(&self) -> u16 {
let mut header_lines: u16 = if self.prompt.is_some() { 2 } else { 1 };
if self.config.show_separator {
header_lines += 1;
}
if self.is_table_mode() {
header_lines += 2;
}
header_lines
}
fn fuzzy_filter_row(&self) -> u16 {
if self.prompt.is_some() { 1 } else { 0 }
}
fn update_term_size(&mut self, width: u16, height: u16) {
let new_width = width.saturating_sub(1);
let width_changed = self.term_width != new_width;
self.term_width = new_width;
if width_changed {
self.width_changed = true;
}
if width_changed && self.config.show_separator {
self.generate_separator_line();
}
if width_changed {
self.update_table_layout();
}
let mut reserved: u16 = if self.prompt.is_some() { 1 } else { 0 };
if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
reserved += 1; if self.config.show_separator {
reserved += 1; }
}
if self.is_table_mode() {
reserved += 2; }
if self.config.show_footer {
reserved += 1; }
self.visible_height = height.saturating_sub(reserved).max(1);
}
fn run(&mut self) -> io::Result<InteractMode> {
let mut stderr = io::stderr();
enable_raw_mode()?;
scopeguard::defer! {
let _ = disable_raw_mode();
}
if self.mode != SelectMode::Fuzzy && self.mode != SelectMode::FuzzyMulti {
execute!(stderr, Hide)?;
}
scopeguard::defer! {
let _ = execute!(io::stderr(), Show);
}
let (term_width, term_height) = terminal::size()?;
self.update_term_size(term_width, term_height);
self.render(&mut stderr)?;
loop {
if event::poll(std::time::Duration::from_millis(100))? {
match event::read()? {
Event::Key(key_event) => {
match self.handle_key(key_event) {
KeyAction::Continue => {}
KeyAction::Cancel => {
self.clear_display(&mut stderr)?;
return Ok(match self.mode {
SelectMode::Multi => InteractMode::Multi(None),
_ => InteractMode::Single(None),
});
}
KeyAction::Confirm => {
self.clear_display(&mut stderr)?;
return Ok(self.get_result());
}
}
self.render(&mut stderr)?;
}
Event::Resize(width, height) => {
self.clear_display(&mut stderr)?;
self.update_term_size(width, height);
self.first_render = true;
self.render(&mut stderr)?;
}
_ => {}
}
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
if key.kind == KeyEventKind::Release {
return KeyAction::Continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return KeyAction::Cancel;
}
match self.mode {
SelectMode::Single => self.handle_single_key(key),
SelectMode::Multi => self.handle_multi_key(key),
SelectMode::Fuzzy => self.handle_fuzzy_key(key),
SelectMode::FuzzyMulti => self.handle_fuzzy_multi_key(key),
}
}
fn handle_single_key(&mut self, key: KeyEvent) -> KeyAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
KeyCode::Enter => KeyAction::Confirm,
KeyCode::Char('p' | 'P') if ctrl => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Up | KeyCode::Char('k') => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Char('n' | 'N') if ctrl => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_columns_left();
KeyAction::Continue
}
KeyCode::Right | KeyCode::Char('l') => {
self.scroll_columns_right();
KeyAction::Continue
}
KeyCode::Home => {
self.navigate_home();
KeyAction::Continue
}
KeyCode::End => {
self.navigate_end();
KeyAction::Continue
}
KeyCode::PageUp => {
self.navigate_page_up();
KeyAction::Continue
}
KeyCode::PageDown => {
self.navigate_page_down();
KeyAction::Continue
}
_ => KeyAction::Continue,
}
}
fn handle_multi_key(&mut self, key: KeyEvent) -> KeyAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc | KeyCode::Char('q') => KeyAction::Cancel,
KeyCode::Enter => KeyAction::Confirm,
KeyCode::Char('r' | 'R') if ctrl => {
self.refine_list();
KeyAction::Continue
}
KeyCode::Char('p' | 'P') if ctrl => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Up | KeyCode::Char('k') => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Char('n' | 'N') if ctrl => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_columns_left();
KeyAction::Continue
}
KeyCode::Right | KeyCode::Char('l') => {
self.scroll_columns_right();
KeyAction::Continue
}
KeyCode::Char(' ') => {
self.toggle_current();
KeyAction::Continue
}
KeyCode::Char('a') => {
self.toggle_all();
KeyAction::Continue
}
KeyCode::Home => {
self.navigate_home();
KeyAction::Continue
}
KeyCode::End => {
self.navigate_end();
KeyAction::Continue
}
KeyCode::PageUp => {
self.navigate_page_up();
KeyAction::Continue
}
KeyCode::PageDown => {
self.navigate_page_down();
KeyAction::Continue
}
_ => KeyAction::Continue,
}
}
fn handle_fuzzy_key(&mut self, key: KeyEvent) -> KeyAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => KeyAction::Cancel,
KeyCode::Enter => KeyAction::Confirm,
KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Up => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Down => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Left if shift => {
self.scroll_columns_left();
KeyAction::Continue
}
KeyCode::Right if shift => {
self.scroll_columns_right();
KeyAction::Continue
}
KeyCode::Char('a' | 'A') if ctrl => {
self.filter_cursor = 0;
KeyAction::Continue
}
KeyCode::Char('e' | 'E') if ctrl => {
self.filter_cursor = self.filter_text.len();
KeyAction::Continue
}
KeyCode::Char('b' | 'B') if ctrl => {
self.move_filter_cursor_left();
KeyAction::Continue
}
KeyCode::Char('f' | 'F') if ctrl => {
self.move_filter_cursor_right();
KeyAction::Continue
}
KeyCode::Char('b' | 'B') if alt => {
self.move_filter_cursor_word_left();
KeyAction::Continue
}
KeyCode::Char('f' | 'F') if alt => {
self.move_filter_cursor_word_right();
KeyAction::Continue
}
KeyCode::Char('c' | 'C') if alt => {
self.toggle_case_sensitivity();
KeyAction::Continue
}
KeyCode::Char('p' | 'P') if alt => {
self.toggle_per_column();
KeyAction::Continue
}
KeyCode::Left if ctrl || alt => {
self.move_filter_cursor_word_left();
KeyAction::Continue
}
KeyCode::Right if ctrl || alt => {
self.move_filter_cursor_word_right();
KeyAction::Continue
}
KeyCode::Left => {
self.move_filter_cursor_left();
KeyAction::Continue
}
KeyCode::Right => {
self.move_filter_cursor_right();
KeyAction::Continue
}
KeyCode::Char('u' | 'U') if ctrl => {
self.filter_text.drain(..self.filter_cursor);
self.filter_cursor = 0;
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('k' | 'K') if ctrl => {
self.filter_text.truncate(self.filter_cursor);
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('d' | 'D') if ctrl => {
if self.filter_cursor < self.filter_text.len() {
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Delete => {
if self.filter_cursor < self.filter_text.len() {
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char('d' | 'D') if alt => {
self.delete_word_forwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
self.delete_word_backwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Backspace if alt => {
self.delete_word_backwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Backspace => {
if self.filter_cursor > 0 {
let mut new_pos = self.filter_cursor - 1;
while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
new_pos -= 1;
}
self.filter_cursor = new_pos;
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char('t' | 'T') if ctrl => {
let old_text = self.filter_text.clone();
self.transpose_chars();
if self.filter_text != old_text {
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char(c) => {
self.filter_text.insert(self.filter_cursor, c);
self.filter_cursor += c.len_utf8();
self.update_filter();
KeyAction::Continue
}
KeyCode::Home => {
self.navigate_home();
KeyAction::Continue
}
KeyCode::End => {
self.navigate_end();
KeyAction::Continue
}
KeyCode::PageUp => {
self.navigate_page_up();
KeyAction::Continue
}
KeyCode::PageDown => {
self.navigate_page_down();
KeyAction::Continue
}
_ => KeyAction::Continue,
}
}
fn handle_fuzzy_multi_key(&mut self, key: KeyEvent) -> KeyAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => KeyAction::Cancel,
KeyCode::Enter => KeyAction::Confirm,
KeyCode::Char('r' | 'R') if ctrl => {
self.refine_list();
KeyAction::Continue
}
KeyCode::Tab | KeyCode::Char('\t') => {
self.toggle_current_fuzzy();
self.navigate_down();
KeyAction::Continue
}
KeyCode::BackTab => {
self.toggle_current_fuzzy();
self.navigate_up();
KeyAction::Continue
}
KeyCode::Up | KeyCode::Char('p' | 'P') if ctrl => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Down | KeyCode::Char('n' | 'N') if ctrl => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Up => {
self.navigate_up();
KeyAction::Continue
}
KeyCode::Down => {
self.navigate_down();
KeyAction::Continue
}
KeyCode::Left if shift => {
self.scroll_columns_left();
KeyAction::Continue
}
KeyCode::Right if shift => {
self.scroll_columns_right();
KeyAction::Continue
}
KeyCode::Char('a' | 'A') if ctrl => {
self.filter_cursor = 0;
KeyAction::Continue
}
KeyCode::Char('e' | 'E') if ctrl => {
self.filter_cursor = self.filter_text.len();
KeyAction::Continue
}
KeyCode::Char('b' | 'B') if ctrl => {
self.move_filter_cursor_left();
KeyAction::Continue
}
KeyCode::Char('f' | 'F') if ctrl => {
self.move_filter_cursor_right();
KeyAction::Continue
}
KeyCode::Char('b' | 'B') if alt => {
self.move_filter_cursor_word_left();
KeyAction::Continue
}
KeyCode::Char('f' | 'F') if alt => {
self.move_filter_cursor_word_right();
KeyAction::Continue
}
KeyCode::Char('c' | 'C') if alt => {
self.toggle_case_sensitivity();
KeyAction::Continue
}
KeyCode::Char('p' | 'P') if alt => {
self.toggle_per_column();
KeyAction::Continue
}
KeyCode::Left if ctrl || alt => {
self.move_filter_cursor_word_left();
KeyAction::Continue
}
KeyCode::Right if ctrl || alt => {
self.move_filter_cursor_word_right();
KeyAction::Continue
}
KeyCode::Left => {
self.move_filter_cursor_left();
KeyAction::Continue
}
KeyCode::Right => {
self.move_filter_cursor_right();
KeyAction::Continue
}
KeyCode::Char('u' | 'U') if ctrl => {
self.filter_text.drain(..self.filter_cursor);
self.filter_cursor = 0;
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('k' | 'K') if ctrl => {
self.filter_text.truncate(self.filter_cursor);
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('d' | 'D') if ctrl => {
if self.filter_cursor < self.filter_text.len() {
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Delete => {
if self.filter_cursor < self.filter_text.len() {
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char('d' | 'D') if alt => {
self.delete_word_forwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Char('w' | 'W' | 'h' | 'H') if ctrl => {
self.delete_word_backwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Backspace if alt => {
self.delete_word_backwards();
self.update_filter();
KeyAction::Continue
}
KeyCode::Backspace => {
if self.filter_cursor > 0 {
let mut new_pos = self.filter_cursor - 1;
while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
new_pos -= 1;
}
self.filter_cursor = new_pos;
self.filter_text.remove(self.filter_cursor);
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char('t' | 'T') if ctrl => {
let old_text = self.filter_text.clone();
self.transpose_chars();
if self.filter_text != old_text {
self.update_filter();
}
KeyAction::Continue
}
KeyCode::Char('a' | 'A') if alt => {
self.toggle_all_fuzzy();
KeyAction::Continue
}
KeyCode::Char(c) => {
self.filter_text.insert(self.filter_cursor, c);
self.filter_cursor += c.len_utf8();
self.update_filter();
KeyAction::Continue
}
KeyCode::Home => {
self.navigate_home();
KeyAction::Continue
}
KeyCode::End => {
self.navigate_end();
KeyAction::Continue
}
KeyCode::PageUp => {
self.navigate_page_up();
KeyAction::Continue
}
KeyCode::PageDown => {
self.navigate_page_down();
KeyAction::Continue
}
_ => KeyAction::Continue,
}
}
fn navigate_up(&mut self) {
let list_len = self.current_list_len();
if self.cursor > 0 {
self.cursor -= 1;
self.adjust_scroll_up();
} else if list_len > 0 {
self.cursor = list_len - 1;
self.adjust_scroll_down();
}
}
fn navigate_down(&mut self) {
let list_len = self.current_list_len();
if self.cursor + 1 < list_len {
self.cursor += 1;
self.adjust_scroll_down();
} else {
self.cursor = 0;
self.scroll_offset = 0;
}
}
fn adjust_scroll_down(&mut self) {
let max_visible = self.scroll_offset + self.visible_height as usize;
if self.cursor >= max_visible {
self.scroll_offset = self.cursor - self.visible_height as usize + 1;
}
}
fn adjust_scroll_up(&mut self) {
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
}
}
fn current_list_len(&self) -> usize {
match self.mode {
SelectMode::Fuzzy | SelectMode::FuzzyMulti => self.filtered_indices.len(),
SelectMode::Multi if self.refined => self.filtered_indices.len(),
_ => self.items.len(),
}
}
fn navigate_home(&mut self) {
self.cursor = 0;
self.scroll_offset = 0;
}
fn navigate_end(&mut self) {
self.cursor = self.current_list_len().saturating_sub(1);
self.adjust_scroll_down();
}
fn navigate_page_up(&mut self) {
let page_top = self.scroll_offset;
if self.cursor == page_top {
self.cursor = self.cursor.saturating_sub(self.visible_height as usize);
self.adjust_scroll_up();
} else {
self.cursor = page_top;
}
}
fn navigate_page_down(&mut self) {
let list_len = self.current_list_len();
let page_bottom =
(self.scroll_offset + self.visible_height as usize - 1).min(list_len.saturating_sub(1));
if self.cursor == page_bottom {
self.cursor =
(self.cursor + self.visible_height as usize).min(list_len.saturating_sub(1));
self.adjust_scroll_down();
} else {
self.cursor = page_bottom;
}
}
fn scroll_columns_left(&mut self) -> bool {
if !self.is_table_mode() || self.horizontal_offset == 0 {
return false;
}
self.horizontal_offset -= 1;
self.horizontal_scroll_changed = true;
self.update_table_layout();
true
}
fn scroll_columns_right(&mut self) -> bool {
let Some(layout) = &self.table_layout else {
return false;
};
let (cols_visible, has_more_right) = self.calculate_visible_columns();
if !has_more_right {
return false;
}
if self.horizontal_offset + cols_visible >= layout.col_widths.len() {
return false;
}
self.horizontal_offset += 1;
self.horizontal_scroll_changed = true;
self.update_table_layout();
true
}
fn toggle_current(&mut self) {
if self.refined && self.filtered_indices.is_empty() {
return;
}
let real_idx = if self.refined {
self.filtered_indices[self.cursor]
} else {
self.cursor
};
self.toggle_index(real_idx);
}
fn toggle_index(&mut self, real_idx: usize) {
if self.selected.contains(&real_idx) {
self.selected.remove(&real_idx);
} else {
self.selected.insert(real_idx);
}
self.toggled_item = Some(self.cursor);
}
fn toggle_current_fuzzy(&mut self) -> bool {
if self.filtered_indices.is_empty() {
return false;
}
let real_idx = self.filtered_indices[self.cursor];
self.toggle_index(real_idx);
true
}
fn toggle_all(&mut self) {
let all_selected = if self.refined {
self.filtered_indices
.iter()
.all(|i| self.selected.contains(i))
} else {
(0..self.items.len()).all(|i| self.selected.contains(&i))
};
if all_selected {
if self.refined {
for i in &self.filtered_indices {
self.selected.remove(i);
}
} else {
self.selected.clear();
}
} else {
if self.refined {
self.selected.extend(self.filtered_indices.iter().copied());
} else {
self.selected.extend(0..self.items.len());
}
}
self.toggled_all = true;
}
fn toggle_all_fuzzy(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let all_selected = self
.filtered_indices
.iter()
.all(|i| self.selected.contains(i));
if all_selected {
for i in &self.filtered_indices {
self.selected.remove(i);
}
} else {
self.selected.extend(self.filtered_indices.iter().copied());
}
self.toggled_all = true;
}
fn refine_list(&mut self) {
if self.selected.is_empty() {
return;
}
let mut indices: Vec<usize> = self.selected.iter().copied().collect();
indices.sort();
self.filtered_indices = indices.clone();
self.refined_base_indices = indices;
self.cursor = 0;
self.scroll_offset = 0;
if self.mode == SelectMode::FuzzyMulti {
self.filter_text.clear();
self.filter_cursor = 0;
self.filter_text_changed = true;
}
self.refined = true;
self.first_render = true;
}
fn move_filter_cursor_left(&mut self) {
if self.filter_cursor > 0 {
let mut new_pos = self.filter_cursor - 1;
while new_pos > 0 && !self.filter_text.is_char_boundary(new_pos) {
new_pos -= 1;
}
self.filter_cursor = new_pos;
}
}
fn move_filter_cursor_right(&mut self) {
if self.filter_cursor < self.filter_text.len() {
let mut new_pos = self.filter_cursor + 1;
while new_pos < self.filter_text.len() && !self.filter_text.is_char_boundary(new_pos) {
new_pos += 1;
}
self.filter_cursor = new_pos;
}
}
fn move_filter_cursor_word_left(&mut self) {
if self.filter_cursor == 0 {
return;
}
let bytes = self.filter_text.as_bytes();
let mut pos = self.filter_cursor;
while pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
pos -= 1;
}
while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
pos -= 1;
}
self.filter_cursor = pos;
}
fn move_filter_cursor_word_right(&mut self) {
let len = self.filter_text.len();
if self.filter_cursor >= len {
return;
}
let bytes = self.filter_text.as_bytes();
let mut pos = self.filter_cursor;
while pos < len && !bytes[pos].is_ascii_whitespace() {
pos += 1;
}
while pos < len && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
self.filter_cursor = pos;
}
fn delete_word_backwards(&mut self) {
if self.filter_cursor == 0 {
return;
}
let start = self.filter_cursor;
while self.filter_cursor > 0
&& self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
{
self.filter_cursor -= 1;
}
while self.filter_cursor > 0
&& !self.filter_text.as_bytes()[self.filter_cursor - 1].is_ascii_whitespace()
{
self.filter_cursor -= 1;
}
self.filter_text.drain(self.filter_cursor..start);
}
fn delete_word_forwards(&mut self) {
let len = self.filter_text.len();
if self.filter_cursor >= len {
return;
}
let start = self.filter_cursor;
let bytes = self.filter_text.as_bytes();
let mut end = start;
while end < len && !bytes[end].is_ascii_whitespace() {
end += 1;
}
while end < len && bytes[end].is_ascii_whitespace() {
end += 1;
}
self.filter_text.drain(start..end);
}
fn transpose_chars(&mut self) {
let len = self.filter_text.len();
if len < 2 {
return;
}
if self.filter_cursor == 0 {
return;
}
let pos = if self.filter_cursor >= len {
len - 1
} else {
self.filter_cursor
};
if pos == 0 {
return;
}
if self.filter_text.is_char_boundary(pos - 1)
&& self.filter_text.is_char_boundary(pos)
&& pos < len
&& self.filter_text.is_char_boundary(pos + 1)
{
let bytes = self.filter_text.as_bytes();
if bytes[pos - 1].is_ascii() && bytes[pos].is_ascii() {
let bytes = unsafe { self.filter_text.as_bytes_mut() };
bytes.swap(pos - 1, pos);
if self.filter_cursor < len {
self.filter_cursor += 1;
}
}
}
}
fn score_per_column(&self, item: &SelectItem) -> Option<i64> {
item.cells.as_ref().and_then(|cells| {
cells
.iter()
.filter_map(|(cell_text, _)| self.matcher.fuzzy_match(cell_text, &self.filter_text))
.max()
})
}
fn score_item(&self, item: &SelectItem) -> Option<i64> {
if self.per_column && item.cells.is_some() {
self.score_per_column(item)
} else {
self.matcher.fuzzy_match(&item.name, &self.filter_text)
}
}
fn update_filter(&mut self) {
let old_indices = std::mem::take(&mut self.filtered_indices);
let use_refined = self.refined && !self.refined_base_indices.is_empty();
if self.filter_text.is_empty() {
self.filtered_indices = if use_refined {
self.refined_base_indices.clone()
} else {
(0..self.items.len()).collect()
};
} else {
let mut scored: Vec<(usize, i64)> = if use_refined {
self.refined_base_indices
.iter()
.filter_map(|&i| self.score_item(&self.items[i]).map(|score| (i, score)))
.collect()
} else {
(0..self.items.len())
.filter_map(|i| self.score_item(&self.items[i]).map(|score| (i, score)))
.collect()
};
scored.sort_by(|a, b| b.1.cmp(&a.1));
self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
}
self.results_changed = old_indices != self.filtered_indices;
self.filter_text_changed = true;
if self.results_changed {
self.cursor = 0;
self.scroll_offset = 0;
}
if self.is_table_mode() && !self.filter_text.is_empty() && !self.filtered_indices.is_empty()
{
self.auto_scroll_to_match_column();
}
}
fn auto_scroll_to_match_column(&mut self) {
let Some(layout) = &self.table_layout else {
return;
};
let first_idx = self.filtered_indices[0];
let item = &self.items[first_idx];
let Some(cells) = &item.cells else {
return;
};
let mut first_match_col: Option<usize> = None;
for (col_idx, (cell_text, _)) in cells.iter().enumerate() {
if self.per_column {
if self
.matcher
.fuzzy_match(cell_text, &self.filter_text)
.is_some()
{
first_match_col = Some(col_idx);
break;
}
} else {
let cell_start: usize = cells[..col_idx]
.iter()
.map(|(s, _)| s.chars().count() + 1) .sum();
let cell_char_count = cell_text.chars().count();
if let Some((_, indices)) =
self.matcher.fuzzy_indices(&item.name, &self.filter_text)
{
if indices
.iter()
.any(|&idx| idx >= cell_start && idx < cell_start + cell_char_count)
{
first_match_col = Some(col_idx);
break;
}
}
}
}
if let Some(match_col) = first_match_col {
let (cols_visible, _) = self.calculate_visible_columns();
let visible_start = self.horizontal_offset;
let visible_end = self.horizontal_offset + cols_visible;
if match_col < visible_start {
self.horizontal_offset = match_col;
self.horizontal_scroll_changed = true;
self.update_table_layout();
} else if match_col >= visible_end {
self.horizontal_offset = match_col;
let max_offset = layout.col_widths.len().saturating_sub(1);
self.horizontal_offset = self.horizontal_offset.min(max_offset);
self.horizontal_scroll_changed = true;
self.update_table_layout();
}
}
}
fn get_result(&self) -> InteractMode {
match self.mode {
SelectMode::Single => InteractMode::Single(Some(self.cursor)),
SelectMode::Multi => {
let mut indices: Vec<usize> = self.selected.iter().copied().collect();
indices.sort();
InteractMode::Multi(Some(indices))
}
SelectMode::Fuzzy => {
if self.filtered_indices.is_empty() {
InteractMode::Single(None)
} else {
InteractMode::Single(Some(self.filtered_indices[self.cursor]))
}
}
SelectMode::FuzzyMulti => {
let mut indices: Vec<usize> = self.selected.iter().copied().collect();
indices.sort();
InteractMode::Multi(Some(indices))
}
}
}
fn can_do_multi_toggle_only_update(&self) -> bool {
if self.first_render || self.width_changed || self.mode != SelectMode::Multi {
return false;
}
if let Some(toggled) = self.toggled_item {
let visible_start = self.scroll_offset;
let visible_end = self.scroll_offset + self.visible_height as usize;
toggled >= visible_start && toggled < visible_end
} else {
false
}
}
fn can_do_fuzzy_multi_toggle_update(&self) -> bool {
if self.first_render || self.width_changed || self.mode != SelectMode::FuzzyMulti {
return false;
}
if self.scroll_offset != self.prev_scroll_offset {
return false; }
if self.filter_text_changed || self.results_changed {
return false; }
if let Some(toggled) = self.toggled_item {
let visible_start = self.scroll_offset;
let visible_end = self.scroll_offset + self.visible_height as usize;
let toggled_visible = toggled >= visible_start && toggled < visible_end;
let cursor_visible = self.cursor >= visible_start && self.cursor < visible_end;
toggled_visible && cursor_visible
} else {
false
}
}
fn can_do_fuzzy_multi_toggle_all_update(&self) -> bool {
!self.first_render
&& !self.width_changed
&& self.mode == SelectMode::FuzzyMulti
&& self.toggled_all
&& !self.filter_text_changed
&& !self.results_changed
&& self.scroll_offset == self.prev_scroll_offset
&& !self.horizontal_scroll_changed
}
fn can_do_multi_toggle_all_update(&self) -> bool {
!self.first_render
&& !self.width_changed
&& self.mode == SelectMode::Multi
&& self.toggled_all
}
fn render_fuzzy_multi_toggle_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
let toggled = self.toggled_item.expect("toggled_item must be Some");
execute!(stderr, BeginSynchronizedUpdate)?;
let header_lines = self.fuzzy_header_lines();
let toggled_display_row = (toggled - self.scroll_offset) as u16;
let cursor_display_row = (self.cursor - self.scroll_offset) as u16;
let toggled_item_row = header_lines + toggled_display_row;
let cursor_item_row = header_lines + cursor_display_row;
let filter_row = self.fuzzy_filter_row();
let down_to_toggled = toggled_item_row.saturating_sub(filter_row);
execute!(stderr, MoveDown(down_to_toggled), MoveToColumn(0))?;
let toggled_real_idx = self.filtered_indices[toggled];
let toggled_item = &self.items[toggled_real_idx];
let toggled_checked = self.selected.contains(&toggled_real_idx);
if self.is_table_mode() {
self.render_table_row_fuzzy_multi(stderr, toggled_item, toggled_checked, false)?;
} else {
self.render_fuzzy_multi_item_inline(
stderr,
&toggled_item.name,
toggled_checked,
false,
)?;
}
if cursor_item_row > toggled_item_row {
let lines_down = cursor_item_row - toggled_item_row;
execute!(stderr, MoveDown(lines_down), MoveToColumn(0))?;
} else if cursor_item_row < toggled_item_row {
let lines_up = toggled_item_row - cursor_item_row;
execute!(stderr, MoveUp(lines_up), MoveToColumn(0))?;
}
let cursor_real_idx = self.filtered_indices[self.cursor];
let cursor_item = &self.items[cursor_real_idx];
let cursor_checked = self.selected.contains(&cursor_real_idx);
if self.is_table_mode() {
self.render_table_row_fuzzy_multi(stderr, cursor_item, cursor_checked, true)?;
} else {
self.render_fuzzy_multi_item_inline(stderr, &cursor_item.name, cursor_checked, true)?;
}
if self.has_footer() {
let total_count = self.current_list_len();
let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
let visible_count = (end - self.scroll_offset) as u16;
let footer_row = header_lines + visible_count;
let down_to_footer = footer_row.saturating_sub(cursor_item_row);
execute!(stderr, MoveDown(down_to_footer))?;
self.render_footer_inline(stderr)?;
let up_to_filter = footer_row.saturating_sub(filter_row);
execute!(stderr, MoveUp(up_to_filter))?;
} else {
let up_to_filter = cursor_item_row.saturating_sub(filter_row);
execute!(stderr, MoveUp(up_to_filter))?;
}
self.position_fuzzy_cursor(stderr)?;
self.prev_cursor = self.cursor;
self.toggled_item = None;
execute!(stderr, EndSynchronizedUpdate)?;
stderr.flush()
}
fn render_multi_toggle_only(&mut self, stderr: &mut Stderr) -> io::Result<()> {
let toggled = self.toggled_item.expect("toggled_item must be Some");
execute!(stderr, BeginSynchronizedUpdate)?;
let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
if self.is_table_mode() {
header_lines += 2; }
let display_row = (toggled - self.scroll_offset) as u16;
let items_rendered = self.rendered_lines - header_lines as usize;
let lines_up = (items_rendered as u16)
.saturating_sub(1)
.saturating_sub(display_row);
execute!(stderr, MoveUp(lines_up))?;
execute!(stderr, MoveToColumn(2))?;
let checkbox = if self.selected.contains(&toggled) {
"[x]"
} else {
"[ ]"
};
execute!(stderr, Print(checkbox))?;
execute!(stderr, MoveDown(lines_up))?;
if self.has_footer() {
self.render_footer_inline(stderr)?;
}
self.toggled_item = None;
execute!(stderr, EndSynchronizedUpdate)?;
stderr.flush()
}
fn render_multi_toggle_all(&mut self, stderr: &mut Stderr) -> io::Result<()> {
execute!(stderr, BeginSynchronizedUpdate)?;
let mut header_lines: u16 = if self.prompt.is_some() { 1 } else { 0 };
if self.is_table_mode() {
header_lines += 2; }
let items_rendered = self.rendered_lines - header_lines as usize;
let visible_end = (self.scroll_offset + self.visible_height as usize).min(self.items.len());
let visible_count = visible_end - self.scroll_offset;
execute!(stderr, MoveUp((items_rendered as u16).saturating_sub(1)))?;
for i in 0..visible_count {
let item_idx = self.scroll_offset + i;
let checkbox = if self.selected.contains(&item_idx) {
"[x]"
} else {
"[ ]"
};
execute!(stderr, MoveToColumn(2), Print(checkbox))?;
if i + 1 < visible_count {
execute!(stderr, MoveDown(1))?;
}
}
let remaining = items_rendered as u16 - visible_count as u16;
if remaining > 0 {
execute!(stderr, MoveDown(remaining))?;
}
if self.has_footer() {
self.render_footer_inline(stderr)?;
}
self.toggled_all = false;
execute!(stderr, EndSynchronizedUpdate)?;
stderr.flush()
}
fn render_fuzzy_multi_toggle_all_update(&mut self, stderr: &mut Stderr) -> io::Result<()> {
execute!(stderr, BeginSynchronizedUpdate)?;
let header_lines = self.fuzzy_header_lines();
let total_count = self.current_list_len();
let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
let visible_count = end.saturating_sub(self.scroll_offset);
let filter_row = self.fuzzy_filter_row();
let down_to_first = header_lines.saturating_sub(filter_row);
execute!(stderr, MoveDown(down_to_first), MoveToColumn(0))?;
for (i, idx) in (self.scroll_offset..end).enumerate() {
let real_idx = self.filtered_indices[idx];
let item = &self.items[real_idx];
let checked = self.selected.contains(&real_idx);
let active = idx == self.cursor;
if self.is_table_mode() {
self.render_table_row_fuzzy_multi(stderr, item, checked, active)?;
} else {
self.render_fuzzy_multi_item_inline(stderr, &item.name, checked, active)?;
}
if i + 1 < visible_count {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
}
if self.has_footer() {
let footer_row = header_lines + visible_count as u16;
let last_item_row = header_lines + visible_count.saturating_sub(1) as u16;
let down_to_footer = footer_row.saturating_sub(last_item_row);
execute!(stderr, MoveDown(down_to_footer))?;
self.render_footer_inline(stderr)?;
let up_to_filter = footer_row.saturating_sub(filter_row);
execute!(stderr, MoveUp(up_to_filter))?;
} else {
let up_to_filter =
(header_lines + visible_count.saturating_sub(1) as u16).saturating_sub(filter_row);
execute!(stderr, MoveUp(up_to_filter))?;
}
self.position_fuzzy_cursor(stderr)?;
self.toggled_all = false;
execute!(stderr, EndSynchronizedUpdate)?;
stderr.flush()
}
#[allow(clippy::collapsible_if)]
fn render(&mut self, stderr: &mut Stderr) -> io::Result<()> {
if self.can_do_fuzzy_multi_toggle_all_update() {
return self.render_fuzzy_multi_toggle_all_update(stderr);
}
if self.can_do_multi_toggle_all_update() {
return self.render_multi_toggle_all(stderr);
}
if self.can_do_multi_toggle_only_update() {
return self.render_multi_toggle_only(stderr);
}
if self.can_do_fuzzy_multi_toggle_update() {
return self.render_fuzzy_multi_toggle_update(stderr);
}
if !self.first_render
&& !self.width_changed
&& self.cursor == self.prev_cursor
&& self.scroll_offset == self.prev_scroll_offset
&& !self.results_changed
&& !self.filter_text_changed
&& !self.horizontal_scroll_changed
&& !self.settings_changed
&& !self.toggled_all
{
return Ok(());
}
execute!(stderr, BeginSynchronizedUpdate)?;
let total_count = self.current_list_len();
let end = (self.scroll_offset + self.visible_height as usize).min(total_count);
let has_scroll_indicator = self.has_footer();
let items_to_render = end - self.scroll_offset;
let mut lines_needed: usize = 0;
if self.prompt.is_some() {
lines_needed += 1;
}
if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
lines_needed += 1; if self.config.show_separator {
lines_needed += 1;
}
}
if self.is_table_mode() {
lines_needed += 2; }
lines_needed += items_to_render;
if has_scroll_indicator {
lines_needed += 1;
}
if self.first_render && lines_needed > 1 {
for _ in 0..(lines_needed - 1) {
execute!(stderr, Print("\n"))?;
}
execute!(stderr, MoveUp((lines_needed - 1) as u16))?;
}
if self.fuzzy_cursor_offset > 0 {
execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
self.fuzzy_cursor_offset = 0;
}
if self.rendered_lines > 1 {
execute!(stderr, MoveUp((self.rendered_lines - 1) as u16))?;
}
execute!(stderr, MoveToColumn(0))?;
let mut lines_rendered: usize = 0;
if self.first_render {
if let Some(prompt) = self.prompt {
execute!(stderr, Print(prompt), Clear(ClearType::UntilNewLine))?;
}
}
if self.prompt.is_some() {
lines_rendered += 1;
if lines_rendered < lines_needed {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
}
if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
execute!(
stderr,
Print(self.prompt_marker()),
Print(&self.filter_text),
Clear(ClearType::UntilNewLine),
)?;
lines_rendered += 1;
if lines_rendered < lines_needed {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
if self.config.show_separator {
execute!(
stderr,
Print(self.config.separator.paint(&self.separator_line)),
Clear(ClearType::UntilNewLine),
)?;
lines_rendered += 1;
if lines_rendered < lines_needed {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
}
}
if self.is_table_mode() {
let need_header_redraw = self.first_render || self.horizontal_scroll_changed;
if need_header_redraw {
self.render_table_header(stderr)?;
}
lines_rendered += 1;
if lines_rendered < lines_needed {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
if need_header_redraw {
self.render_table_header_separator(stderr)?;
}
lines_rendered += 1;
if lines_rendered < lines_needed {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
}
for idx in self.scroll_offset..end {
let is_active = idx == self.cursor;
let is_last_line = lines_rendered + 1 == lines_needed;
if self.is_table_mode() {
match self.mode {
SelectMode::Single => {
let item = &self.items[idx];
self.render_table_row_single(stderr, item, is_active)?;
}
SelectMode::Multi => {
let real_idx = if self.refined {
self.filtered_indices[idx]
} else {
idx
};
let item = &self.items[real_idx];
let is_checked = self.selected.contains(&real_idx);
self.render_table_row_multi(stderr, item, is_checked, is_active)?;
}
SelectMode::Fuzzy => {
let real_idx = self.filtered_indices[idx];
let item = &self.items[real_idx];
self.render_table_row_fuzzy(stderr, item, is_active)?;
}
SelectMode::FuzzyMulti => {
let real_idx = self.filtered_indices[idx];
let item = &self.items[real_idx];
let is_checked = self.selected.contains(&real_idx);
self.render_table_row_fuzzy_multi(stderr, item, is_checked, is_active)?;
}
}
} else {
match self.mode {
SelectMode::Single => {
let item = &self.items[idx];
self.render_single_item_inline(stderr, &item.name, is_active)?;
}
SelectMode::Multi => {
let real_idx = if self.refined {
self.filtered_indices[idx]
} else {
idx
};
let item = &self.items[real_idx];
let is_checked = self.selected.contains(&real_idx);
self.render_multi_item_inline(stderr, &item.name, is_checked, is_active)?;
}
SelectMode::Fuzzy => {
let real_idx = self.filtered_indices[idx];
let item = &self.items[real_idx];
self.render_fuzzy_item_inline(stderr, &item.name, is_active)?;
}
SelectMode::FuzzyMulti => {
let real_idx = self.filtered_indices[idx];
let item = &self.items[real_idx];
let is_checked = self.selected.contains(&real_idx);
self.render_fuzzy_multi_item_inline(
stderr, &item.name, is_checked, is_active,
)?;
}
}
}
lines_rendered += 1;
if !is_last_line {
execute!(stderr, MoveDown(1), MoveToColumn(0))?;
}
}
if has_scroll_indicator {
let indicator = self.generate_footer();
execute!(
stderr,
Print(self.config.footer.paint(&indicator)),
Clear(ClearType::UntilNewLine),
)?;
lines_rendered += 1;
}
if lines_rendered < self.rendered_lines {
let extra_lines = self.rendered_lines - lines_rendered;
for _ in 0..extra_lines {
execute!(
stderr,
MoveDown(1),
MoveToColumn(0),
Clear(ClearType::CurrentLine)
)?;
}
execute!(stderr, MoveUp(extra_lines as u16))?;
}
self.rendered_lines = lines_rendered;
self.prev_cursor = self.cursor;
self.prev_scroll_offset = self.scroll_offset;
self.first_render = false;
self.filter_text_changed = false;
self.results_changed = false;
self.horizontal_scroll_changed = false;
self.width_changed = false;
self.toggled_item = None;
self.toggled_all = false;
self.settings_changed = false;
if self.mode == SelectMode::Fuzzy || self.mode == SelectMode::FuzzyMulti {
let filter_row = self.fuzzy_filter_row() as usize;
self.fuzzy_cursor_offset = lines_rendered.saturating_sub(filter_row + 1);
if self.fuzzy_cursor_offset > 0 {
execute!(stderr, MoveUp(self.fuzzy_cursor_offset as u16))?;
}
self.position_fuzzy_cursor(stderr)?;
}
execute!(stderr, EndSynchronizedUpdate)?;
stderr.flush()
}
fn render_single_item_inline(
&self,
stderr: &mut Stderr,
text: &str,
active: bool,
) -> io::Result<()> {
let prefix = if active { self.selected_marker() } else { " " };
let prefix_width = 2;
execute!(stderr, Print(prefix))?;
self.render_truncated_text(stderr, text, prefix_width)?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_multi_item_inline(
&self,
stderr: &mut Stderr,
text: &str,
checked: bool,
active: bool,
) -> io::Result<()> {
let cursor = if active { self.selected_marker() } else { " " };
let checkbox = if checked { "[x] " } else { "[ ] " };
let prefix_width = 6;
execute!(stderr, Print(cursor), Print(checkbox))?;
self.render_truncated_text(stderr, text, prefix_width)?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_fuzzy_item_inline(
&self,
stderr: &mut Stderr,
text: &str,
active: bool,
) -> io::Result<()> {
let prefix = if active { self.selected_marker() } else { " " };
let prefix_width = 2;
execute!(stderr, Print(prefix))?;
if self.filter_text.is_empty() {
self.render_truncated_text(stderr, text, prefix_width)?;
} else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
{
self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
} else {
self.render_truncated_text(stderr, text, prefix_width)?;
}
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_fuzzy_multi_item_inline(
&self,
stderr: &mut Stderr,
text: &str,
checked: bool,
active: bool,
) -> io::Result<()> {
let cursor = if active { self.selected_marker() } else { " " };
let checkbox = if checked { "[x] " } else { "[ ] " };
let prefix_width = 6; execute!(stderr, Print(cursor), Print(checkbox))?;
if self.filter_text.is_empty() {
self.render_truncated_text(stderr, text, prefix_width)?;
} else if let Some((_score, indices)) = self.matcher.fuzzy_indices(text, &self.filter_text)
{
self.render_truncated_fuzzy_text(stderr, text, &indices, prefix_width)?;
} else {
self.render_truncated_text(stderr, text, prefix_width)?;
}
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_truncated_text(
&self,
stderr: &mut Stderr,
text: &str,
prefix_width: usize,
) -> io::Result<()> {
let available_width = (self.term_width as usize).saturating_sub(prefix_width);
let text_width = UnicodeWidthStr::width(text);
if text_width <= available_width {
execute!(stderr, Print(text))?;
} else if available_width <= 1 {
execute!(stderr, Print("…"))?;
} else {
let target_width = available_width - 1;
let mut current_width = 0;
let mut end_pos = 0;
for (byte_pos, c) in text.char_indices() {
let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > target_width {
break;
}
end_pos = byte_pos + c.len_utf8();
current_width += char_width;
}
execute!(stderr, Print(&text[..end_pos]))?;
execute!(stderr, Print("…"))?;
}
Ok(())
}
fn render_truncated_fuzzy_text(
&self,
stderr: &mut Stderr,
text: &str,
match_indices: &[usize],
prefix_width: usize,
) -> io::Result<()> {
let available_width = (self.term_width as usize).saturating_sub(prefix_width);
let text_width = UnicodeWidthStr::width(text);
let mut char_buf = [0u8; 4];
if text_width <= available_width {
let mut match_iter = match_indices.iter().peekable();
for (idx, c) in text.chars().enumerate() {
while match_iter.peek().is_some_and(|&&i| i < idx) {
match_iter.next();
}
let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
if is_match {
let s = c.encode_utf8(&mut char_buf);
execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
} else {
execute!(stderr, Print(c))?;
}
}
} else if available_width <= 1 {
let has_any_matches = !match_indices.is_empty();
if has_any_matches {
execute!(stderr, Print(self.config.match_text.paint("…")))?;
} else {
execute!(stderr, Print("…"))?;
}
} else {
let target_width = available_width - 1;
let mut current_width = 0;
let mut chars_to_render: usize = 0;
for c in text.chars() {
let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > target_width {
break;
}
current_width += char_width;
chars_to_render += 1;
}
let mut match_iter = match_indices.iter().peekable();
for (idx, c) in text.chars().enumerate() {
if idx >= chars_to_render {
break;
}
while match_iter.peek().is_some_and(|&&i| i < idx) {
match_iter.next();
}
let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
if is_match {
let s = c.encode_utf8(&mut char_buf);
execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
} else {
execute!(stderr, Print(c))?;
}
}
let has_hidden_matches = match_iter.any(|&idx| idx >= chars_to_render);
if has_hidden_matches {
execute!(stderr, Print(self.config.match_text.paint("…")))?;
} else {
execute!(stderr, Print("…"))?;
}
}
Ok(())
}
fn render_table_header(&self, stderr: &mut Stderr) -> io::Result<()> {
let Some(layout) = &self.table_layout else {
return Ok(());
};
let prefix_width = self.row_prefix_width();
let (cols_visible, has_more_right) = self.calculate_visible_columns();
let has_more_left = self.horizontal_offset > 0;
execute!(stderr, Print(" ".repeat(prefix_width)))?;
if has_more_left {
let sep = self.table_column_separator();
execute!(
stderr,
Print(self.config.table_separator.paint("…")),
Print(self.config.table_separator.paint(&sep))
)?;
}
let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
for (i, col_idx) in visible_range.enumerate() {
if col_idx >= layout.columns.len() {
break;
}
if i > 0 {
let sep = self.table_column_separator();
execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
}
let header = &layout.columns[col_idx];
let col_width = layout.col_widths[col_idx];
let header_width = header.width();
let padding = col_width.saturating_sub(header_width);
let left_pad = padding / 2;
let right_pad = padding - left_pad;
let header_padded = format!(
"{}{}{}",
" ".repeat(left_pad),
header,
" ".repeat(right_pad)
);
execute!(
stderr,
Print(self.config.table_header.paint(&header_padded))
)?;
}
if has_more_right {
let sep = self.table_column_separator();
execute!(
stderr,
Print(self.config.table_separator.paint(&sep)),
Print(self.config.table_separator.paint("…"))
)?;
}
execute!(stderr, Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_header_separator(&self, stderr: &mut Stderr) -> io::Result<()> {
let Some(layout) = &self.table_layout else {
return Ok(());
};
let prefix_width = self.row_prefix_width();
let (cols_visible, has_more_right) = self.calculate_visible_columns();
let has_more_left = self.horizontal_offset > 0;
let h_char = self.config.table_header_separator;
let int_char = self.config.table_header_intersection;
let prefix_line: String = std::iter::repeat_n(h_char, prefix_width).collect();
execute!(
stderr,
Print(self.config.table_separator.paint(&prefix_line))
)?;
if has_more_left {
let left_indicator = format!("{}{}{}{}", h_char, h_char, int_char, h_char);
execute!(
stderr,
Print(self.config.table_separator.paint(&left_indicator))
)?;
}
let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
for (i, col_idx) in visible_range.enumerate() {
if col_idx >= layout.col_widths.len() {
break;
}
if i > 0 {
let intersection = format!("{}{}{}", h_char, int_char, h_char);
execute!(
stderr,
Print(self.config.table_separator.paint(&intersection))
)?;
}
let col_width = layout.col_widths[col_idx];
let line: String = std::iter::repeat_n(h_char, col_width).collect();
execute!(stderr, Print(self.config.table_separator.paint(&line)))?;
}
if has_more_right {
let right_indicator = format!("{}{}{}{}", h_char, int_char, h_char, h_char);
execute!(
stderr,
Print(self.config.table_separator.paint(&right_indicator))
)?;
}
execute!(stderr, Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_row_single(
&self,
stderr: &mut Stderr,
item: &SelectItem,
active: bool,
) -> io::Result<()> {
let prefix = if active { self.selected_marker() } else { " " };
execute!(stderr, Print(prefix))?;
self.render_table_cells(stderr, item, None)?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_row_multi(
&self,
stderr: &mut Stderr,
item: &SelectItem,
checked: bool,
active: bool,
) -> io::Result<()> {
let cursor = if active { self.selected_marker() } else { " " };
let checkbox = if checked { "[x] " } else { "[ ] " };
execute!(stderr, Print(cursor), Print(checkbox))?;
self.render_table_cells(stderr, item, None)?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_row_fuzzy(
&self,
stderr: &mut Stderr,
item: &SelectItem,
active: bool,
) -> io::Result<()> {
let prefix = if active { self.selected_marker() } else { " " };
execute!(stderr, Print(prefix))?;
let match_indices = if !self.filter_text.is_empty() && !self.per_column {
self.matcher
.fuzzy_indices(&item.name, &self.filter_text)
.map(|(_, indices)| indices)
} else {
None
};
self.render_table_cells(stderr, item, match_indices.as_deref())?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_row_fuzzy_multi(
&self,
stderr: &mut Stderr,
item: &SelectItem,
checked: bool,
active: bool,
) -> io::Result<()> {
let cursor = if active { self.selected_marker() } else { " " };
let checkbox = if checked { "[x] " } else { "[ ] " };
execute!(stderr, Print(cursor), Print(checkbox))?;
let match_indices = if !self.filter_text.is_empty() && !self.per_column {
self.matcher
.fuzzy_indices(&item.name, &self.filter_text)
.map(|(_, indices)| indices)
} else {
None
};
self.render_table_cells(stderr, item, match_indices.as_deref())?;
execute!(stderr, Print(RESET), Clear(ClearType::UntilNewLine))?;
Ok(())
}
fn render_table_cells(
&self,
stderr: &mut Stderr,
item: &SelectItem,
match_indices: Option<&[usize]>,
) -> io::Result<()> {
let Some(layout) = &self.table_layout else {
return Ok(());
};
let Some(cells) = &item.cells else {
return Ok(());
};
let (cols_visible, has_more_right) = self.calculate_visible_columns();
let has_more_left = self.horizontal_offset > 0;
let mut matches_in_hidden_left = false;
let mut matches_in_hidden_right = false;
let per_column_matches: Vec<Option<Vec<usize>>> =
if self.per_column && !self.filter_text.is_empty() {
cells
.iter()
.map(|(cell_text, _)| {
self.matcher
.fuzzy_indices(cell_text, &self.filter_text)
.map(|(_, indices)| indices)
})
.collect()
} else {
vec![]
};
let cell_offsets: Vec<usize> = if match_indices.is_some() {
let mut offsets = Vec::with_capacity(cells.len());
let mut offset = 0;
for (i, (cell_text, _)) in cells.iter().enumerate() {
offsets.push(offset);
offset += cell_text.chars().count();
if i + 1 < cells.len() {
offset += 1; }
}
offsets
} else {
vec![]
};
if self.per_column && !self.filter_text.is_empty() {
for col_idx in 0..self.horizontal_offset {
if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
matches_in_hidden_left = true;
break;
}
}
} else if let Some(indices) = match_indices {
for col_idx in 0..self.horizontal_offset {
if col_idx < cell_offsets.len() && col_idx + 1 < cell_offsets.len() {
let cell_start = cell_offsets[col_idx];
let cell_end = cell_offsets[col_idx + 1].saturating_sub(1); if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
matches_in_hidden_left = true;
break;
}
}
}
}
if has_more_left {
let sep = self.table_column_separator();
if matches_in_hidden_left {
execute!(
stderr,
Print(self.config.match_text.paint("…")),
Print(self.config.table_separator.paint(&sep))
)?;
} else {
execute!(
stderr,
Print(self.config.table_separator.paint("…")),
Print(self.config.table_separator.paint(&sep))
)?;
}
}
let visible_range = self.horizontal_offset..(self.horizontal_offset + cols_visible);
for (i, col_idx) in visible_range.enumerate() {
if col_idx >= cells.len() {
break;
}
if i > 0 {
let sep = self.table_column_separator();
execute!(stderr, Print(self.config.table_separator.paint(&sep)))?;
}
let (cell_text, cell_style) = &cells[col_idx];
let col_width = layout.col_widths[col_idx];
let cell_matches: Option<Vec<usize>> =
if self.per_column && !self.filter_text.is_empty() {
per_column_matches.get(col_idx).cloned().flatten()
} else if let Some(indices) = match_indices {
if col_idx < cell_offsets.len() {
let cell_start = cell_offsets[col_idx];
let cell_char_count = cell_text.chars().count();
let relative_indices: Vec<usize> = indices
.iter()
.filter_map(|&idx| {
if idx >= cell_start && idx < cell_start + cell_char_count {
Some(idx - cell_start)
} else {
None
}
})
.collect();
if relative_indices.is_empty() {
None
} else {
Some(relative_indices)
}
} else {
None
}
} else {
None
};
self.render_table_cell(
stderr,
cell_text,
cell_style,
col_width,
cell_matches.as_deref(),
)?;
}
if self.per_column && !self.filter_text.is_empty() {
for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
if col_idx < per_column_matches.len() && per_column_matches[col_idx].is_some() {
matches_in_hidden_right = true;
break;
}
}
} else if let Some(indices) = match_indices {
for col_idx in (self.horizontal_offset + cols_visible)..cells.len() {
if col_idx < cell_offsets.len() {
let cell_start = cell_offsets[col_idx];
let cell_end = if col_idx + 1 < cell_offsets.len() {
cell_offsets[col_idx + 1].saturating_sub(1)
} else {
item.name.chars().count()
};
if indices.iter().any(|&i| i >= cell_start && i < cell_end) {
matches_in_hidden_right = true;
break;
}
}
}
}
if has_more_right {
let sep = self.table_column_separator();
if matches_in_hidden_right {
execute!(
stderr,
Print(self.config.table_separator.paint(&sep)),
Print(self.config.match_text.paint("…"))
)?;
} else {
execute!(
stderr,
Print(self.config.table_separator.paint(&sep)),
Print(self.config.table_separator.paint("…"))
)?;
}
}
Ok(())
}
fn render_table_cell(
&self,
stderr: &mut Stderr,
cell: &str,
cell_style: &TextStyle,
col_width: usize,
match_indices: Option<&[usize]>,
) -> io::Result<()> {
let cell_width = cell.width();
let padding_needed = col_width.saturating_sub(cell_width);
let (left_pad, right_pad) = match cell_style.alignment {
Alignment::Left => (0, padding_needed),
Alignment::Right => (padding_needed, 0),
Alignment::Center => {
let left = padding_needed / 2;
(left, padding_needed - left)
}
};
if left_pad > 0 {
execute!(stderr, Print(" ".repeat(left_pad)))?;
}
if let Some(indices) = match_indices {
let mut char_buf = [0u8; 4];
let mut match_iter = indices.iter().peekable();
for (idx, c) in cell.chars().enumerate() {
while match_iter.peek().is_some_and(|&&i| i < idx) {
match_iter.next();
}
let is_match = match_iter.peek().is_some_and(|&&i| i == idx);
if is_match {
let s = c.encode_utf8(&mut char_buf);
execute!(stderr, Print(self.config.match_text.paint(&*s)))?;
} else {
let s = c.encode_utf8(&mut char_buf);
if let Some(color) = cell_style.color_style {
execute!(stderr, Print(color.paint(&*s)))?;
} else {
execute!(stderr, Print(&*s))?;
}
}
}
} else {
if let Some(color) = cell_style.color_style {
execute!(stderr, Print(color.paint(cell)))?;
} else {
execute!(stderr, Print(cell))?;
}
}
if right_pad > 0 {
execute!(stderr, Print(" ".repeat(right_pad)))?;
}
Ok(())
}
fn clear_display(&mut self, stderr: &mut Stderr) -> io::Result<()> {
if self.fuzzy_cursor_offset > 0 {
execute!(stderr, MoveDown(self.fuzzy_cursor_offset as u16))?;
self.fuzzy_cursor_offset = 0;
}
if self.rendered_lines > 0 {
execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
for _ in 1..self.rendered_lines {
execute!(
stderr,
MoveUp(1),
MoveToColumn(0),
Clear(ClearType::CurrentLine)
)?;
}
}
self.rendered_lines = 0;
stderr.flush()
}
}
enum KeyAction {
Continue,
Cancel,
Confirm,
}
#[cfg(test)]
mod test {
use super::*;
fn make_widget(items: &[&str]) -> SelectWidget<'static> {
let options: Vec<SelectItem> = items
.iter()
.map(|s| SelectItem {
name: s.to_string(),
cells: None,
value: nu_protocol::Value::nothing(nu_protocol::Span::unknown()),
})
.collect();
let leaked: &'static [SelectItem] = Box::leak(options.into_boxed_slice());
SelectWidget::new(
SelectMode::Single,
None,
leaked,
InputListConfig::default(),
None,
false,
)
}
#[test]
fn wrap_up_and_down_cycles() {
let mut w = make_widget(&["A", "B", "C"]);
w.navigate_up();
assert_eq!(w.cursor, 2);
w.navigate_up();
assert_eq!(w.cursor, 1);
w.navigate_up();
assert_eq!(w.cursor, 0);
w.navigate_down();
assert_eq!(w.cursor, 1);
w.navigate_down();
assert_eq!(w.cursor, 2);
w.navigate_down();
assert_eq!(w.cursor, 0);
}
#[test]
fn down_navigation_cycles_with_full_redraw() -> io::Result<()> {
let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
w.first_render = false;
w.prev_cursor = 0;
w.prev_scroll_offset = 0;
w.cursor = 0;
w.scroll_offset = 0;
let mut stderr = io::stderr();
for _ in 0..7 {
w.navigate_down();
w.render(&mut stderr)?;
assert_eq!(w.scroll_offset, 0);
}
Ok(())
}
#[test]
fn up_arrow_sequence_state_and_render() -> io::Result<()> {
let mut w = make_widget(&["Banana", "Kiwi", "Pear"]);
w.first_render = false;
w.prev_cursor = 0;
w.prev_scroll_offset = 0;
w.cursor = 0;
w.scroll_offset = 0;
let mut stderr = io::stderr();
w.render(&mut stderr)?;
assert_eq!(w.cursor, 0);
w.navigate_up();
w.render(&mut stderr)?;
assert_eq!(w.cursor, 2);
w.navigate_up();
w.render(&mut stderr)?;
assert_eq!(w.cursor, 1);
Ok(())
}
#[test]
fn test_examples() -> nu_test_support::Result {
nu_test_support::test().examples(InputList)
}
}