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