Skip to main content

sbom_tools/tui/
traits.rs

1//! TUI trait abstractions for view state management.
2//!
3//! This module provides the `ViewState` trait for decomposing the monolithic App
4//! into focused, testable view state machines.
5//!
6//! # Architecture
7//!
8//! The TUI follows a state machine pattern where each view (Summary, Components,
9//! Dependencies, etc.) implements `ViewState` to handle its own:
10//! - Event processing
11//! - State management
12//! - Rendering
13//! - Keyboard shortcuts
14//!
15//! The main `App` struct acts as an orchestrator that:
16//! - Manages global state (overlays, search, navigation)
17//! - Dispatches events to the active view
18//! - Coordinates cross-view navigation
19//!
20//! # Example
21//!
22//! ```ignore
23//! use sbom_tools::tui::traits::{ViewState, EventResult, Shortcut};
24//!
25//! struct MyView {
26//!     selected: usize,
27//!     items: Vec<String>,
28//! }
29//!
30//! impl ViewState for MyView {
31//!     fn handle_key(&mut self, key: KeyEvent, _ctx: &mut ViewContext) -> EventResult {
32//!         match key.code {
33//!             KeyCode::Up => {
34//!                 self.select_prev();
35//!                 EventResult::Consumed
36//!             }
37//!             KeyCode::Down => {
38//!                 self.select_next();
39//!                 EventResult::Consumed
40//!             }
41//!             _ => EventResult::Ignored,
42//!         }
43//!     }
44//!
45//!     fn title(&self) -> &str { "My View" }
46//!     fn shortcuts(&self) -> Vec<Shortcut> { vec![] }
47//! }
48//! ```
49
50use crossterm::event::{KeyEvent, MouseEvent};
51use std::fmt;
52
53/// Result of handling an event in a view.
54///
55/// Views return this to indicate whether they consumed the event
56/// or if it should be handled by the orchestrator.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum EventResult {
59    /// Event was handled by this view
60    Consumed,
61    /// Event was not handled, let parent process it
62    Ignored,
63    /// Navigate to a different tab
64    NavigateTo(TabTarget),
65    /// Request to exit the application
66    Exit,
67    /// Request to show an overlay
68    ShowOverlay(OverlayKind),
69    /// Set a status message
70    StatusMessage(String),
71}
72
73impl EventResult {
74    /// Create a status message result
75    pub fn status(msg: impl Into<String>) -> Self {
76        Self::StatusMessage(msg.into())
77    }
78
79    /// Create a navigation result
80    #[must_use]
81    pub const fn navigate(target: TabTarget) -> Self {
82        Self::NavigateTo(target)
83    }
84}
85
86/// Target for tab navigation
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum TabTarget {
89    Summary,
90    Components,
91    Dependencies,
92    Licenses,
93    Vulnerabilities,
94    Quality,
95    Compliance,
96    SideBySide,
97    GraphChanges,
98    Source,
99    /// Navigate to a specific component by name
100    ComponentByName(String),
101    /// Navigate to a specific vulnerability by ID
102    VulnerabilityById(String),
103}
104
105impl TabTarget {
106    /// Convert to `TabKind` if this is a simple tab navigation
107    #[must_use]
108    pub const fn to_tab_kind(&self) -> Option<super::app::TabKind> {
109        match self {
110            Self::Summary => Some(super::app::TabKind::Summary),
111            Self::Components | Self::ComponentByName(_) => Some(super::app::TabKind::Components),
112            Self::Dependencies => Some(super::app::TabKind::Dependencies),
113            Self::Licenses => Some(super::app::TabKind::Licenses),
114            Self::Vulnerabilities | Self::VulnerabilityById(_) => {
115                Some(super::app::TabKind::Vulnerabilities)
116            }
117            Self::Quality => Some(super::app::TabKind::Quality),
118            Self::Compliance => Some(super::app::TabKind::Compliance),
119            Self::SideBySide => Some(super::app::TabKind::SideBySide),
120            Self::GraphChanges => Some(super::app::TabKind::GraphChanges),
121            Self::Source => Some(super::app::TabKind::Source),
122        }
123    }
124
125    /// Convert from `TabKind`
126    #[must_use]
127    pub const fn from_tab_kind(kind: super::app::TabKind) -> Self {
128        match kind {
129            super::app::TabKind::Summary => Self::Summary,
130            super::app::TabKind::Components => Self::Components,
131            super::app::TabKind::Dependencies => Self::Dependencies,
132            super::app::TabKind::Licenses => Self::Licenses,
133            super::app::TabKind::Vulnerabilities => Self::Vulnerabilities,
134            super::app::TabKind::Quality => Self::Quality,
135            super::app::TabKind::Compliance => Self::Compliance,
136            super::app::TabKind::SideBySide => Self::SideBySide,
137            super::app::TabKind::GraphChanges => Self::GraphChanges,
138            super::app::TabKind::Source => Self::Source,
139        }
140    }
141}
142
143/// Overlay types that can be shown
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum OverlayKind {
146    Help,
147    Export,
148    Legend,
149    Search,
150    Shortcuts,
151}
152
153/// A keyboard shortcut for display in help/footer
154#[derive(Debug, Clone)]
155pub struct Shortcut {
156    /// Key sequence (e.g., "j/k", "Tab", "Enter")
157    pub key: String,
158    /// Brief description (e.g., "Navigate", "Switch tab")
159    pub description: String,
160    /// Whether this is a primary shortcut (shown in footer)
161    pub primary: bool,
162}
163
164impl Shortcut {
165    /// Create a new shortcut
166    pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
167        Self {
168            key: key.into(),
169            description: description.into(),
170            primary: false,
171        }
172    }
173
174    /// Create a primary shortcut (shown in footer)
175    pub fn primary(key: impl Into<String>, description: impl Into<String>) -> Self {
176        Self {
177            key: key.into(),
178            description: description.into(),
179            primary: true,
180        }
181    }
182}
183
184/// Context provided to views for accessing shared state
185pub struct ViewContext<'a> {
186    /// Current application mode
187    pub mode: ViewMode,
188    /// Whether the view is currently focused
189    pub focused: bool,
190    /// Terminal width
191    pub width: u16,
192    /// Terminal height
193    pub height: u16,
194    /// Current tick count for animations
195    pub tick: u64,
196    /// Mutable status message slot
197    pub status_message: &'a mut Option<String>,
198}
199
200impl ViewContext<'_> {
201    /// Set a status message
202    pub fn set_status(&mut self, msg: impl Into<String>) {
203        *self.status_message = Some(msg.into());
204    }
205
206    /// Clear the status message
207    pub fn clear_status(&mut self) {
208        *self.status_message = None;
209    }
210}
211
212/// Application mode for context
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum ViewMode {
215    /// Comparing two SBOMs
216    Diff,
217    /// Viewing a single SBOM
218    View,
219    /// Multi-diff comparison
220    MultiDiff,
221    /// Timeline analysis
222    Timeline,
223    /// Matrix comparison
224    Matrix,
225}
226
227impl ViewMode {
228    /// Convert from the legacy `AppMode` enum
229    #[must_use]
230    pub const fn from_app_mode(mode: super::app::AppMode) -> Self {
231        match mode {
232            super::app::AppMode::Diff => Self::Diff,
233            super::app::AppMode::View => Self::View,
234            super::app::AppMode::MultiDiff => Self::MultiDiff,
235            super::app::AppMode::Timeline => Self::Timeline,
236            super::app::AppMode::Matrix => Self::Matrix,
237        }
238    }
239}
240
241/// Trait for view state machines.
242///
243/// Each tab/view in the TUI should implement this trait to handle
244/// its own events and state management independently.
245///
246/// # Event Flow
247///
248/// 1. App receives event from terminal
249/// 2. App checks for global handlers (quit, overlays, search)
250/// 3. App dispatches to active view's `handle_key` or `handle_mouse`
251/// 4. View processes event and returns `EventResult`
252/// 5. App acts on result (navigation, status, etc.)
253///
254/// # State Management
255///
256/// Views own their state and should be self-contained. The only
257/// shared state comes through `ViewContext`, which provides:
258/// - Current mode (Diff, View, `MultiDiff`, etc.)
259/// - Terminal dimensions
260/// - Animation tick
261///
262/// # Rendering
263///
264/// Rendering is handled separately by the UI module, which reads
265/// from view state. Views should expose their state through getters.
266pub trait ViewState: Send {
267    /// Handle a key event.
268    ///
269    /// Returns `EventResult` indicating how the event was processed.
270    /// Views should return `EventResult::Ignored` for unhandled keys
271    /// to allow parent handling.
272    fn handle_key(&mut self, key: KeyEvent, ctx: &mut ViewContext) -> EventResult;
273
274    /// Handle a mouse event.
275    ///
276    /// Default implementation ignores all mouse events.
277    fn handle_mouse(&mut self, _mouse: MouseEvent, _ctx: &mut ViewContext) -> EventResult {
278        EventResult::Ignored
279    }
280
281    /// Get the title for this view (used in tabs).
282    fn title(&self) -> &str;
283
284    /// Get keyboard shortcuts for this view.
285    ///
286    /// These are displayed in the help overlay and footer hints.
287    fn shortcuts(&self) -> Vec<Shortcut>;
288
289    /// Called when this view becomes active.
290    ///
291    /// Use this to refresh data or reset transient state.
292    fn on_enter(&mut self, _ctx: &mut ViewContext) {}
293
294    /// Called when this view is deactivated.
295    ///
296    /// Use this to clean up or save state.
297    fn on_leave(&mut self, _ctx: &mut ViewContext) {}
298
299    /// Called on every tick for animations.
300    ///
301    /// Default implementation does nothing.
302    fn on_tick(&mut self, _ctx: &mut ViewContext) {}
303
304    /// Check if the view has any modal/overlay active.
305    ///
306    /// Used by App to determine if global shortcuts should be suppressed.
307    fn has_modal(&self) -> bool {
308        false
309    }
310}
311
312/// Extension trait for list-based views.
313///
314/// Provides common navigation behavior for views that display
315/// a selectable list of items.
316pub trait ListViewState: ViewState {
317    /// Get the current selection index.
318    fn selected(&self) -> usize;
319
320    /// Set the selection index.
321    fn set_selected(&mut self, idx: usize);
322
323    /// Get the total number of items.
324    fn total(&self) -> usize;
325
326    /// Move selection to the next item.
327    fn select_next(&mut self) {
328        let total = self.total();
329        let selected = self.selected();
330        if total > 0 && selected < total.saturating_sub(1) {
331            self.set_selected(selected + 1);
332        }
333    }
334
335    /// Move selection to the previous item.
336    fn select_prev(&mut self) {
337        let selected = self.selected();
338        if selected > 0 {
339            self.set_selected(selected - 1);
340        }
341    }
342
343    /// Move selection down by a page.
344    fn page_down(&mut self) {
345        use super::constants::PAGE_SIZE;
346        let total = self.total();
347        let selected = self.selected();
348        if total > 0 {
349            self.set_selected((selected + PAGE_SIZE).min(total.saturating_sub(1)));
350        }
351    }
352
353    /// Move selection up by a page.
354    fn page_up(&mut self) {
355        use super::constants::PAGE_SIZE;
356        let selected = self.selected();
357        self.set_selected(selected.saturating_sub(PAGE_SIZE));
358    }
359
360    /// Move to the first item.
361    fn go_first(&mut self) {
362        self.set_selected(0);
363    }
364
365    /// Move to the last item.
366    fn go_last(&mut self) {
367        let total = self.total();
368        if total > 0 {
369            self.set_selected(total.saturating_sub(1));
370        }
371    }
372
373    /// Handle common navigation keys for list views.
374    ///
375    /// Call this from `handle_key` to get standard navigation behavior:
376    /// - j/Down: select next
377    /// - k/Up: select prev
378    /// - g/Home: go to first
379    /// - G/End: go to last
380    /// - PageUp/PageDown: page navigation
381    fn handle_list_nav_key(&mut self, key: KeyEvent) -> EventResult {
382        use crossterm::event::KeyCode;
383
384        match key.code {
385            KeyCode::Down | KeyCode::Char('j') => {
386                self.select_next();
387                EventResult::Consumed
388            }
389            KeyCode::Up | KeyCode::Char('k') => {
390                self.select_prev();
391                EventResult::Consumed
392            }
393            KeyCode::Home | KeyCode::Char('g') => {
394                self.go_first();
395                EventResult::Consumed
396            }
397            KeyCode::End | KeyCode::Char('G') => {
398                self.go_last();
399                EventResult::Consumed
400            }
401            KeyCode::PageDown => {
402                self.page_down();
403                EventResult::Consumed
404            }
405            KeyCode::PageUp => {
406                self.page_up();
407                EventResult::Consumed
408            }
409            _ => EventResult::Ignored,
410        }
411    }
412}
413
414/// Display formatting for `EventResult` (for debugging)
415impl fmt::Display for EventResult {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        match self {
418            Self::Consumed => write!(f, "Consumed"),
419            Self::Ignored => write!(f, "Ignored"),
420            Self::NavigateTo(target) => write!(f, "NavigateTo({target:?})"),
421            Self::Exit => write!(f, "Exit"),
422            Self::ShowOverlay(kind) => write!(f, "ShowOverlay({kind:?})"),
423            Self::StatusMessage(msg) => write!(f, "StatusMessage({msg})"),
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crossterm::event::{KeyCode, KeyModifiers};
432
433    /// Test implementation for verification
434    struct TestListView {
435        selected: usize,
436        total: usize,
437    }
438
439    impl TestListView {
440        fn new(total: usize) -> Self {
441            Self { selected: 0, total }
442        }
443    }
444
445    impl ViewState for TestListView {
446        fn handle_key(&mut self, key: KeyEvent, _ctx: &mut ViewContext) -> EventResult {
447            self.handle_list_nav_key(key)
448        }
449
450        fn title(&self) -> &str {
451            "Test View"
452        }
453
454        fn shortcuts(&self) -> Vec<Shortcut> {
455            vec![
456                Shortcut::primary("j/k", "Navigate"),
457                Shortcut::new("g/G", "First/Last"),
458            ]
459        }
460    }
461
462    impl ListViewState for TestListView {
463        fn selected(&self) -> usize {
464            self.selected
465        }
466
467        fn set_selected(&mut self, idx: usize) {
468            self.selected = idx;
469        }
470
471        fn total(&self) -> usize {
472            self.total
473        }
474    }
475
476    fn make_key_event(code: KeyCode) -> KeyEvent {
477        KeyEvent::new(code, KeyModifiers::empty())
478    }
479
480    fn make_context() -> ViewContext<'static> {
481        let status: &'static mut Option<String> = Box::leak(Box::new(None));
482        ViewContext {
483            mode: ViewMode::Diff,
484            focused: true,
485            width: 80,
486            height: 24,
487            tick: 0,
488            status_message: status,
489        }
490    }
491
492    #[test]
493    fn test_list_view_navigation() {
494        let mut view = TestListView::new(10);
495        let mut ctx = make_context();
496
497        // Initially at 0
498        assert_eq!(view.selected(), 0);
499
500        // Move down
501        let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
502        assert_eq!(result, EventResult::Consumed);
503        assert_eq!(view.selected(), 1);
504
505        // Move up
506        let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
507        assert_eq!(result, EventResult::Consumed);
508        assert_eq!(view.selected(), 0);
509
510        // Can't go below 0
511        let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
512        assert_eq!(result, EventResult::Consumed);
513        assert_eq!(view.selected(), 0);
514    }
515
516    #[test]
517    fn test_list_view_go_to_end() {
518        let mut view = TestListView::new(10);
519        let mut ctx = make_context();
520
521        // Go to last
522        let result = view.handle_key(make_key_event(KeyCode::Char('G')), &mut ctx);
523        assert_eq!(result, EventResult::Consumed);
524        assert_eq!(view.selected(), 9);
525
526        // Can't go past end
527        let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
528        assert_eq!(result, EventResult::Consumed);
529        assert_eq!(view.selected(), 9);
530    }
531
532    #[test]
533    fn test_event_result_display() {
534        assert_eq!(format!("{}", EventResult::Consumed), "Consumed");
535        assert_eq!(format!("{}", EventResult::Ignored), "Ignored");
536        assert_eq!(format!("{}", EventResult::Exit), "Exit");
537    }
538
539    #[test]
540    fn test_shortcut_creation() {
541        let shortcut = Shortcut::new("Enter", "Select item");
542        assert_eq!(shortcut.key, "Enter");
543        assert_eq!(shortcut.description, "Select item");
544        assert!(!shortcut.primary);
545
546        let primary = Shortcut::primary("q", "Quit");
547        assert!(primary.primary);
548    }
549
550    #[test]
551    fn test_event_result_helpers() {
552        let result = EventResult::status("Test message");
553        assert_eq!(
554            result,
555            EventResult::StatusMessage("Test message".to_string())
556        );
557
558        let nav = EventResult::navigate(TabTarget::Components);
559        assert_eq!(nav, EventResult::NavigateTo(TabTarget::Components));
560    }
561}