Skip to main content

sbom_tools/tui/viewmodel/
mod.rs

1//! Shared ViewModel layer for TUI views.
2//!
3//! This module provides reusable state management components that can be
4//! shared between the diff TUI (`App`) and view TUI (`ViewApp`).
5//!
6//! # Components
7//!
8//! - [`SearchState`] - Generic search state with query and results
9//! - [`OverlayState`] - Overlay management (help, export, legend)
10//! - [`StatusMessage`] - Temporary status message display
11//! - [`FilterState`] - Generic filter/toggle state with cycling
12//! - [`QualityViewState`] - Quality report display state
13//!
14//! # Usage
15//!
16//! Instead of duplicating state structs in each TUI app, embed these
17//! shared components:
18//!
19//! ```ignore
20//! use crate::tui::viewmodel::{OverlayState, SearchState, StatusMessage};
21//!
22//! pub struct MyApp {
23//!     // ... other fields ...
24//!     pub overlay: OverlayState,
25//!     pub search: SearchState<MySearchResult>,
26//!     pub status: StatusMessage,
27//! }
28//! ```
29//!
30//! # Migration Guide
31//!
32//! ## SearchState Migration
33//!
34//! Replace `DiffSearchState` or local `SearchState` with:
35//! ```ignore
36//! // Old: pub search_state: DiffSearchState,
37//! // New:
38//! pub search_state: SearchState<YourResultType>,
39//! ```
40//!
41//! ## Overlay Migration
42//!
43//! Replace individual `show_help`, `show_export`, `show_legend` booleans with:
44//! ```ignore
45//! // Old:
46//! // pub show_help: bool,
47//! // pub show_export: bool,
48//! // pub show_legend: bool,
49//!
50//! // New:
51//! pub overlay: OverlayState,
52//!
53//! // Update toggle methods:
54//! // fn toggle_help(&mut self) { self.overlay.toggle_help(); }
55//! ```
56//!
57//! ## Filter Migration
58//!
59//! For enum-based filters that cycle through options:
60//! ```ignore
61//! // Implement CycleFilter for your enum
62//! impl CycleFilter for MyFilter { ... }
63//!
64//! // Then use FilterState
65//! pub filter: FilterState<MyFilter>,
66//!
67//! // Cycle: filter.next() or filter.prev()
68//! ```
69//!
70//! # Backwards Compatibility
71//!
72//! Existing types in `app.rs` and `view/app.rs` remain functional.
73//! These shared types enable incremental migration without breaking changes.
74
75mod filter;
76mod overlay;
77mod search;
78pub mod security_filter;
79mod status;
80
81pub use filter::{CycleFilter, FilterState};
82pub use overlay::{OverlayKind as ViewModelOverlayKind, OverlayState};
83pub use search::{SearchState, SearchStateCore};
84pub use security_filter::{
85    LicenseCategory, QuickFilter, RiskLevel, SecurityFilterCriteria, SecurityFilterState,
86};
87pub use status::StatusMessage;
88
89/// Quality view state shared between diff and view modes.
90///
91/// Provides common state for quality report display including
92/// recommendation selection and scroll position.
93#[derive(Debug, Clone)]
94pub struct QualityViewState {
95    /// Current view mode (summary, recommendations, metrics)
96    pub view_mode: QualityViewMode,
97    /// Selected recommendation index
98    pub selected_recommendation: usize,
99    /// Total recommendations count
100    pub total_recommendations: usize,
101    /// Scroll offset for content
102    pub scroll_offset: usize,
103}
104
105impl Default for QualityViewState {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl QualityViewState {
112    pub fn new() -> Self {
113        Self {
114            view_mode: QualityViewMode::Summary,
115            selected_recommendation: 0,
116            total_recommendations: 0,
117            scroll_offset: 0,
118        }
119    }
120
121    pub fn with_recommendations(total: usize) -> Self {
122        Self {
123            view_mode: QualityViewMode::Summary,
124            selected_recommendation: 0,
125            total_recommendations: total,
126            scroll_offset: 0,
127        }
128    }
129
130    /// Cycle to the next view mode.
131    pub fn next_mode(&mut self) {
132        self.view_mode = match self.view_mode {
133            QualityViewMode::Summary => QualityViewMode::Recommendations,
134            QualityViewMode::Recommendations => QualityViewMode::Metrics,
135            QualityViewMode::Metrics => QualityViewMode::Summary,
136        };
137    }
138
139    /// Select next recommendation.
140    pub fn select_next(&mut self) {
141        if self.selected_recommendation < self.total_recommendations.saturating_sub(1) {
142            self.selected_recommendation += 1;
143        }
144    }
145
146    /// Select previous recommendation.
147    pub fn select_prev(&mut self) {
148        if self.selected_recommendation > 0 {
149            self.selected_recommendation -= 1;
150        }
151    }
152
153    /// Go to first recommendation.
154    pub fn go_first(&mut self) {
155        self.selected_recommendation = 0;
156        self.scroll_offset = 0;
157    }
158
159    /// Go to last recommendation.
160    pub fn go_last(&mut self) {
161        self.selected_recommendation = self.total_recommendations.saturating_sub(1);
162    }
163
164    /// Update total recommendations count.
165    pub fn set_total(&mut self, total: usize) {
166        self.total_recommendations = total;
167        if self.selected_recommendation >= total {
168            self.selected_recommendation = total.saturating_sub(1);
169        }
170    }
171}
172
173/// Quality view modes.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum QualityViewMode {
176    #[default]
177    Summary,
178    Recommendations,
179    Metrics,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_quality_view_state_navigation() {
188        let mut state = QualityViewState::with_recommendations(10);
189
190        assert_eq!(state.selected_recommendation, 0);
191
192        state.select_next();
193        assert_eq!(state.selected_recommendation, 1);
194
195        state.select_prev();
196        assert_eq!(state.selected_recommendation, 0);
197
198        // Can't go below 0
199        state.select_prev();
200        assert_eq!(state.selected_recommendation, 0);
201
202        // Go to last
203        state.go_last();
204        assert_eq!(state.selected_recommendation, 9);
205
206        // Can't go past end
207        state.select_next();
208        assert_eq!(state.selected_recommendation, 9);
209
210        // Go to first
211        state.go_first();
212        assert_eq!(state.selected_recommendation, 0);
213    }
214
215    #[test]
216    fn test_quality_view_state_mode_cycling() {
217        let mut state = QualityViewState::new();
218
219        assert_eq!(state.view_mode, QualityViewMode::Summary);
220
221        state.next_mode();
222        assert_eq!(state.view_mode, QualityViewMode::Recommendations);
223
224        state.next_mode();
225        assert_eq!(state.view_mode, QualityViewMode::Metrics);
226
227        state.next_mode();
228        assert_eq!(state.view_mode, QualityViewMode::Summary);
229    }
230
231    #[test]
232    fn test_quality_view_state_set_total() {
233        let mut state = QualityViewState::with_recommendations(10);
234        state.selected_recommendation = 8;
235
236        // Shrink total - selection should clamp
237        state.set_total(5);
238        assert_eq!(state.total_recommendations, 5);
239        assert_eq!(state.selected_recommendation, 4);
240
241        // Grow total - selection should stay
242        state.set_total(20);
243        assert_eq!(state.total_recommendations, 20);
244        assert_eq!(state.selected_recommendation, 4);
245    }
246}