Skip to main content

sbom_tools/tui/
theme.rs

1//! Centralized theme and color scheme for TUI.
2//!
3//! This module provides consistent styling across all TUI views and modes.
4
5use ratatui::prelude::*;
6use std::sync::RwLock;
7
8/// Color scheme for the TUI application.
9/// Provides semantic colors for different UI elements.
10#[derive(Debug, Clone, Copy)]
11pub struct ColorScheme {
12    // Change status colors
13    pub added: Color,
14    pub removed: Color,
15    pub modified: Color,
16    pub unchanged: Color,
17
18    // Severity colors
19    pub critical: Color,
20    pub high: Color,
21    pub medium: Color,
22    pub low: Color,
23    pub info: Color,
24
25    // License category colors
26    pub permissive: Color,
27    pub copyleft: Color,
28    pub weak_copyleft: Color,
29    pub proprietary: Color,
30    pub unknown_license: Color,
31
32    // UI element colors
33    pub primary: Color,
34    pub secondary: Color,
35    pub accent: Color,
36    pub muted: Color,
37    pub border: Color,
38    pub border_focused: Color,
39    pub background: Color,
40    pub background_alt: Color,
41    pub text: Color,
42    pub text_muted: Color,
43    pub selection: Color,
44    pub highlight: Color,
45
46    // Status colors
47    pub success: Color,
48    pub warning: Color,
49    pub error: Color,
50
51    // Badge foreground colors (for text on colored backgrounds)
52    pub badge_fg_dark: Color, // For badges on bright backgrounds (yellow, cyan)
53    pub badge_fg_light: Color, // For badges on dark backgrounds (magenta, red, blue)
54
55    // Side-by-side view colors
56    pub selection_bg: Color,        // Background for selected row
57    pub search_highlight_bg: Color, // Background for search matches
58    pub error_bg: Color,            // Background for removed/error highlights
59    pub success_bg: Color,          // Background for added/success highlights
60}
61
62impl Default for ColorScheme {
63    fn default() -> Self {
64        Self::dark()
65    }
66}
67
68impl ColorScheme {
69    /// Const dark theme for static initialization
70    const fn dark_const() -> Self {
71        Self {
72            // Change status
73            added: Color::Green,
74            removed: Color::Red,
75            modified: Color::Yellow,
76            unchanged: Color::Gray,
77
78            // Severity
79            critical: Color::Magenta,
80            high: Color::Red,
81            medium: Color::Yellow,
82            low: Color::Cyan,
83            info: Color::Blue,
84
85            // License categories
86            permissive: Color::Green,
87            copyleft: Color::Yellow,
88            weak_copyleft: Color::Cyan,
89            proprietary: Color::Red,
90            unknown_license: Color::DarkGray,
91
92            // UI elements
93            primary: Color::Cyan,
94            secondary: Color::Blue,
95            accent: Color::Yellow,
96            muted: Color::DarkGray,
97            border: Color::DarkGray,
98            border_focused: Color::Cyan,
99            background: Color::Reset,
100            background_alt: Color::Rgb(30, 30, 40),
101            text: Color::White,
102            text_muted: Color::Gray,
103            selection: Color::DarkGray,
104            highlight: Color::Yellow,
105
106            // Status
107            success: Color::Green,
108            warning: Color::Yellow,
109            error: Color::Red,
110
111            // Badge foregrounds
112            badge_fg_dark: Color::Black,
113            badge_fg_light: Color::White,
114
115            // Side-by-side view colors
116            selection_bg: Color::Rgb(60, 60, 80),
117            search_highlight_bg: Color::Rgb(100, 80, 0),
118            error_bg: Color::Rgb(80, 30, 30),
119            success_bg: Color::Rgb(30, 80, 30),
120        }
121    }
122
123    /// Dark theme (default)
124    #[must_use]
125    pub const fn dark() -> Self {
126        Self {
127            // Change status
128            added: Color::Green,
129            removed: Color::Red,
130            modified: Color::Yellow,
131            unchanged: Color::Gray,
132
133            // Severity
134            critical: Color::Magenta,
135            high: Color::Red,
136            medium: Color::Yellow,
137            low: Color::Cyan,
138            info: Color::Blue,
139
140            // License categories
141            permissive: Color::Green,
142            copyleft: Color::Yellow,
143            weak_copyleft: Color::Cyan,
144            proprietary: Color::Red,
145            unknown_license: Color::DarkGray,
146
147            // UI elements
148            primary: Color::Cyan,
149            secondary: Color::Blue,
150            accent: Color::Yellow,
151            muted: Color::DarkGray,
152            border: Color::DarkGray,
153            border_focused: Color::Cyan,
154            background: Color::Reset,
155            background_alt: Color::Rgb(30, 30, 40),
156            text: Color::White,
157            text_muted: Color::Gray,
158            selection: Color::DarkGray,
159            highlight: Color::Yellow,
160
161            // Status
162            success: Color::Green,
163            warning: Color::Yellow,
164            error: Color::Red,
165
166            // Badge foregrounds
167            badge_fg_dark: Color::Black,
168            badge_fg_light: Color::White,
169
170            // Side-by-side view colors
171            selection_bg: Color::Rgb(60, 60, 80),
172            search_highlight_bg: Color::Rgb(100, 80, 0),
173            error_bg: Color::Rgb(80, 30, 30),
174            success_bg: Color::Rgb(30, 80, 30),
175        }
176    }
177
178    /// Light theme
179    #[must_use]
180    pub const fn light() -> Self {
181        Self {
182            // Change status
183            added: Color::Rgb(0, 128, 0),
184            removed: Color::Rgb(200, 0, 0),
185            modified: Color::Rgb(180, 140, 0),
186            unchanged: Color::Rgb(100, 100, 100),
187
188            // Severity
189            critical: Color::Rgb(128, 0, 128),
190            high: Color::Rgb(200, 0, 0),
191            medium: Color::Rgb(180, 140, 0),
192            low: Color::Rgb(0, 128, 128),
193            info: Color::Rgb(0, 0, 200),
194
195            // License categories
196            permissive: Color::Rgb(0, 128, 0),
197            copyleft: Color::Rgb(180, 140, 0),
198            weak_copyleft: Color::Rgb(0, 128, 128),
199            proprietary: Color::Rgb(200, 0, 0),
200            unknown_license: Color::Rgb(100, 100, 100),
201
202            // UI elements
203            primary: Color::Rgb(0, 100, 150),
204            secondary: Color::Rgb(0, 0, 150),
205            accent: Color::Rgb(180, 140, 0),
206            muted: Color::Rgb(150, 150, 150),
207            border: Color::Rgb(180, 180, 180),
208            border_focused: Color::Rgb(0, 100, 150),
209            background: Color::Rgb(255, 255, 255),
210            background_alt: Color::Rgb(240, 240, 245),
211            text: Color::Rgb(30, 30, 30),
212            text_muted: Color::Rgb(100, 100, 100),
213            selection: Color::Rgb(200, 220, 240),
214            highlight: Color::Rgb(180, 140, 0),
215
216            // Status
217            success: Color::Rgb(0, 128, 0),
218            warning: Color::Rgb(180, 140, 0),
219            error: Color::Rgb(200, 0, 0),
220
221            // Badge foregrounds (reversed for light theme)
222            badge_fg_dark: Color::Rgb(30, 30, 30),
223            badge_fg_light: Color::White,
224
225            // Side-by-side view colors (lighter for light theme)
226            selection_bg: Color::Rgb(200, 220, 240),
227            search_highlight_bg: Color::Rgb(255, 230, 150),
228            error_bg: Color::Rgb(255, 200, 200),
229            success_bg: Color::Rgb(200, 255, 200),
230        }
231    }
232
233    /// High contrast theme (accessibility)
234    #[must_use]
235    pub const fn high_contrast() -> Self {
236        Self {
237            // Change status
238            added: Color::Green,
239            removed: Color::LightRed,
240            modified: Color::LightYellow,
241            unchanged: Color::White,
242
243            // Severity
244            critical: Color::LightMagenta,
245            high: Color::LightRed,
246            medium: Color::LightYellow,
247            low: Color::LightCyan,
248            info: Color::LightBlue,
249
250            // License categories
251            permissive: Color::LightGreen,
252            copyleft: Color::LightYellow,
253            weak_copyleft: Color::LightCyan,
254            proprietary: Color::LightRed,
255            unknown_license: Color::Gray,
256
257            // UI elements
258            primary: Color::LightCyan,
259            secondary: Color::LightBlue,
260            accent: Color::LightYellow,
261            muted: Color::Gray,
262            border: Color::White,
263            border_focused: Color::LightCyan,
264            background: Color::Black,
265            background_alt: Color::Rgb(20, 20, 20),
266            text: Color::White,
267            text_muted: Color::Gray,
268            selection: Color::White,
269            highlight: Color::LightYellow,
270
271            // Status
272            success: Color::LightGreen,
273            warning: Color::LightYellow,
274            error: Color::LightRed,
275
276            // Badge foregrounds
277            badge_fg_dark: Color::Black,
278            badge_fg_light: Color::White,
279
280            // Side-by-side view colors (high contrast)
281            selection_bg: Color::Rgb(50, 50, 80),
282            search_highlight_bg: Color::Rgb(120, 100, 0),
283            error_bg: Color::Rgb(100, 30, 30),
284            success_bg: Color::Rgb(30, 100, 30),
285        }
286    }
287
288    /// Get color for severity level
289    #[must_use]
290    pub fn severity_color(&self, severity: &str) -> Color {
291        match severity.to_lowercase().as_str() {
292            "critical" => self.critical,
293            "high" => self.high,
294            "medium" | "moderate" => self.medium,
295            "low" => self.low,
296            "info" | "informational" | "none" => self.info,
297            _ => self.text_muted,
298        }
299    }
300
301    /// Get a subtle background tint for severity (used for row highlighting)
302    #[must_use]
303    pub fn severity_bg_tint(&self, severity: &str) -> Color {
304        match severity.to_lowercase().as_str() {
305            "critical" => Color::Rgb(50, 15, 50),
306            "high" => Color::Rgb(50, 15, 15),
307            "medium" => Color::Rgb(45, 40, 10),
308            "low" => Color::Rgb(15, 35, 40),
309            _ => Color::Reset,
310        }
311    }
312
313    /// Get color for change status
314    #[must_use]
315    pub fn change_color(&self, status: &str) -> Color {
316        match status.to_lowercase().as_str() {
317            "added" | "new" | "introduced" => self.added,
318            "removed" | "deleted" | "resolved" => self.removed,
319            "modified" | "changed" | "updated" => self.modified,
320            _ => self.unchanged,
321        }
322    }
323
324    /// Get color for license category
325    #[must_use]
326    pub fn license_color(&self, category: &str) -> Color {
327        match category.to_lowercase().as_str() {
328            "permissive" => self.permissive,
329            "copyleft" | "strong copyleft" => self.copyleft,
330            "weak copyleft" => self.weak_copyleft,
331            "proprietary" | "commercial" => self.proprietary,
332            _ => self.unknown_license,
333        }
334    }
335
336    /// Get appropriate foreground color for severity badges
337    /// Returns light fg for dark backgrounds (critical, high, info) and dark fg for bright backgrounds
338    #[must_use]
339    pub fn severity_badge_fg(&self, severity: &str) -> Color {
340        match severity.to_lowercase().as_str() {
341            "critical" | "high" | "info" | "informational" => self.badge_fg_light,
342            _ => self.badge_fg_dark,
343        }
344    }
345
346    /// Get KEV (Known Exploited Vulnerabilities) badge color
347    /// Returns a bright red/orange color to indicate active exploitation
348    #[must_use]
349    pub const fn kev(&self) -> Color {
350        Color::Rgb(255, 100, 50) // Bright orange-red for urgency
351    }
352
353    /// Get KEV badge foreground color
354    #[must_use]
355    pub const fn kev_badge_fg(&self) -> Color {
356        self.badge_fg_dark
357    }
358
359    /// Get direct dependency badge background color (green - easy to fix)
360    #[must_use]
361    pub const fn direct_dep(&self) -> Color {
362        Color::Rgb(46, 160, 67) // GitHub green
363    }
364
365    /// Get transitive dependency badge background color (gray - harder to fix)
366    #[must_use]
367    pub const fn transitive_dep(&self) -> Color {
368        Color::Rgb(110, 118, 129) // Muted gray
369    }
370
371    /// Get appropriate foreground color for change status badges
372    /// All change colors (green, red, yellow) work best with dark foreground
373    #[must_use]
374    pub const fn change_badge_fg(&self) -> Color {
375        self.badge_fg_dark
376    }
377
378    /// Get appropriate foreground color for license category badges
379    #[must_use]
380    pub fn license_badge_fg(&self, category: &str) -> Color {
381        match category.to_lowercase().as_str() {
382            "proprietary" | "commercial" => self.badge_fg_light,
383            _ => self.badge_fg_dark,
384        }
385    }
386
387    /// Chart color palette for visualizations
388    #[must_use]
389    pub const fn chart_palette(&self) -> [Color; 5] {
390        [
391            self.primary,
392            self.success,
393            self.warning,
394            self.critical,
395            self.secondary,
396        ]
397    }
398}
399
400/// Global theme instance (runtime switchable)
401static THEME: RwLock<Theme> = RwLock::new(Theme::dark_const());
402
403/// Theme configuration
404#[derive(Debug, Clone)]
405pub struct Theme {
406    pub colors: ColorScheme,
407    pub name: &'static str,
408}
409
410impl Default for Theme {
411    fn default() -> Self {
412        Self::dark()
413    }
414}
415
416impl Theme {
417    /// Const dark theme for static initialization
418    const fn dark_const() -> Self {
419        Self {
420            colors: ColorScheme::dark_const(),
421            name: "dark",
422        }
423    }
424
425    #[must_use]
426    pub const fn dark() -> Self {
427        Self {
428            colors: ColorScheme::dark(),
429            name: "dark",
430        }
431    }
432
433    #[must_use]
434    pub const fn light() -> Self {
435        Self {
436            colors: ColorScheme::light(),
437            name: "light",
438        }
439    }
440
441    #[must_use]
442    pub const fn high_contrast() -> Self {
443        Self {
444            colors: ColorScheme::high_contrast(),
445            name: "high-contrast",
446        }
447    }
448
449    #[must_use]
450    pub fn from_name(name: &str) -> Self {
451        match name.to_lowercase().as_str() {
452            "light" => Self::light(),
453            "high-contrast" | "highcontrast" | "hc" => Self::high_contrast(),
454            _ => Self::dark(),
455        }
456    }
457
458    /// Get the next theme in the rotation
459    #[must_use]
460    pub fn next(&self) -> Self {
461        match self.name {
462            "dark" => Self::light(),
463            "light" => Self::high_contrast(),
464            _ => Self::dark(),
465        }
466    }
467}
468
469/// Get the current theme name
470pub fn current_theme_name() -> &'static str {
471    THEME.read().expect("THEME lock not poisoned").name
472}
473
474/// Set the current theme
475pub fn set_theme(theme: Theme) {
476    *THEME.write().expect("THEME lock not poisoned") = theme;
477}
478
479/// Toggle to the next theme in rotation (dark -> light -> high-contrast -> dark)
480pub fn toggle_theme() -> &'static str {
481    let mut theme = THEME.write().expect("THEME lock not poisoned");
482    *theme = theme.next();
483    theme.name
484}
485
486/// Convenience function to get current colors
487pub fn colors() -> ColorScheme {
488    THEME.read().expect("THEME lock not poisoned").colors
489}
490
491// ============================================================================
492// Style Helpers
493// ============================================================================
494
495/// Common style presets for consistent UI elements
496pub struct Styles;
497
498impl Styles {
499    /// Header title style
500    #[must_use]
501    pub fn header_title() -> Style {
502        Style::default().fg(colors().primary).bold()
503    }
504
505    /// Section title style
506    #[must_use]
507    pub fn section_title() -> Style {
508        Style::default().fg(colors().primary).bold()
509    }
510
511    /// Subsection title style
512    #[must_use]
513    pub fn subsection_title() -> Style {
514        Style::default().fg(colors().primary)
515    }
516
517    /// Normal text style
518    #[must_use]
519    pub fn text() -> Style {
520        Style::default().fg(colors().text)
521    }
522
523    /// Muted/secondary text style
524    #[must_use]
525    pub fn text_muted() -> Style {
526        Style::default().fg(colors().text_muted)
527    }
528
529    /// Label text style
530    #[must_use]
531    pub fn label() -> Style {
532        Style::default().fg(colors().muted)
533    }
534
535    /// Value text style (for data values)
536    #[must_use]
537    pub fn value() -> Style {
538        Style::default().fg(colors().text).bold()
539    }
540
541    /// Highlighted/accent style
542    #[must_use]
543    pub fn highlight() -> Style {
544        Style::default().fg(colors().highlight).bold()
545    }
546
547    /// Selection style (for selected items)
548    #[must_use]
549    pub fn selected() -> Style {
550        Style::default()
551            .bg(colors().selection)
552            .fg(colors().text)
553            .bold()
554    }
555
556    /// Border style (unfocused)
557    #[must_use]
558    pub fn border() -> Style {
559        Style::default().fg(colors().border)
560    }
561
562    /// Border style (focused)
563    #[must_use]
564    pub fn border_focused() -> Style {
565        Style::default().fg(colors().border_focused)
566    }
567
568    /// Status bar background style
569    #[must_use]
570    pub fn status_bar() -> Style {
571        Style::default().bg(colors().background_alt)
572    }
573
574    /// Keyboard shortcut style
575    #[must_use]
576    pub fn shortcut_key() -> Style {
577        Style::default().fg(colors().accent)
578    }
579
580    /// Shortcut description style
581    #[must_use]
582    pub fn shortcut_desc() -> Style {
583        Style::default().fg(colors().text_muted)
584    }
585
586    /// Success style
587    #[must_use]
588    pub fn success() -> Style {
589        Style::default().fg(colors().success)
590    }
591
592    /// Warning style
593    #[must_use]
594    pub fn warning() -> Style {
595        Style::default().fg(colors().warning)
596    }
597
598    /// Error style
599    #[must_use]
600    pub fn error() -> Style {
601        Style::default().fg(colors().error)
602    }
603
604    /// Added item style
605    #[must_use]
606    pub fn added() -> Style {
607        Style::default().fg(colors().added)
608    }
609
610    /// Removed item style
611    #[must_use]
612    pub fn removed() -> Style {
613        Style::default().fg(colors().removed)
614    }
615
616    /// Modified item style
617    #[must_use]
618    pub fn modified() -> Style {
619        Style::default().fg(colors().modified)
620    }
621
622    /// Critical severity style
623    #[must_use]
624    pub fn critical() -> Style {
625        Style::default().fg(colors().critical).bold()
626    }
627
628    /// High severity style
629    #[must_use]
630    pub fn high() -> Style {
631        Style::default().fg(colors().high).bold()
632    }
633
634    /// Medium severity style
635    #[must_use]
636    pub fn medium() -> Style {
637        Style::default().fg(colors().medium)
638    }
639
640    /// Low severity style
641    #[must_use]
642    pub fn low() -> Style {
643        Style::default().fg(colors().low)
644    }
645}
646
647// ============================================================================
648// Badge Rendering Helpers
649// ============================================================================
650
651/// Render a status badge with consistent styling
652#[must_use]
653pub fn status_badge(status: &str) -> Span<'static> {
654    let scheme = colors();
655    let (label, color, symbol) = match status.to_lowercase().as_str() {
656        "added" | "new" | "introduced" => ("ADDED", scheme.added, "+"),
657        "removed" | "deleted" | "resolved" => ("REMOVED", scheme.removed, "-"),
658        "modified" | "changed" | "updated" => ("MODIFIED", scheme.modified, "~"),
659        _ => ("UNCHANGED", scheme.unchanged, "="),
660    };
661
662    Span::styled(
663        format!(" {symbol} {label} "),
664        Style::default()
665            .fg(scheme.change_badge_fg())
666            .bg(color)
667            .bold(),
668    )
669}
670
671/// Render a severity badge with consistent styling
672#[must_use]
673pub fn severity_badge(severity: &str) -> Span<'static> {
674    let scheme = colors();
675    let (label, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
676        "critical" => ("CRITICAL", scheme.critical, false),
677        "high" => ("HIGH", scheme.high, false),
678        "medium" | "moderate" => ("MEDIUM", scheme.medium, false),
679        "low" => ("LOW", scheme.low, false),
680        "info" | "informational" => ("INFO", scheme.info, false),
681        "none" => ("NONE", scheme.muted, false),
682        _ => ("UNKNOWN", scheme.muted, true),
683    };
684    let fg_color = scheme.severity_badge_fg(severity);
685
686    let style = if is_unknown {
687        Style::default().fg(fg_color).bg(bg_color).dim()
688    } else {
689        Style::default().fg(fg_color).bg(bg_color).bold()
690    };
691
692    Span::styled(format!(" {label} "), style)
693}
694
695/// Render a compact severity indicator (single char)
696#[must_use]
697pub fn severity_indicator(severity: &str) -> Span<'static> {
698    let scheme = colors();
699    let (symbol, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
700        "critical" => ("C", scheme.critical, false),
701        "high" => ("H", scheme.high, false),
702        "medium" | "moderate" => ("M", scheme.medium, false),
703        "low" => ("L", scheme.low, false),
704        "info" | "informational" => ("I", scheme.info, false),
705        "none" => ("-", scheme.muted, false),
706        _ => ("U", scheme.muted, true),
707    };
708    let fg_color = scheme.severity_badge_fg(severity);
709
710    let style = if is_unknown {
711        Style::default().fg(fg_color).bg(bg_color).dim()
712    } else {
713        Style::default().fg(fg_color).bg(bg_color).bold()
714    };
715
716    Span::styled(format!(" {symbol} "), style)
717}
718
719/// Render a count badge
720#[must_use]
721pub fn count_badge(count: usize, bg_color: Color) -> Span<'static> {
722    let scheme = colors();
723    Span::styled(
724        format!(" {count} "),
725        Style::default()
726            .fg(scheme.badge_fg_dark)
727            .bg(bg_color)
728            .bold(),
729    )
730}
731
732/// Render a filter/group badge showing current state
733#[must_use]
734pub fn filter_badge(label: &str, value: &str) -> Vec<Span<'static>> {
735    let scheme = colors();
736    vec![
737        Span::styled(format!("{label}: "), Style::default().fg(scheme.text_muted)),
738        Span::styled(
739            format!(" {value} "),
740            Style::default()
741                .fg(scheme.badge_fg_dark)
742                .bg(scheme.accent)
743                .bold(),
744        ),
745    ]
746}
747
748// ============================================================================
749// Mode Indicator
750// ============================================================================
751
752/// Render a mode indicator badge
753#[must_use]
754pub fn mode_badge(mode: &str) -> Span<'static> {
755    let scheme = colors();
756    let color = match mode.to_lowercase().as_str() {
757        "diff" => scheme.modified,
758        "view" => scheme.primary,
759        "multi-diff" | "multidiff" => scheme.added,
760        "timeline" => scheme.secondary,
761        "matrix" => scheme.high,
762        _ => scheme.muted,
763    };
764
765    Span::styled(
766        format!(" {} ", mode.to_uppercase()),
767        Style::default().fg(scheme.badge_fg_dark).bg(color).bold(),
768    )
769}
770
771// ============================================================================
772// Footer Hints
773// ============================================================================
774
775/// Tab-specific footer hints
776pub struct FooterHints;
777
778impl FooterHints {
779    /// Get hints for a specific tab in diff mode
780    #[must_use]
781    pub fn for_diff_tab(tab: &str) -> Vec<(&'static str, &'static str)> {
782        let mut hints = Self::global();
783
784        match tab.to_lowercase().as_str() {
785            "components" => {
786                hints.insert(0, ("f", "filter: All→Added→Removed→Modified"));
787                hints.insert(1, ("s", "sort: Name→Version→Ecosystem"));
788            }
789            "dependencies" => {
790                hints.insert(0, ("t", "toggle transitive"));
791                hints.insert(1, ("h", "toggle highlight"));
792                hints.insert(2, ("Enter", "expand/collapse"));
793                hints.insert(3, ("c", "go to component"));
794            }
795            "licenses" => {
796                hints.insert(0, ("g", "group: License→Component→Compat"));
797            }
798            "vulnerabilities" | "vulns" => {
799                hints.insert(0, ("f", "filter: All→Intro→Resolved→Critical→High"));
800            }
801            "sidebyside" | "side-by-side" | "diff" => {
802                hints.insert(0, ("←→/p", "switch panel"));
803                hints.insert(1, ("↑↓/jk", "scroll focused"));
804                hints.insert(2, ("J/K", "scroll both"));
805            }
806            "quality" => {
807                hints.insert(0, ("v", "view: Summary→Metrics→Recs"));
808                hints.insert(1, ("↑↓", "select recommendation"));
809            }
810            "graphchanges" | "graph" => {
811                hints.insert(0, ("↑↓/jk", "select change"));
812                hints.insert(1, ("PgUp/Dn", "page scroll"));
813                hints.insert(2, ("Home/End", "first/last"));
814            }
815            _ => {}
816        }
817
818        hints
819    }
820
821    /// Get hints for a specific tab in view mode
822    #[must_use]
823    pub fn for_view_tab(tab: &str) -> Vec<(&'static str, &'static str)> {
824        let mut hints = Self::global();
825
826        match tab.to_lowercase().as_str() {
827            "tree" | "components" => {
828                hints.insert(0, ("g", "group: Eco→License→Vuln→Flat"));
829                hints.insert(1, ("f", "filter: All→HasVuln→Critical"));
830                hints.insert(2, ("p", "toggle panel focus"));
831                hints.insert(3, ("Enter", "expand/select"));
832                hints.insert(4, ("[ ]", "detail tabs"));
833            }
834            "vulnerabilities" | "vulns" => {
835                hints.insert(0, ("f", "filter: All→Critical→High"));
836                hints.insert(1, ("g", "group: Severity→Component→Flat"));
837                hints.insert(2, ("d", "deduplicate by CVE"));
838                hints.insert(3, ("Enter", "jump to component"));
839            }
840            "licenses" => {
841                hints.insert(0, ("g", "group: License→Category"));
842            }
843            "dependencies" => {
844                hints.insert(0, ("Enter/→", "expand"));
845                hints.insert(1, ("←", "collapse"));
846            }
847            "quality" => {
848                hints.insert(0, ("v", "view: Summary→Metrics→Recs"));
849                hints.insert(1, ("↑↓", "select recommendation"));
850            }
851            "source" => {
852                hints.insert(0, ("v", "tree/raw"));
853                hints.insert(1, ("p", "panel focus"));
854                hints.insert(2, ("H/L", "collapse/expand all"));
855                hints.insert(3, ("Enter", "expand/jump"));
856            }
857            _ => {}
858        }
859
860        hints
861    }
862
863    /// Global hints (always shown)
864    #[must_use]
865    pub fn global() -> Vec<(&'static str, &'static str)> {
866        vec![
867            ("Tab", "switch"),
868            ("↑↓/jk", "navigate"),
869            ("/", "search"),
870            ("e", "export"),
871            ("l", "legend"),
872            ("T", "theme"),
873            ("?", "help"),
874            ("q", "quit"),
875        ]
876    }
877}
878
879/// Render footer hints as spans
880#[must_use]
881pub fn render_footer_hints(hints: &[(&str, &str)]) -> Vec<Span<'static>> {
882    let mut spans = Vec::new();
883
884    for (i, (key, desc)) in hints.iter().enumerate() {
885        if i > 0 {
886            spans.push(Span::raw(" "));
887        }
888        spans.push(Span::styled(format!("[{key}]"), Styles::shortcut_key()));
889        spans.push(Span::styled(desc.to_string(), Styles::shortcut_desc()));
890    }
891
892    spans
893}