Skip to main content

datui_lib/widgets/ui/
picker.rs

1//! The pick-one list: type to narrow, `↑↓` move, Enter chooses. A radio group
2//! is a short Picker, not a grid.
3
4use crate::pointer::Hit;
5use crate::render::context::RenderContext;
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::widgets::{Paragraph, Widget};
10
11/// The list and where the cursor is in it. Owns the narrowing; the caller owns
12/// what choosing means.
13#[derive(Debug, Clone, Default)]
14pub struct PickerState {
15    items: Vec<String>,
16    pub filter: String,
17    /// Index into the full item list, so narrowing never moves the cursor off
18    /// the item it was on.
19    selected: usize,
20}
21
22impl PickerState {
23    pub fn new(items: Vec<String>) -> Self {
24        Self {
25            items,
26            filter: String::new(),
27            selected: 0,
28        }
29    }
30
31    /// Every item, whatever the filter admits.
32    pub fn items(&self) -> &[String] {
33        &self.items
34    }
35
36    /// The items the filter admits, with their original indices: the one it names
37    /// first, then those it starts, then the rest, each in list order.
38    pub fn filtered(&self) -> Vec<(usize, &str)> {
39        let mut ranked: Vec<(u8, usize, &str)> = self
40            .items
41            .iter()
42            .enumerate()
43            .filter_map(|(i, item)| {
44                crate::fuzzy::substring_rank(&self.filter, item).map(|r| (r, i, item.as_str()))
45            })
46            .collect();
47        ranked.sort_by_key(|(rank, i, _)| (*rank, *i));
48        ranked.into_iter().map(|(_, i, item)| (i, item)).collect()
49    }
50
51    /// Where the cursor sits among the visible items; 0 when the item it was
52    /// on has been filtered away.
53    pub fn visible_selection(&self) -> usize {
54        self.filtered()
55            .iter()
56            .position(|(i, _)| *i == self.selected)
57            .unwrap_or(0)
58    }
59
60    /// The selected item's original index, or None when the filter admits nothing.
61    pub fn selected_original(&self) -> Option<usize> {
62        let filtered = self.filtered();
63        filtered
64            .get(self.visible_selection())
65            .or_else(|| filtered.first())
66            .map(|(i, _)| *i)
67    }
68
69    pub fn select_original(&mut self, index: usize) {
70        if index < self.items.len() {
71            self.selected = index;
72        }
73    }
74
75    pub fn type_char(&mut self, c: char) {
76        self.filter.push(c);
77        self.settle();
78    }
79
80    pub fn backspace(&mut self) {
81        self.filter.pop();
82        self.settle();
83    }
84
85    pub fn clear_filter(&mut self) {
86        self.filter.clear();
87        self.settle();
88    }
89
90    /// One typed character, with its modifiers: plain characters narrow,
91    /// Ctrl+W drops a word, Ctrl+U clears, and any other chord is a chord —
92    /// never a letter typed into the filter.
93    pub fn filter_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
94        use crossterm::event::KeyModifiers;
95        let ctrl = mods.contains(KeyModifiers::CONTROL);
96        if ctrl && c == 'w' {
97            self.delete_word();
98        } else if ctrl && c == 'u' {
99            self.clear_filter();
100        } else if !ctrl && !mods.contains(KeyModifiers::ALT) {
101            self.type_char(c);
102        }
103    }
104
105    /// Ctrl+W: drop the word before the cursor, readline-style. The filter
106    /// is append-only, so the cursor is always the end.
107    pub fn delete_word(&mut self) {
108        while self.filter.ends_with(' ') {
109            self.filter.pop();
110        }
111        while self.filter.chars().next_back().is_some_and(|c| c != ' ') {
112            self.filter.pop();
113        }
114        self.settle();
115    }
116
117    pub fn move_up(&mut self) {
118        self.step(-1);
119    }
120
121    pub fn move_down(&mut self) {
122        self.step(1);
123    }
124
125    fn step(&mut self, delta: isize) {
126        let filtered = self.filtered();
127        if filtered.is_empty() {
128            return;
129        }
130        let at = self.visible_selection() as isize;
131        let n = filtered.len() as isize;
132        let next = (at + delta).rem_euclid(n) as usize;
133        self.selected = filtered[next].0;
134    }
135
136    /// After the filter changes, land the cursor on something visible: the item
137    /// the filter names in full, else the one it was on, else the first.
138    fn settle(&mut self) {
139        let filtered = self.filtered();
140        let named = filtered
141            .first()
142            .filter(|(_, item)| crate::fuzzy::substring_rank(&self.filter, item) == Some(0));
143        if let Some((exact, _)) = named {
144            self.selected = *exact;
145        } else if filtered.iter().all(|(i, _)| *i != self.selected)
146            && let Some((first, _)) = filtered.first()
147        {
148            self.selected = *first;
149        }
150    }
151}
152
153/// Draws a pick-one list: the selection carries the rail, and the tint when
154/// the list is focused. Selected-but-unfocused stays visible. With marks it
155/// is a toggle list: each item carries a checkbox, and choosing means Space.
156pub struct Picker<'a> {
157    items: Vec<&'a str>,
158    selected: Option<usize>,
159    focused: bool,
160    marks: Option<Vec<bool>>,
161    /// Beside each item, right-aligned and dimmed: a count.
162    details: Option<Vec<String>>,
163    clicks: Option<Clicks>,
164}
165
166/// What a click on a line does, recorded as the list is drawn.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum Clicks {
169    /// An open picker: the cursor goes to the line, which is chosen (toggled in a
170    /// list of marks).
171    Choose,
172    /// The analysis tools list.
173    Tool,
174}
175
176impl<'a> Picker<'a> {
177    pub fn new(items: Vec<&'a str>, selected: Option<usize>, focused: bool) -> Self {
178        Self {
179            items,
180            selected,
181            focused,
182            marks: None,
183            details: None,
184            clicks: None,
185        }
186    }
187
188    /// What a click on a line does. A list from a [`PickerState`] is an open picker
189    /// and chooses already.
190    pub fn on_click(mut self, clicks: Clicks) -> Self {
191        self.clicks = Some(clicks);
192        self
193    }
194
195    pub fn from_state(state: &'a PickerState, focused: bool) -> Self {
196        let items = state.filtered().into_iter().map(|(_, item)| item).collect();
197        Self {
198            items,
199            selected: Some(state.visible_selection()),
200            focused,
201            marks: None,
202            details: None,
203            clicks: Some(Clicks::Choose),
204        }
205    }
206
207    /// Checkbox states, one per visible item in order.
208    pub fn marks(mut self, marks: Vec<bool>) -> Self {
209        self.marks = Some(marks);
210        self
211    }
212
213    /// A note per visible item in order, right-aligned beside it.
214    pub fn details(mut self, details: Vec<String>) -> Self {
215        self.details = Some(details);
216        self
217    }
218
219    pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
220        if area.height == 0 || area.width == 0 {
221            return;
222        }
223        let g = crate::glyphs::get();
224        let height = area.height as usize;
225        // Scroll just enough to keep the selection in view, and count what ran
226        // off the bottom rather than half-showing it.
227        let selected = self.selected.unwrap_or(0);
228        let offset = selected.saturating_sub(height.saturating_sub(1));
229        let below = self.items.len().saturating_sub(offset + height);
230        if self.clicks == Some(Clicks::Choose) {
231            crate::pointer::record(area, Hit::Picker);
232        }
233        for row in 0..height.min(self.items.len().saturating_sub(offset)) {
234            let i = offset + row;
235            let is_selected = self.selected == Some(i);
236            let row_area = Rect {
237                y: area.y + row as u16,
238                height: 1,
239                ..area
240            };
241            if row + 1 == height && below > 0 && !is_selected {
242                let more = format!("  {} {} more", g.ellipsis, below + 1);
243                Paragraph::new(more)
244                    .style(Style::default().fg(ctx.dimmed))
245                    .render(row_area, buf);
246                break;
247            }
248            // The rail marks focus, not selection: inside one Surface it is
249            // the one "you are here", and Tab visibly moves it. The choice
250            // stays marked without focus by a glyph, not by color alone —
251            // under NO_COLOR an accent-only selection disappeared.
252            let (marker, mut style) = if is_selected {
253                (
254                    if self.focused { g.rail } else { g.middot },
255                    Style::default().fg(ctx.accent),
256                )
257            } else {
258                (" ", Style::default().fg(ctx.text_primary))
259            };
260            if is_selected && self.focused {
261                style = style.patch(ctx.highlight_style());
262            }
263            let mark = match &self.marks {
264                Some(marks) => {
265                    let on = marks.get(i).copied().unwrap_or(false);
266                    format!("{} ", if on { g.checkbox_on } else { g.checkbox_off })
267                }
268                None => String::new(),
269            };
270            Paragraph::new(format!("{}{}{}", marker, mark, self.items[i]))
271                .style(style)
272                .render(row_area, buf);
273            if let Some(detail) = self.details.as_ref().and_then(|d| d.get(i)) {
274                let w = crate::glyphs::display_width(detail) as u16;
275                if w + 2 < row_area.width {
276                    let x = row_area.right() - w;
277                    buf.set_string(x - 1, row_area.y, " ", style);
278                    buf.set_string(x, row_area.y, detail, style.fg(ctx.dimmed));
279                }
280            }
281            if let Some(hit) = self.click_on(i) {
282                crate::pointer::record(row_area, hit);
283            }
284        }
285    }
286}
287
288impl Picker<'_> {
289    /// What a click on line `i` lands on.
290    fn click_on(&self, i: usize) -> Option<Hit> {
291        let selected = self.selected.unwrap_or(0);
292        Some(match self.clicks.as_ref()? {
293            Clicks::Choose => Hit::PickerItem {
294                visible: i,
295                selected,
296                multi: self.marks.is_some(),
297            },
298            Clicks::Tool => Hit::Tool(i),
299        })
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn state() -> PickerState {
308        PickerState::new(
309            ["CSV", "Parquet", "JSON", "NDJSON", "Arrow", "Avro"]
310                .iter()
311                .map(|s| s.to_string())
312                .collect(),
313        )
314    }
315
316    #[test]
317    fn typing_narrows_and_backspace_widens() {
318        let mut s = state();
319        s.type_char('a');
320        let names: Vec<&str> = s.filtered().iter().map(|(_, n)| *n).collect();
321        assert_eq!(names, ["Arrow", "Avro", "Parquet"], "starts first");
322        s.type_char('r');
323        let names: Vec<&str> = s.filtered().iter().map(|(_, n)| *n).collect();
324        assert_eq!(
325            names,
326            ["Arrow", "Parquet"],
327            "matches anywhere, ignoring case"
328        );
329        s.backspace();
330        s.backspace();
331        assert_eq!(s.filtered().len(), 6);
332    }
333
334    #[test]
335    fn narrowing_keeps_the_cursor_on_its_item_when_it_survives() {
336        let mut s = state();
337        s.select_original(4); // Arrow
338        s.type_char('r');
339        assert_eq!(
340            s.selected_original(),
341            Some(4),
342            "Arrow matches 'r' and keeps the cursor"
343        );
344        s.type_char('q');
345        assert_eq!(
346            s.selected_original(),
347            Some(1),
348            "'rq' filters Arrow away, so the cursor lands on the first match"
349        );
350    }
351
352    #[test]
353    fn movement_walks_the_visible_items_and_wraps() {
354        let mut s = state();
355        s.type_char('a'); // Arrow, Avro, Parquet
356        s.move_down();
357        assert_eq!(s.selected_original(), Some(5));
358        s.move_down();
359        assert_eq!(s.selected_original(), Some(1));
360        s.move_down();
361        assert_eq!(s.selected_original(), Some(4), "wraps to the top");
362        s.move_up();
363        assert_eq!(s.selected_original(), Some(1), "and back around");
364    }
365
366    /// Typing a whole name lists it first and takes the cursor there, off an item
367    /// that only contains it: `hour` picks `hour`, not the `time_hour` it was on.
368    #[test]
369    fn a_name_typed_whole_ranks_first_and_takes_the_cursor() {
370        let mut s = PickerState::new(
371            ["time_hour", "dep_delay", "Hour", "hours"]
372                .iter()
373                .map(|s| s.to_string())
374                .collect(),
375        );
376        s.select_original(0);
377        for c in "hour".chars() {
378            s.type_char(c);
379        }
380        let names: Vec<&str> = s.filtered().iter().map(|(_, n)| *n).collect();
381        assert_eq!(names, ["Hour", "hours", "time_hour"]);
382        assert_eq!(s.selected_original(), Some(2));
383        s.backspace();
384        assert_eq!(
385            s.selected_original(),
386            Some(2),
387            "kept while it still matches"
388        );
389    }
390
391    #[test]
392    fn a_filter_that_admits_nothing_chooses_nothing_and_never_panics() {
393        let mut s = state();
394        for c in "zzz".chars() {
395            s.type_char(c);
396        }
397        assert_eq!(s.selected_original(), None);
398        s.move_down();
399        s.move_up();
400        assert_eq!(s.selected_original(), None);
401    }
402
403    fn render_rows(picker: &Picker, width: u16, height: u16) -> Vec<String> {
404        let ctx = RenderContext::for_test();
405        let area = Rect::new(0, 0, width, height);
406        let mut buf = Buffer::empty(area);
407        picker.render(area, &mut buf, &ctx);
408        (0..height)
409            .map(|y| {
410                (0..width)
411                    .map(|x| buf[(x, y)].symbol().to_string())
412                    .collect::<String>()
413            })
414            .collect()
415    }
416
417    #[test]
418    fn the_selection_carries_the_rail_and_only_the_selection() {
419        let g = crate::glyphs::get();
420        let picker = Picker::new(vec!["CSV", "Parquet", "JSON"], Some(1), true);
421        let rows = render_rows(&picker, 20, 3);
422        assert!(
423            rows[1].starts_with(&format!("{}Parquet", g.rail)),
424            "got {rows:?}"
425        );
426        assert!(rows[0].starts_with(" CSV"), "got {rows:?}");
427        assert!(rows[2].starts_with(" JSON"), "got {rows:?}");
428    }
429
430    /// The rail leaves with focus — it is the form's one "you are here" —
431    /// but the chosen item never stops being visible: it keeps the accent
432    /// and a marker glyph, so it survives a terminal with no color at all.
433    #[test]
434    fn an_unfocused_selection_stays_visible_without_the_rail() {
435        let g = crate::glyphs::get();
436        let picker = Picker::new(vec!["CSV", "Parquet"], Some(0), false);
437        let rows = render_rows(&picker, 20, 2);
438        assert!(
439            rows[0].starts_with(&format!("{}CSV", g.middot)),
440            "the choice keeps a glyph without focus: {rows:?}"
441        );
442        assert!(
443            !rows[0].starts_with(g.rail),
444            "but never the rail, which means focus: {rows:?}"
445        );
446
447        let ctx = RenderContext::for_test();
448        let area = Rect::new(0, 0, 20, 2);
449        let mut buf = Buffer::empty(area);
450        picker.render(area, &mut buf, &ctx);
451        assert_ne!(
452            buf[(1, 0)].fg,
453            buf[(1, 1)].fg,
454            "the chosen item still reads apart from the rest"
455        );
456    }
457
458    /// Items past the window are counted, not half-drawn.
459    #[test]
460    fn overflow_is_counted_on_the_last_row() {
461        let picker = Picker::new(vec!["a", "b", "c", "d", "e"], Some(0), true);
462        let rows = render_rows(&picker, 20, 3);
463        assert!(rows[2].contains("3 more"), "got {rows:?}");
464    }
465
466    /// Marks turn the list into a toggle list: every item carries a checkbox,
467    /// on or off, so what is already chosen never has to be remembered.
468    #[test]
469    fn marks_draw_a_checkbox_on_every_item() {
470        let g = crate::glyphs::get();
471        let picker = Picker::new(vec!["dept", "region"], Some(0), true).marks(vec![true, false]);
472        let rows = render_rows(&picker, 20, 2);
473        assert!(
474            rows[0].contains(&format!("{} dept", g.checkbox_on)),
475            "got {rows:?}"
476        );
477        assert!(
478            rows[1].contains(&format!("{} region", g.checkbox_off)),
479            "got {rows:?}"
480        );
481    }
482
483    #[test]
484    fn scrolling_keeps_the_selection_in_view() {
485        let picker = Picker::new(vec!["a", "b", "c", "d", "e"], Some(4), true);
486        let g = crate::glyphs::get();
487        let rows = render_rows(&picker, 20, 3);
488        assert!(
489            rows[2].starts_with(&format!("{}e", g.rail)),
490            "the selected last item is drawn, not the overflow count: {rows:?}"
491        );
492    }
493    /// A chord is a chord: Ctrl+W edits the filter, and no modified
494    /// character ever lands in it as a letter.
495    #[test]
496    fn the_filter_keeps_readline_chords_out_of_the_text() {
497        use crossterm::event::KeyModifiers;
498        let mut p = PickerState::new(vec!["first_name".to_string(), "start date".to_string()]);
499        for c in "start d".chars() {
500            p.filter_key(c, KeyModifiers::NONE);
501        }
502        assert_eq!(p.filter, "start d");
503        p.filter_key('w', KeyModifiers::CONTROL);
504        assert_eq!(p.filter, "start ", "Ctrl+W drops the word, not types w");
505        p.filter_key('u', KeyModifiers::CONTROL);
506        assert_eq!(p.filter, "", "Ctrl+U clears, not types u");
507        p.filter_key('x', KeyModifiers::ALT);
508        assert_eq!(p.filter, "", "an Alt chord is not a letter");
509    }
510}