use crate::element::{Component, Element};
use crate::style::{Color, Modifier, Style};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct MultiSelectItem {
pub label: String,
pub value: Option<String>,
pub disabled: bool,
}
impl MultiSelectItem {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
value: None,
disabled: false,
}
}
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn get_value(&self) -> &str {
self.value.as_deref().unwrap_or(&self.label)
}
}
impl<S: Into<String>> From<S> for MultiSelectItem {
fn from(s: S) -> Self {
MultiSelectItem::new(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MultiSelectStyle {
#[default]
Bracket,
Unicode,
Circle,
Check,
}
impl MultiSelectStyle {
pub fn chars(&self) -> (&'static str, &'static str) {
match self {
MultiSelectStyle::Bracket => ("[x]", "[ ]"),
MultiSelectStyle::Unicode => (" ✓ ", " ○ "),
MultiSelectStyle::Circle => (" ● ", " ○ "),
MultiSelectStyle::Check => (" ☑ ", " ☐ "),
}
}
}
#[derive(Debug, Clone)]
pub struct MultiSelectProps {
pub items: Vec<MultiSelectItem>,
pub cursor: usize,
pub selected: HashSet<usize>,
pub style: MultiSelectStyle,
pub cursor_color: Option<Color>,
pub selected_color: Option<Color>,
pub unselected_color: Option<Color>,
pub disabled_color: Option<Color>,
pub max_visible: Option<usize>,
pub scroll_offset: usize,
pub cursor_indicator: &'static str,
}
impl Default for MultiSelectProps {
fn default() -> Self {
Self {
items: Vec::new(),
cursor: 0,
selected: HashSet::new(),
style: MultiSelectStyle::Bracket,
cursor_color: Some(Color::Cyan),
selected_color: Some(Color::Green),
unselected_color: None,
disabled_color: Some(Color::DarkGray),
max_visible: None,
scroll_offset: 0,
cursor_indicator: "❯",
}
}
}
impl MultiSelectProps {
pub fn new<I, T>(items: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<MultiSelectItem>,
{
Self {
items: items.into_iter().map(Into::into).collect(),
..Default::default()
}
}
#[must_use]
pub fn cursor(mut self, cursor: usize) -> Self {
self.cursor = cursor.min(self.items.len().saturating_sub(1));
self
}
#[must_use]
pub fn selected(mut self, selected: HashSet<usize>) -> Self {
self.selected = selected;
self
}
#[must_use]
pub fn style(mut self, style: MultiSelectStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn cursor_color(mut self, color: Color) -> Self {
self.cursor_color = Some(color);
self
}
#[must_use]
pub fn selected_color(mut self, color: Color) -> Self {
self.selected_color = Some(color);
self
}
#[must_use]
pub fn unselected_color(mut self, color: Color) -> Self {
self.unselected_color = Some(color);
self
}
#[must_use]
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = Some(max);
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn is_selected(&self, index: usize) -> bool {
self.selected.contains(&index)
}
pub fn selected_items(&self) -> Vec<&MultiSelectItem> {
self.selected
.iter()
.filter_map(|&i| self.items.get(i))
.collect()
}
pub fn selected_values(&self) -> Vec<&str> {
self.selected
.iter()
.filter_map(|&i| self.items.get(i).map(|item| item.get_value()))
.collect()
}
fn visible_items(&self) -> Vec<(usize, &MultiSelectItem)> {
let items: Vec<_> = self.items.iter().enumerate().collect();
if let Some(max) = self.max_visible {
if items.len() > max {
let start = self.scroll_offset.min(items.len().saturating_sub(max));
return items.into_iter().skip(start).take(max).collect();
}
}
items
}
pub fn render_lines(&self) -> Vec<(String, Style)> {
let (checked_char, unchecked_char) = self.style.chars();
let visible_items = self.visible_items();
let max_label_width = self
.items
.iter()
.map(|item| item.label.chars().count())
.max()
.unwrap_or(0);
visible_items
.iter()
.map(|(idx, item)| {
let is_cursor = *idx == self.cursor;
let is_checked = self.selected.contains(idx);
let cursor_char = if is_cursor {
self.cursor_indicator
} else {
" "
};
let checkbox = if is_checked {
checked_char
} else {
unchecked_char
};
let padding = max_label_width.saturating_sub(item.label.chars().count());
let line = format!(
"{} {} {}{}",
cursor_char,
checkbox,
item.label,
" ".repeat(padding)
);
let mut style = Style::new();
if item.disabled {
if let Some(color) = self.disabled_color {
style = style.fg(color);
}
style = style.add_modifier(Modifier::DIM);
} else if is_cursor {
if let Some(color) = self.cursor_color {
style = style.fg(color);
}
style = style.add_modifier(Modifier::BOLD);
} else if is_checked {
if let Some(color) = self.selected_color {
style = style.fg(color);
}
} else if let Some(color) = self.unselected_color {
style = style.fg(color);
}
(line, style)
})
.collect()
}
}
pub struct MultiSelect;
impl Component for MultiSelect {
type Props = MultiSelectProps;
fn render(props: &Self::Props) -> Element {
let lines = props.render_lines();
let content: String = lines
.iter()
.map(|(line, _)| line.as_str())
.collect::<Vec<_>>()
.join("\n");
let style = lines
.iter()
.find(|(_, s)| s.modifiers.contains(Modifier::BOLD))
.map(|(_, s)| *s)
.unwrap_or_default();
Element::styled_text(&content, style)
}
}
#[derive(Debug, Clone)]
pub struct MultiSelectState {
pub cursor: usize,
pub selected: HashSet<usize>,
pub count: usize,
pub scroll_offset: usize,
pub max_visible: Option<usize>,
}
impl MultiSelectState {
pub fn new(count: usize) -> Self {
Self {
cursor: 0,
selected: HashSet::new(),
count,
scroll_offset: 0,
max_visible: None,
}
}
#[must_use]
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = Some(max);
self
}
#[must_use]
pub fn with_selected(mut self, selected: impl IntoIterator<Item = usize>) -> Self {
self.selected = selected.into_iter().filter(|&i| i < self.count).collect();
self
}
pub fn up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.adjust_scroll();
}
}
pub fn down(&mut self) {
if self.cursor < self.count.saturating_sub(1) {
self.cursor += 1;
self.adjust_scroll();
}
}
pub fn first(&mut self) {
self.cursor = 0;
self.scroll_offset = 0;
}
pub fn last(&mut self) {
self.cursor = self.count.saturating_sub(1);
self.adjust_scroll();
}
pub fn toggle(&mut self) {
if self.selected.contains(&self.cursor) {
self.selected.remove(&self.cursor);
} else {
self.selected.insert(self.cursor);
}
}
pub fn select(&mut self) {
self.selected.insert(self.cursor);
}
pub fn deselect(&mut self) {
self.selected.remove(&self.cursor);
}
pub fn select_all(&mut self) {
self.selected = (0..self.count).collect();
}
pub fn deselect_all(&mut self) {
self.selected.clear();
}
pub fn toggle_all(&mut self) {
let all: HashSet<usize> = (0..self.count).collect();
self.selected = all.difference(&self.selected).copied().collect();
}
pub fn is_selected(&self, index: usize) -> bool {
self.selected.contains(&index)
}
pub fn selected_count(&self) -> usize {
self.selected.len()
}
pub fn selected_indices(&self) -> Vec<usize> {
let mut indices: Vec<_> = self.selected.iter().copied().collect();
indices.sort();
indices
}
pub fn has_selection(&self) -> bool {
!self.selected.is_empty()
}
pub fn all_selected(&self) -> bool {
self.selected.len() == self.count
}
fn adjust_scroll(&mut self) {
if let Some(max) = self.max_visible {
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
} else if self.cursor >= self.scroll_offset + max {
self.scroll_offset = self.cursor - max + 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiselect_item_new() {
let item = MultiSelectItem::new("Test");
assert_eq!(item.label, "Test");
assert!(item.value.is_none());
assert!(!item.disabled);
}
#[test]
fn test_multiselect_item_with_value() {
let item = MultiSelectItem::new("Display").value("actual_value");
assert_eq!(item.label, "Display");
assert_eq!(item.get_value(), "actual_value");
}
#[test]
fn test_multiselect_item_from_str() {
let item: MultiSelectItem = "Option".into();
assert_eq!(item.label, "Option");
}
#[test]
fn test_multiselect_props_new() {
let props = MultiSelectProps::new(vec!["A", "B", "C"]);
assert_eq!(props.items.len(), 3);
assert_eq!(props.cursor, 0);
assert!(props.selected.is_empty());
}
#[test]
fn test_multiselect_props_selected() {
let mut selected = HashSet::new();
selected.insert(0);
selected.insert(2);
let props = MultiSelectProps::new(vec!["A", "B", "C"]).selected(selected);
assert!(props.is_selected(0));
assert!(!props.is_selected(1));
assert!(props.is_selected(2));
}
#[test]
fn test_multiselect_style_chars() {
assert_eq!(MultiSelectStyle::Bracket.chars(), ("[x]", "[ ]"));
assert_eq!(MultiSelectStyle::Unicode.chars(), (" ✓ ", " ○ "));
assert_eq!(MultiSelectStyle::Circle.chars(), (" ● ", " ○ "));
assert_eq!(MultiSelectStyle::Check.chars(), (" ☑ ", " ☐ "));
}
#[test]
fn test_multiselect_selected_values() {
let mut selected = HashSet::new();
selected.insert(0);
selected.insert(1);
let props = MultiSelectProps::new(vec![
MultiSelectItem::new("A").value("val_a"),
MultiSelectItem::new("B").value("val_b"),
MultiSelectItem::new("C"),
])
.selected(selected);
let values = props.selected_values();
assert_eq!(values.len(), 2);
assert!(values.contains(&"val_a"));
assert!(values.contains(&"val_b"));
}
#[test]
fn test_multiselect_state_navigation() {
let mut state = MultiSelectState::new(5);
assert_eq!(state.cursor, 0);
state.down();
assert_eq!(state.cursor, 1);
state.down();
state.down();
assert_eq!(state.cursor, 3);
state.up();
assert_eq!(state.cursor, 2);
state.first();
assert_eq!(state.cursor, 0);
state.last();
assert_eq!(state.cursor, 4);
}
#[test]
fn test_multiselect_state_toggle() {
let mut state = MultiSelectState::new(3);
state.toggle();
assert!(state.is_selected(0));
state.toggle();
assert!(!state.is_selected(0));
state.down();
state.toggle();
assert!(state.is_selected(1));
assert!(!state.is_selected(0));
}
#[test]
fn test_multiselect_state_select_all() {
let mut state = MultiSelectState::new(3);
state.select_all();
assert!(state.all_selected());
assert_eq!(state.selected_count(), 3);
state.deselect_all();
assert!(!state.has_selection());
assert_eq!(state.selected_count(), 0);
}
#[test]
fn test_multiselect_state_toggle_all() {
let mut state = MultiSelectState::new(4);
state.selected.insert(0);
state.selected.insert(2);
state.toggle_all();
assert!(!state.is_selected(0));
assert!(state.is_selected(1));
assert!(!state.is_selected(2));
assert!(state.is_selected(3));
}
#[test]
fn test_multiselect_state_selected_indices() {
let mut state = MultiSelectState::new(5);
state.selected.insert(3);
state.selected.insert(1);
state.selected.insert(4);
let indices = state.selected_indices();
assert_eq!(indices, vec![1, 3, 4]); }
#[test]
fn test_multiselect_render_lines() {
let mut selected = HashSet::new();
selected.insert(1);
let props = MultiSelectProps::new(vec!["A", "B", "C"])
.cursor(0)
.selected(selected);
let lines = props.render_lines();
assert_eq!(lines.len(), 3);
assert!(lines[0].0.contains("[ ]")); assert!(lines[1].0.contains("[x]")); assert!(lines[2].0.contains("[ ]")); }
#[test]
fn test_multiselect_component_render() {
let props = MultiSelectProps::new(vec!["A", "B"]);
let elem = MultiSelect::render(&props);
assert!(elem.is_text());
}
}