flake_edit/tui/components/list/
model.rs

1use std::collections::HashSet;
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5/// Actions that can be taken in a list selection UI
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ListAction {
8    Up,
9    Down,
10    Select,
11    ToggleDiff,
12    Cancel,
13    Toggle,
14    ToggleAll,
15    None,
16}
17
18impl ListAction {
19    pub fn from_key(key: KeyEvent) -> Self {
20        match key.code {
21            KeyCode::Up | KeyCode::Char('k') => ListAction::Up,
22            KeyCode::Down | KeyCode::Char('j') => ListAction::Down,
23            KeyCode::Enter => ListAction::Select,
24            KeyCode::Esc | KeyCode::Char('q') => ListAction::Cancel,
25            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
26                ListAction::ToggleDiff
27            }
28            KeyCode::Char(' ') => ListAction::Toggle,
29            KeyCode::Char('u') | KeyCode::Char('U') => ListAction::ToggleAll,
30            _ => ListAction::None,
31        }
32    }
33}
34
35/// Result from list state machine
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ListResult {
38    /// Selected indices and show_diff state
39    Select(Vec<usize>, bool),
40    Cancel,
41}
42
43/// Unified list state machine for single and multi-select
44#[derive(Debug, Clone)]
45pub struct ListState {
46    cursor: usize,
47    len: usize,
48    selected: HashSet<usize>,
49    show_diff: bool,
50    multi_select: bool,
51}
52
53impl ListState {
54    pub fn new(len: usize, multi_select: bool, initial_diff: bool) -> Self {
55        Self {
56            cursor: 0,
57            len,
58            selected: HashSet::new(),
59            show_diff: initial_diff,
60            multi_select,
61        }
62    }
63
64    pub fn cursor(&self) -> usize {
65        self.cursor
66    }
67
68    pub fn show_diff(&self) -> bool {
69        self.show_diff
70    }
71
72    pub fn is_selected(&self, index: usize) -> bool {
73        self.selected.contains(&index)
74    }
75
76    pub fn selected_count(&self) -> usize {
77        self.selected.len()
78    }
79
80    /// Get sorted list of selected indices for deterministic iteration
81    pub fn selected_indices(&self) -> Vec<usize> {
82        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
83        indices.sort_unstable();
84        indices
85    }
86
87    pub fn multi_select(&self) -> bool {
88        self.multi_select
89    }
90
91    /// Handle a list action, returns Some if the interaction is complete
92    pub fn handle(&mut self, action: ListAction) -> Option<ListResult> {
93        match action {
94            ListAction::Up => {
95                self.cursor = if self.cursor == 0 {
96                    self.len.saturating_sub(1)
97                } else {
98                    self.cursor - 1
99                };
100            }
101            ListAction::Down => {
102                self.cursor = if self.cursor >= self.len.saturating_sub(1) {
103                    0
104                } else {
105                    self.cursor + 1
106                };
107            }
108            ListAction::Toggle if self.multi_select => {
109                if self.selected.contains(&self.cursor) {
110                    self.selected.remove(&self.cursor);
111                } else {
112                    self.selected.insert(self.cursor);
113                }
114                // Move to next item after toggling
115                self.cursor = if self.cursor >= self.len.saturating_sub(1) {
116                    0
117                } else {
118                    self.cursor + 1
119                };
120            }
121            ListAction::ToggleAll if self.multi_select => {
122                if self.selected.len() == self.len {
123                    self.selected.clear();
124                } else {
125                    self.selected = (0..self.len).collect();
126                }
127            }
128            ListAction::ToggleDiff => {
129                self.show_diff = !self.show_diff;
130            }
131            ListAction::Select => {
132                if self.multi_select {
133                    if self.selected.is_empty() {
134                        return Some(ListResult::Cancel);
135                    }
136                    let mut indices: Vec<usize> = self.selected.iter().copied().collect();
137                    indices.sort_unstable();
138                    return Some(ListResult::Select(indices, self.show_diff));
139                } else {
140                    return Some(ListResult::Select(vec![self.cursor], self.show_diff));
141                }
142            }
143            ListAction::Cancel => return Some(ListResult::Cancel),
144            _ => {}
145        }
146        None
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_single_select_navigation() {
156        let mut state = ListState::new(3, false, false);
157        assert_eq!(state.cursor(), 0);
158
159        state.handle(ListAction::Down);
160        assert_eq!(state.cursor(), 1);
161
162        state.handle(ListAction::Down);
163        assert_eq!(state.cursor(), 2);
164
165        state.handle(ListAction::Down);
166        assert_eq!(state.cursor(), 0); // Wrap around
167
168        state.handle(ListAction::Up);
169        assert_eq!(state.cursor(), 2); // Wrap around up
170    }
171
172    #[test]
173    fn test_single_select_toggle_diff() {
174        let mut state = ListState::new(3, false, false);
175        assert!(!state.show_diff());
176
177        state.handle(ListAction::ToggleDiff);
178        assert!(state.show_diff());
179    }
180
181    #[test]
182    fn test_single_select_select() {
183        let mut state = ListState::new(3, false, true);
184        state.handle(ListAction::Down);
185        let result = state.handle(ListAction::Select);
186        assert_eq!(result, Some(ListResult::Select(vec![1], true)));
187    }
188
189    #[test]
190    fn test_single_select_ignores_toggle() {
191        let mut state = ListState::new(3, false, false);
192        state.handle(ListAction::Toggle);
193        assert_eq!(state.selected_count(), 0); // Toggle ignored
194        assert_eq!(state.cursor(), 0); // Cursor unchanged
195    }
196
197    #[test]
198    fn test_multi_select_toggle() {
199        let mut state = ListState::new(3, true, false);
200        assert_eq!(state.selected_count(), 0);
201
202        state.handle(ListAction::Toggle);
203        assert_eq!(state.selected_count(), 1);
204        assert!(state.is_selected(0));
205        assert_eq!(state.cursor(), 1); // Cursor moves after toggle
206    }
207
208    #[test]
209    fn test_multi_select_toggle_all() {
210        let mut state = ListState::new(3, true, false);
211        state.handle(ListAction::ToggleAll);
212        assert_eq!(state.selected_count(), 3);
213
214        state.handle(ListAction::ToggleAll);
215        assert_eq!(state.selected_count(), 0);
216    }
217
218    #[test]
219    fn test_multi_select_submit_empty() {
220        let mut state = ListState::new(3, true, false);
221        let result = state.handle(ListAction::Select);
222        assert_eq!(result, Some(ListResult::Cancel)); // Empty selection cancels
223    }
224}