1use 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#[derive(Debug, Clone, Default)]
14pub struct PickerState {
15 items: Vec<String>,
16 pub filter: String,
17 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 pub fn items(&self) -> &[String] {
33 &self.items
34 }
35
36 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 pub fn visible_selection(&self) -> usize {
54 self.filtered()
55 .iter()
56 .position(|(i, _)| *i == self.selected)
57 .unwrap_or(0)
58 }
59
60 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 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 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 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
153pub struct Picker<'a> {
157 items: Vec<&'a str>,
158 selected: Option<usize>,
159 focused: bool,
160 marks: Option<Vec<bool>>,
161 details: Option<Vec<String>>,
163 clicks: Option<Clicks>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum Clicks {
169 Choose,
172 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 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 pub fn marks(mut self, marks: Vec<bool>) -> Self {
209 self.marks = Some(marks);
210 self
211 }
212
213 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 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 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 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); 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'); 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 #[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 #[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 #[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 #[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 #[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}