Skip to main content

tix/ui/
pickers.rs

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(&current);
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}