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