1use ratatui::prelude::*;
6use std::sync::RwLock;
7
8#[derive(Debug, Clone, Copy)]
11pub struct ColorScheme {
12 pub added: Color,
14 pub removed: Color,
15 pub modified: Color,
16 pub unchanged: Color,
17
18 pub critical: Color,
20 pub high: Color,
21 pub medium: Color,
22 pub low: Color,
23 pub info: Color,
24
25 pub permissive: Color,
27 pub copyleft: Color,
28 pub weak_copyleft: Color,
29 pub proprietary: Color,
30 pub unknown_license: Color,
31
32 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 pub success: Color,
48 pub warning: Color,
49 pub error: Color,
50
51 pub badge_fg_dark: Color, pub badge_fg_light: Color, pub selection_bg: Color, pub search_highlight_bg: Color, pub error_bg: Color, pub success_bg: Color, pub scope_bg: Color, pub monochrome: bool,
67}
68
69impl Default for ColorScheme {
70 fn default() -> Self {
71 Self::dark()
72 }
73}
74
75impl ColorScheme {
76 const fn dark_const() -> Self {
78 Self {
79 added: Color::Green,
81 removed: Color::Red,
82 modified: Color::Yellow,
83 unchanged: Color::Gray,
84
85 critical: Color::Magenta,
87 high: Color::Red,
88 medium: Color::Yellow,
89 low: Color::Cyan,
90 info: Color::Blue,
91
92 permissive: Color::Green,
94 copyleft: Color::Yellow,
95 weak_copyleft: Color::Cyan,
96 proprietary: Color::Red,
97 unknown_license: Color::DarkGray,
98
99 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 success: Color::Green,
115 warning: Color::Yellow,
116 error: Color::Red,
117
118 badge_fg_dark: Color::Black,
120 badge_fg_light: Color::White,
121
122 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 scope_bg: Color::Rgb(35, 35, 50),
130
131 monochrome: false,
132 }
133 }
134
135 #[must_use]
137 pub const fn dark() -> Self {
138 Self {
139 added: Color::Green,
141 removed: Color::Red,
142 modified: Color::Yellow,
143 unchanged: Color::Gray,
144
145 critical: Color::Magenta,
147 high: Color::Red,
148 medium: Color::Yellow,
149 low: Color::Cyan,
150 info: Color::Blue,
151
152 permissive: Color::Green,
154 copyleft: Color::Yellow,
155 weak_copyleft: Color::Cyan,
156 proprietary: Color::Red,
157 unknown_license: Color::DarkGray,
158
159 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 success: Color::Green,
175 warning: Color::Yellow,
176 error: Color::Red,
177
178 badge_fg_dark: Color::Black,
180 badge_fg_light: Color::White,
181
182 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 scope_bg: Color::Rgb(35, 35, 50),
190
191 monochrome: false,
192 }
193 }
194
195 #[must_use]
197 pub const fn light() -> Self {
198 Self {
199 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 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 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 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 success: Color::Rgb(0, 128, 0),
235 warning: Color::Rgb(180, 140, 0),
236 error: Color::Rgb(200, 0, 0),
237
238 badge_fg_dark: Color::Rgb(30, 30, 30),
240 badge_fg_light: Color::White,
241
242 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 scope_bg: Color::Rgb(235, 240, 250),
250
251 monochrome: false,
252 }
253 }
254
255 #[must_use]
257 pub const fn high_contrast() -> Self {
258 Self {
259 added: Color::Green,
261 removed: Color::LightRed,
262 modified: Color::LightYellow,
263 unchanged: Color::White,
264
265 critical: Color::LightMagenta,
267 high: Color::LightRed,
268 medium: Color::LightYellow,
269 low: Color::LightCyan,
270 info: Color::LightBlue,
271
272 permissive: Color::LightGreen,
274 copyleft: Color::LightYellow,
275 weak_copyleft: Color::LightCyan,
276 proprietary: Color::LightRed,
277 unknown_license: Color::Gray,
278
279 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: Color::Rgb(80, 80, 120),
294 highlight: Color::LightYellow,
295
296 success: Color::LightGreen,
298 warning: Color::LightYellow,
299 error: Color::LightRed,
300
301 badge_fg_dark: Color::Black,
303 badge_fg_light: Color::White,
304
305 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 scope_bg: Color::Rgb(25, 25, 40),
313
314 monochrome: false,
315 }
316 }
317
318 #[must_use]
322 pub const fn monochrome() -> Self {
323 Self {
324 added: Color::Reset,
326 removed: Color::Reset,
327 modified: Color::Reset,
328 unchanged: Color::Reset,
329
330 critical: Color::Reset,
332 high: Color::Reset,
333 medium: Color::Reset,
334 low: Color::Reset,
335 info: Color::Reset,
336
337 permissive: Color::Reset,
339 copyleft: Color::Reset,
340 weak_copyleft: Color::Reset,
341 proprietary: Color::Reset,
342 unknown_license: Color::Reset,
343
344 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: Color::DarkGray,
358 highlight: Color::Reset,
359
360 success: Color::Reset,
362 warning: Color::Reset,
363 error: Color::Reset,
364
365 badge_fg_dark: Color::Reset,
367 badge_fg_light: Color::Reset,
368
369 selection_bg: Color::DarkGray,
371 search_highlight_bg: Color::Reset,
372 error_bg: Color::Reset,
373 success_bg: Color::Reset,
374
375 scope_bg: Color::Reset,
377
378 monochrome: true,
379 }
380 }
381
382 #[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 #[must_use]
397 pub fn severity_bg_tint(&self, severity: &str) -> Color {
403 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 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 #[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 #[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 #[must_use]
461 pub fn kev(&self) -> Color {
462 if self.monochrome {
463 Color::Reset
464 } else {
465 Color::Rgb(255, 100, 50) }
467 }
468
469 #[must_use]
471 pub const fn kev_badge_fg(&self) -> Color {
472 self.badge_fg_dark
473 }
474
475 #[must_use]
477 pub fn direct_dep(&self) -> Color {
478 if self.monochrome {
479 Color::Reset
480 } else {
481 Color::Rgb(46, 160, 67) }
483 }
484
485 #[must_use]
487 pub fn transitive_dep(&self) -> Color {
488 if self.monochrome {
489 Color::Reset
490 } else {
491 Color::Rgb(110, 118, 129) }
493 }
494
495 #[must_use]
498 pub const fn change_badge_fg(&self) -> Color {
499 self.badge_fg_dark
500 }
501
502 #[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 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 #[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 #[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
552static THEME: RwLock<Theme> = RwLock::new(Theme::dark_const());
554
555#[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 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 #[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
633pub fn current_theme_name() -> &'static str {
635 THEME.read().expect("THEME lock not poisoned").name
636}
637
638pub fn set_theme(theme: Theme) {
640 *THEME.write().expect("THEME lock not poisoned") = theme;
641}
642
643#[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
652fn 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
670pub 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
677pub fn colors() -> ColorScheme {
679 THEME.read().expect("THEME lock not poisoned").colors
680}
681
682pub struct Styles;
688
689impl Styles {
690 #[must_use]
692 pub fn header_title() -> Style {
693 Style::default().fg(colors().primary).bold()
694 }
695
696 #[must_use]
698 pub fn section_title() -> Style {
699 Style::default().fg(colors().primary).bold()
700 }
701
702 #[must_use]
704 pub fn subsection_title() -> Style {
705 Style::default().fg(colors().primary)
706 }
707
708 #[must_use]
710 pub fn text() -> Style {
711 Style::default().fg(colors().text)
712 }
713
714 #[must_use]
716 pub fn text_muted() -> Style {
717 Style::default().fg(colors().text_muted)
718 }
719
720 #[must_use]
722 pub fn label() -> Style {
723 Style::default().fg(colors().muted)
724 }
725
726 #[must_use]
728 pub fn value() -> Style {
729 Style::default().fg(colors().text).bold()
730 }
731
732 #[must_use]
734 pub fn highlight() -> Style {
735 Style::default().fg(colors().highlight).bold()
736 }
737
738 #[must_use]
740 pub fn selected() -> Style {
741 Style::default()
742 .bg(colors().selection)
743 .fg(colors().text)
744 .bold()
745 }
746
747 #[must_use]
749 pub fn border() -> Style {
750 Style::default().fg(colors().border)
751 }
752
753 #[must_use]
755 pub fn border_focused() -> Style {
756 Style::default().fg(colors().border_focused)
757 }
758
759 #[must_use]
761 pub fn status_bar() -> Style {
762 Style::default().bg(colors().background_alt)
763 }
764
765 #[must_use]
767 pub fn shortcut_key() -> Style {
768 Style::default().fg(colors().accent)
769 }
770
771 #[must_use]
773 pub fn shortcut_desc() -> Style {
774 Style::default().fg(colors().text_muted)
775 }
776
777 #[must_use]
779 pub fn success() -> Style {
780 Style::default().fg(colors().success)
781 }
782
783 #[must_use]
785 pub fn warning() -> Style {
786 Style::default().fg(colors().warning)
787 }
788
789 #[must_use]
791 pub fn error() -> Style {
792 Style::default().fg(colors().error)
793 }
794
795 #[must_use]
797 pub fn added() -> Style {
798 Style::default().fg(colors().added)
799 }
800
801 #[must_use]
803 pub fn removed() -> Style {
804 Style::default().fg(colors().removed)
805 }
806
807 #[must_use]
809 pub fn modified() -> Style {
810 Style::default().fg(colors().modified)
811 }
812
813 #[must_use]
815 pub fn critical() -> Style {
816 Style::default().fg(colors().critical).bold()
817 }
818
819 #[must_use]
821 pub fn high() -> Style {
822 Style::default().fg(colors().high).bold()
823 }
824
825 #[must_use]
827 pub fn medium() -> Style {
828 Style::default().fg(colors().medium)
829 }
830
831 #[must_use]
833 pub fn low() -> Style {
834 Style::default().fg(colors().low)
835 }
836}
837
838#[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#[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#[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#[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#[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#[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
962pub struct FooterHints;
968
969impl FooterHints {
970 #[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 #[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 hints.insert(0, ("P", "cycle profile"));
1019 }
1020 "tree" | "components" => {
1021 hints.insert(0, ("p", "panel"));
1025 hints.insert(1, ("Enter", "select"));
1026 hints.insert(2, ("1-4", "detail tabs"));
1027 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 "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 #[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 pub const GLOBAL_COUNT: usize = 5;
1107}
1108
1109#[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 w += UnicodeWidthStr::width(*key) as u16 + 2 + UnicodeWidthStr::width(*desc) as u16;
1123 }
1124 if hints.len() > FooterHints::GLOBAL_COUNT {
1125 w += 2; }
1127 w
1128}
1129
1130#[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 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#[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 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 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 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 #[test]
1238 fn no_color_flag_forces_monochrome_independently_of_env() {
1239 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 #[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 #[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 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 #[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 assert_eq!(super::Theme::monochrome().next().name, "monochrome");
1317 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 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 assert_eq!(s.badge_fg_for(Color::Yellow), s.badge_fg_dark);
1397 assert_eq!(s.badge_fg_for(Color::Rgb(200, 0, 0)), s.badge_fg_light);
1399 assert_eq!(s.badge_fg_for(Color::Rgb(255, 230, 150)), s.badge_fg_dark);
1401 assert_eq!(s.badge_fg_for(Color::Red), s.badge_fg_light);
1403 }
1404
1405 #[test]
1409 fn count_badge_routes_fg_through_badge_fg_for() {
1410 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 const BASELINE: &[(&str, usize)] = &[
1445 ("shared/quality.rs", 2),
1447 ("view/ui/render_snapshot_tests.rs", 1),
1449 ("views/sidebyside.rs", 4),
1451 ];
1452
1453 fn color_literal_lines(src: &str) -> usize {
1454 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 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 #[test]
1545 fn fit_footer_hints_keeps_global_tail() {
1546 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 #[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}