Skip to main content

sbom_tools/tui/events/
mod.rs

1//! Event handling for the TUI.
2//!
3//! This module provides event handling for the TUI, including:
4//! - Key and mouse event polling
5//! - Event dispatch to the appropriate handlers
6//! - Integration with the `EventResult` type from `traits`
7
8mod components;
9mod compliance;
10mod dependencies;
11mod graph_changes;
12mod helpers;
13mod licenses;
14mod matrix;
15pub mod mouse;
16mod multi_diff;
17mod quality;
18mod sidebyside;
19mod source;
20mod timeline;
21mod vulnerabilities;
22
23use crate::config::TuiPreferences;
24use crate::tui::toggle_theme;
25use crossterm::event::{
26    self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
27};
28use std::time::Duration;
29
30pub use mouse::handle_mouse_event;
31
32/// Application event
33#[derive(Debug)]
34pub enum Event {
35    /// Key press event
36    Key(KeyEvent),
37    /// Mouse event
38    Mouse(MouseEvent),
39    /// Terminal tick (for animations)
40    Tick,
41    /// Resize event
42    Resize(u16, u16),
43}
44
45/// Event handler
46pub struct EventHandler {
47    /// Tick rate in milliseconds
48    tick_rate: Duration,
49}
50
51impl EventHandler {
52    /// Create a new event handler
53    pub fn new(tick_rate: u64) -> Self {
54        Self {
55            tick_rate: Duration::from_millis(tick_rate),
56        }
57    }
58
59    /// Poll for the next event
60    pub fn next(&self) -> Result<Event, std::io::Error> {
61        if event::poll(self.tick_rate)? {
62            match event::read()? {
63                CrosstermEvent::Key(key) => Ok(Event::Key(key)),
64                CrosstermEvent::Mouse(mouse) => Ok(Event::Mouse(mouse)),
65                CrosstermEvent::Resize(width, height) => Ok(Event::Resize(width, height)),
66                _ => Ok(Event::Tick),
67            }
68        } else {
69            Ok(Event::Tick)
70        }
71    }
72}
73
74impl Default for EventHandler {
75    fn default() -> Self {
76        Self::new(250)
77    }
78}
79
80/// Handle key events and update app state
81pub fn handle_key_event(app: &mut super::App, key: KeyEvent) {
82    // Clear any status message on key press
83    app.clear_status_message();
84
85    // Handle search mode separately
86    if app.overlays.search.active {
87        match key.code {
88            KeyCode::Esc => app.stop_search(),
89            KeyCode::Enter => {
90                // Jump to selected search result
91                app.jump_to_search_result();
92            }
93            KeyCode::Backspace => {
94                app.search_pop();
95                // Live search as user types
96                app.execute_search();
97            }
98            KeyCode::Up => app.overlays.search.select_prev(),
99            KeyCode::Down => app.overlays.search.select_next(),
100            KeyCode::Char(c) => {
101                app.search_push(c);
102                // Live search as user types
103                app.execute_search();
104            }
105            _ => {}
106        }
107        return;
108    }
109
110    // Handle threshold tuning overlay
111    if app.overlays.threshold_tuning.visible {
112        match key.code {
113            KeyCode::Esc | KeyCode::Char('q') => {
114                app.overlays.threshold_tuning.visible = false;
115            }
116            KeyCode::Up | KeyCode::Char('k') => {
117                app.overlays.threshold_tuning.increase();
118                app.update_threshold_preview();
119            }
120            KeyCode::Down | KeyCode::Char('j') => {
121                app.overlays.threshold_tuning.decrease();
122                app.update_threshold_preview();
123            }
124            KeyCode::Right | KeyCode::Char('l') => {
125                app.overlays.threshold_tuning.fine_increase();
126                app.update_threshold_preview();
127            }
128            KeyCode::Left | KeyCode::Char('h') => {
129                app.overlays.threshold_tuning.fine_decrease();
130                app.update_threshold_preview();
131            }
132            KeyCode::Char('r') => {
133                app.overlays.threshold_tuning.reset();
134                app.update_threshold_preview();
135            }
136            KeyCode::Enter => {
137                app.apply_threshold();
138            }
139            _ => {}
140        }
141        return;
142    }
143
144    // Handle overlays (help, export, legend)
145    if app.has_overlay() {
146        match key.code {
147            KeyCode::Esc => app.close_overlays(),
148            KeyCode::Char('?') if app.overlays.show_help => app.toggle_help(),
149            KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
150            KeyCode::Char('l') if app.overlays.show_legend => app.toggle_legend(),
151            KeyCode::Char('q') => app.close_overlays(),
152            // Export format selection in export dialog
153            KeyCode::Char('j') if app.overlays.show_export => {
154                app.close_overlays();
155                app.export(super::export::ExportFormat::Json);
156            }
157            KeyCode::Char('m') if app.overlays.show_export => {
158                app.close_overlays();
159                app.export(super::export::ExportFormat::Markdown);
160            }
161            KeyCode::Char('h') if app.overlays.show_export => {
162                app.close_overlays();
163                app.export(super::export::ExportFormat::Html);
164            }
165            KeyCode::Char('s') if app.overlays.show_export => {
166                app.close_overlays();
167                app.export(super::export::ExportFormat::Sarif);
168            }
169            KeyCode::Char('d') | KeyCode::Char('c') if app.overlays.show_export => {
170                app.close_overlays();
171                app.export(super::export::ExportFormat::Csv);
172            }
173            _ => {}
174        }
175        return;
176    }
177
178    // Handle view switcher overlay (for multi-comparison modes)
179    if app.overlays.view_switcher.visible {
180        match key.code {
181            KeyCode::Esc => app.overlays.view_switcher.hide(),
182            KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
183            KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
184            KeyCode::Enter | KeyCode::Char(' ') => {
185                if let Some(view) = app.overlays.view_switcher.current_view() {
186                    app.overlays.view_switcher.hide();
187                    mouse::switch_to_view(app, view);
188                }
189            }
190            KeyCode::Char('1') => {
191                app.overlays.view_switcher.hide();
192                mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
193            }
194            KeyCode::Char('2') => {
195                app.overlays.view_switcher.hide();
196                mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
197            }
198            KeyCode::Char('3') => {
199                app.overlays.view_switcher.hide();
200                mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
201            }
202            _ => {}
203        }
204        return;
205    }
206
207    // Handle shortcuts overlay
208    if app.overlays.shortcuts.visible {
209        match key.code {
210            KeyCode::Esc | KeyCode::Char('K') | KeyCode::F(1) => app.overlays.shortcuts.hide(),
211            _ => {}
212        }
213        return;
214    }
215
216    // Handle component deep dive modal
217    if app.overlays.component_deep_dive.visible {
218        match key.code {
219            KeyCode::Esc => app.overlays.component_deep_dive.close(),
220            KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
221                app.overlays.component_deep_dive.next_section()
222            }
223            KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
224                app.overlays.component_deep_dive.prev_section()
225            }
226            _ => {}
227        }
228        return;
229    }
230
231    // Global key bindings
232    match key.code {
233        KeyCode::Char('q') => app.should_quit = true,
234        KeyCode::Char('?') => app.toggle_help(),
235        KeyCode::Char('e') => app.toggle_export(),
236        KeyCode::Char('l') => app.toggle_legend(),
237        KeyCode::Char('T') => {
238            // Toggle theme (dark -> light -> high-contrast) and save preference
239            let theme_name = toggle_theme();
240            let prefs = TuiPreferences {
241                theme: theme_name.to_string(),
242            };
243            let _ = prefs.save();
244        }
245        // View switcher (V key in multi-comparison modes)
246        KeyCode::Char('V') => {
247            if matches!(
248                app.mode,
249                super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
250            ) {
251                app.overlays.view_switcher.toggle();
252            }
253        }
254        // Match threshold tuning overlay (only in Diff mode)
255        KeyCode::Char('M') => {
256            if matches!(app.mode, super::AppMode::Diff) {
257                app.toggle_threshold_tuning();
258            }
259        }
260        // Keyboard shortcuts overlay
261        KeyCode::Char('K') | KeyCode::F(1) => {
262            let context = match app.mode {
263                super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
264                super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
265                super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
266                super::AppMode::Diff => super::app::ShortcutsContext::Diff,
267                super::AppMode::View => super::app::ShortcutsContext::Global,
268            };
269            app.overlays.shortcuts.show(context);
270        }
271        // Component deep dive (D key)
272        KeyCode::Char('D') => {
273            if let Some(component_name) = helpers::get_selected_component_name(app) {
274                app.overlays.component_deep_dive.open(component_name, None);
275            }
276        }
277        // Policy/Compliance check (P key)
278        KeyCode::Char('P') => {
279            if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
280                app.run_compliance_check();
281            }
282        }
283        // Cycle policy preset (Shift+P cycles policies)
284        KeyCode::Char('p') => {
285            if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
286                app.next_policy();
287            }
288        }
289        KeyCode::Esc => app.close_overlays(),
290        KeyCode::Char('b') | KeyCode::Backspace => {
291            // Navigate back using breadcrumbs
292            if app.has_navigation_history() {
293                app.navigate_back();
294            }
295        }
296        KeyCode::Tab => {
297            if key.modifiers.contains(KeyModifiers::SHIFT) {
298                app.prev_tab();
299            } else {
300                app.next_tab();
301            }
302        }
303        KeyCode::Char('/') => app.start_search(),
304        KeyCode::Char('1') => app.select_tab(super::TabKind::Summary),
305        KeyCode::Char('2') => app.select_tab(super::TabKind::Components),
306        KeyCode::Char('3') => app.select_tab(super::TabKind::Dependencies),
307        KeyCode::Char('4') => app.select_tab(super::TabKind::Licenses),
308        KeyCode::Char('5') => app.select_tab(super::TabKind::Vulnerabilities),
309        KeyCode::Char('6') => app.select_tab(super::TabKind::Quality),
310        KeyCode::Char('7') => {
311            // Compliance only in diff mode
312            if app.mode == super::AppMode::Diff {
313                app.select_tab(super::TabKind::Compliance);
314            }
315        }
316        KeyCode::Char('8') => {
317            // Side-by-side only in diff mode
318            if app.mode == super::AppMode::Diff {
319                app.select_tab(super::TabKind::SideBySide);
320            }
321        }
322        KeyCode::Char('9') => {
323            // Graph changes tab only when graph diff data is available
324            if let Some(ref result) = app.data.diff_result {
325                if !result.graph_changes.is_empty() {
326                    app.select_tab(super::TabKind::GraphChanges);
327                }
328            }
329        }
330        KeyCode::Char('0') => {
331            // Source tab only in diff mode
332            if app.mode == super::AppMode::Diff {
333                app.select_tab(super::TabKind::Source);
334            }
335        }
336        // Navigation
337        KeyCode::Up | KeyCode::Char('k') => app.select_up(),
338        KeyCode::Down | KeyCode::Char('j') => app.select_down(),
339        KeyCode::PageUp => app.page_up(),
340        KeyCode::PageDown => app.page_down(),
341        KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
342            app.select_first()
343        }
344        KeyCode::End | KeyCode::Char('G') => app.select_last(),
345        _ => {}
346    }
347
348    // Tab-specific key bindings
349    match app.active_tab {
350        super::TabKind::Components => components::handle_components_keys(app, key),
351        super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
352        super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
353        super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
354        super::TabKind::Quality => quality::handle_quality_keys(app, key),
355        super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
356        super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
357        super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
358        super::TabKind::Source => source::handle_source_keys(app, key),
359        _ => {}
360    }
361
362    // Mode-specific key bindings for multi-diff, timeline, and matrix
363    match app.mode {
364        super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
365        super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
366        super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
367        _ => {}
368    }
369}
370