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