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