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    #[must_use]
113    pub const fn new() -> Self {
114        Self {
115            view_mode: QualityViewMode::Summary,
116            selected_recommendation: 0,
117            total_recommendations: 0,
118            scroll_offset: 0,
119        }
120    }
121
122    #[must_use]
123    pub const fn with_recommendations(total: usize) -> Self {
124        Self {
125            view_mode: QualityViewMode::Summary,
126            selected_recommendation: 0,
127            total_recommendations: total,
128            scroll_offset: 0,
129        }
130    }
131
132    /// Cycle to the next view mode.
133    pub const fn next_mode(&mut self) {
134        self.view_mode = match self.view_mode {
135            QualityViewMode::Summary => QualityViewMode::Recommendations,
136            QualityViewMode::Recommendations => QualityViewMode::Metrics,
137            QualityViewMode::Metrics => QualityViewMode::Summary,
138        };
139    }
140
141    /// Select next recommendation.
142    pub const fn select_next(&mut self) {
143        if self.selected_recommendation < self.total_recommendations.saturating_sub(1) {
144            self.selected_recommendation += 1;
145        }
146    }
147
148    /// Select previous recommendation.
149    pub const fn select_prev(&mut self) {
150        if self.selected_recommendation > 0 {
151            self.selected_recommendation -= 1;
152        }
153    }
154
155    /// Go to first recommendation.
156    pub const fn go_first(&mut self) {
157        self.selected_recommendation = 0;
158        self.scroll_offset = 0;
159    }
160
161    /// Go to last recommendation.
162    pub const fn go_last(&mut self) {
163        self.selected_recommendation = self.total_recommendations.saturating_sub(1);
164    }
165
166    /// Update total recommendations count.
167    pub const fn set_total(&mut self, total: usize) {
168        self.total_recommendations = total;
169        if self.selected_recommendation >= total {
170            self.selected_recommendation = total.saturating_sub(1);
171        }
172    }
173}
174
175/// Quality view modes.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum QualityViewMode {
178    #[default]
179    Summary,
180    Recommendations,
181    Metrics,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_quality_view_state_navigation() {
190        let mut state = QualityViewState::with_recommendations(10);
191
192        assert_eq!(state.selected_recommendation, 0);
193
194        state.select_next();
195        assert_eq!(state.selected_recommendation, 1);
196
197        state.select_prev();
198        assert_eq!(state.selected_recommendation, 0);
199
200        // Can't go below 0
201        state.select_prev();
202        assert_eq!(state.selected_recommendation, 0);
203
204        // Go to last
205        state.go_last();
206        assert_eq!(state.selected_recommendation, 9);
207
208        // Can't go past end
209        state.select_next();
210        assert_eq!(state.selected_recommendation, 9);
211
212        // Go to first
213        state.go_first();
214        assert_eq!(state.selected_recommendation, 0);
215    }
216
217    #[test]
218    fn test_quality_view_state_mode_cycling() {
219        let mut state = QualityViewState::new();
220
221        assert_eq!(state.view_mode, QualityViewMode::Summary);
222
223        state.next_mode();
224        assert_eq!(state.view_mode, QualityViewMode::Recommendations);
225
226        state.next_mode();
227        assert_eq!(state.view_mode, QualityViewMode::Metrics);
228
229        state.next_mode();
230        assert_eq!(state.view_mode, QualityViewMode::Summary);
231    }
232
233    #[test]
234    fn test_quality_view_state_set_total() {
235        let mut state = QualityViewState::with_recommendations(10);
236        state.selected_recommendation = 8;
237
238        // Shrink total - selection should clamp
239        state.set_total(5);
240        assert_eq!(state.total_recommendations, 5);
241        assert_eq!(state.selected_recommendation, 4);
242
243        // Grow total - selection should stay
244        state.set_total(20);
245        assert_eq!(state.total_recommendations, 20);
246        assert_eq!(state.selected_recommendation, 4);
247    }
248}