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