1use std::collections::BTreeSet;
2
3use crossterm::event::KeyCode;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PickerItem {
7 pub id: String,
8 pub label: String,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum SelectionModel {
13 Single(Option<String>),
14 Multi(BTreeSet<String>),
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PickerMode {
19 Idle,
20 Filtering,
21 InlineCreate,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PickerEvent {
26 None,
27 Selected(Vec<String>),
28 Created(String),
29 Cancelled,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DropdownState {
34 pub options: Vec<PickerItem>,
35 pub selection: SelectionModel,
36 pub filter: String,
37 pub cursor: usize,
38 pub mode: PickerMode,
39 pub create_buffer: String,
40}
41
42impl DropdownState {
43 pub fn single(options: Vec<PickerItem>) -> Self {
44 Self {
45 options,
46 selection: SelectionModel::Single(None),
47 filter: String::new(),
48 cursor: 0,
49 mode: PickerMode::Idle,
50 create_buffer: String::new(),
51 }
52 }
53
54 pub fn multi(options: Vec<PickerItem>) -> Self {
55 Self {
56 options,
57 selection: SelectionModel::Multi(BTreeSet::new()),
58 filter: String::new(),
59 cursor: 0,
60 mode: PickerMode::Idle,
61 create_buffer: String::new(),
62 }
63 }
64
65 pub fn visible_indices(&self) -> Vec<usize> {
66 let q = self.filter.trim().to_lowercase();
67 self.options
68 .iter()
69 .enumerate()
70 .filter(|(_, item)| q.is_empty() || item.label.to_lowercase().contains(&q))
71 .map(|(idx, _)| idx)
72 .collect()
73 }
74
75 fn selected_ids(&self) -> Vec<String> {
76 match &self.selection {
77 SelectionModel::Single(id) => id.iter().cloned().collect(),
78 SelectionModel::Multi(ids) => ids.iter().cloned().collect(),
79 }
80 }
81
82 fn current_option(&self) -> Option<&PickerItem> {
83 let visible = self.visible_indices();
84 let idx = *visible.get(self.cursor)?;
85 self.options.get(idx)
86 }
87
88 fn move_up(&mut self) {
89 let total = self.visible_indices().len();
90 if total == 0 {
91 self.cursor = 0;
92 } else if self.cursor == 0 {
93 self.cursor = total - 1;
94 } else {
95 self.cursor -= 1;
96 }
97 }
98
99 fn move_down(&mut self) {
100 let total = self.visible_indices().len();
101 if total == 0 {
102 self.cursor = 0;
103 } else {
104 self.cursor = (self.cursor + 1) % total;
105 }
106 }
107
108 fn clamp_cursor(&mut self) {
109 let total = self.visible_indices().len();
110 if total == 0 {
111 self.cursor = 0;
112 } else if self.cursor >= total {
113 self.cursor = total - 1;
114 }
115 }
116
117 fn toggle_current(&mut self) {
118 let current = self.current_option().map(|item| item.id.clone());
119 let Some(current) = current else {
120 return;
121 };
122 match &mut self.selection {
123 SelectionModel::Single(selected) => *selected = Some(current),
124 SelectionModel::Multi(selected) => {
125 if !selected.insert(current.clone()) {
126 selected.remove(¤t);
127 }
128 }
129 }
130 }
131
132 pub fn handle_key(&mut self, key: KeyCode) -> PickerEvent {
133 match self.mode {
134 PickerMode::InlineCreate => match key {
135 KeyCode::Esc => {
136 self.mode = PickerMode::Idle;
137 self.create_buffer.clear();
138 PickerEvent::Cancelled
139 }
140 KeyCode::Enter => {
141 let created = self.create_buffer.trim().to_string();
142 self.mode = PickerMode::Idle;
143 self.create_buffer.clear();
144 if created.is_empty() {
145 PickerEvent::Cancelled
146 } else {
147 PickerEvent::Created(created)
148 }
149 }
150 KeyCode::Backspace => {
151 self.create_buffer.pop();
152 PickerEvent::None
153 }
154 KeyCode::Char(ch) => {
155 self.create_buffer.push(ch);
156 PickerEvent::None
157 }
158 _ => PickerEvent::None,
159 },
160 PickerMode::Idle | PickerMode::Filtering => match key {
161 KeyCode::Up => {
162 self.move_up();
163 PickerEvent::None
164 }
165 KeyCode::Down => {
166 self.move_down();
167 PickerEvent::None
168 }
169 KeyCode::Enter => {
170 self.toggle_current();
171 PickerEvent::Selected(self.selected_ids())
172 }
173 KeyCode::Esc => {
174 self.mode = PickerMode::Idle;
175 self.filter.clear();
176 self.cursor = 0;
177 PickerEvent::Cancelled
178 }
179 KeyCode::Char('+') | KeyCode::Insert => {
180 self.mode = PickerMode::InlineCreate;
181 self.create_buffer.clear();
182 PickerEvent::None
183 }
184 KeyCode::Backspace => {
185 self.mode = PickerMode::Filtering;
186 self.filter.pop();
187 self.clamp_cursor();
188 PickerEvent::None
189 }
190 KeyCode::Char(ch) => {
191 self.mode = PickerMode::Filtering;
192 self.filter.push(ch);
193 self.clamp_cursor();
194 PickerEvent::None
195 }
196 _ => PickerEvent::None,
197 },
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn options() -> Vec<PickerItem> {
207 vec![
208 PickerItem {
209 id: "a".into(),
210 label: "Alpha".into(),
211 },
212 PickerItem {
213 id: "b".into(),
214 label: "Beta".into(),
215 },
216 PickerItem {
217 id: "g".into(),
218 label: "Gamma".into(),
219 },
220 ]
221 }
222
223 #[test]
224 fn navigation_wraps() {
225 let mut state = DropdownState::single(options());
226 state.handle_key(KeyCode::Up);
227 assert_eq!(state.cursor, 2);
228 state.handle_key(KeyCode::Down);
229 assert_eq!(state.cursor, 0);
230 }
231
232 #[test]
233 fn typeahead_filters_results() {
234 let mut state = DropdownState::single(options());
235 state.handle_key(KeyCode::Char('g'));
236 assert_eq!(state.visible_indices(), vec![2]);
237 }
238
239 #[test]
240 fn multi_mode_toggles_selection() {
241 let mut state = DropdownState::multi(options());
242 let first = state.handle_key(KeyCode::Enter);
243 assert_eq!(first, PickerEvent::Selected(vec!["a".into()]));
244 let second = state.handle_key(KeyCode::Enter);
245 assert_eq!(second, PickerEvent::Selected(Vec::new()));
246 }
247
248 #[test]
249 fn inline_create_emits_created_event() {
250 let mut state = DropdownState::single(options());
251 state.handle_key(KeyCode::Char('+'));
252 state.handle_key(KeyCode::Char('N'));
253 state.handle_key(KeyCode::Char('e'));
254 state.handle_key(KeyCode::Char('w'));
255 let event = state.handle_key(KeyCode::Enter);
256 assert_eq!(event, PickerEvent::Created("New".into()));
257 }
258}