1use 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 offset: usize,
32 window: usize,
34 field_rect: Option<Rect>,
35 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 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 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 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 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); 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 assert_eq!(
390 p.handle_click(3, 2),
391 PickerOutcome::Accepted("priority".into())
392 );
393 assert_eq!(p.value(), "priority");
394 }
395}