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    /// Seed a single-select dropdown on `value`: record it as the selection and put
76    /// the cursor on its option. A no-op when `value` is empty or names no option, so
77    /// a caller can seed from a criterion that is unset or stale.
78    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(&current);
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}