use std::collections::BTreeSet;
use crossterm::event::KeyCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerItem {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectionModel {
Single(Option<String>),
Multi(BTreeSet<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerMode {
Idle,
Filtering,
InlineCreate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerEvent {
None,
Selected(Vec<String>),
Created(String),
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropdownState {
pub options: Vec<PickerItem>,
pub selection: SelectionModel,
pub filter: String,
pub cursor: usize,
pub mode: PickerMode,
pub create_buffer: String,
}
impl DropdownState {
pub fn single(options: Vec<PickerItem>) -> Self {
Self {
options,
selection: SelectionModel::Single(None),
filter: String::new(),
cursor: 0,
mode: PickerMode::Idle,
create_buffer: String::new(),
}
}
pub fn multi(options: Vec<PickerItem>) -> Self {
Self {
options,
selection: SelectionModel::Multi(BTreeSet::new()),
filter: String::new(),
cursor: 0,
mode: PickerMode::Idle,
create_buffer: String::new(),
}
}
pub fn visible_indices(&self) -> Vec<usize> {
let q = self.filter.trim().to_lowercase();
self.options
.iter()
.enumerate()
.filter(|(_, item)| q.is_empty() || item.label.to_lowercase().contains(&q))
.map(|(idx, _)| idx)
.collect()
}
pub fn seed_single(&mut self, value: &str) {
if value.is_empty() {
return;
}
let Some(index) = self.options.iter().position(|option| option.id == value) else {
return;
};
self.selection = SelectionModel::Single(Some(value.to_string()));
if let Some(position) = self
.visible_indices()
.iter()
.position(|visible| *visible == index)
{
self.cursor = position;
}
}
fn selected_ids(&self) -> Vec<String> {
match &self.selection {
SelectionModel::Single(id) => id.iter().cloned().collect(),
SelectionModel::Multi(ids) => ids.iter().cloned().collect(),
}
}
fn current_option(&self) -> Option<&PickerItem> {
let visible = self.visible_indices();
let idx = *visible.get(self.cursor)?;
self.options.get(idx)
}
fn move_up(&mut self) {
let total = self.visible_indices().len();
if total == 0 {
self.cursor = 0;
} else if self.cursor == 0 {
self.cursor = total - 1;
} else {
self.cursor -= 1;
}
}
fn move_down(&mut self) {
let total = self.visible_indices().len();
if total == 0 {
self.cursor = 0;
} else {
self.cursor = (self.cursor + 1) % total;
}
}
fn clamp_cursor(&mut self) {
let total = self.visible_indices().len();
if total == 0 {
self.cursor = 0;
} else if self.cursor >= total {
self.cursor = total - 1;
}
}
fn toggle_current(&mut self) {
let current = self.current_option().map(|item| item.id.clone());
let Some(current) = current else {
return;
};
match &mut self.selection {
SelectionModel::Single(selected) => *selected = Some(current),
SelectionModel::Multi(selected) => {
if !selected.insert(current.clone()) {
selected.remove(¤t);
}
}
}
}
pub fn handle_key(&mut self, key: KeyCode) -> PickerEvent {
match self.mode {
PickerMode::InlineCreate => match key {
KeyCode::Esc => {
self.mode = PickerMode::Idle;
self.create_buffer.clear();
PickerEvent::Cancelled
}
KeyCode::Enter => {
let created = self.create_buffer.trim().to_string();
self.mode = PickerMode::Idle;
self.create_buffer.clear();
if created.is_empty() {
PickerEvent::Cancelled
} else {
PickerEvent::Created(created)
}
}
KeyCode::Backspace => {
self.create_buffer.pop();
PickerEvent::None
}
KeyCode::Char(ch) => {
self.create_buffer.push(ch);
PickerEvent::None
}
_ => PickerEvent::None,
},
PickerMode::Idle | PickerMode::Filtering => match key {
KeyCode::Up => {
self.move_up();
PickerEvent::None
}
KeyCode::Down => {
self.move_down();
PickerEvent::None
}
KeyCode::Enter => {
self.toggle_current();
PickerEvent::Selected(self.selected_ids())
}
KeyCode::Esc => {
self.mode = PickerMode::Idle;
self.filter.clear();
self.cursor = 0;
PickerEvent::Cancelled
}
KeyCode::Char('+') | KeyCode::Insert => {
self.mode = PickerMode::InlineCreate;
self.create_buffer.clear();
PickerEvent::None
}
KeyCode::Backspace => {
self.mode = PickerMode::Filtering;
self.filter.pop();
self.clamp_cursor();
PickerEvent::None
}
KeyCode::Char(ch) => {
self.mode = PickerMode::Filtering;
self.filter.push(ch);
self.clamp_cursor();
PickerEvent::None
}
_ => PickerEvent::None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> Vec<PickerItem> {
vec![
PickerItem {
id: "a".into(),
label: "Alpha".into(),
},
PickerItem {
id: "b".into(),
label: "Beta".into(),
},
PickerItem {
id: "g".into(),
label: "Gamma".into(),
},
]
}
#[test]
fn navigation_wraps() {
let mut state = DropdownState::single(options());
state.handle_key(KeyCode::Up);
assert_eq!(state.cursor, 2);
state.handle_key(KeyCode::Down);
assert_eq!(state.cursor, 0);
}
#[test]
fn typeahead_filters_results() {
let mut state = DropdownState::single(options());
state.handle_key(KeyCode::Char('g'));
assert_eq!(state.visible_indices(), vec![2]);
}
#[test]
fn multi_mode_toggles_selection() {
let mut state = DropdownState::multi(options());
let first = state.handle_key(KeyCode::Enter);
assert_eq!(first, PickerEvent::Selected(vec!["a".into()]));
let second = state.handle_key(KeyCode::Enter);
assert_eq!(second, PickerEvent::Selected(Vec::new()));
}
#[test]
fn inline_create_emits_created_event() {
let mut state = DropdownState::single(options());
state.handle_key(KeyCode::Char('+'));
state.handle_key(KeyCode::Char('N'));
state.handle_key(KeyCode::Char('e'));
state.handle_key(KeyCode::Char('w'));
let event = state.handle_key(KeyCode::Enter);
assert_eq!(event, PickerEvent::Created("New".into()));
}
}