deciduous/tui/
msg.rs

1//! TEA Message Types for the TUI
2//!
3//! This module defines the Msg enum representing all possible user actions.
4//! Following The Elm Architecture (TEA), messages are:
5//! - Data describing what happened (not how to handle it)
6//! - The only way to trigger state changes
7//! - Processed by a single update function
8//!
9//! Key principles:
10//! - Messages are just data (no behavior)
11//! - Messages are exhaustive (every user action has a message)
12//! - Messages are named by what happened, not what will happen
13
14use crossterm::event::{KeyCode, KeyModifiers, MouseEvent};
15
16/// All possible messages/actions in the TUI
17#[derive(Debug, Clone, PartialEq)]
18pub enum Msg {
19    // === Navigation ===
20    /// Move selection up by one
21    MoveUp,
22    /// Move selection down by one
23    MoveDown,
24    /// Move selection up by page
25    PageUp,
26    /// Move selection down by page
27    PageDown,
28    /// Jump to first item
29    JumpToTop,
30    /// Jump to last item
31    JumpToBottom,
32    /// Select item by index (for mouse clicks)
33    SelectIndex(usize),
34
35    // === View Switching ===
36    /// Cycle to next view (Tab)
37    NextView,
38    /// Cycle to previous view (Shift+Tab)
39    PrevView,
40    /// Switch to specific view
41    SwitchToView(ViewKind),
42
43    // === Filtering ===
44    /// Cycle through type filters
45    CycleTypeFilter,
46    /// Cycle through branch filters
47    CycleBranchFilter,
48    /// Open branch search modal
49    OpenBranchSearch,
50    /// Update search query
51    SetSearchQuery(String),
52    /// Clear all filters
53    ClearFilters,
54
55    // === Search Modal ===
56    /// Add character to search input
57    SearchInput(char),
58    /// Remove character from search input
59    SearchBackspace,
60    /// Confirm search and close modal
61    SearchConfirm,
62    /// Cancel search and close modal
63    SearchCancel,
64
65    // === Detail Panel ===
66    /// Toggle detail panel visibility
67    ToggleDetailPanel,
68    /// Scroll detail panel up
69    DetailScrollUp,
70    /// Scroll detail panel down
71    DetailScrollDown,
72
73    // === Modals ===
74    /// Toggle help modal
75    ToggleHelp,
76    /// Open prompt modal for current node
77    OpenPromptModal,
78    /// Close any open modal
79    CloseModal,
80    /// Scroll modal content up
81    ModalScrollUp,
82    /// Scroll modal content down
83    ModalScrollDown,
84
85    // === File Browser ===
86    /// Toggle file browser visibility
87    ToggleFileBrowser,
88    /// Navigate into selected directory
89    FileBrowserEnter,
90    /// Navigate to parent directory
91    FileBrowserBack,
92    /// Expand/collapse file tree node
93    FileBrowserToggle,
94    /// Preview selected file
95    PreviewFile,
96    /// Show diff for selected file
97    ShowFileDiff,
98
99    // === Goal Story ===
100    /// Toggle goal story view
101    ToggleGoalStory,
102    /// Expand/collapse goal in story view
103    GoalStoryToggle,
104
105    // === Actions ===
106    /// Open associated files in editor
107    OpenFiles,
108    /// Refresh graph from database
109    RefreshGraph,
110    /// Copy current node info to clipboard
111    CopyToClipboard,
112
113    // === Lifecycle ===
114    /// Quit the application
115    Quit,
116    /// Tick event (for animations/updates)
117    Tick,
118    /// Window resized
119    Resize(u16, u16),
120    /// Mouse event
121    Mouse(MouseEvent),
122
123    // === Internal ===
124    /// No operation (for unhandled keys)
125    Noop,
126}
127
128/// View types in the TUI
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum ViewKind {
131    Timeline,
132    Dag,
133    Graph,
134}
135
136impl ViewKind {
137    pub fn next(self) -> Self {
138        match self {
139            ViewKind::Timeline => ViewKind::Dag,
140            ViewKind::Dag => ViewKind::Graph,
141            ViewKind::Graph => ViewKind::Timeline,
142        }
143    }
144
145    pub fn prev(self) -> Self {
146        match self {
147            ViewKind::Timeline => ViewKind::Graph,
148            ViewKind::Dag => ViewKind::Timeline,
149            ViewKind::Graph => ViewKind::Dag,
150        }
151    }
152}
153
154/// Convert a key event to a message
155///
156/// This is a pure function - no side effects, just pattern matching.
157/// The result is a message that describes what the user intended.
158pub fn key_to_msg(
159    code: KeyCode,
160    modifiers: KeyModifiers,
161    modal_open: bool,
162    search_active: bool,
163) -> Msg {
164    // Handle search mode first
165    if search_active {
166        return match code {
167            KeyCode::Enter => Msg::SearchConfirm,
168            KeyCode::Esc => Msg::SearchCancel,
169            KeyCode::Backspace => Msg::SearchBackspace,
170            KeyCode::Char(c) => Msg::SearchInput(c),
171            _ => Msg::Noop,
172        };
173    }
174
175    // Handle modal mode
176    if modal_open {
177        return match code {
178            KeyCode::Esc | KeyCode::Char('q') => Msg::CloseModal,
179            KeyCode::Char('j') | KeyCode::Down => Msg::ModalScrollDown,
180            KeyCode::Char('k') | KeyCode::Up => Msg::ModalScrollUp,
181            KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => Msg::ModalScrollDown,
182            KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => Msg::ModalScrollUp,
183            _ => Msg::Noop,
184        };
185    }
186
187    // Normal mode
188    match code {
189        // Quit
190        KeyCode::Char('q') => Msg::Quit,
191        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Msg::Quit,
192
193        // Navigation
194        KeyCode::Char('j') | KeyCode::Down => Msg::MoveDown,
195        KeyCode::Char('k') | KeyCode::Up => Msg::MoveUp,
196        KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => Msg::PageDown,
197        KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => Msg::PageUp,
198        KeyCode::Char('g') => Msg::JumpToTop, // Simplified - real vim needs 'gg'
199        KeyCode::Char('G') => Msg::JumpToBottom,
200        KeyCode::PageDown => Msg::PageDown,
201        KeyCode::PageUp => Msg::PageUp,
202        KeyCode::Home => Msg::JumpToTop,
203        KeyCode::End => Msg::JumpToBottom,
204
205        // View switching
206        KeyCode::Tab => {
207            if modifiers.contains(KeyModifiers::SHIFT) {
208                Msg::PrevView
209            } else {
210                Msg::NextView
211            }
212        }
213        KeyCode::Char('1') => Msg::SwitchToView(ViewKind::Timeline),
214        KeyCode::Char('2') => Msg::SwitchToView(ViewKind::Dag),
215        KeyCode::Char('3') => Msg::SwitchToView(ViewKind::Graph),
216
217        // Filtering
218        KeyCode::Char('t') => Msg::CycleTypeFilter,
219        KeyCode::Char('b') => Msg::CycleBranchFilter,
220        KeyCode::Char('B') => Msg::OpenBranchSearch,
221        KeyCode::Char('/') => Msg::OpenBranchSearch, // Also opens search
222
223        // Detail panel
224        KeyCode::Char('l') | KeyCode::Right => Msg::DetailScrollDown, // In detail context
225        KeyCode::Char('h') | KeyCode::Left => Msg::DetailScrollUp,    // In detail context
226        KeyCode::Enter => Msg::ToggleDetailPanel,
227
228        // Modals
229        KeyCode::Char('?') => Msg::ToggleHelp,
230        KeyCode::Char('P') => Msg::OpenPromptModal,
231        KeyCode::Esc => Msg::CloseModal,
232
233        // File browser
234        KeyCode::Char('F') => Msg::ToggleFileBrowser,
235        KeyCode::Char('p') => Msg::PreviewFile,
236
237        // Goal story
238        KeyCode::Char('s') => Msg::ToggleGoalStory,
239
240        // Actions
241        KeyCode::Char('o') => Msg::OpenFiles,
242        KeyCode::Char('r') => Msg::RefreshGraph,
243        KeyCode::Char('y') => Msg::CopyToClipboard,
244
245        _ => Msg::Noop,
246    }
247}
248
249/// Check if a message should cause the app to quit
250pub fn is_quit(msg: &Msg) -> bool {
251    matches!(msg, Msg::Quit)
252}
253
254/// Check if a message is a navigation action
255pub fn is_navigation(msg: &Msg) -> bool {
256    matches!(
257        msg,
258        Msg::MoveUp
259            | Msg::MoveDown
260            | Msg::PageUp
261            | Msg::PageDown
262            | Msg::JumpToTop
263            | Msg::JumpToBottom
264            | Msg::SelectIndex(_)
265    )
266}
267
268/// Check if a message affects filtering
269pub fn is_filter_change(msg: &Msg) -> bool {
270    matches!(
271        msg,
272        Msg::CycleTypeFilter
273            | Msg::CycleBranchFilter
274            | Msg::SetSearchQuery(_)
275            | Msg::ClearFilters
276            | Msg::SearchConfirm
277    )
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_view_kind_cycle() {
286        assert_eq!(ViewKind::Timeline.next(), ViewKind::Dag);
287        assert_eq!(ViewKind::Dag.next(), ViewKind::Graph);
288        assert_eq!(ViewKind::Graph.next(), ViewKind::Timeline);
289
290        assert_eq!(ViewKind::Timeline.prev(), ViewKind::Graph);
291        assert_eq!(ViewKind::Graph.prev(), ViewKind::Dag);
292    }
293
294    #[test]
295    fn test_key_to_msg_navigation() {
296        assert_eq!(
297            key_to_msg(KeyCode::Char('j'), KeyModifiers::NONE, false, false),
298            Msg::MoveDown
299        );
300        assert_eq!(
301            key_to_msg(KeyCode::Char('k'), KeyModifiers::NONE, false, false),
302            Msg::MoveUp
303        );
304        assert_eq!(
305            key_to_msg(KeyCode::Down, KeyModifiers::NONE, false, false),
306            Msg::MoveDown
307        );
308        assert_eq!(
309            key_to_msg(KeyCode::Up, KeyModifiers::NONE, false, false),
310            Msg::MoveUp
311        );
312    }
313
314    #[test]
315    fn test_key_to_msg_quit() {
316        assert_eq!(
317            key_to_msg(KeyCode::Char('q'), KeyModifiers::NONE, false, false),
318            Msg::Quit
319        );
320        assert_eq!(
321            key_to_msg(KeyCode::Char('c'), KeyModifiers::CONTROL, false, false),
322            Msg::Quit
323        );
324    }
325
326    #[test]
327    fn test_key_to_msg_search_mode() {
328        assert_eq!(
329            key_to_msg(KeyCode::Char('a'), KeyModifiers::NONE, false, true),
330            Msg::SearchInput('a')
331        );
332        assert_eq!(
333            key_to_msg(KeyCode::Enter, KeyModifiers::NONE, false, true),
334            Msg::SearchConfirm
335        );
336        assert_eq!(
337            key_to_msg(KeyCode::Esc, KeyModifiers::NONE, false, true),
338            Msg::SearchCancel
339        );
340        assert_eq!(
341            key_to_msg(KeyCode::Backspace, KeyModifiers::NONE, false, true),
342            Msg::SearchBackspace
343        );
344    }
345
346    #[test]
347    fn test_key_to_msg_modal_mode() {
348        assert_eq!(
349            key_to_msg(KeyCode::Char('j'), KeyModifiers::NONE, true, false),
350            Msg::ModalScrollDown
351        );
352        assert_eq!(
353            key_to_msg(KeyCode::Char('k'), KeyModifiers::NONE, true, false),
354            Msg::ModalScrollUp
355        );
356        assert_eq!(
357            key_to_msg(KeyCode::Esc, KeyModifiers::NONE, true, false),
358            Msg::CloseModal
359        );
360    }
361
362    #[test]
363    fn test_is_quit() {
364        assert!(is_quit(&Msg::Quit));
365        assert!(!is_quit(&Msg::MoveDown));
366    }
367
368    #[test]
369    fn test_is_navigation() {
370        assert!(is_navigation(&Msg::MoveUp));
371        assert!(is_navigation(&Msg::MoveDown));
372        assert!(is_navigation(&Msg::PageUp));
373        assert!(!is_navigation(&Msg::Quit));
374        assert!(!is_navigation(&Msg::ToggleHelp));
375    }
376
377    #[test]
378    fn test_is_filter_change() {
379        assert!(is_filter_change(&Msg::CycleTypeFilter));
380        assert!(is_filter_change(&Msg::CycleBranchFilter));
381        assert!(!is_filter_change(&Msg::MoveUp));
382    }
383
384    #[test]
385    fn test_key_to_msg_actions() {
386        // Test action keys - these trigger side effects
387        assert_eq!(
388            key_to_msg(KeyCode::Char('o'), KeyModifiers::NONE, false, false),
389            Msg::OpenFiles
390        );
391        assert_eq!(
392            key_to_msg(KeyCode::Char('r'), KeyModifiers::NONE, false, false),
393            Msg::RefreshGraph
394        );
395        assert_eq!(
396            key_to_msg(KeyCode::Char('y'), KeyModifiers::NONE, false, false),
397            Msg::CopyToClipboard
398        );
399    }
400
401    #[test]
402    fn test_key_to_msg_view_switching() {
403        assert_eq!(
404            key_to_msg(KeyCode::Tab, KeyModifiers::NONE, false, false),
405            Msg::NextView
406        );
407        assert_eq!(
408            key_to_msg(KeyCode::Tab, KeyModifiers::SHIFT, false, false),
409            Msg::PrevView
410        );
411        assert_eq!(
412            key_to_msg(KeyCode::Char('1'), KeyModifiers::NONE, false, false),
413            Msg::SwitchToView(ViewKind::Timeline)
414        );
415        assert_eq!(
416            key_to_msg(KeyCode::Char('2'), KeyModifiers::NONE, false, false),
417            Msg::SwitchToView(ViewKind::Dag)
418        );
419        assert_eq!(
420            key_to_msg(KeyCode::Char('3'), KeyModifiers::NONE, false, false),
421            Msg::SwitchToView(ViewKind::Graph)
422        );
423    }
424
425    #[test]
426    fn test_key_to_msg_filtering() {
427        assert_eq!(
428            key_to_msg(KeyCode::Char('t'), KeyModifiers::NONE, false, false),
429            Msg::CycleTypeFilter
430        );
431        assert_eq!(
432            key_to_msg(KeyCode::Char('b'), KeyModifiers::NONE, false, false),
433            Msg::CycleBranchFilter
434        );
435        assert_eq!(
436            key_to_msg(KeyCode::Char('B'), KeyModifiers::NONE, false, false),
437            Msg::OpenBranchSearch
438        );
439        assert_eq!(
440            key_to_msg(KeyCode::Char('/'), KeyModifiers::NONE, false, false),
441            Msg::OpenBranchSearch
442        );
443    }
444
445    #[test]
446    fn test_key_to_msg_modals() {
447        assert_eq!(
448            key_to_msg(KeyCode::Char('?'), KeyModifiers::NONE, false, false),
449            Msg::ToggleHelp
450        );
451        assert_eq!(
452            key_to_msg(KeyCode::Char('P'), KeyModifiers::NONE, false, false),
453            Msg::OpenPromptModal
454        );
455        assert_eq!(
456            key_to_msg(KeyCode::Esc, KeyModifiers::NONE, false, false),
457            Msg::CloseModal
458        );
459    }
460
461    #[test]
462    fn test_key_to_msg_file_browser() {
463        assert_eq!(
464            key_to_msg(KeyCode::Char('F'), KeyModifiers::NONE, false, false),
465            Msg::ToggleFileBrowser
466        );
467        assert_eq!(
468            key_to_msg(KeyCode::Char('p'), KeyModifiers::NONE, false, false),
469            Msg::PreviewFile
470        );
471    }
472
473    #[test]
474    fn test_key_to_msg_detail_panel() {
475        assert_eq!(
476            key_to_msg(KeyCode::Enter, KeyModifiers::NONE, false, false),
477            Msg::ToggleDetailPanel
478        );
479        assert_eq!(
480            key_to_msg(KeyCode::Char('l'), KeyModifiers::NONE, false, false),
481            Msg::DetailScrollDown
482        );
483        assert_eq!(
484            key_to_msg(KeyCode::Char('h'), KeyModifiers::NONE, false, false),
485            Msg::DetailScrollUp
486        );
487    }
488
489    #[test]
490    fn test_key_to_msg_page_navigation() {
491        assert_eq!(
492            key_to_msg(KeyCode::Char('d'), KeyModifiers::CONTROL, false, false),
493            Msg::PageDown
494        );
495        assert_eq!(
496            key_to_msg(KeyCode::Char('u'), KeyModifiers::CONTROL, false, false),
497            Msg::PageUp
498        );
499        assert_eq!(
500            key_to_msg(KeyCode::PageDown, KeyModifiers::NONE, false, false),
501            Msg::PageDown
502        );
503        assert_eq!(
504            key_to_msg(KeyCode::PageUp, KeyModifiers::NONE, false, false),
505            Msg::PageUp
506        );
507        assert_eq!(
508            key_to_msg(KeyCode::Char('g'), KeyModifiers::NONE, false, false),
509            Msg::JumpToTop
510        );
511        assert_eq!(
512            key_to_msg(KeyCode::Char('G'), KeyModifiers::NONE, false, false),
513            Msg::JumpToBottom
514        );
515        assert_eq!(
516            key_to_msg(KeyCode::Home, KeyModifiers::NONE, false, false),
517            Msg::JumpToTop
518        );
519        assert_eq!(
520            key_to_msg(KeyCode::End, KeyModifiers::NONE, false, false),
521            Msg::JumpToBottom
522        );
523    }
524
525    #[test]
526    fn test_key_to_msg_goal_story() {
527        assert_eq!(
528            key_to_msg(KeyCode::Char('s'), KeyModifiers::NONE, false, false),
529            Msg::ToggleGoalStory
530        );
531    }
532
533    #[test]
534    fn test_key_to_msg_unhandled() {
535        // Keys that aren't mapped should return Noop
536        assert_eq!(
537            key_to_msg(KeyCode::Char('z'), KeyModifiers::NONE, false, false),
538            Msg::Noop
539        );
540        assert_eq!(
541            key_to_msg(KeyCode::Char('x'), KeyModifiers::NONE, false, false),
542            Msg::Noop
543        );
544    }
545}