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