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 compliance;
9mod components;
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 const 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    // Ctrl+C copies the selected item (universal shortcut)
86    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
87        handle_yank(app);
88        return;
89    }
90
91    // Handle search mode separately
92    if app.overlays.search.active {
93        match key.code {
94            KeyCode::Esc => app.stop_search(),
95            KeyCode::Enter => {
96                // Jump to selected search result
97                app.jump_to_search_result();
98            }
99            KeyCode::Backspace => {
100                app.search_pop();
101                // Live search as user types
102                app.execute_search();
103            }
104            KeyCode::Up => app.overlays.search.select_prev(),
105            KeyCode::Down => app.overlays.search.select_next(),
106            KeyCode::Char(c) => {
107                app.search_push(c);
108                // Live search as user types
109                app.execute_search();
110            }
111            _ => {}
112        }
113        return;
114    }
115
116    // Handle threshold tuning overlay
117    if app.overlays.threshold_tuning.visible {
118        match key.code {
119            KeyCode::Esc | KeyCode::Char('q') => {
120                app.overlays.threshold_tuning.visible = false;
121            }
122            KeyCode::Up | KeyCode::Char('k') => {
123                app.overlays.threshold_tuning.increase();
124                app.update_threshold_preview();
125            }
126            KeyCode::Down | KeyCode::Char('j') => {
127                app.overlays.threshold_tuning.decrease();
128                app.update_threshold_preview();
129            }
130            KeyCode::Right | KeyCode::Char('l') => {
131                app.overlays.threshold_tuning.fine_increase();
132                app.update_threshold_preview();
133            }
134            KeyCode::Left | KeyCode::Char('h') => {
135                app.overlays.threshold_tuning.fine_decrease();
136                app.update_threshold_preview();
137            }
138            KeyCode::Char('r') => {
139                app.overlays.threshold_tuning.reset();
140                app.update_threshold_preview();
141            }
142            KeyCode::Enter => {
143                app.apply_threshold();
144            }
145            _ => {}
146        }
147        return;
148    }
149
150    // Handle overlays (help, export, legend)
151    if app.has_overlay() {
152        match key.code {
153            KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
154            KeyCode::Char('?') if app.overlays.show_help => app.toggle_help(),
155            KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
156            KeyCode::Char('l') if app.overlays.show_legend => app.toggle_legend(),
157            // Export format selection in export dialog
158            KeyCode::Char('j') if app.overlays.show_export => {
159                app.close_overlays();
160                dispatch_export(app, super::export::ExportFormat::Json);
161            }
162            KeyCode::Char('m') if app.overlays.show_export => {
163                app.close_overlays();
164                dispatch_export(app, super::export::ExportFormat::Markdown);
165            }
166            KeyCode::Char('h') if app.overlays.show_export => {
167                app.close_overlays();
168                dispatch_export(app, super::export::ExportFormat::Html);
169            }
170            KeyCode::Char('s') if app.overlays.show_export => {
171                app.close_overlays();
172                dispatch_export(app, super::export::ExportFormat::Sarif);
173            }
174            KeyCode::Char('c') if app.overlays.show_export => {
175                app.close_overlays();
176                dispatch_export(app, super::export::ExportFormat::Csv);
177            }
178            _ => {}
179        }
180        return;
181    }
182
183    // Handle view switcher overlay (for multi-comparison modes)
184    if app.overlays.view_switcher.visible {
185        match key.code {
186            KeyCode::Esc => app.overlays.view_switcher.hide(),
187            KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
188            KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
189            KeyCode::Enter | KeyCode::Char(' ') => {
190                if let Some(view) = app.overlays.view_switcher.current_view() {
191                    app.overlays.view_switcher.hide();
192                    mouse::switch_to_view(app, view);
193                }
194            }
195            KeyCode::Char('1') => {
196                app.overlays.view_switcher.hide();
197                mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
198            }
199            KeyCode::Char('2') => {
200                app.overlays.view_switcher.hide();
201                mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
202            }
203            KeyCode::Char('3') => {
204                app.overlays.view_switcher.hide();
205                mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
206            }
207            _ => {}
208        }
209        return;
210    }
211
212    // Handle shortcuts overlay
213    if app.overlays.shortcuts.visible {
214        match key.code {
215            KeyCode::Esc | KeyCode::Char('K') | KeyCode::F(1) => app.overlays.shortcuts.hide(),
216            _ => {}
217        }
218        return;
219    }
220
221    // Handle component deep dive modal
222    if app.overlays.component_deep_dive.visible {
223        match key.code {
224            KeyCode::Esc => app.overlays.component_deep_dive.close(),
225            KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
226                app.overlays.component_deep_dive.next_section();
227            }
228            KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
229                app.overlays.component_deep_dive.prev_section();
230            }
231            _ => {}
232        }
233        return;
234    }
235
236    // Global key bindings
237    match key.code {
238        KeyCode::Char('q') => {
239            // Save last active tab before quitting
240            let mut prefs = crate::config::TuiPreferences::load();
241            prefs.last_tab = Some(app.active_tab.as_str().to_string());
242            let _ = prefs.save();
243            app.should_quit = true;
244        }
245        KeyCode::Char('?') => app.toggle_help(),
246        KeyCode::Char('e') => app.toggle_export(),
247        KeyCode::Char('l') => app.toggle_legend(),
248        KeyCode::Char('T') => {
249            // Toggle theme (dark -> light -> high-contrast) and save preference
250            let theme_name = toggle_theme();
251            let mut prefs = TuiPreferences::load();
252            prefs.theme = theme_name.to_string();
253            let _ = prefs.save();
254        }
255        // View switcher (V key in multi-comparison modes)
256        KeyCode::Char('V') => {
257            if matches!(
258                app.mode,
259                super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
260            ) {
261                app.overlays.view_switcher.toggle();
262            }
263        }
264        // Keyboard shortcuts overlay
265        KeyCode::Char('K') | KeyCode::F(1) => {
266            let context = match app.mode {
267                super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
268                super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
269                super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
270                super::AppMode::Diff => super::app::ShortcutsContext::Diff,
271                super::AppMode::View => super::app::ShortcutsContext::Global,
272            };
273            app.overlays.shortcuts.show(context);
274        }
275        // Component deep dive (D key)
276        KeyCode::Char('D') => {
277            if let Some(component_name) = helpers::get_selected_component_name(app) {
278                app.overlays.component_deep_dive.open(component_name, None);
279            }
280        }
281        // Policy/Compliance check (P key)
282        KeyCode::Char('P') => {
283            if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
284                app.run_compliance_check();
285            }
286        }
287        // Cycle policy preset (Shift+P cycles policies)
288        KeyCode::Char('p') => {
289            if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
290                app.next_policy();
291            }
292        }
293        // Yank (copy) selected item to clipboard
294        KeyCode::Char('y') => {
295            handle_yank(app);
296        }
297        KeyCode::Esc => app.close_overlays(),
298        KeyCode::Char('b') | KeyCode::Backspace => {
299            // Navigate back using breadcrumbs
300            if app.has_navigation_history() {
301                app.navigate_back();
302            }
303        }
304        KeyCode::Tab => {
305            if key.modifiers.contains(KeyModifiers::SHIFT) {
306                app.prev_tab();
307            } else {
308                app.next_tab();
309            }
310        }
311        KeyCode::Char('/') => app.start_search(),
312        KeyCode::Char('1') => app.select_tab(super::TabKind::Summary),
313        KeyCode::Char('2') => app.select_tab(super::TabKind::Components),
314        KeyCode::Char('3') => app.select_tab(super::TabKind::Dependencies),
315        KeyCode::Char('4') => app.select_tab(super::TabKind::Licenses),
316        KeyCode::Char('5') => app.select_tab(super::TabKind::Vulnerabilities),
317        KeyCode::Char('6') => app.select_tab(super::TabKind::Quality),
318        KeyCode::Char('7') => {
319            // Compliance only in diff mode
320            if app.mode == super::AppMode::Diff {
321                app.select_tab(super::TabKind::Compliance);
322            }
323        }
324        KeyCode::Char('8') => {
325            // Side-by-side only in diff mode
326            if app.mode == super::AppMode::Diff {
327                app.select_tab(super::TabKind::SideBySide);
328            }
329        }
330        KeyCode::Char('9') => {
331            // Graph changes tab when graph diff data is available, otherwise Source
332            let has_graph = app
333                .data
334                .diff_result
335                .as_ref()
336                .is_some_and(|r| !r.graph_changes.is_empty());
337            if has_graph {
338                app.select_tab(super::TabKind::GraphChanges);
339            } else if app.mode == super::AppMode::Diff {
340                app.select_tab(super::TabKind::Source);
341            }
342        }
343        KeyCode::Char('0') => {
344            // Source tab as 10th tab (only when graph changes exist)
345            let has_graph = app
346                .data
347                .diff_result
348                .as_ref()
349                .is_some_and(|r| !r.graph_changes.is_empty());
350            if has_graph && app.mode == super::AppMode::Diff {
351                app.select_tab(super::TabKind::Source);
352            }
353        }
354        // Navigation
355        KeyCode::Up | KeyCode::Char('k') => app.select_up(),
356        KeyCode::Down | KeyCode::Char('j') => app.select_down(),
357        KeyCode::PageUp => app.page_up(),
358        KeyCode::PageDown => app.page_down(),
359        KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
360            app.select_first();
361        }
362        KeyCode::End | KeyCode::Char('G') => app.select_last(),
363        _ => {}
364    }
365
366    // Tab-specific key bindings
367    match app.active_tab {
368        super::TabKind::Components => components::handle_components_keys(app, key),
369        super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
370        super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
371        super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
372        super::TabKind::Quality => quality::handle_quality_keys(app, key),
373        super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
374        super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
375        super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
376        super::TabKind::Source => source::handle_source_keys(app, key),
377        super::TabKind::Summary => {}
378    }
379
380    // Mode-specific key bindings for multi-diff, timeline, and matrix
381    match app.mode {
382        super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
383        super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
384        super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
385        _ => {}
386    }
387}
388
389/// Get the text that would be copied for the current selection in diff mode.
390///
391/// Returns `None` if nothing is selected or the tab has no copyable item.
392pub fn get_yank_text(app: &super::App) -> Option<String> {
393    match app.active_tab {
394        super::TabKind::Components => helpers::get_selected_component_name(app),
395        super::TabKind::Vulnerabilities => {
396            let idx = app.tabs.vulnerabilities.selected;
397            let result = app.data.diff_result.as_ref()?;
398            let vulns: Vec<_> = result
399                .vulnerabilities
400                .introduced
401                .iter()
402                .chain(result.vulnerabilities.resolved.iter())
403                .collect();
404            vulns.get(idx).map(|v| v.id.clone())
405        }
406        super::TabKind::Dependencies => {
407            let idx = app.tabs.dependencies.selected;
408            let result = app.data.diff_result.as_ref()?;
409            let deps: Vec<_> = result
410                .dependencies
411                .added
412                .iter()
413                .chain(result.dependencies.removed.iter())
414                .collect();
415            deps.get(idx)
416                .map(|dep| format!("{} → {}", dep.from, dep.to))
417        }
418        super::TabKind::Licenses => {
419            let idx = app.tabs.licenses.selected;
420            let result = app.data.diff_result.as_ref()?;
421            let licenses: Vec<_> = result
422                .licenses
423                .new_licenses
424                .iter()
425                .chain(result.licenses.removed_licenses.iter())
426                .collect();
427            licenses.get(idx).map(|lic| lic.license.clone())
428        }
429        super::TabKind::Quality => {
430            let report = app
431                .data
432                .new_quality
433                .as_ref()
434                .or(app.data.old_quality.as_ref())?;
435            report
436                .recommendations
437                .get(app.tabs.quality.selected_recommendation)
438                .map(|rec| rec.message.clone())
439        }
440        super::TabKind::Compliance => {
441            let results = app
442                .data
443                .new_compliance_results
444                .as_ref()
445                .or(app.data.old_compliance_results.as_ref())?;
446            let result = results.get(app.tabs.diff_compliance.selected_standard)?;
447            result
448                .violations
449                .get(app.tabs.diff_compliance.selected_violation)
450                .map(|v| v.message.clone())
451        }
452        super::TabKind::Source => {
453            let panel = match app.tabs.source.active_side {
454                crate::tui::app_states::SourceSide::Old => &app.tabs.source.old_panel,
455                crate::tui::app_states::SourceSide::New => &app.tabs.source.new_panel,
456            };
457            match panel.view_mode {
458                super::app_states::SourceViewMode::Tree => {
459                    // Cache is already warm from rendering
460                    panel.cached_flat_items.get(panel.selected).map(|item| {
461                        if !item.value_preview.is_empty() {
462                            // Strip surrounding quotes for string values
463                            let v = &item.value_preview;
464                            if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
465                                v[1..v.len() - 1].to_string()
466                            } else {
467                                v.clone()
468                            }
469                        } else {
470                            item.node_id.clone()
471                        }
472                    })
473                }
474                super::app_states::SourceViewMode::Raw => panel
475                    .raw_lines
476                    .get(panel.selected)
477                    .map(|line| line.trim().to_string()),
478            }
479        }
480        _ => None,
481    }
482}
483
484/// Handle `y` / `Ctrl+C` to copy the focused item to clipboard.
485fn handle_yank(app: &mut super::App) {
486    let Some(text) = get_yank_text(app) else {
487        app.set_status_message("Nothing selected to copy");
488        return;
489    };
490
491    if crate::tui::clipboard::copy_to_clipboard(&text) {
492        let display = if text.len() > 50 {
493            format!("{}...", &text[..47])
494        } else {
495            text
496        };
497        app.set_status_message(format!("Copied: {display}"));
498    } else {
499        app.set_status_message("Failed to copy to clipboard");
500    }
501}
502
503/// Route an export to either the standard reporter pipeline or the
504/// compliance-specific exporter depending on the active tab.
505fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
506    if app.active_tab == super::TabKind::Compliance {
507        app.export_compliance(format);
508    } else {
509        app.export(format);
510    }
511}