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::app::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::home::fuzzy::substring_rank(&self.filter, item)
45                    .map(|r| (r, i, item.as_str()))
46            })
47            .collect();
48        ranked.sort_by_key(|(rank, i, _)| (*rank, *i));
49        ranked.into_iter().map(|(_, i, item)| (i, item)).collect()
50    }
51
52    /// Where the cursor sits among the visible items; 0 when the item it was
53    /// on has been filtered away.
54    pub fn visible_selection(&self) -> usize {
55        self.filtered()
56            .iter()
57            .position(|(i, _)| *i == self.selected)
58            .unwrap_or(0)
59    }
60
61    /// The selected item's original index, or None when the filter admits nothing.
62    pub fn selected_original(&self) -> Option<usize> {
63        let filtered = self.filtered();
64        filtered
65            .get(self.visible_selection())
66            .or_else(|| filtered.first())
67            .map(|(i, _)| *i)
68    }
69
70    pub fn select_original(&mut self, index: usize) {
71        if index < self.items.len() {
72            self.selected = index;
73        }
74    }
75
76    pub fn type_char(&mut self, c: char) {
77        self.filter.push(c);
78        self.settle();
79    }
80
81    pub fn backspace(&mut self) {
82        self.filter.pop();
83        self.settle();
84    }
85
86    pub fn clear_filter(&mut self) {
87        self.filter.clear();
88        self.settle();
89    }
90
91    /// One typed character, with its modifiers: plain characters narrow,
92    /// Ctrl+W drops a word, Ctrl+U clears, and any other chord is a chord —
93    /// never a letter typed into the filter.
94    pub fn filter_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
95        use crossterm::event::KeyModifiers;
96        let ctrl = mods.contains(KeyModifiers::CONTROL);
97        if ctrl && c == 'w' {
98            self.delete_word();
99        } else if ctrl && c == 'u' {
100            self.clear_filter();
101        } else if !ctrl && !mods.contains(KeyModifiers::ALT) {
102            self.type_char(c);
103        }
104    }
105
106    /// Ctrl+W: drop the word before the cursor, readline-style. The filter
107    /// is append-only, so the cursor is always the end.
108    pub fn delete_word(&mut self) {
109        while self.filter.ends_with(' ') {
110            self.filter.pop();
111        }
112        while self.filter.chars().next_back().is_some_and(|c| c != ' ') {
113            self.filter.pop();
114        }
115        self.settle();
116    }
117
118    pub fn move_up(&mut self) {
119        self.step(-1);
120    }
121
122    pub fn move_down(&mut self) {
123        self.step(1);
124    }
125
126    fn step(&mut self, delta: isize) {
127        let filtered = self.filtered();
128        if filtered.is_empty() {
129            return;
130        }
131        let at = self.visible_selection() as isize;
132        let n = filtered.len() as isize;
133        let next = (at + delta).rem_euclid(n) as usize;
134        self.selected = filtered[next].0;
135    }
136
137    /// After the filter changes, land the cursor on something visible: the item
138    /// the filter names in full, else the one it was on, else the first.
139    fn settle(&mut self) {
140        let filtered = self.filtered();
141        let named = filtered
142            .first()
143            .filter(|(_, item)| crate::home::fuzzy::substring_rank(&self.filter, item) == Some(0));
144        if let Some((exact, _)) = named {
145            self.selected = *exact;
146        } else if filtered.iter().all(|(i, _)| *i != self.selected)
147            && let Some((first, _)) = filtered.first()
148        {
149            self.selected = *first;
150        }
151    }
152}
153
154/// Draws a pick-one list: the selection carries the rail, and the tint when
155/// the list is focused. Selected-but-unfocused stays visible. With marks it
156/// is a toggle list: each item carries a checkbox, and choosing means Space.
157pub struct Picker<'a> {
158    items: Vec<&'a str>,
159    selected: Option<usize>,
160    focused: bool,
161    marks: Option<Vec<bool>>,
162    /// Beside each item, right-aligned and dimmed: a count.
163    details: Option<Vec<String>>,
164    clicks: Option<Clicks>,
165}
166
167/// What a click on a line does, recorded as the list is drawn.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Clicks {
170    /// An open picker: the cursor goes to the line, which is chosen (toggled in a
171    /// list of marks).
172    Choose,
173    /// The analysis tools list.
174    Tool,
175}
176
177impl<'a> Picker<'a> {
178    pub fn new(items: Vec<&'a str>, selected: Option<usize>, focused: bool) -> Self {
179        Self {
180            items,
181            selected,
182            focused,
183            marks: None,
184            details: None,
185            clicks: None,
186        }
187    }
188
189    /// What a click on a line does. A list from a [`PickerState`] is an open picker
190    /// and chooses already.
191    pub fn on_click(mut self, clicks: Clicks) -> Self {
192        self.clicks = Some(clicks);
193        self
194    }
195
196    pub fn from_state(state: &'a PickerState, focused: bool) -> Self {
197        let items = state.filtered().into_iter().map(|(_, item)| item).collect();
198        Self {
199            items,
200            selected: Some(state.visible_selection()),
201            focused,
202            marks: None,
203            details: None,
204            clicks: Some(Clicks::Choose),
205        }
206    }
207
208    /// Checkbox states, one per visible item in order.
209    pub fn marks(mut self, marks: Vec<bool>) -> Self {
210        self.marks = Some(marks);
211        self
212    }
213
214    /// A note per visible item in order, right-aligned beside it.
215    pub fn details(mut self, details: Vec<String>) -> Self {
216        self.details = Some(details);
217        self
218    }
219
220    pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
221        if area.height == 0 || area.width == 0 {
222            return;
223        }
224        let g = crate::glyphs::get();
225        let height = area.height as usize;
226        // Scroll just enough to keep the selection in view, and count what ran
227        // off the bottom rather than half-showing it.
228        let selected = self.selected.unwrap_or(0);
229        let offset = selected.saturating_sub(height.saturating_sub(1));
230        let below = self.items.len().saturating_sub(offset + height);
231        if self.clicks == Some(Clicks::Choose) {
232            crate::app::pointer::record(area, Hit::Picker);
233        }
234        for row in 0..height.min(self.items.len().saturating_sub(offset)) {
235            let i = offset + row;
236            let is_selected = self.selected == Some(i);
237            let row_area = Rect {
238                y: area.y + row as u16,
239                height: 1,
240                ..area
241            };
242            if row + 1 == height && below > 0 && !is_selected {
243                let more = format!("  {} {} more", g.ellipsis, below + 1);
244                Paragraph::new(more)
245                    .style(Style::default().fg(ctx.dimmed))
246                    .render(row_area, buf);
247                break;
248            }
249            // The rail marks focus, not selection: inside one Surface it is
250            // the one "you are here", and Tab visibly moves it. The choice
251            // stays marked without focus by a glyph, not by color alone —
252            // under NO_COLOR an accent-only selection disappeared.
253            let (marker, mut style) = if is_selected {
254                (
255                    if self.focused { g.rail } else { g.middot },
256                    Style::default().fg(ctx.accent),
257                )
258            } else {
259                (" ", Style::default().fg(ctx.text_primary))
260            };
261            if is_selected && self.focused {
262                style = style.patch(ctx.highlight_style());
263            }
264            let mark = match &self.marks {
265                Some(marks) => {
266                    let on = marks.get(i).copied().unwrap_or(false);
267                    format!("{} ", if on { g.checkbox_on } else { g.checkbox_off })
268                }
269                None => String::new(),
270            };
271            Paragraph::new(format!("{}{}{}", marker, mark, self.items[i]))
272                .style(style)
273                .render(row_area, buf);
274            if let Some(detail) = self.details.as_ref().and_then(|d| d.get(i)) {
275                let w = crate::glyphs::display_width(detail) as u16;
276                if w + 2 < row_area.width {
277                    let x = row_area.right() - w;
278                    buf.set_string(x - 1, row_area.y, " ", style);
279                    buf.set_string(x, row_area.y, detail, style.fg(ctx.dimmed));
280                }
281            }
282            if let Some(hit) = self.click_on(i) {
283                crate::app::pointer::record(row_area, hit);
284            }
285        }
286    }
287}
288
289impl Picker<'_> {
290    /// What a click on line `i` lands on.
291    fn click_on(&self, i: usize) -> Option<Hit> {
292        let selected = self.selected.unwrap_or(0);
293        Some(match self.clicks.as_ref()? {
294            Clicks::Choose => Hit::PickerItem {
295                visible: i,
296                selected,
297                multi: self.marks.is_some(),
298            },
299            Clicks::Tool => Hit::Tool(i),
300        })
301    }
302}
303
304#[cfg(test)]
305mod tests;