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 pub fn seed_single(&mut self, value: &str) {
79 if value.is_empty() {
80 return;
81 }
82 let Some(index) = self.options.iter().position(|option| option.id == value) else {
83 return;
84 };
85 self.selection = SelectionModel::Single(Some(value.to_string()));
86 if let Some(position) = self
87 .visible_indices()
88 .iter()
89 .position(|visible| *visible == index)
90 {
91 self.cursor = position;
92 }
93 }
94
95 fn selected_ids(&self) -> Vec<String> {
96 match &self.selection {
97 SelectionModel::Single(id) => id.iter().cloned().collect(),
98 SelectionModel::Multi(ids) => ids.iter().cloned().collect(),
99 }
100 }
101
102 fn current_option(&self) -> Option<&PickerItem> {
103 let visible = self.visible_indices();
104 let idx = *visible.get(self.cursor)?;
105 self.options.get(idx)
106 }
107
108 fn move_up(&mut self) {
109 let total = self.visible_indices().len();
110 if total == 0 {
111 self.cursor = 0;
112 } else if self.cursor == 0 {
113 self.cursor = total - 1;
114 } else {
115 self.cursor -= 1;
116 }
117 }
118
119 fn move_down(&mut self) {
120 let total = self.visible_indices().len();
121 if total == 0 {
122 self.cursor = 0;
123 } else {
124 self.cursor = (self.cursor + 1) % total;
125 }
126 }
127
128 fn clamp_cursor(&mut self) {
129 let total = self.visible_indices().len();
130 if total == 0 {
131 self.cursor = 0;
132 } else if self.cursor >= total {
133 self.cursor = total - 1;
134 }
135 }
136
137 fn toggle_current(&mut self) {
138 let current = self.current_option().map(|item| item.id.clone());
139 let Some(current) = current else {
140 return;
141 };
142 match &mut self.selection {
143 SelectionModel::Single(selected) => *selected = Some(current),
144 SelectionModel::Multi(selected) => {
145 if !selected.insert(current.clone()) {
146 selected.remove(¤t);
147 }
148 }
149 }
150 }
151
152 pub fn handle_key(&mut self, key: KeyCode) -> PickerEvent {
153 match self.mode {
154 PickerMode::InlineCreate => match key {
155 KeyCode::Esc => {
156 self.mode = PickerMode::Idle;
157 self.create_buffer.clear();
158 PickerEvent::Cancelled
159 }
160 KeyCode::Enter => {
161 let created = self.create_buffer.trim().to_string();
162 self.mode = PickerMode::Idle;
163 self.create_buffer.clear();
164 if created.is_empty() {
165 PickerEvent::Cancelled
166 } else {
167 PickerEvent::Created(created)
168 }
169 }
170 KeyCode::Backspace => {
171 self.create_buffer.pop();
172 PickerEvent::None
173 }
174 KeyCode::Char(ch) => {
175 self.create_buffer.push(ch);
176 PickerEvent::None
177 }
178 _ => PickerEvent::None,
179 },
180 PickerMode::Idle | PickerMode::Filtering => match key {
181 KeyCode::Up => {
182 self.move_up();
183 PickerEvent::None
184 }
185 KeyCode::Down => {
186 self.move_down();
187 PickerEvent::None
188 }
189 KeyCode::Enter => {
190 self.toggle_current();
191 PickerEvent::Selected(self.selected_ids())
192 }
193 KeyCode::Esc => {
194 self.mode = PickerMode::Idle;
195 self.filter.clear();
196 self.cursor = 0;
197 PickerEvent::Cancelled
198 }
199 KeyCode::Char('+') | KeyCode::Insert => {
200 self.mode = PickerMode::InlineCreate;
201 self.create_buffer.clear();
202 PickerEvent::None
203 }
204 KeyCode::Backspace => {
205 self.mode = PickerMode::Filtering;
206 self.filter.pop();
207 self.clamp_cursor();
208 PickerEvent::None
209 }
210 KeyCode::Char(ch) => {
211 self.mode = PickerMode::Filtering;
212 self.filter.push(ch);
213 self.clamp_cursor();
214 PickerEvent::None
215 }
216 _ => PickerEvent::None,
217 },
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 #[allow(clippy::wildcard_imports)]
225 use super::*;
226
227 fn options() -> Vec<PickerItem> {
228 vec![
229 PickerItem {
230 id: "a".into(),
231 label: "Alpha".into(),
232 },
233 PickerItem {
234 id: "b".into(),
235 label: "Beta".into(),
236 },
237 PickerItem {
238 id: "g".into(),
239 label: "Gamma".into(),
240 },
241 ]
242 }
243
244 #[test]
245 fn navigation_wraps() {
246 let mut state = DropdownState::single(options());
247 state.handle_key(KeyCode::Up);
248 assert_eq!(state.cursor, 2);
249 state.handle_key(KeyCode::Down);
250 assert_eq!(state.cursor, 0);
251 }
252
253 #[test]
254 fn typeahead_filters_results() {
255 let mut state = DropdownState::single(options());
256 state.handle_key(KeyCode::Char('g'));
257 assert_eq!(state.visible_indices(), vec![2]);
258 }
259
260 #[test]
261 fn multi_mode_toggles_selection() {
262 let mut state = DropdownState::multi(options());
263 let first = state.handle_key(KeyCode::Enter);
264 assert_eq!(first, PickerEvent::Selected(vec!["a".into()]));
265 let second = state.handle_key(KeyCode::Enter);
266 assert_eq!(second, PickerEvent::Selected(Vec::new()));
267 }
268
269 #[test]
270 fn inline_create_emits_created_event() {
271 let mut state = DropdownState::single(options());
272 state.handle_key(KeyCode::Char('+'));
273 state.handle_key(KeyCode::Char('N'));
274 state.handle_key(KeyCode::Char('e'));
275 state.handle_key(KeyCode::Char('w'));
276 let event = state.handle_key(KeyCode::Enter);
277 assert_eq!(event, PickerEvent::Created("New".into()));
278 }
279}