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