Skip to main content

kimun_notes/components/
key_picker.rs

1//! A text field with a filtered list of known property keys under it. Free
2//! text is always allowed; a suggestion is a shortcut, never a constraint.
3
4use ratatui::Frame;
5use ratatui::crossterm::event::{KeyCode, KeyEvent};
6use ratatui::layout::{Position, Rect};
7use ratatui::style::{Modifier, Style};
8use ratatui::widgets::{Clear, Paragraph};
9
10use crate::components::single_line_input::{InputOutcome, SingleLineInput};
11use crate::settings::themes::Theme;
12
13const MAX_ROWS: usize = 5;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PickerOutcome {
17    Consumed,
18    Changed,
19    Accepted(String),
20    Submit,
21    Cancel,
22    NotConsumed,
23}
24
25pub struct KeyPicker {
26    input: SingleLineInput,
27    keys: Vec<String>,
28    open: bool,
29    highlighted: usize,
30    /// First suggestion shown; follows `highlighted` so it stays on screen.
31    offset: usize,
32    /// Rows the last render could show (the real window for `offset`).
33    window: usize,
34    field_rect: Option<Rect>,
35    /// (rect, suggestion) of each visible row from the last render.
36    row_rects: Vec<(Rect, String)>,
37}
38
39impl KeyPicker {
40    pub fn new(initial: &str) -> Self {
41        Self {
42            input: SingleLineInput::with_value(initial),
43            keys: Vec::new(),
44            open: false,
45            highlighted: 0,
46            offset: 0,
47            window: MAX_ROWS,
48            field_rect: None,
49            row_rects: Vec::new(),
50        }
51    }
52
53    pub fn set_keys(&mut self, keys: Vec<String>) {
54        self.keys = keys;
55        let count = self.suggestions().len();
56        if count == 0 {
57            self.open = false;
58        }
59        self.highlighted = self.highlighted.min(count.saturating_sub(1));
60        self.follow_highlight();
61    }
62
63    /// Scrolls `offset` just enough to keep `highlighted` inside the window.
64    fn follow_highlight(&mut self) {
65        let window = self.window.max(1);
66        if self.highlighted < self.offset {
67            self.offset = self.highlighted;
68        } else if self.highlighted >= self.offset + window {
69            self.offset = self.highlighted + 1 - window;
70        }
71    }
72
73    pub fn value(&self) -> &str {
74        self.input.value()
75    }
76
77    /// Moves the caret to the end of the text (a click into the field).
78    pub fn cursor_to_end(&mut self) {
79        let text = self.input.value().to_string();
80        self.input.set_value(text);
81    }
82
83    pub fn is_list_open(&self) -> bool {
84        self.open
85    }
86
87    pub fn field_rect(&self) -> Option<Rect> {
88        self.field_rect
89    }
90
91    pub(crate) fn suggestions(&self) -> Vec<&str> {
92        let needle = self.input.value().to_lowercase();
93        if needle.is_empty() {
94            return Vec::new();
95        }
96        self.keys
97            .iter()
98            .filter(|k| k.to_lowercase().contains(&needle))
99            .map(String::as_str)
100            .collect()
101    }
102
103    fn refresh_open(&mut self) {
104        let value = self.input.value().to_lowercase();
105        let s = self.suggestions();
106        self.open = !s.is_empty() && !(s.len() == 1 && s[0].to_lowercase() == value);
107        self.highlighted = 0;
108        self.offset = 0;
109    }
110
111    fn accept(&mut self, key: String) -> PickerOutcome {
112        self.input.set_value(key.clone());
113        self.open = false;
114        self.row_rects.clear();
115        PickerOutcome::Accepted(key)
116    }
117
118    pub fn handle_key(&mut self, key: &KeyEvent) -> PickerOutcome {
119        if self.open {
120            let count = self.suggestions().len();
121            match key.code {
122                KeyCode::Up => {
123                    self.highlighted = self.highlighted.saturating_sub(1);
124                    self.follow_highlight();
125                    return PickerOutcome::Consumed;
126                }
127                KeyCode::Down => {
128                    self.highlighted = (self.highlighted + 1).min(count.saturating_sub(1));
129                    self.follow_highlight();
130                    return PickerOutcome::Consumed;
131                }
132                KeyCode::Enter | KeyCode::Right => {
133                    if let Some(k) = self.suggestions().get(self.highlighted) {
134                        let k = k.to_string();
135                        return self.accept(k);
136                    }
137                }
138                KeyCode::Esc => {
139                    self.open = false;
140                    return PickerOutcome::Consumed;
141                }
142                _ => {}
143            }
144        }
145        match self.input.handle_key(key) {
146            InputOutcome::Changed => {
147                self.refresh_open();
148                PickerOutcome::Changed
149            }
150            InputOutcome::Submit => PickerOutcome::Submit,
151            InputOutcome::Cancel => PickerOutcome::Cancel,
152            InputOutcome::Consumed => PickerOutcome::Consumed,
153            InputOutcome::NotConsumed => PickerOutcome::NotConsumed,
154        }
155    }
156
157    /// Field row is `field`; the suggestion list renders below it inside
158    /// `area`, at most 5 rows. Render the picker last so the list sits on top.
159    pub fn render(&mut self, f: &mut Frame, field: Rect, area: Rect, theme: &Theme, focused: bool) {
160        self.field_rect = Some(field);
161        let style = Style::default()
162            .fg(theme.fg_bright.to_ratatui())
163            .bg(theme.bg.to_ratatui());
164        self.input.render(f, field, style, 0, focused);
165        self.row_rects.clear();
166        if !self.open || !focused {
167            return;
168        }
169        let room = area.bottom().saturating_sub(field.y + 1) as usize;
170        self.window = MAX_ROWS.min(room).max(1);
171        self.follow_highlight();
172        let rows: Vec<String> = self
173            .suggestions()
174            .into_iter()
175            .skip(self.offset)
176            .take(MAX_ROWS.min(room))
177            .map(str::to_string)
178            .collect();
179        for (i, key) in rows.into_iter().enumerate() {
180            let y = field.y + 1 + i as u16;
181            let rect = Rect {
182                x: field.x,
183                y,
184                width: field.width,
185                height: 1,
186            };
187            let st = if self.offset + i == self.highlighted {
188                Style::default()
189                    .fg(theme.selection_fg.to_ratatui())
190                    .bg(theme.selection_bg.to_ratatui())
191                    .add_modifier(Modifier::BOLD)
192            } else {
193                Style::default()
194                    .fg(theme.fg.to_ratatui())
195                    .bg(theme.bg_panel.to_ratatui())
196            };
197            f.render_widget(Clear, rect);
198            f.render_widget(Paragraph::new(format!(" {key}")).style(st), rect);
199            self.row_rects.push((rect, key));
200        }
201    }
202
203    /// Click at (col,row): on the field → focus (`Consumed`); on a suggestion
204    /// → `Accepted(key)`; elsewhere → `NotConsumed`.
205    pub fn handle_click(&mut self, col: u16, row: u16) -> PickerOutcome {
206        let pos = Position { x: col, y: row };
207        if let Some((_, key)) = self.row_rects.iter().find(|(r, _)| r.contains(pos)) {
208            let key = key.clone();
209            return self.accept(key);
210        }
211        if self.field_rect.is_some_and(|r| r.contains(pos)) {
212            return PickerOutcome::Consumed;
213        }
214        PickerOutcome::NotConsumed
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use ratatui::crossterm::event::{KeyCode, KeyEvent};
222    use ratatui::{Terminal, backend::TestBackend};
223
224    fn picker() -> KeyPicker {
225        let mut p = KeyPicker::new("");
226        p.set_keys(vec!["due".into(), "status".into(), "priority".into()]);
227        p
228    }
229    fn k(code: KeyCode) -> KeyEvent {
230        KeyEvent::from(code)
231    }
232    fn type_str(p: &mut KeyPicker, s: &str) {
233        for c in s.chars() {
234            p.handle_key(&k(KeyCode::Char(c)));
235        }
236    }
237
238    #[test]
239    fn cursor_to_end_moves_the_caret_after_the_text() {
240        let mut p = KeyPicker::new("status");
241        p.handle_key(&k(KeyCode::Home));
242        p.cursor_to_end();
243        type_str(&mut p, "x");
244        assert_eq!(p.value(), "statusx");
245    }
246
247    #[test]
248    fn typing_filters_case_insensitively() {
249        let mut p = picker();
250        type_str(&mut p, "PRI");
251        assert!(p.is_list_open());
252        assert_eq!(p.suggestions(), vec!["priority"]);
253    }
254
255    #[test]
256    fn enter_accepts_highlighted_then_submits() {
257        let mut p = picker();
258        type_str(&mut p, "t");
259        assert_eq!(p.suggestions(), vec!["status", "priority"]);
260        p.handle_key(&k(KeyCode::Down));
261        assert_eq!(
262            p.handle_key(&k(KeyCode::Enter)),
263            PickerOutcome::Accepted("priority".into())
264        );
265        assert_eq!(p.value(), "priority");
266        assert!(!p.is_list_open());
267        assert_eq!(p.handle_key(&k(KeyCode::Enter)), PickerOutcome::Submit);
268    }
269
270    #[test]
271    fn esc_closes_list_before_cancelling() {
272        let mut p = picker();
273        type_str(&mut p, "d");
274        assert_eq!(p.handle_key(&k(KeyCode::Esc)), PickerOutcome::Consumed);
275        assert!(!p.is_list_open());
276        assert_eq!(p.handle_key(&k(KeyCode::Esc)), PickerOutcome::Cancel);
277    }
278
279    #[test]
280    fn free_text_is_kept() {
281        let mut p = picker();
282        type_str(&mut p, "brand new");
283        assert!(!p.is_list_open());
284        assert_eq!(p.handle_key(&k(KeyCode::Enter)), PickerOutcome::Submit);
285        assert_eq!(p.value(), "brand new");
286    }
287
288    fn draw(p: &mut KeyPicker, h: u16) {
289        let theme = Theme::gruvbox_dark();
290        let mut t = Terminal::new(TestBackend::new(30, h)).unwrap();
291        t.draw(|f| {
292            let area = f.area();
293            let field = Rect { height: 1, ..area };
294            p.render(f, field, area, &theme, true);
295        })
296        .unwrap();
297    }
298
299    #[test]
300    fn highlight_stays_in_rendered_window() {
301        let mut p = KeyPicker::new("");
302        p.set_keys((0..8).map(|i| format!("key{i}")).collect());
303        type_str(&mut p, "key");
304        for _ in 0..6 {
305            p.handle_key(&k(KeyCode::Down));
306        }
307        draw(&mut p, 10);
308        let rows: Vec<&str> = p.row_rects.iter().map(|(_, s)| s.as_str()).collect();
309        assert_eq!(rows.len(), 5);
310        assert!(rows.contains(&"key6"), "rendered {rows:?}");
311        assert_eq!(
312            p.handle_key(&k(KeyCode::Enter)),
313            PickerOutcome::Accepted("key6".into())
314        );
315    }
316
317    #[test]
318    fn highlight_follows_in_clipped_area() {
319        let mut p = KeyPicker::new("");
320        p.set_keys((0..8).map(|i| format!("key{i}")).collect());
321        type_str(&mut p, "key");
322        draw(&mut p, 3); // field + 2 rows
323        for _ in 0..4 {
324            p.handle_key(&k(KeyCode::Down));
325        }
326        draw(&mut p, 3);
327        let rows: Vec<&str> = p.row_rects.iter().map(|(_, s)| s.as_str()).collect();
328        assert_eq!(rows, vec!["key3", "key4"]);
329    }
330
331    #[test]
332    fn right_accepts_and_up_moves_back() {
333        let mut p = picker();
334        type_str(&mut p, "t");
335        p.handle_key(&k(KeyCode::Down));
336        p.handle_key(&k(KeyCode::Up));
337        assert_eq!(
338            p.handle_key(&k(KeyCode::Right)),
339            PickerOutcome::Accepted("status".into())
340        );
341    }
342
343    #[test]
344    fn click_on_field_is_consumed_and_outside_is_not() {
345        let mut p = picker();
346        draw(&mut p, 8);
347        assert_eq!(p.handle_click(3, 0), PickerOutcome::Consumed);
348        assert_eq!(p.handle_click(3, 5), PickerOutcome::NotConsumed);
349    }
350
351    #[test]
352    fn second_click_before_rerender_is_ignored() {
353        let mut p = picker();
354        type_str(&mut p, "t");
355        draw(&mut p, 8);
356        assert!(matches!(p.handle_click(3, 1), PickerOutcome::Accepted(_)));
357        assert_eq!(p.handle_click(3, 1), PickerOutcome::NotConsumed);
358    }
359
360    #[test]
361    fn set_keys_clamps_stale_state() {
362        let mut p = picker();
363        type_str(&mut p, "t");
364        p.handle_key(&k(KeyCode::Down));
365        p.set_keys(vec!["status".into()]);
366        assert_eq!(
367            p.handle_key(&k(KeyCode::Enter)),
368            PickerOutcome::Accepted("status".into())
369        );
370        let mut p = picker();
371        type_str(&mut p, "t");
372        p.set_keys(vec![]);
373        assert!(!p.is_list_open());
374    }
375
376    #[test]
377    fn click_on_suggestion_accepts_it() {
378        let mut p = picker();
379        type_str(&mut p, "t");
380        let theme = Theme::gruvbox_dark();
381        let mut t = Terminal::new(TestBackend::new(30, 8)).unwrap();
382        t.draw(|f| {
383            let area = f.area();
384            let field = Rect { height: 1, ..area };
385            p.render(f, field, area, &theme, true);
386        })
387        .unwrap();
388        // Suggestions start on the row below the field: row 1 = "status", row 2 = "priority".
389        assert_eq!(
390            p.handle_click(3, 2),
391            PickerOutcome::Accepted("priority".into())
392        );
393        assert_eq!(p.value(), "priority");
394    }
395}