use std::collections::{HashMap, HashSet};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthChar;
use crate::ui::fuzzy::fuzzy_match;
use crate::ui::widgets::preview_helpers::{
calculate_preview_height, render_preview_metadata, PREVIEW_MAX_WIDTH,
};
use crate::ui::{SelectItemMetadata, TextInputState};
#[derive(Debug, Clone)]
pub struct MultiSelectItem {
pub label: String,
pub value: String,
pub description: Option<String>,
pub disabled: bool,
pub disabled_reason: Option<String>,
pub metadata: Option<SelectItemMetadata>,
}
impl MultiSelectItem {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
description: None,
disabled: false,
disabled_reason: None,
metadata: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn disabled(mut self, reason: impl Into<String>) -> Self {
self.disabled = true;
self.disabled_reason = Some(reason.into());
self
}
pub fn with_metadata(mut self, metadata: SelectItemMetadata) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone)]
pub struct MultiSelectState {
pub items: Vec<MultiSelectItem>,
pub selected_indices: HashSet<usize>,
pub cursor_index: usize,
pub scroll_offset: usize,
pub filtered_indices: Vec<usize>,
pub max_display: usize,
pub match_indices: HashMap<usize, Vec<usize>>,
}
impl MultiSelectState {
pub fn new(items: Vec<MultiSelectItem>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
Self {
items,
selected_indices: HashSet::new(),
cursor_index: 0,
scroll_offset: 0,
filtered_indices,
max_display: 10,
match_indices: HashMap::new(),
}
}
pub fn with_max_display(mut self, max_display: usize) -> Self {
self.max_display = max_display;
self
}
pub fn toggle_current(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let item_idx = self.filtered_indices[self.cursor_index];
if self.items[item_idx].disabled {
return;
}
if self.selected_indices.contains(&item_idx) {
self.selected_indices.remove(&item_idx);
} else {
self.selected_indices.insert(item_idx);
}
}
pub fn toggle_all(&mut self) {
let selectable: Vec<usize> = self
.filtered_indices
.iter()
.copied()
.filter(|&i| !self.items[i].disabled)
.collect();
let all_selected = selectable.iter().all(|i| self.selected_indices.contains(i));
if all_selected {
for idx in selectable {
self.selected_indices.remove(&idx);
}
} else {
for idx in selectable {
self.selected_indices.insert(idx);
}
}
}
pub fn move_up(&mut self) {
if self.cursor_index > 0 {
self.cursor_index -= 1;
if self.cursor_index < self.scroll_offset {
self.scroll_offset = self.cursor_index;
}
}
}
pub fn move_down(&mut self) {
if self.cursor_index + 1 < self.filtered_indices.len() {
self.cursor_index += 1;
if self.cursor_index >= self.scroll_offset + self.max_display {
self.scroll_offset = self.cursor_index - self.max_display + 1;
}
}
}
pub fn update_filter(&mut self, query: &str) {
self.match_indices.clear();
if query.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
self.adjust_cursor_and_scroll();
return;
}
let mut matches: Vec<(usize, i64, Vec<usize>)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
fuzzy_match(query, &item.label).map(|m| (i, m.score, m.indices))
})
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
self.filtered_indices = matches.iter().map(|(i, _, _)| *i).collect();
self.match_indices = matches
.into_iter()
.map(|(i, _, indices)| (i, indices))
.collect();
self.adjust_cursor_and_scroll();
}
fn adjust_cursor_and_scroll(&mut self) {
let max_index = self.filtered_indices.len().saturating_sub(1);
self.cursor_index = self.cursor_index.min(max_index);
self.scroll_offset = self.scroll_offset.min(self.cursor_index);
}
pub fn selected_items(&self) -> Vec<&MultiSelectItem> {
self.selected_indices
.iter()
.map(|&i| &self.items[i])
.collect()
}
pub fn selected_count(&self) -> usize {
self.selected_indices.len()
}
pub fn selectable_count(&self) -> usize {
self.items.iter().filter(|item| !item.disabled).count()
}
}
pub struct MultiSelectListWidget<'a> {
title: &'a str,
placeholder: &'a str,
input: &'a TextInputState,
state: &'a MultiSelectState,
}
impl<'a> MultiSelectListWidget<'a> {
pub fn new(
title: &'a str,
placeholder: &'a str,
input: &'a TextInputState,
state: &'a MultiSelectState,
) -> Self {
Self {
title,
placeholder,
input,
state,
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_label_with_highlight(
buf: &mut Buffer,
x: u16,
y: u16,
label: &str,
match_indices: &[usize],
base_style: Style,
highlight_style: Style,
max_width: u16,
) {
let mut current_x = x;
for (i, c) in label.chars().enumerate() {
if current_x >= x + max_width {
break;
}
let style = if match_indices.contains(&i) {
highlight_style
} else {
base_style
};
buf.set_string(current_x, y, c.to_string(), style);
let char_width = UnicodeWidthChar::width(c).unwrap_or(1) as u16;
current_x += char_width;
}
}
fn adjust_scroll_offset(
desired_scroll_offset: usize,
cursor_index: usize,
max_display: usize,
total_items: usize,
) -> usize {
if total_items == 0 {
return 0;
}
let max_display = max_display.max(1);
let mut scroll_offset = desired_scroll_offset.min(cursor_index);
if cursor_index >= scroll_offset + max_display {
scroll_offset = cursor_index.saturating_sub(max_display - 1);
}
scroll_offset.min(total_items.saturating_sub(max_display))
}
impl Widget for MultiSelectListWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 20 || area.height < 10 {
return;
}
const TITLE_HEIGHT: u16 = 2; const STATS_HEIGHT: u16 = 2; const INPUT_HEIGHT: u16 = 2; const LIST_HEADER_HEIGHT: u16 = 1; const HELP_HEIGHT: u16 = 3; const MAX_PREVIEW_HEIGHT: u16 = 15;
const MIN_LIST_HEIGHT: u16 = 4;
let selected_count = self.state.selected_indices.len();
let ideal_preview_height = if !self.state.filtered_indices.is_empty() {
let cursor_item_idx = self.state.filtered_indices[self.state.cursor_index];
let cursor_item = &self.state.items[cursor_item_idx];
calculate_preview_height(&cursor_item.metadata).min(MAX_PREVIEW_HEIGHT)
} else {
0
};
let selected_panel_height: u16 = if selected_count > 0 {
(selected_count.min(5) as u16) + 3 } else {
0
};
let fixed_top_height = TITLE_HEIGHT + STATS_HEIGHT + INPUT_HEIGHT + LIST_HEADER_HEIGHT;
let available_height = area.height.saturating_sub(fixed_top_height + HELP_HEIGHT);
let preview_and_selected = if ideal_preview_height >= 3 {
ideal_preview_height + 1 + selected_panel_height
} else {
selected_panel_height
};
let (list_max_height, preview_height) =
if available_height >= MIN_LIST_HEIGHT + preview_and_selected {
(
available_height.saturating_sub(preview_and_selected),
ideal_preview_height,
)
} else if available_height >= MIN_LIST_HEIGHT {
let remaining = available_height.saturating_sub(MIN_LIST_HEIGHT);
let adjusted_preview = if remaining >= 3 + selected_panel_height {
(remaining.saturating_sub(selected_panel_height + 1)).max(3)
} else {
0 };
(MIN_LIST_HEIGHT, adjusted_preview)
} else {
(available_height, 0)
};
let mut y = area.y;
let title_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
buf.set_string(area.x, y, self.title, title_style);
y += 2;
let cursor_pos = if self.state.filtered_indices.is_empty() {
0
} else {
self.state.cursor_index + 1
};
let stats_line = Line::from(vec![
Span::styled(
format!(
"{} / {} items",
self.state.filtered_indices.len(),
self.state.items.len()
),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!(" • cursor at {}", cursor_pos),
Style::default().fg(Color::DarkGray),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{} selected", self.state.selected_indices.len()),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]);
buf.set_line(area.x, y, &stats_line, area.width);
y += 2;
buf.set_string(
area.x,
y,
self.placeholder,
Style::default().fg(Color::DarkGray),
);
y += 1;
buf.set_string(
area.x,
y,
"❯ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let before_cursor = self.input.text_before_cursor();
buf.set_string(
area.x + 2,
y,
&before_cursor,
Style::default().fg(Color::White),
);
let cursor_x = area.x + 2 + before_cursor.chars().count() as u16;
if cursor_x < area.x + area.width {
buf.set_string(cursor_x, y, "█", Style::default().fg(Color::Cyan));
}
let after_cursor = self.input.text_after_cursor();
if cursor_x + 1 < area.x + area.width {
buf.set_string(
cursor_x + 1,
y,
&after_cursor,
Style::default().fg(Color::White),
);
}
y += 2;
let list_end_y = y + list_max_height;
if self.state.filtered_indices.is_empty() {
buf.set_string(
area.x,
y,
"No matches found",
Style::default().fg(Color::Red),
);
y += 2;
} else {
let list_height = list_max_height as usize;
let total_items = self.state.filtered_indices.len();
let cursor_index = self.state.cursor_index.min(total_items.saturating_sub(1));
let desired_scroll_offset = self.state.scroll_offset.min(cursor_index);
let desired_max_display = self.state.max_display.max(1);
let min_visible_items = 3usize.min(list_height).max(1);
let mut show_top_more = false;
let mut show_bottom_more = false;
let mut effective_max_display = if list_height > 0 {
desired_max_display.min(list_height).max(1)
} else {
0
};
let mut effective_scroll_offset = desired_scroll_offset;
if effective_max_display > 0 {
for _ in 0..3 {
let indicator_lines = (show_top_more as usize) + (show_bottom_more as usize);
effective_max_display = desired_max_display
.min(list_height.saturating_sub(indicator_lines))
.max(1);
effective_scroll_offset = adjust_scroll_offset(
desired_scroll_offset,
cursor_index,
effective_max_display,
total_items,
);
let visible_end =
(effective_scroll_offset + effective_max_display).min(total_items);
let hidden_above = effective_scroll_offset > 0;
let hidden_below = visible_end < total_items;
let mut next_show_top_more = hidden_above;
let mut next_show_bottom_more = hidden_below;
while (next_show_top_more as usize + next_show_bottom_more as usize) > 0
&& list_height.saturating_sub(
next_show_top_more as usize + next_show_bottom_more as usize,
) < min_visible_items
{
if next_show_top_more {
next_show_top_more = false;
} else {
next_show_bottom_more = false;
}
}
if next_show_top_more == show_top_more
&& next_show_bottom_more == show_bottom_more
{
show_top_more = next_show_top_more;
show_bottom_more = next_show_bottom_more;
break;
}
show_top_more = next_show_top_more;
show_bottom_more = next_show_bottom_more;
}
let indicator_lines = (show_top_more as usize) + (show_bottom_more as usize);
effective_max_display = desired_max_display
.min(list_height.saturating_sub(indicator_lines))
.max(1);
effective_scroll_offset = adjust_scroll_offset(
desired_scroll_offset,
cursor_index,
effective_max_display,
total_items,
);
}
let visible_end = (effective_scroll_offset + effective_max_display).min(total_items);
if show_top_more && y < list_end_y {
let msg = format!("↑ {} more", effective_scroll_offset);
buf.set_string(area.x, y, &msg, Style::default().fg(Color::Yellow));
y += 1;
}
for display_idx in effective_scroll_offset..visible_end {
if y >= list_end_y {
break;
}
let item_idx = self.state.filtered_indices[display_idx];
let item = &self.state.items[item_idx];
let is_cursor = display_idx == self.state.cursor_index;
let is_selected = self.state.selected_indices.contains(&item_idx);
let cursor_prefix = if is_cursor { "▶ " } else { " " };
let cursor_style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
buf.set_string(area.x, y, cursor_prefix, cursor_style);
let (checkbox, checkbox_style) = if item.disabled {
("[-]", Style::default().fg(Color::DarkGray))
} else if is_selected {
(
"[x]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
("[ ]", Style::default().fg(Color::Gray))
};
buf.set_string(area.x + 2, y, checkbox, checkbox_style);
let label_style = if item.disabled {
Style::default().fg(Color::DarkGray)
} else if is_cursor {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
if let Some(indices) = self.state.match_indices.get(&item_idx) {
let highlight_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
render_label_with_highlight(
buf,
area.x + 6,
y,
&item.label,
indices,
label_style,
highlight_style,
area.width.saturating_sub(6),
);
} else {
buf.set_string(area.x + 6, y, &item.label, label_style);
}
if item.disabled {
if let Some(ref reason) = item.disabled_reason {
let reason_x = area.x + 6 + item.label.chars().count() as u16 + 1;
if reason_x < area.x + area.width {
buf.set_string(
reason_x,
y,
format!("({})", reason),
Style::default().fg(Color::DarkGray),
);
}
}
}
y += 1;
}
let hidden_below = self
.state
.filtered_indices
.len()
.saturating_sub(visible_end);
if show_bottom_more && hidden_below > 0 && y < list_end_y {
let msg = format!("↓ {} more", hidden_below);
buf.set_string(area.x, y, &msg, Style::default().fg(Color::Yellow));
y += 1;
}
y += 1;
if !self.state.filtered_indices.is_empty() && preview_height >= 3 {
let cursor_item_idx = self.state.filtered_indices[self.state.cursor_index];
let cursor_item = &self.state.items[cursor_item_idx];
let preview_width = area.width.min(PREVIEW_MAX_WIDTH);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Preview ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let preview_area = Rect::new(area.x, y, preview_width, preview_height);
let inner = block.inner(preview_area);
block.render(preview_area, buf);
buf.set_string(
inner.x,
inner.y,
&cursor_item.label,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
if let Some(ref metadata) = cursor_item.metadata {
render_preview_metadata(buf, inner, metadata, preview_width);
}
y += preview_height + 1;
}
let selected_items: Vec<_> = self.state.selected_items();
let selected_panel_height = (selected_items.len().min(5) + 2) as u16;
if !selected_items.is_empty() && y + selected_panel_height < area.y + area.height {
let title = format!("Selected ({} items)", selected_items.len());
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green))
.title(Span::styled(
title,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
let selected_preview_height = (selected_items.len().min(5) + 2) as u16;
let selected_preview_width = area.width.min(50);
let selected_preview_area =
Rect::new(area.x, y, selected_preview_width, selected_preview_height);
let inner = block.inner(selected_preview_area);
block.render(selected_preview_area, buf);
for (i, item) in selected_items.iter().take(5).enumerate() {
buf.set_string(
inner.x,
inner.y + i as u16,
format!("• {}", item.label),
Style::default().fg(Color::Gray),
);
}
if selected_items.len() > 5 {
buf.set_string(
inner.x,
inner.y + 5,
format!("... {} more", selected_items.len() - 5),
Style::default().fg(Color::DarkGray),
);
}
y += selected_preview_height + 1;
}
}
if y < area.y + area.height {
let help_line1 = Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" navigate • "),
Span::styled("Space", Style::default().fg(Color::Yellow)),
Span::raw(" toggle • "),
Span::styled("Enter", Style::default().fg(Color::Green)),
Span::raw(" confirm • "),
Span::styled("Esc", Style::default().fg(Color::Red)),
Span::raw(" cancel"),
]);
buf.set_line(area.x, y, &help_line1, area.width);
y += 1;
}
if y < area.y + area.height {
let help_line2 = Line::from(vec![
Span::styled("Ctrl+A", Style::default().fg(Color::Cyan)),
Span::raw(" select all / clear all"),
]);
buf.set_line(area.x, y, &help_line2, area.width);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_items() -> Vec<MultiSelectItem> {
vec![
MultiSelectItem::new("feature/auth", "/path/auth"),
MultiSelectItem::new("main", "/path/main").disabled("MAIN"),
MultiSelectItem::new("feature/dashboard", "/path/dashboard"),
MultiSelectItem::new("feature/settings", "/path/settings"),
]
}
#[test]
fn test_multi_select_state_new() {
let items = create_test_items();
let state = MultiSelectState::new(items);
assert_eq!(state.items.len(), 4);
assert!(state.selected_indices.is_empty());
assert_eq!(state.cursor_index, 0);
assert_eq!(state.filtered_indices, vec![0, 1, 2, 3]);
}
#[test]
fn test_toggle_current() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
assert!(state.selected_indices.is_empty());
state.toggle_current();
assert!(state.selected_indices.contains(&0));
state.toggle_current();
assert!(!state.selected_indices.contains(&0));
}
#[test]
fn test_toggle_disabled_item() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.cursor_index = 1;
state.toggle_current();
assert!(!state.selected_indices.contains(&1));
}
#[test]
fn test_toggle_all() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.toggle_all();
assert!(state.selected_indices.contains(&0));
assert!(!state.selected_indices.contains(&1)); assert!(state.selected_indices.contains(&2));
assert!(state.selected_indices.contains(&3));
assert_eq!(state.selected_indices.len(), 3);
state.toggle_all();
assert!(state.selected_indices.is_empty());
}
#[test]
fn test_move_up_down() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
assert_eq!(state.cursor_index, 0);
state.move_down();
assert_eq!(state.cursor_index, 1);
state.move_down();
assert_eq!(state.cursor_index, 2);
state.move_up();
assert_eq!(state.cursor_index, 1);
state.cursor_index = 0;
state.move_up();
assert_eq!(state.cursor_index, 0);
state.cursor_index = 3;
state.move_down();
assert_eq!(state.cursor_index, 3);
}
#[test]
fn test_update_filter() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.update_filter("feature");
assert_eq!(state.filtered_indices.len(), 3);
assert!(state.filtered_indices.contains(&0));
assert!(state.filtered_indices.contains(&2));
assert!(state.filtered_indices.contains(&3));
state.update_filter("auth");
assert_eq!(state.filtered_indices.len(), 1);
assert!(state.filtered_indices.contains(&0));
state.update_filter("");
assert_eq!(state.filtered_indices, vec![0, 1, 2, 3]);
}
#[test]
fn test_fuzzy_match() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.update_filter("fauth");
assert!(!state.filtered_indices.is_empty());
assert!(state.filtered_indices.contains(&0));
}
#[test]
fn test_match_indices() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.update_filter("auth");
assert!(!state.match_indices.is_empty());
assert!(state.match_indices.contains_key(&0));
let indices = state.match_indices.get(&0).unwrap();
assert!(!indices.is_empty());
}
#[test]
fn test_selected_items() {
let items = create_test_items();
let mut state = MultiSelectState::new(items);
state.selected_indices.insert(0);
state.selected_indices.insert(2);
let selected = state.selected_items();
assert_eq!(selected.len(), 2);
let labels: Vec<&str> = selected.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"feature/auth"));
assert!(labels.contains(&"feature/dashboard"));
}
#[test]
fn test_selectable_count() {
let items = create_test_items();
let state = MultiSelectState::new(items);
assert_eq!(state.selectable_count(), 3);
}
#[test]
fn test_render_keeps_cursor_visible_when_list_height_is_small() {
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
let input = TextInputState::new();
let items: Vec<_> = (0..20)
.map(|i| MultiSelectItem::new(format!("item-{i:02}"), format!("/path/{i:02}")))
.collect();
let mut state = MultiSelectState::new(items);
state.cursor_index = 6;
state.scroll_offset = 0;
state.max_display = 10;
let area = Rect::new(0, 0, 80, 17);
let mut buf = Buffer::empty(area);
MultiSelectListWidget::new("Title", "Search...", &input, &state).render(area, &mut buf);
let mut cursor_line = None;
for y in 0..area.height {
let line: String = (0..area.width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect();
if line.contains('▶') {
cursor_line = Some(line);
break;
}
}
let cursor_line = cursor_line.expect("cursor line should be rendered");
assert!(cursor_line.contains("item-06"));
}
}