1use std::collections::HashSet;
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
5use nucleo_matcher::{Config, Matcher, Utf32Str};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ListAction {
10 Up,
11 Down,
12 Select,
13 ToggleDiff,
14 Cancel,
15 Toggle,
16 ToggleAll,
17 SearchStart,
19 SearchInput(char),
21 SearchBackspace,
23 SearchCancel,
25 None,
26}
27
28impl ListAction {
29 pub fn from_key(key: KeyEvent, search_active: bool) -> Self {
36 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
37 if search_active {
38 return match key.code {
39 KeyCode::Up => ListAction::Up,
40 KeyCode::Down => ListAction::Down,
41 KeyCode::Enter => ListAction::Select,
42 KeyCode::Esc => ListAction::SearchCancel,
43 KeyCode::Backspace => ListAction::SearchBackspace,
44 KeyCode::Char('j') if ctrl => ListAction::Down,
47 KeyCode::Char('k') if ctrl => ListAction::Up,
48 KeyCode::Char('d') if ctrl => ListAction::ToggleDiff,
49 KeyCode::Char(' ') => ListAction::Toggle,
53 KeyCode::Char(c) if !ctrl => ListAction::SearchInput(c),
54 _ => ListAction::None,
55 };
56 }
57 match key.code {
58 KeyCode::Up | KeyCode::Char('k') => ListAction::Up,
59 KeyCode::Down | KeyCode::Char('j') => ListAction::Down,
60 KeyCode::Enter => ListAction::Select,
61 KeyCode::Esc | KeyCode::Char('q') => ListAction::Cancel,
62 KeyCode::Char('d') if ctrl => ListAction::ToggleDiff,
63 KeyCode::Char(' ') => ListAction::Toggle,
64 KeyCode::Char('u') | KeyCode::Char('U') => ListAction::ToggleAll,
65 KeyCode::Char('/') => ListAction::SearchStart,
66 _ => ListAction::None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ListResult {
74 Select(Vec<usize>, bool),
76 Cancel,
77}
78
79#[derive(Debug, Clone)]
87pub struct ListState {
88 items: Vec<String>,
89 cursor: usize,
90 selected: HashSet<usize>,
91 show_diff: bool,
92 multi_select: bool,
93 query: Option<String>,
95 visible: Vec<usize>,
97}
98
99impl ListState {
100 pub fn new(items: Vec<String>, multi_select: bool, initial_diff: bool) -> Self {
101 let visible = (0..items.len()).collect();
102 Self {
103 items,
104 cursor: 0,
105 selected: HashSet::new(),
106 show_diff: initial_diff,
107 multi_select,
108 query: None,
109 visible,
110 }
111 }
112
113 pub fn items(&self) -> &[String] {
114 &self.items
115 }
116
117 pub fn len(&self) -> usize {
118 self.items.len()
119 }
120
121 pub fn is_empty(&self) -> bool {
122 self.items.is_empty()
123 }
124
125 pub fn cursor(&self) -> usize {
127 self.cursor
128 }
129
130 pub fn search_active(&self) -> bool {
131 self.query.is_some()
132 }
133
134 pub fn search_query(&self) -> Option<&str> {
137 self.query.as_deref()
138 }
139
140 pub fn visible_items(&self) -> impl Iterator<Item = (usize, &str)> {
142 self.visible.iter().map(|&i| (i, self.items[i].as_str()))
143 }
144
145 pub fn highlighted(&self) -> Option<usize> {
148 self.visible.get(self.cursor).copied()
149 }
150
151 pub fn show_diff(&self) -> bool {
152 self.show_diff
153 }
154
155 pub fn set_show_diff(&mut self, show_diff: bool) {
156 self.show_diff = show_diff;
157 }
158
159 pub fn is_selected(&self, index: usize) -> bool {
160 self.selected.contains(&index)
161 }
162
163 pub fn selected_count(&self) -> usize {
164 self.selected.len()
165 }
166
167 pub fn selected_indices(&self) -> Vec<usize> {
169 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
170 indices.sort_unstable();
171 indices
172 }
173
174 pub fn multi_select(&self) -> bool {
175 self.multi_select
176 }
177
178 pub fn handle(&mut self, action: ListAction) -> Option<ListResult> {
180 match action {
181 ListAction::Up => {
182 self.cursor = if self.cursor == 0 {
183 self.visible.len().saturating_sub(1)
184 } else {
185 self.cursor - 1
186 };
187 }
188 ListAction::Down => {
189 self.cursor = if self.cursor >= self.visible.len().saturating_sub(1) {
190 0
191 } else {
192 self.cursor + 1
193 };
194 }
195 ListAction::Toggle if self.multi_select => {
196 if let Some(&index) = self.visible.get(self.cursor) {
197 if !self.selected.remove(&index) {
198 self.selected.insert(index);
199 }
200 self.cursor = if self.cursor >= self.visible.len().saturating_sub(1) {
202 0
203 } else {
204 self.cursor + 1
205 };
206 }
207 }
208 ListAction::ToggleAll if self.multi_select => {
209 let all_visible_selected = !self.visible.is_empty()
210 && self.visible.iter().all(|i| self.selected.contains(i));
211 if all_visible_selected {
212 for index in &self.visible {
213 self.selected.remove(index);
214 }
215 } else {
216 self.selected.extend(self.visible.iter().copied());
217 }
218 }
219 ListAction::ToggleDiff => {
220 self.show_diff = !self.show_diff;
221 }
222 ListAction::Select => {
223 if self.multi_select {
224 if self.selected.is_empty() {
225 return Some(ListResult::Cancel);
226 }
227 return Some(ListResult::Select(self.selected_indices(), self.show_diff));
228 }
229 if let Some(&index) = self.visible.get(self.cursor) {
230 return Some(ListResult::Select(vec![index], self.show_diff));
231 }
232 }
233 ListAction::Cancel => return Some(ListResult::Cancel),
234 ListAction::SearchStart => {
235 self.query = Some(String::new());
236 self.refilter();
237 }
238 ListAction::SearchInput(c) => {
239 if let Some(query) = &mut self.query {
240 query.push(c);
241 self.refilter();
242 }
243 }
244 ListAction::SearchBackspace => {
245 if let Some(query) = &mut self.query {
246 query.pop();
247 self.refilter();
248 }
249 }
250 ListAction::SearchCancel => {
251 let highlighted = self.highlighted();
255 self.query = None;
256 self.refilter();
257 self.cursor = highlighted.unwrap_or(0);
258 }
259 _ => {}
260 }
261 None
262 }
263
264 fn refilter(&mut self) {
269 let query = self.query.as_deref().unwrap_or("");
270 if query.is_empty() {
271 self.visible = (0..self.items.len()).collect();
272 } else {
273 let mut matcher = Matcher::new(Config::DEFAULT);
274 let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
275 let mut char_buf: Vec<char> = Vec::new();
276 let mut scored: Vec<(u32, usize)> = self
277 .items
278 .iter()
279 .enumerate()
280 .filter_map(|(i, item)| {
281 char_buf.clear();
282 let haystack = Utf32Str::new(item, &mut char_buf);
283 pattern
284 .score(haystack, &mut matcher)
285 .map(|score| (score, i))
286 })
287 .collect();
288 scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
289 self.visible = scored.into_iter().map(|(_, i)| i).collect();
290 }
291 self.cursor = 0;
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn items(n: usize) -> Vec<String> {
300 (0..n).map(|i| format!("item{i}")).collect()
301 }
302
303 #[test]
304 fn test_single_select_navigation() {
305 let mut state = ListState::new(items(3), false, false);
306 assert_eq!(state.cursor(), 0);
307
308 state.handle(ListAction::Down);
309 assert_eq!(state.cursor(), 1);
310
311 state.handle(ListAction::Down);
312 assert_eq!(state.cursor(), 2);
313
314 state.handle(ListAction::Down);
315 assert_eq!(state.cursor(), 0); state.handle(ListAction::Up);
318 assert_eq!(state.cursor(), 2); }
320
321 #[test]
322 fn test_single_select_toggle_diff() {
323 let mut state = ListState::new(items(3), false, false);
324 assert!(!state.show_diff());
325
326 state.handle(ListAction::ToggleDiff);
327 assert!(state.show_diff());
328 }
329
330 #[test]
331 fn test_single_select_select() {
332 let mut state = ListState::new(items(3), false, true);
333 state.handle(ListAction::Down);
334 let result = state.handle(ListAction::Select);
335 assert_eq!(result, Some(ListResult::Select(vec![1], true)));
336 }
337
338 #[test]
339 fn test_single_select_ignores_toggle() {
340 let mut state = ListState::new(items(3), false, false);
341 state.handle(ListAction::Toggle);
342 assert_eq!(state.selected_count(), 0); assert_eq!(state.cursor(), 0); }
345
346 #[test]
347 fn test_multi_select_toggle() {
348 let mut state = ListState::new(items(3), true, false);
349 assert_eq!(state.selected_count(), 0);
350
351 state.handle(ListAction::Toggle);
352 assert_eq!(state.selected_count(), 1);
353 assert!(state.is_selected(0));
354 assert_eq!(state.cursor(), 1); }
356
357 #[test]
358 fn test_multi_select_toggle_all() {
359 let mut state = ListState::new(items(3), true, false);
360 state.handle(ListAction::ToggleAll);
361 assert_eq!(state.selected_count(), 3);
362
363 state.handle(ListAction::ToggleAll);
364 assert_eq!(state.selected_count(), 0);
365 }
366
367 fn input_ids() -> Vec<String> {
368 [
369 "bookah",
370 "centerpiece",
371 "clan",
372 "crane",
373 "depot",
374 "direnv-instant",
375 "disko",
376 "distro",
377 "flake-fmt",
378 ]
379 .iter()
380 .map(|s| s.to_string())
381 .collect()
382 }
383
384 fn key(code: KeyCode) -> KeyEvent {
385 KeyEvent::new(code, KeyModifiers::NONE)
386 }
387
388 fn visible_texts(state: &ListState) -> Vec<&str> {
389 state.visible_items().map(|(_, text)| text).collect()
390 }
391
392 #[test]
393 fn test_slash_starts_search_in_normal_mode() {
394 assert_eq!(
395 ListAction::from_key(key(KeyCode::Char('/')), false),
396 ListAction::SearchStart
397 );
398 assert_eq!(
400 ListAction::from_key(key(KeyCode::Char('j')), false),
401 ListAction::Down
402 );
403 assert_eq!(
404 ListAction::from_key(key(KeyCode::Char('q')), false),
405 ListAction::Cancel
406 );
407 }
408
409 #[test]
410 fn test_search_mode_key_mapping() {
411 for c in ['j', 'k', 'q', 'u', 'U', '/', 'f'] {
414 assert_eq!(
415 ListAction::from_key(key(KeyCode::Char(c)), true),
416 ListAction::SearchInput(c)
417 );
418 }
419 assert_eq!(
420 ListAction::from_key(key(KeyCode::Esc), true),
421 ListAction::SearchCancel
422 );
423 assert_eq!(
424 ListAction::from_key(key(KeyCode::Enter), true),
425 ListAction::Select
426 );
427 assert_eq!(
428 ListAction::from_key(key(KeyCode::Backspace), true),
429 ListAction::SearchBackspace
430 );
431 assert_eq!(ListAction::from_key(key(KeyCode::Up), true), ListAction::Up);
432 assert_eq!(
433 ListAction::from_key(key(KeyCode::Down), true),
434 ListAction::Down
435 );
436 assert_eq!(
438 ListAction::from_key(key(KeyCode::Char(' ')), true),
439 ListAction::Toggle
440 );
441 assert_eq!(
443 ListAction::from_key(
444 KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
445 true
446 ),
447 ListAction::ToggleDiff
448 );
449 assert_eq!(
450 ListAction::from_key(
451 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
452 true
453 ),
454 ListAction::None
455 );
456 assert_eq!(
458 ListAction::from_key(
459 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
460 true
461 ),
462 ListAction::Down
463 );
464 assert_eq!(
465 ListAction::from_key(
466 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
467 true
468 ),
469 ListAction::Up
470 );
471 }
472
473 #[test]
474 fn test_search_filters_incrementally() {
475 let mut state = ListState::new(input_ids(), false, false);
476 assert!(!state.search_active());
477
478 state.handle(ListAction::SearchStart);
479 assert!(state.search_active());
480 assert_eq!(state.search_query(), Some(""));
481 assert_eq!(state.visible_items().count(), 9);
483
484 state.handle(ListAction::SearchInput('f'));
485 state.handle(ListAction::SearchInput('l'));
486 assert_eq!(state.search_query(), Some("fl"));
487 assert_eq!(visible_texts(&state), vec!["flake-fmt"]);
488 assert_eq!(state.highlighted(), Some(8));
490 }
491
492 #[test]
493 fn test_search_enter_returns_original_index() {
494 let mut state = ListState::new(input_ids(), false, true);
495 state.handle(ListAction::SearchStart);
496 state.handle(ListAction::SearchInput('f'));
497 state.handle(ListAction::SearchInput('l'));
498
499 let result = state.handle(ListAction::Select);
500 assert_eq!(result, Some(ListResult::Select(vec![8], true)));
501 }
502
503 #[test]
504 fn test_search_escape_restores_full_list() {
505 let mut state = ListState::new(input_ids(), false, false);
506 state.handle(ListAction::SearchStart);
507 state.handle(ListAction::SearchInput('f'));
508 state.handle(ListAction::SearchInput('l'));
509
510 state.handle(ListAction::SearchCancel);
511 assert!(!state.search_active());
512 assert_eq!(state.search_query(), None);
513 assert_eq!(state.visible_items().count(), 9);
514 assert_eq!(state.highlighted(), Some(8));
516 }
517
518 #[test]
519 fn test_search_no_match_then_backspace() {
520 let mut state = ListState::new(input_ids(), false, false);
521 state.handle(ListAction::SearchStart);
522 state.handle(ListAction::SearchInput('f'));
523 state.handle(ListAction::SearchInput('l'));
524 state.handle(ListAction::SearchInput('z'));
525 assert_eq!(state.visible_items().count(), 0);
526 assert_eq!(state.highlighted(), None);
527
528 assert_eq!(state.handle(ListAction::Select), None);
530 state.handle(ListAction::Down);
532 state.handle(ListAction::Up);
533 assert_eq!(state.cursor(), 0);
534 assert_eq!(state.highlighted(), None);
535
536 state.handle(ListAction::SearchBackspace);
538 assert_eq!(visible_texts(&state), vec!["flake-fmt"]);
539 }
540
541 #[test]
542 fn test_search_smart_case_matches_capitals() {
543 let items = vec!["Flake-Fmt".to_string(), "bookah".to_string()];
544 let mut state = ListState::new(items, false, false);
545 state.handle(ListAction::SearchStart);
546 state.handle(ListAction::SearchInput('f'));
547 state.handle(ListAction::SearchInput('l'));
548 assert_eq!(visible_texts(&state), vec!["Flake-Fmt"]);
549 }
550
551 #[test]
552 fn test_search_best_match_first() {
553 let items = vec!["fenix-lib".to_string(), "flake-fmt".to_string()];
554 let mut state = ListState::new(items, false, false);
555 state.handle(ListAction::SearchStart);
556 state.handle(ListAction::SearchInput('f'));
557 state.handle(ListAction::SearchInput('l'));
558 assert_eq!(visible_texts(&state), vec!["flake-fmt", "fenix-lib"]);
562 assert_eq!(state.highlighted(), Some(1));
563 }
564
565 #[test]
566 fn test_search_navigation_within_filtered_list() {
567 let mut state = ListState::new(input_ids(), false, false);
568 state.handle(ListAction::SearchStart);
569 state.handle(ListAction::SearchInput('d'));
570 state.handle(ListAction::SearchInput('i'));
571 let mut visible: Vec<usize> = state.visible_items().map(|(i, _)| i).collect();
573 visible.sort_unstable();
574 assert_eq!(visible, vec![5, 6, 7]);
575
576 let target = state
578 .visible_items()
579 .position(|(i, _)| i == 7)
580 .expect("distro is visible");
581 for _ in 0..target {
582 state.handle(ListAction::Down);
583 }
584 let result = state.handle(ListAction::Select);
585 assert_eq!(result, Some(ListResult::Select(vec![7], false)));
586 }
587
588 #[test]
589 fn test_multi_select_selections_survive_filtering() {
590 let mut state = ListState::new(input_ids(), true, false);
591 state.handle(ListAction::Toggle);
593 assert!(state.is_selected(0));
594
595 state.handle(ListAction::SearchStart);
597 state.handle(ListAction::SearchInput('f'));
598 state.handle(ListAction::SearchInput('l'));
599 state.handle(ListAction::Toggle);
600 assert!(state.is_selected(8));
601
602 state.handle(ListAction::SearchCancel);
604 assert_eq!(state.selected_indices(), vec![0, 8]);
605
606 let result = state.handle(ListAction::Select);
607 assert_eq!(result, Some(ListResult::Select(vec![0, 8], false)));
608 }
609
610 #[test]
611 fn test_multi_select_submit_empty() {
612 let mut state = ListState::new(items(3), true, false);
613 let result = state.handle(ListAction::Select);
614 assert_eq!(result, Some(ListResult::Cancel)); }
616}