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    /// True for the NO_COLOR / monochrome scheme: hue-dependent helpers
65    /// (severity tints, KEV/dependency badges) must return `Color::Reset`.
66    pub monochrome: bool,
67}
68
69impl Default for ColorScheme {
70    fn default() -> Self {
71        Self::dark()
72    }
73}
74
75impl ColorScheme {
76    /// Const dark theme for static initialization
77    const fn dark_const() -> Self {
78        Self {
79            // Change status
80            added: Color::Green,
81            removed: Color::Red,
82            modified: Color::Yellow,
83            unchanged: Color::Gray,
84
85            // Severity
86            critical: Color::Magenta,
87            high: Color::Red,
88            medium: Color::Yellow,
89            low: Color::Cyan,
90            info: Color::Blue,
91
92            // License categories
93            permissive: Color::Green,
94            copyleft: Color::Yellow,
95            weak_copyleft: Color::Cyan,
96            proprietary: Color::Red,
97            unknown_license: Color::DarkGray,
98
99            // UI elements
100            primary: Color::Cyan,
101            secondary: Color::Blue,
102            accent: Color::Yellow,
103            muted: Color::DarkGray,
104            border: Color::DarkGray,
105            border_focused: Color::Cyan,
106            background: Color::Reset,
107            background_alt: Color::Rgb(30, 30, 40),
108            text: Color::White,
109            text_muted: Color::Gray,
110            selection: Color::Rgb(50, 50, 70),
111            highlight: Color::Yellow,
112
113            // Status
114            success: Color::Green,
115            warning: Color::Yellow,
116            error: Color::Red,
117
118            // Badge foregrounds
119            badge_fg_dark: Color::Black,
120            badge_fg_light: Color::White,
121
122            // Side-by-side view colors
123            selection_bg: Color::Rgb(60, 60, 80),
124            search_highlight_bg: Color::Rgb(100, 80, 0),
125            error_bg: Color::Rgb(80, 30, 30),
126            success_bg: Color::Rgb(30, 80, 30),
127
128            // Source view scope highlighting
129            scope_bg: Color::Rgb(35, 35, 50),
130
131            monochrome: false,
132        }
133    }
134
135    /// Dark theme (default)
136    #[must_use]
137    pub const fn dark() -> Self {
138        Self {
139            // Change status
140            added: Color::Green,
141            removed: Color::Red,
142            modified: Color::Yellow,
143            unchanged: Color::Gray,
144
145            // Severity
146            critical: Color::Magenta,
147            high: Color::Red,
148            medium: Color::Yellow,
149            low: Color::Cyan,
150            info: Color::Blue,
151
152            // License categories
153            permissive: Color::Green,
154            copyleft: Color::Yellow,
155            weak_copyleft: Color::Cyan,
156            proprietary: Color::Red,
157            unknown_license: Color::DarkGray,
158
159            // UI elements
160            primary: Color::Cyan,
161            secondary: Color::Blue,
162            accent: Color::Yellow,
163            muted: Color::DarkGray,
164            border: Color::DarkGray,
165            border_focused: Color::Cyan,
166            background: Color::Reset,
167            background_alt: Color::Rgb(30, 30, 40),
168            text: Color::White,
169            text_muted: Color::Gray,
170            selection: Color::Rgb(50, 50, 70),
171            highlight: Color::Yellow,
172
173            // Status
174            success: Color::Green,
175            warning: Color::Yellow,
176            error: Color::Red,
177
178            // Badge foregrounds
179            badge_fg_dark: Color::Black,
180            badge_fg_light: Color::White,
181
182            // Side-by-side view colors
183            selection_bg: Color::Rgb(60, 60, 80),
184            search_highlight_bg: Color::Rgb(100, 80, 0),
185            error_bg: Color::Rgb(80, 30, 30),
186            success_bg: Color::Rgb(30, 80, 30),
187
188            // Source view scope highlighting
189            scope_bg: Color::Rgb(35, 35, 50),
190
191            monochrome: false,
192        }
193    }
194
195    /// Light theme
196    #[must_use]
197    pub const fn light() -> Self {
198        Self {
199            // Change status
200            added: Color::Rgb(0, 128, 0),
201            removed: Color::Rgb(200, 0, 0),
202            modified: Color::Rgb(180, 140, 0),
203            unchanged: Color::Rgb(100, 100, 100),
204
205            // Severity
206            critical: Color::Rgb(128, 0, 128),
207            high: Color::Rgb(200, 0, 0),
208            medium: Color::Rgb(180, 140, 0),
209            low: Color::Rgb(0, 128, 128),
210            info: Color::Rgb(0, 0, 200),
211
212            // License categories
213            permissive: Color::Rgb(0, 128, 0),
214            copyleft: Color::Rgb(180, 140, 0),
215            weak_copyleft: Color::Rgb(0, 128, 128),
216            proprietary: Color::Rgb(200, 0, 0),
217            unknown_license: Color::Rgb(100, 100, 100),
218
219            // UI elements
220            primary: Color::Rgb(0, 100, 150),
221            secondary: Color::Rgb(0, 0, 150),
222            accent: Color::Rgb(180, 140, 0),
223            muted: Color::Rgb(150, 150, 150),
224            border: Color::Rgb(180, 180, 180),
225            border_focused: Color::Rgb(0, 100, 150),
226            background: Color::Rgb(255, 255, 255),
227            background_alt: Color::Rgb(240, 240, 245),
228            text: Color::Rgb(30, 30, 30),
229            text_muted: Color::Rgb(100, 100, 100),
230            selection: Color::Rgb(200, 220, 240),
231            highlight: Color::Rgb(180, 140, 0),
232
233            // Status
234            success: Color::Rgb(0, 128, 0),
235            warning: Color::Rgb(180, 140, 0),
236            error: Color::Rgb(200, 0, 0),
237
238            // Badge foregrounds (reversed for light theme)
239            badge_fg_dark: Color::Rgb(30, 30, 30),
240            badge_fg_light: Color::White,
241
242            // Side-by-side view colors (lighter for light theme)
243            selection_bg: Color::Rgb(200, 220, 240),
244            search_highlight_bg: Color::Rgb(255, 230, 150),
245            error_bg: Color::Rgb(255, 200, 200),
246            success_bg: Color::Rgb(200, 255, 200),
247
248            // Source view scope highlighting
249            scope_bg: Color::Rgb(235, 240, 250),
250
251            monochrome: false,
252        }
253    }
254
255    /// High contrast theme (accessibility)
256    #[must_use]
257    pub const fn high_contrast() -> Self {
258        Self {
259            // Change status
260            added: Color::Green,
261            removed: Color::LightRed,
262            modified: Color::LightYellow,
263            unchanged: Color::White,
264
265            // Severity
266            critical: Color::LightMagenta,
267            high: Color::LightRed,
268            medium: Color::LightYellow,
269            low: Color::LightCyan,
270            info: Color::LightBlue,
271
272            // License categories
273            permissive: Color::LightGreen,
274            copyleft: Color::LightYellow,
275            weak_copyleft: Color::LightCyan,
276            proprietary: Color::LightRed,
277            unknown_license: Color::Gray,
278
279            // UI elements
280            primary: Color::LightCyan,
281            secondary: Color::LightBlue,
282            accent: Color::LightYellow,
283            muted: Color::Gray,
284            border: Color::White,
285            border_focused: Color::LightCyan,
286            background: Color::Black,
287            background_alt: Color::Rgb(20, 20, 20),
288            text: Color::White,
289            text_muted: Color::Gray,
290            // `selection` is used as a row *background* (with `.fg(text)`), and text is
291            // White here — so a White selection made selected rows invisible. Use a
292            // distinct dark blue-grey that stays readable under White text.
293            selection: Color::Rgb(80, 80, 120),
294            highlight: Color::LightYellow,
295
296            // Status
297            success: Color::LightGreen,
298            warning: Color::LightYellow,
299            error: Color::LightRed,
300
301            // Badge foregrounds
302            badge_fg_dark: Color::Black,
303            badge_fg_light: Color::White,
304
305            // Side-by-side view colors (high contrast)
306            selection_bg: Color::Rgb(50, 50, 80),
307            search_highlight_bg: Color::Rgb(120, 100, 0),
308            error_bg: Color::Rgb(100, 30, 30),
309            success_bg: Color::Rgb(30, 100, 30),
310
311            // Source view scope highlighting
312            scope_bg: Color::Rgb(25, 25, 40),
313
314            monochrome: false,
315        }
316    }
317
318    /// Monochrome theme honoring the `NO_COLOR` convention: grayscale only —
319    /// no named hue, no RGB. Structure is carried by weight (bold), glyphs, and
320    /// gray levels instead of color.
321    #[must_use]
322    pub const fn monochrome() -> Self {
323        Self {
324            // Change status
325            added: Color::Reset,
326            removed: Color::Reset,
327            modified: Color::Reset,
328            unchanged: Color::Reset,
329
330            // Severity
331            critical: Color::Reset,
332            high: Color::Reset,
333            medium: Color::Reset,
334            low: Color::Reset,
335            info: Color::Reset,
336
337            // License categories
338            permissive: Color::Reset,
339            copyleft: Color::Reset,
340            weak_copyleft: Color::Reset,
341            proprietary: Color::Reset,
342            unknown_license: Color::Reset,
343
344            // UI elements
345            primary: Color::Reset,
346            secondary: Color::Reset,
347            accent: Color::Reset,
348            muted: Color::DarkGray,
349            border: Color::DarkGray,
350            border_focused: Color::White,
351            background: Color::Reset,
352            background_alt: Color::Reset,
353            text: Color::Reset,
354            text_muted: Color::Gray,
355            // `selection` is a row *background*: DarkGray keeps selected rows
356            // visible (≠ text Reset, ≠ background Reset) without introducing hue.
357            selection: Color::DarkGray,
358            highlight: Color::Reset,
359
360            // Status
361            success: Color::Reset,
362            warning: Color::Reset,
363            error: Color::Reset,
364
365            // Badge foregrounds (badges fall back to bold)
366            badge_fg_dark: Color::Reset,
367            badge_fg_light: Color::Reset,
368
369            // Side-by-side view colors
370            selection_bg: Color::DarkGray,
371            search_highlight_bg: Color::Reset,
372            error_bg: Color::Reset,
373            success_bg: Color::Reset,
374
375            // Source view scope highlighting
376            scope_bg: Color::Reset,
377
378            monochrome: true,
379        }
380    }
381
382    /// Get color for severity level
383    #[must_use]
384    pub fn severity_color(&self, severity: &str) -> Color {
385        match severity.to_lowercase().as_str() {
386            "critical" => self.critical,
387            "high" => self.high,
388            "medium" | "moderate" => self.medium,
389            "low" => self.low,
390            "info" | "informational" | "none" => self.info,
391            _ => self.text_muted,
392        }
393    }
394
395    /// Get a subtle background tint for severity (used for row highlighting)
396    #[must_use]
397    /// Subtle row-background tint for a severity, adapted to the active theme.
398    ///
399    /// Dark themes use dark tints; light themes use pale tints so the dark row text
400    /// stays readable (the previous hardcoded dark tints made light-theme rows
401    /// unreadable — dark-on-dark).
402    pub fn severity_bg_tint(&self, severity: &str) -> Color {
403        // Monochrome: no tint at all (the dark-RGB fallback below would otherwise
404        // fire, since is_light() is false for a Reset background).
405        if self.monochrome {
406            return Color::Reset;
407        }
408        if self.is_light() {
409            match severity.to_lowercase().as_str() {
410                "critical" => Color::Rgb(250, 228, 250),
411                "high" => Color::Rgb(255, 226, 226),
412                "medium" => Color::Rgb(255, 247, 214),
413                "low" => Color::Rgb(222, 244, 248),
414                _ => Color::Reset,
415            }
416        } else {
417            match severity.to_lowercase().as_str() {
418                "critical" => Color::Rgb(50, 15, 50),
419                "high" => Color::Rgb(50, 15, 15),
420                "medium" => Color::Rgb(45, 40, 10),
421                "low" => Color::Rgb(15, 35, 40),
422                _ => Color::Reset,
423            }
424        }
425    }
426
427    /// Whether the theme has a light background (implying dark text), so tints should
428    /// be light rather than dark. Dark themes use `Color::Reset`/`Black` backgrounds
429    /// (not matched here); the light theme uses a bright RGB background.
430    fn is_light(&self) -> bool {
431        matches!(
432            self.background,
433            Color::Rgb(r, g, b) if u16::from(r) + u16::from(g) + u16::from(b) > 480
434        )
435    }
436
437    /// Get color for change status
438    #[must_use]
439    pub fn change_color(&self, status: &str) -> Color {
440        match status.to_lowercase().as_str() {
441            "added" | "new" | "introduced" => self.added,
442            "removed" | "deleted" | "resolved" => self.removed,
443            "modified" | "changed" | "updated" => self.modified,
444            _ => self.unchanged,
445        }
446    }
447
448    /// Get appropriate foreground color for severity badges
449    /// Returns light fg for dark backgrounds (critical, high, info) and dark fg for bright backgrounds
450    #[must_use]
451    pub fn severity_badge_fg(&self, severity: &str) -> Color {
452        match severity.to_lowercase().as_str() {
453            "critical" | "high" | "info" | "informational" => self.badge_fg_light,
454            _ => self.badge_fg_dark,
455        }
456    }
457
458    /// Get KEV (Known Exploited Vulnerabilities) badge color
459    /// Returns a bright red/orange color to indicate active exploitation
460    #[must_use]
461    pub fn kev(&self) -> Color {
462        if self.monochrome {
463            Color::Reset
464        } else {
465            Color::Rgb(255, 100, 50) // Bright orange-red for urgency
466        }
467    }
468
469    /// Get KEV badge foreground color
470    #[must_use]
471    pub const fn kev_badge_fg(&self) -> Color {
472        self.badge_fg_dark
473    }
474
475    /// Get direct dependency badge background color (green - easy to fix)
476    #[must_use]
477    pub fn direct_dep(&self) -> Color {
478        if self.monochrome {
479            Color::Reset
480        } else {
481            Color::Rgb(46, 160, 67) // GitHub green
482        }
483    }
484
485    /// Get transitive dependency badge background color (gray - harder to fix)
486    #[must_use]
487    pub fn transitive_dep(&self) -> Color {
488        if self.monochrome {
489            Color::Reset
490        } else {
491            Color::Rgb(110, 118, 129) // Muted gray
492        }
493    }
494
495    /// Get appropriate foreground color for change status badges
496    /// All change colors (green, red, yellow) work best with dark foreground
497    #[must_use]
498    pub const fn change_badge_fg(&self) -> Color {
499        self.badge_fg_dark
500    }
501
502    /// Pick a readable badge foreground for an arbitrary badge background:
503    /// bright ANSI colors and high-luminance RGB get the dark foreground,
504    /// everything else the light one.
505    #[must_use]
506    pub fn badge_fg_for(&self, bg: Color) -> Color {
507        match bg {
508            Color::Yellow
509            | Color::LightYellow
510            | Color::Cyan
511            | Color::LightCyan
512            | Color::Green
513            | Color::LightGreen
514            | Color::White
515            | Color::Gray => self.badge_fg_dark,
516            Color::Rgb(r, g, b) => {
517                // Standard perceived-luminance approximation (ITU-R BT.601).
518                let luminance =
519                    (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
520                if luminance > 128 {
521                    self.badge_fg_dark
522                } else {
523                    self.badge_fg_light
524                }
525            }
526            _ => self.badge_fg_light,
527        }
528    }
529
530    /// Get appropriate foreground color for license category badges
531    #[must_use]
532    pub fn license_badge_fg(&self, category: &str) -> Color {
533        match category.to_lowercase().as_str() {
534            "proprietary" | "commercial" => self.badge_fg_light,
535            _ => self.badge_fg_dark,
536        }
537    }
538
539    /// Chart color palette for visualizations
540    #[must_use]
541    pub const fn chart_palette(&self) -> [Color; 5] {
542        [
543            self.primary,
544            self.success,
545            self.warning,
546            self.critical,
547            self.secondary,
548        ]
549    }
550}
551
552/// Global theme instance (runtime switchable)
553static THEME: RwLock<Theme> = RwLock::new(Theme::dark_const());
554
555/// Theme configuration
556#[derive(Debug, Clone)]
557pub struct Theme {
558    pub colors: ColorScheme,
559    pub name: &'static str,
560}
561
562impl Default for Theme {
563    fn default() -> Self {
564        Self::dark()
565    }
566}
567
568impl Theme {
569    /// Const dark theme for static initialization
570    const fn dark_const() -> Self {
571        Self {
572            colors: ColorScheme::dark_const(),
573            name: "dark",
574        }
575    }
576
577    #[must_use]
578    pub const fn dark() -> Self {
579        Self {
580            colors: ColorScheme::dark(),
581            name: "dark",
582        }
583    }
584
585    #[must_use]
586    pub const fn light() -> Self {
587        Self {
588            colors: ColorScheme::light(),
589            name: "light",
590        }
591    }
592
593    #[must_use]
594    pub const fn high_contrast() -> Self {
595        Self {
596            colors: ColorScheme::high_contrast(),
597            name: "high-contrast",
598        }
599    }
600
601    #[must_use]
602    pub const fn monochrome() -> Self {
603        Self {
604            colors: ColorScheme::monochrome(),
605            name: "monochrome",
606        }
607    }
608
609    #[must_use]
610    pub fn from_name(name: &str) -> Self {
611        match name.to_lowercase().as_str() {
612            "light" => Self::light(),
613            "high-contrast" | "highcontrast" | "hc" => Self::high_contrast(),
614            "monochrome" | "mono" => Self::monochrome(),
615            _ => Self::dark(),
616        }
617    }
618
619    /// Get the next theme in the rotation. Monochrome is sticky: it is only
620    /// entered via `NO_COLOR` (or explicit preference), and the T-toggle must
621    /// not reintroduce color for those users.
622    #[must_use]
623    pub fn next(&self) -> Self {
624        match self.name {
625            "dark" => Self::light(),
626            "light" => Self::high_contrast(),
627            "monochrome" => Self::monochrome(),
628            _ => Self::dark(),
629        }
630    }
631}
632
633/// Get the current theme name
634pub fn current_theme_name() -> &'static str {
635    THEME.read().expect("THEME lock not poisoned").name
636}
637
638/// Set the current theme
639pub fn set_theme(theme: Theme) {
640    *THEME.write().expect("THEME lock not poisoned") = theme;
641}
642
643/// Resolve the theme to use at TUI startup, honoring the `NO_COLOR` convention that
644/// the rest of the CLI respects: when `NO_COLOR` is set in the environment, force the
645/// monochrome theme (grayscale only, no hue) regardless of the saved preference.
646/// Otherwise use the saved theme name.
647#[must_use]
648pub fn startup_theme(no_color_flag: bool, prefs_name: &str) -> Theme {
649    startup_theme_for(no_color_flag || no_color_env(), prefs_name)
650}
651
652/// Whether the `NO_COLOR` convention asks for monochrome.
653///
654/// Per the convention, the variable counts only when present AND non-empty —
655/// `NO_COLOR=` means "not set". This matches the check the CLI applies to log
656/// and report output, so one invocation cannot be monochrome in one surface
657/// and colored in another.
658fn no_color_env() -> bool {
659    std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
660}
661
662fn startup_theme_for(no_color: bool, prefs_name: &str) -> Theme {
663    if no_color {
664        Theme::monochrome()
665    } else {
666        Theme::from_name(prefs_name)
667    }
668}
669
670/// Toggle to the next theme in rotation (dark -> light -> high-contrast -> dark)
671pub fn toggle_theme() -> &'static str {
672    let mut theme = THEME.write().expect("THEME lock not poisoned");
673    *theme = theme.next();
674    theme.name
675}
676
677/// Convenience function to get current colors
678pub fn colors() -> ColorScheme {
679    THEME.read().expect("THEME lock not poisoned").colors
680}
681
682// ============================================================================
683// Style Helpers
684// ============================================================================
685
686/// Common style presets for consistent UI elements
687pub struct Styles;
688
689impl Styles {
690    /// Header title style
691    #[must_use]
692    pub fn header_title() -> Style {
693        Style::default().fg(colors().primary).bold()
694    }
695
696    /// Section title style
697    #[must_use]
698    pub fn section_title() -> Style {
699        Style::default().fg(colors().primary).bold()
700    }
701
702    /// Subsection title style
703    #[must_use]
704    pub fn subsection_title() -> Style {
705        Style::default().fg(colors().primary)
706    }
707
708    /// Normal text style
709    #[must_use]
710    pub fn text() -> Style {
711        Style::default().fg(colors().text)
712    }
713
714    /// Muted/secondary text style
715    #[must_use]
716    pub fn text_muted() -> Style {
717        Style::default().fg(colors().text_muted)
718    }
719
720    /// Label text style
721    #[must_use]
722    pub fn label() -> Style {
723        Style::default().fg(colors().muted)
724    }
725
726    /// Value text style (for data values)
727    #[must_use]
728    pub fn value() -> Style {
729        Style::default().fg(colors().text).bold()
730    }
731
732    /// Highlighted/accent style
733    #[must_use]
734    pub fn highlight() -> Style {
735        Style::default().fg(colors().highlight).bold()
736    }
737
738    /// Selection style (for selected items)
739    #[must_use]
740    pub fn selected() -> Style {
741        Style::default()
742            .bg(colors().selection)
743            .fg(colors().text)
744            .bold()
745    }
746
747    /// Border style (unfocused)
748    #[must_use]
749    pub fn border() -> Style {
750        Style::default().fg(colors().border)
751    }
752
753    /// Border style (focused)
754    #[must_use]
755    pub fn border_focused() -> Style {
756        Style::default().fg(colors().border_focused)
757    }
758
759    /// Status bar background style
760    #[must_use]
761    pub fn status_bar() -> Style {
762        Style::default().bg(colors().background_alt)
763    }
764
765    /// Keyboard shortcut style
766    #[must_use]
767    pub fn shortcut_key() -> Style {
768        Style::default().fg(colors().accent)
769    }
770
771    /// Shortcut description style
772    #[must_use]
773    pub fn shortcut_desc() -> Style {
774        Style::default().fg(colors().text_muted)
775    }
776
777    /// Success style
778    #[must_use]
779    pub fn success() -> Style {
780        Style::default().fg(colors().success)
781    }
782
783    /// Warning style
784    #[must_use]
785    pub fn warning() -> Style {
786        Style::default().fg(colors().warning)
787    }
788
789    /// Error style
790    #[must_use]
791    pub fn error() -> Style {
792        Style::default().fg(colors().error)
793    }
794
795    /// Added item style
796    #[must_use]
797    pub fn added() -> Style {
798        Style::default().fg(colors().added)
799    }
800
801    /// Removed item style
802    #[must_use]
803    pub fn removed() -> Style {
804        Style::default().fg(colors().removed)
805    }
806
807    /// Modified item style
808    #[must_use]
809    pub fn modified() -> Style {
810        Style::default().fg(colors().modified)
811    }
812
813    /// Critical severity style
814    #[must_use]
815    pub fn critical() -> Style {
816        Style::default().fg(colors().critical).bold()
817    }
818
819    /// High severity style
820    #[must_use]
821    pub fn high() -> Style {
822        Style::default().fg(colors().high).bold()
823    }
824
825    /// Medium severity style
826    #[must_use]
827    pub fn medium() -> Style {
828        Style::default().fg(colors().medium)
829    }
830
831    /// Low severity style
832    #[must_use]
833    pub fn low() -> Style {
834        Style::default().fg(colors().low)
835    }
836}
837
838// ============================================================================
839// Badge Rendering Helpers
840// ============================================================================
841
842/// Render a status badge with consistent styling
843#[must_use]
844pub fn status_badge(status: &str) -> Span<'static> {
845    let scheme = colors();
846    let (label, color, symbol) = match status.to_lowercase().as_str() {
847        "added" | "new" | "introduced" => ("ADDED", scheme.added, "+"),
848        "removed" | "deleted" | "resolved" => ("REMOVED", scheme.removed, "-"),
849        "modified" | "changed" | "updated" => ("MODIFIED", scheme.modified, "~"),
850        _ => ("UNCHANGED", scheme.unchanged, "="),
851    };
852
853    Span::styled(
854        format!(" {symbol} {label} "),
855        Style::default()
856            .fg(scheme.change_badge_fg())
857            .bg(color)
858            .bold(),
859    )
860}
861
862/// Render a severity badge with consistent styling
863#[must_use]
864pub fn severity_badge(severity: &str) -> Span<'static> {
865    let scheme = colors();
866    let (label, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
867        "critical" => ("CRITICAL", scheme.critical, false),
868        "high" => ("HIGH", scheme.high, false),
869        "medium" | "moderate" => ("MEDIUM", scheme.medium, false),
870        "low" => ("LOW", scheme.low, false),
871        "info" | "informational" => ("INFO", scheme.info, false),
872        "none" => ("NONE", scheme.muted, false),
873        _ => ("UNKNOWN", scheme.muted, true),
874    };
875    let fg_color = scheme.severity_badge_fg(severity);
876
877    let style = if is_unknown {
878        Style::default().fg(fg_color).bg(bg_color).dim()
879    } else {
880        Style::default().fg(fg_color).bg(bg_color).bold()
881    };
882
883    Span::styled(format!(" {label} "), style)
884}
885
886/// Render a compact severity indicator (single char)
887#[must_use]
888pub fn severity_indicator(severity: &str) -> Span<'static> {
889    let scheme = colors();
890    let (symbol, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
891        "critical" => ("C", scheme.critical, false),
892        "high" => ("H", scheme.high, false),
893        "medium" | "moderate" => ("M", scheme.medium, false),
894        "low" => ("L", scheme.low, false),
895        "info" | "informational" => ("I", scheme.info, false),
896        "none" => ("-", scheme.muted, false),
897        _ => ("U", scheme.muted, true),
898    };
899    let fg_color = scheme.severity_badge_fg(severity);
900
901    let style = if is_unknown {
902        Style::default().fg(fg_color).bg(bg_color).dim()
903    } else {
904        Style::default().fg(fg_color).bg(bg_color).bold()
905    };
906
907    Span::styled(format!(" {symbol} "), style)
908}
909
910/// Render a count badge
911#[must_use]
912pub fn count_badge(count: usize, bg_color: Color) -> Span<'static> {
913    let scheme = colors();
914    Span::styled(
915        format!(" {count} "),
916        Style::default()
917            .fg(scheme.badge_fg_for(bg_color))
918            .bg(bg_color)
919            .bold(),
920    )
921}
922
923/// Render a filter/group badge showing current state
924#[must_use]
925pub fn filter_badge(label: &str, value: &str) -> Vec<Span<'static>> {
926    let scheme = colors();
927    vec![
928        Span::styled(format!("{label}: "), Style::default().fg(scheme.text_muted)),
929        Span::styled(
930            format!(" {value} "),
931            Style::default()
932                .fg(scheme.badge_fg_dark)
933                .bg(scheme.accent)
934                .bold(),
935        ),
936    ]
937}
938
939// ============================================================================
940// Mode Indicator
941// ============================================================================
942
943/// Render a mode indicator badge
944#[must_use]
945pub fn mode_badge(mode: &str) -> Span<'static> {
946    let scheme = colors();
947    let color = match mode.to_lowercase().as_str() {
948        "diff" => scheme.modified,
949        "view" => scheme.primary,
950        "multi-diff" | "multidiff" => scheme.added,
951        "timeline" => scheme.secondary,
952        "matrix" => scheme.high,
953        _ => scheme.muted,
954    };
955
956    Span::styled(
957        format!(" {} ", mode.to_uppercase()),
958        Style::default().fg(scheme.badge_fg_dark).bg(color).bold(),
959    )
960}
961
962// ============================================================================
963// Footer Hints
964// ============================================================================
965
966/// Tab-specific footer hints
967pub struct FooterHints;
968
969impl FooterHints {
970    /// Hints for the multi-comparison modes. The tail is exactly
971    /// `GLOBAL_COUNT` honest globals — the multi modes have no diff tab bar,
972    /// help overlay, or export dialog, so the tabbed globals don't apply.
973    #[must_use]
974    pub fn for_multi_mode(mode: &str) -> Vec<(&'static str, &'static str)> {
975        let mut hints: Vec<(&'static str, &'static str)> = match mode {
976            "matrix" => vec![
977                ("t", "threshold"),
978                ("z", "focus"),
979                ("H", "highlight"),
980                ("C", "clusters"),
981                ("Enter", "diff"),
982                ("x", "export"),
983            ],
984            "timeline" => vec![
985                ("g", "jump"),
986                ("d", "diff"),
987                ("t", "stats"),
988                ("f", "filter"),
989                ("m", "metric"),
990            ],
991            _ => vec![
992                ("f", "filter"),
993                ("s", "sort"),
994                ("v", "variable"),
995                ("h", "heatmap"),
996                ("x", "cross-target"),
997            ],
998        };
999        hints.extend([
1000            ("Tab", "panel"),
1001            ("/", "search"),
1002            ("V", "views"),
1003            ("K", "keys"),
1004            ("q", "quit"),
1005        ]);
1006        hints
1007    }
1008
1009    /// Get hints for a specific tab in view mode
1010    #[must_use]
1011    pub fn for_view_tab(tab: &str) -> Vec<(&'static str, &'static str)> {
1012        let mut hints = Self::global();
1013
1014        match tab.to_lowercase().as_str() {
1015            "overview" => {
1016                // 'P' is otherwise undiscoverable, yet it is the only way to
1017                // reach the CBOM/AI-BOM tab sets on a misdetected document.
1018                hints.insert(0, ("P", "cycle profile"));
1019            }
1020            "tree" | "components" => {
1021                // g/f/filter already shown in filter bar at top.
1022                // NOTE: view/ui.rs drops the "1-4" hint unless the detail
1023                // panel is focused (digits switch app tabs otherwise).
1024                hints.insert(0, ("p", "panel"));
1025                hints.insert(1, ("Enter", "select"));
1026                hints.insert(2, ("1-4", "detail tabs"));
1027                // '/' here is an inline list filter, not the jump-to search
1028                // palette the other tabs open — label it honestly.
1029                if let Some(slash) = hints.iter_mut().find(|(k, _)| *k == "/") {
1030                    slash.1 = "filter";
1031                }
1032            }
1033            "vulnerabilities" | "vulns" => {
1034                hints.insert(0, ("f", "filter"));
1035                hints.insert(1, ("s", "sort"));
1036                hints.insert(2, ("g", "group"));
1037                hints.insert(3, ("d", "dedup"));
1038                hints.insert(4, ("Enter", "component"));
1039            }
1040            "licenses" => {
1041                hints.insert(0, ("g", "group"));
1042                hints.insert(1, ("Enter", "inspect"));
1043                hints.insert(2, ("K/J", "scroll"));
1044            }
1045            "dependencies" => {
1046                hints.insert(0, ("Enter", "expand/inspect"));
1047                hints.insert(1, ("←", "collapse"));
1048                hints.insert(2, ("x/X", "fold all"));
1049                hints.insert(3, ("p", "panel"));
1050                hints.insert(4, ("J/K", "scroll"));
1051            }
1052            "quality" => {
1053                hints.insert(0, ("v", "view"));
1054            }
1055            "compliance" => {
1056                hints.insert(0, ("f", "filter"));
1057                hints.insert(1, ("←→", "standard"));
1058                hints.insert(2, ("↑↓", "select"));
1059            }
1060            "source" => {
1061                hints.insert(0, ("v", "tree/raw"));
1062                hints.insert(1, ("p", "panel"));
1063                hints.insert(2, ("H/L", "fold all"));
1064                hints.insert(3, ("Enter", "select"));
1065            }
1066            // No ("Enter", "detail") hints here: Enter is a no-op on the
1067            // four CBOM asset tabs (handle_enter ignores them) and the
1068            // detail panel is already always visible — advertising a dead
1069            // key is dishonest. Mirrors what Models/Datasets already do.
1070            "algorithms" => {
1071                hints.insert(0, ("s", "sort"));
1072                hints.insert(1, ("↑↓", "select"));
1073            }
1074            "certificates" | "keys" | "protocols" => {
1075                hints.insert(0, ("↑↓", "select"));
1076            }
1077            "pqc-compliance" => {
1078                hints.insert(0, ("↑↓", "select"));
1079            }
1080            "models" | "datasets" => {
1081                hints.insert(0, ("↑↓", "select"));
1082                hints.insert(1, ("K/J", "scroll detail"));
1083            }
1084            "ai-readiness" => {
1085                hints.insert(0, ("↑↓", "scroll"));
1086            }
1087            _ => {}
1088        }
1089
1090        hints
1091    }
1092
1093    /// Global hints (always shown)
1094    #[must_use]
1095    pub fn global() -> Vec<(&'static str, &'static str)> {
1096        vec![
1097            ("Tab", "switch"),
1098            ("/", "search"),
1099            ("e", "export"),
1100            ("?", "help"),
1101            ("q", "quit"),
1102        ]
1103    }
1104
1105    /// Number of global hints (used to insert separator).
1106    pub const GLOBAL_COUNT: usize = 5;
1107}
1108
1109/// Measured footer width of a hint list as rendered by
1110/// [`render_footer_hints`]: per hint, a padded key badge (key width + 2),
1111/// a space, and the description; 1-column gaps between hints; plus the
1112/// 2-column "│ " section separator while any tab-specific hint remains.
1113#[must_use]
1114pub fn footer_hints_width(hints: &[(&str, &str)]) -> u16 {
1115    use unicode_width::UnicodeWidthStr;
1116    let mut w: u16 = 0;
1117    for (i, (key, desc)) in hints.iter().enumerate() {
1118        if i > 0 {
1119            w += 1;
1120        }
1121        // Badge " {key} " (key + 2) immediately followed by the description.
1122        w += UnicodeWidthStr::width(*key) as u16 + 2 + UnicodeWidthStr::width(*desc) as u16;
1123    }
1124    if hints.len() > FooterHints::GLOBAL_COUNT {
1125        w += 2; // "│ " separator
1126    }
1127    w
1128}
1129
1130/// Fit a hint list into `max_width` columns by dropping tab-specific hints
1131/// from the END of the tab-specific block (the least-important,
1132/// latest-inserted ones). The trailing `GLOBAL_COUNT` global hints — the
1133/// always-valid `?`/`q` anchors — are never dropped.
1134///
1135/// Returns the kept hints and whether anything was elided (rendered as a
1136/// leading "… ").
1137#[must_use]
1138pub fn fit_footer_hints<'a>(
1139    hints: &[(&'a str, &'a str)],
1140    max_width: u16,
1141) -> (Vec<(&'a str, &'a str)>, bool) {
1142    let mut kept: Vec<(&str, &str)> = hints.to_vec();
1143    let mut elided = false;
1144    // Once anything is elided, the "… " prefix costs 2 more columns.
1145    while footer_hints_width(&kept) + if elided { 2 } else { 0 } > max_width
1146        && kept.len() > FooterHints::GLOBAL_COUNT
1147    {
1148        let tab_count = kept.len() - FooterHints::GLOBAL_COUNT;
1149        kept.remove(tab_count - 1);
1150        elided = true;
1151    }
1152    (kept, elided)
1153}
1154
1155/// Render footer hints as spans with badge-style keys.
1156///
1157/// If the hint list contains more items than `FooterHints::GLOBAL_COUNT`,
1158/// a `│` separator is inserted between tab-specific and global hints. When
1159/// `elided` is true (some hints were dropped by [`fit_footer_hints`]), a
1160/// muted "… " prefix marks the omission.
1161#[must_use]
1162pub fn render_footer_hints(hints: &[(&str, &str)], elided: bool) -> Vec<Span<'static>> {
1163    let scheme = colors();
1164    let mut spans = Vec::new();
1165    if elided {
1166        spans.push(Span::styled("\u{2026} ", Style::default().fg(scheme.muted)));
1167    }
1168    let tab_count = hints.len().saturating_sub(FooterHints::GLOBAL_COUNT);
1169
1170    for (i, (key, desc)) in hints.iter().enumerate() {
1171        if i > 0 {
1172            spans.push(Span::raw(" "));
1173        }
1174        // Insert separator between tab-specific and global hints
1175        if i == tab_count && tab_count > 0 {
1176            spans.push(Span::styled("│ ", Style::default().fg(scheme.muted)));
1177        }
1178        spans.push(Span::styled(
1179            format!(" {key} "),
1180            Style::default()
1181                .fg(scheme.badge_fg_dark)
1182                .bg(scheme.accent)
1183                .bold(),
1184        ));
1185        spans.push(Span::styled(
1186            desc.to_string(),
1187            Style::default().fg(scheme.text_muted),
1188        ));
1189    }
1190
1191    spans
1192}
1193
1194#[cfg(test)]
1195mod a11y_tests {
1196    use super::{ColorScheme, startup_theme_for};
1197    use ratatui::style::Color;
1198
1199    #[test]
1200    fn severity_tint_unchanged_for_dark_and_high_contrast() {
1201        // Snapshot-safe: dark/high-contrast tints are the original dark values.
1202        assert_eq!(
1203            ColorScheme::dark().severity_bg_tint("critical"),
1204            Color::Rgb(50, 15, 50)
1205        );
1206        assert_eq!(
1207            ColorScheme::high_contrast().severity_bg_tint("high"),
1208            Color::Rgb(50, 15, 15)
1209        );
1210    }
1211
1212    #[test]
1213    fn severity_tint_is_pale_for_light_theme() {
1214        // Light theme must use pale tints so the dark row text stays readable.
1215        for sev in ["critical", "high", "medium", "low"] {
1216            match ColorScheme::light().severity_bg_tint(sev) {
1217                Color::Rgb(r, g, b) => assert!(
1218                    u16::from(r) + u16::from(g) + u16::from(b) > 480,
1219                    "light {sev} tint must be pale, got {r},{g},{b}"
1220                ),
1221                other => panic!("expected an RGB tint for {sev}, got {other:?}"),
1222            }
1223        }
1224    }
1225
1226    #[test]
1227    fn no_color_forces_monochrome_theme() {
1228        assert_eq!(startup_theme_for(true, "dark").name, "monochrome");
1229        assert_eq!(startup_theme_for(true, "light").name, "monochrome");
1230        assert_eq!(startup_theme_for(false, "light").name, "light");
1231        assert_eq!(startup_theme_for(false, "dark").name, "dark");
1232    }
1233
1234    /// The `--no-color` FLAG must force monochrome on its own. It previously
1235    /// never reached the TUI at all: only the environment variable was
1236    /// consulted, so `--no-color` launched a fully colored TUI.
1237    #[test]
1238    fn no_color_flag_forces_monochrome_independently_of_env() {
1239        // Passing the resolved flag is enough, whatever the environment says.
1240        assert_eq!(
1241            super::startup_theme(true, "dark").name,
1242            "monochrome",
1243            "--no-color must force monochrome without relying on NO_COLOR"
1244        );
1245        assert_eq!(
1246            super::startup_theme(true, "high-contrast").name,
1247            "monochrome"
1248        );
1249    }
1250
1251    /// Monochrome must survive the `T` theme toggle: a user who asked for no
1252    /// color cannot be cycled back into a colored theme.
1253    #[test]
1254    fn monochrome_is_sticky_under_toggle() {
1255        let mono = super::startup_theme(true, "dark");
1256        assert_eq!(mono.next().name, "monochrome");
1257    }
1258
1259    /// Multi-mode footers must carry exactly GLOBAL_COUNT honest globals so
1260    /// the separator/fit logic works, with no dead keys ('?' and 'e' render
1261    /// only in the tabbed layout, unreachable in multi modes).
1262    #[test]
1263    fn for_multi_mode_hints_are_honest() {
1264        for mode in ["matrix", "timeline", "multi"] {
1265            let hints = super::FooterHints::for_multi_mode(mode);
1266            assert!(hints.len() > super::FooterHints::GLOBAL_COUNT, "{mode}");
1267            assert_eq!(hints.last(), Some(&("q", "quit")), "{mode}");
1268            let tail = &hints[hints.len() - super::FooterHints::GLOBAL_COUNT..];
1269            // The separator/fit contract depends on exactly this tail.
1270            assert_eq!(
1271                tail,
1272                [
1273                    ("Tab", "panel"),
1274                    ("/", "search"),
1275                    ("V", "views"),
1276                    ("K", "keys"),
1277                    ("q", "quit"),
1278                ],
1279                "{mode}"
1280            );
1281            assert!(
1282                !hints.contains(&("?", "help")) && !hints.contains(&("e", "export")),
1283                "{mode}: no dead keys"
1284            );
1285        }
1286        assert!(
1287            super::FooterHints::for_multi_mode("matrix").contains(&("x", "export")),
1288            "matrix keeps its real export key"
1289        );
1290    }
1291
1292    /// The two densest diff tabs must lead with their tab-specific power keys
1293    /// so width fitting sacrifices the memorized globals last. Primary order
1294    /// in ViewState::shortcuts() IS footer order now.
1295    #[test]
1296    fn footer_hints_lead_with_tab_keys() {
1297        use crate::tui::traits::ViewState;
1298        let primaries = |v: &dyn ViewState| -> Vec<(String, String)> {
1299            v.shortcuts()
1300                .into_iter()
1301                .filter(|s| s.primary)
1302                .map(|s| (s.key, s.description))
1303                .collect()
1304        };
1305        let sxs = primaries(&crate::tui::view_states::SideBySideView::new());
1306        assert_eq!(sxs[0], ("a".to_string(), "align".to_string()));
1307        assert!(sxs.iter().any(|(k, d)| k == "n/N" && d == "change"));
1308        let source = primaries(&crate::tui::view_states::SourceView::new());
1309        assert_eq!(source[0], ("u".to_string(), "collapse".to_string()));
1310        assert!(source.iter().any(|(k, _)| k == "z") && source.iter().any(|(k, _)| k == "m"));
1311    }
1312
1313    #[test]
1314    fn monochrome_is_sticky_under_theme_toggle() {
1315        // The T-toggle must not reintroduce color for NO_COLOR users.
1316        assert_eq!(super::Theme::monochrome().next().name, "monochrome");
1317        // ...and "mono" resolves as an alias.
1318        assert_eq!(super::Theme::from_name("mono").name, "monochrome");
1319    }
1320
1321    #[test]
1322    fn monochrome_scheme_is_hue_free() {
1323        let s = ColorScheme::monochrome();
1324        let grayscale = |c: Color| {
1325            matches!(
1326                c,
1327                Color::Reset | Color::Gray | Color::DarkGray | Color::White | Color::Black
1328            )
1329        };
1330        for (name, c) in [
1331            ("added", s.added),
1332            ("removed", s.removed),
1333            ("modified", s.modified),
1334            ("unchanged", s.unchanged),
1335            ("critical", s.critical),
1336            ("high", s.high),
1337            ("medium", s.medium),
1338            ("low", s.low),
1339            ("info", s.info),
1340            ("permissive", s.permissive),
1341            ("copyleft", s.copyleft),
1342            ("weak_copyleft", s.weak_copyleft),
1343            ("proprietary", s.proprietary),
1344            ("unknown_license", s.unknown_license),
1345            ("primary", s.primary),
1346            ("secondary", s.secondary),
1347            ("accent", s.accent),
1348            ("muted", s.muted),
1349            ("border", s.border),
1350            ("border_focused", s.border_focused),
1351            ("background", s.background),
1352            ("background_alt", s.background_alt),
1353            ("text", s.text),
1354            ("text_muted", s.text_muted),
1355            ("selection", s.selection),
1356            ("highlight", s.highlight),
1357            ("success", s.success),
1358            ("warning", s.warning),
1359            ("error", s.error),
1360            ("badge_fg_dark", s.badge_fg_dark),
1361            ("badge_fg_light", s.badge_fg_light),
1362            ("selection_bg", s.selection_bg),
1363            ("search_highlight_bg", s.search_highlight_bg),
1364            ("error_bg", s.error_bg),
1365            ("success_bg", s.success_bg),
1366            ("scope_bg", s.scope_bg),
1367        ] {
1368            assert!(grayscale(c), "monochrome slot {name} carries hue: {c:?}");
1369        }
1370    }
1371
1372    #[test]
1373    fn severity_tint_is_reset_in_monochrome() {
1374        let s = ColorScheme::monochrome();
1375        for sev in ["critical", "high", "medium", "low", "info"] {
1376            assert_eq!(s.severity_bg_tint(sev), Color::Reset);
1377        }
1378    }
1379
1380    #[test]
1381    fn monochrome_kev_and_dep_badges_are_reset() {
1382        let s = ColorScheme::monochrome();
1383        assert_eq!(s.kev(), Color::Reset);
1384        assert_eq!(s.direct_dep(), Color::Reset);
1385        assert_eq!(s.transitive_dep(), Color::Reset);
1386        // Colored themes keep their badge hues.
1387        assert_ne!(ColorScheme::dark().kev(), Color::Reset);
1388        assert_ne!(ColorScheme::dark().direct_dep(), Color::Reset);
1389        assert_ne!(ColorScheme::dark().transitive_dep(), Color::Reset);
1390    }
1391
1392    #[test]
1393    fn badge_fg_tracks_bg_luminance() {
1394        let s = ColorScheme::dark();
1395        // Bright ANSI backgrounds take the dark foreground.
1396        assert_eq!(s.badge_fg_for(Color::Yellow), s.badge_fg_dark);
1397        // Dark RGB backgrounds take the light foreground.
1398        assert_eq!(s.badge_fg_for(Color::Rgb(200, 0, 0)), s.badge_fg_light);
1399        // Pale RGB backgrounds (light-theme search highlight) take the dark one.
1400        assert_eq!(s.badge_fg_for(Color::Rgb(255, 230, 150)), s.badge_fg_dark);
1401        // Dark ANSI backgrounds take the light foreground.
1402        assert_eq!(s.badge_fg_for(Color::Red), s.badge_fg_light);
1403    }
1404
1405    /// count_badge must pick its foreground through badge_fg_for (bright bg ->
1406    /// dark fg, dark bg -> light fg); reverting to unconditional badge_fg_dark
1407    /// reintroduces unreadable dark-on-dark badges with no snapshot noticing.
1408    #[test]
1409    fn count_badge_routes_fg_through_badge_fg_for() {
1410        // count_badge reads the global theme; pin it to dark for determinism.
1411        super::set_theme(super::Theme::dark());
1412        let s = ColorScheme::dark();
1413        let on_bright = super::count_badge(3, Color::Yellow);
1414        assert_eq!(
1415            on_bright.style.fg,
1416            Some(s.badge_fg_dark),
1417            "a bright badge background must take the dark foreground through count_badge"
1418        );
1419        let on_dark = super::count_badge(3, Color::Rgb(120, 0, 0));
1420        assert_eq!(
1421            on_dark.style.fg,
1422            Some(s.badge_fg_light),
1423            "a dark badge background must take the light foreground — an unconditional badge_fg_dark regression renders unreadable badges"
1424        );
1425        assert_eq!(
1426            on_dark.style.bg,
1427            Some(Color::Rgb(120, 0, 0)),
1428            "count_badge must keep the requested badge background"
1429        );
1430    }
1431}
1432
1433#[cfg(test)]
1434mod hardcoded_color_guard {
1435    /// Render-site `Color::` literals bypass the theme and break
1436    /// light/high-contrast/monochrome rendering. This ratchet walks every
1437    /// `src/tui/**/*.rs` file (except this one, which defines the palette)
1438    /// and fails when a file gains a literal that isn't `Color::Reset`.
1439    ///
1440    /// Files with legitimate remaining uses (computed gradients, test
1441    /// assertions about concrete colors) are baselined below with their
1442    /// current counts; counts may only shrink. Delete an entry once its file
1443    /// reaches zero so the ratchet locks in.
1444    const BASELINE: &[(&str, usize)] = &[
1445        // score-gradient Rgb interpolation + a test's Rgb pattern match
1446        ("shared/quality.rs", 2),
1447        // test asserting the selected row bg is themed, not DarkGray
1448        ("view/ui/render_snapshot_tests.rs", 1),
1449        // test-only args to highlight_search_matches (2 lines x fg+bg)
1450        ("views/sidebyside.rs", 4),
1451    ];
1452
1453    fn color_literal_lines(src: &str) -> usize {
1454        // Count occurrences, not lines: `fg(Color::Red).bg(Color::Reset)` on
1455        // one line must still register the Red literal.
1456        src.lines()
1457            .map(|l| l.matches("Color::").count() - l.matches("Color::Reset").count())
1458            .sum()
1459    }
1460
1461    #[test]
1462    fn no_new_hardcoded_colors_in_tui() {
1463        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/tui");
1464        let mut stack = vec![root.clone()];
1465        let mut violations = Vec::new();
1466        let mut seen = 0usize;
1467        while let Some(dir) = stack.pop() {
1468            for entry in std::fs::read_dir(&dir).expect("read_dir under src/tui") {
1469                let path = entry.expect("dir entry").path();
1470                if path.is_dir() {
1471                    stack.push(path);
1472                    continue;
1473                }
1474                if path.extension().is_none_or(|e| e != "rs") {
1475                    continue;
1476                }
1477                let rel = path
1478                    .strip_prefix(&root)
1479                    .expect("walked from src/tui")
1480                    .to_string_lossy()
1481                    .replace('\\', "/");
1482                if rel == "theme.rs" {
1483                    continue;
1484                }
1485                seen += 1;
1486                let src = std::fs::read_to_string(&path).expect("read source file");
1487                let count = color_literal_lines(&src);
1488                let max = BASELINE
1489                    .iter()
1490                    .find(|(p, _)| *p == rel)
1491                    .map_or(0, |&(_, m)| m);
1492                if count > max {
1493                    violations.push(format!(
1494                        "{rel}: {count} raw Color:: lines (baseline {max}) — \
1495                         route colors through ColorScheme/theme helpers"
1496                    ));
1497                } else if max > 0 && count == 0 {
1498                    violations.push(format!(
1499                        "{rel}: now clean — delete its BASELINE entry to lock in the ratchet"
1500                    ));
1501                }
1502            }
1503        }
1504        assert!(seen > 50, "walked only {seen} files — wrong root?");
1505        assert!(violations.is_empty(), "\n{}", violations.join("\n"));
1506    }
1507}
1508
1509#[cfg(test)]
1510mod selection_contrast_tests {
1511    use super::ColorScheme;
1512
1513    /// `selection` is always applied as a row *background* (paired with `.fg(text)`),
1514    /// so in every theme it must differ from both `text` (else the selected row's text
1515    /// is invisible) and `background` (else the selection bar itself is invisible).
1516    /// Regression guard for the high-contrast White-on-White selection bug.
1517    fn assert_selection_visible(name: &str, s: &ColorScheme) {
1518        assert_ne!(
1519            s.selection, s.text,
1520            "{name}: selection == text — selected rows would be invisible"
1521        );
1522        assert_ne!(
1523            s.selection, s.background,
1524            "{name}: selection == background — the selection bar would be invisible"
1525        );
1526    }
1527
1528    #[test]
1529    fn selection_is_visible_in_every_theme() {
1530        assert_selection_visible("default", &ColorScheme::default());
1531        assert_selection_visible("dark", &ColorScheme::dark());
1532        assert_selection_visible("light", &ColorScheme::light());
1533        assert_selection_visible("high_contrast", &ColorScheme::high_contrast());
1534        assert_selection_visible("monochrome", &ColorScheme::monochrome());
1535    }
1536}
1537
1538#[cfg(test)]
1539mod footer_budget_tests {
1540    use super::{FooterHints, fit_footer_hints, footer_hints_width};
1541
1542    /// The global ?/q tail must survive any width squeeze; tab-specific
1543    /// hints drop from the end of their block first.
1544    #[test]
1545    fn fit_footer_hints_keeps_global_tail() {
1546        // Five tab hints ahead of the global tail (the shape every diff tab
1547        // footer now produces from its ViewState::shortcuts() primaries).
1548        let mut hints: Vec<(&str, &str)> = vec![
1549            ("f", "filter"),
1550            ("t", "transitive"),
1551            ("h", "highlight"),
1552            ("Enter", "expand"),
1553            ("c", "component"),
1554        ];
1555        hints.extend(FooterHints::global());
1556        let (kept, elided) = fit_footer_hints(&hints, 40);
1557        assert!(elided, "a 40-col budget must drop something");
1558        assert!(footer_hints_width(&kept) <= 40 || kept.len() == FooterHints::GLOBAL_COUNT);
1559        let tail: Vec<&str> = kept.iter().rev().take(2).map(|(k, _)| *k).collect();
1560        assert_eq!(tail, ["q", "?"], "the global tail must survive: {kept:?}");
1561    }
1562
1563    /// Nothing is dropped when everything fits.
1564    #[test]
1565    fn fit_footer_hints_noop_when_fits() {
1566        let hints = FooterHints::global();
1567        let (kept, elided) = fit_footer_hints(&hints, 200);
1568        assert_eq!(kept.len(), hints.len());
1569        assert!(!elided);
1570    }
1571}