Skip to main content

flake_edit/tui/components/list/
model.rs

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/// Actions that can be taken in a list selection UI
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ListAction {
10    Up,
11    Down,
12    Select,
13    ToggleDiff,
14    Cancel,
15    Toggle,
16    ToggleAll,
17    /// Enter search mode (`/` in normal mode)
18    SearchStart,
19    /// Append a character to the search query
20    SearchInput(char),
21    /// Remove the last character from the search query
22    SearchBackspace,
23    /// Leave search mode and restore the unfiltered list
24    SearchCancel,
25    None,
26}
27
28impl ListAction {
29    /// Map a key event to an action.
30    ///
31    /// The mapping depends on the mode. While searching, printable
32    /// characters (including the normal-mode bindings j/k/q/u) extend
33    /// the query, Escape leaves search mode instead of cancelling, and
34    /// navigation moves to the arrow keys and Ctrl+J/Ctrl+K.
35    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                // Plain j/k are query input here, so navigation needs
45                // Ctrl chords. Ctrl+J/Ctrl+K follow the fzf convention.
46                KeyCode::Char('j') if ctrl => ListAction::Down,
47                KeyCode::Char('k') if ctrl => ListAction::Up,
48                KeyCode::Char('d') if ctrl => ListAction::ToggleDiff,
49                // Space still toggles so multi-select works while
50                // filtered. Item ids never contain spaces, so the
51                // query loses nothing.
52                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/// Result from list state machine
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ListResult {
74    /// Selected indices and show_diff state
75    Select(Vec<usize>, bool),
76    Cancel,
77}
78
79/// Unified list state machine for single and multi-select
80///
81/// Owns the item texts so filtering stays internal to the state
82/// machine. `visible` maps display positions to absolute indices into
83/// `items`, and `cursor` addresses a position within `visible`. The
84/// `selected` set holds absolute indices, which is what lets
85/// selections survive filtering.
86#[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    /// Search query, `Some` while search mode is active
94    query: Option<String>,
95    /// Absolute indices of the items currently shown, best match first
96    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    /// Cursor position within the visible (possibly filtered) list
126    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    /// The current search query, `Some` (possibly empty) while search
135    /// mode is active
136    pub fn search_query(&self) -> Option<&str> {
137        self.query.as_deref()
138    }
139
140    /// The currently visible items as (absolute index, text) pairs
141    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    /// Absolute index of the item under the cursor, `None` when the
146    /// filtered list is empty
147    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    /// Get sorted list of selected indices for deterministic iteration
168    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    /// Handle a list action, returns Some if the interaction is complete
179    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                    // Move to next item after toggling
201                    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                // Keep the match highlighted in the restored full list.
252                // The unfiltered visible order is the identity, so the
253                // absolute index is also the display position.
254                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    /// Recompute `visible` from the current query and reset the cursor.
265    ///
266    /// Matching is fuzzy with smart case, ordered best score first.
267    /// The sort is stable so equal scores keep the original list order.
268    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); // Wrap around
316
317        state.handle(ListAction::Up);
318        assert_eq!(state.cursor(), 2); // Wrap around up
319    }
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); // Toggle ignored
343        assert_eq!(state.cursor(), 0); // Cursor unchanged
344    }
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); // Cursor moves after toggle
355    }
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        // Navigation keys keep their meaning outside of search mode
399        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        // Printable characters become query input, including the
412        // normal-mode bindings j/k/q/u and a literal slash.
413        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        // Space still toggles in multi-select. Item ids never contain spaces.
437        assert_eq!(
438            ListAction::from_key(key(KeyCode::Char(' ')), true),
439            ListAction::Toggle
440        );
441        // Control chords are not query input
442        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        // Ctrl+J / Ctrl+K navigate while searching (fzf convention)
457        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        // Empty query shows the full list
482        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        // The highlighted item refers to the original index
489        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        // The previously highlighted match stays highlighted
515        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        // Enter on an empty filtered list must not select anything
529        assert_eq!(state.handle(ListAction::Select), None);
530        // Navigation on an empty filtered list stays at the empty cursor
531        state.handle(ListAction::Down);
532        state.handle(ListAction::Up);
533        assert_eq!(state.cursor(), 0);
534        assert_eq!(state.highlighted(), None);
535
536        // Backspace recovers the previous matches
537        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        // Both ids match "fl", but the contiguous prefix in flake-fmt
559        // outscores the scattered f..l in fenix-lib, so the better
560        // match lands under the cursor despite coming later in the list
561        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        // "di" matches direnv-instant (5), disko (6), and distro (7)
572        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        // Navigate to "distro" within the filtered view and select it
577        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        // Select "bookah" before filtering
592        state.handle(ListAction::Toggle);
593        assert!(state.is_selected(0));
594
595        // Filter down to flake-fmt and select it too
596        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        // Leaving search keeps both selections intact
603        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)); // Empty selection cancels
615    }
616}