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, }
61
62impl Default for ColorScheme {
63 fn default() -> Self {
64 Self::dark()
65 }
66}
67
68impl ColorScheme {
69 const fn dark_const() -> Self {
71 Self {
72 added: Color::Green,
74 removed: Color::Red,
75 modified: Color::Yellow,
76 unchanged: Color::Gray,
77
78 critical: Color::Magenta,
80 high: Color::Red,
81 medium: Color::Yellow,
82 low: Color::Cyan,
83 info: Color::Blue,
84
85 permissive: Color::Green,
87 copyleft: Color::Yellow,
88 weak_copyleft: Color::Cyan,
89 proprietary: Color::Red,
90 unknown_license: Color::DarkGray,
91
92 primary: Color::Cyan,
94 secondary: Color::Blue,
95 accent: Color::Yellow,
96 muted: Color::DarkGray,
97 border: Color::DarkGray,
98 border_focused: Color::Cyan,
99 background: Color::Reset,
100 background_alt: Color::Rgb(30, 30, 40),
101 text: Color::White,
102 text_muted: Color::Gray,
103 selection: Color::DarkGray,
104 highlight: Color::Yellow,
105
106 success: Color::Green,
108 warning: Color::Yellow,
109 error: Color::Red,
110
111 badge_fg_dark: Color::Black,
113 badge_fg_light: Color::White,
114
115 selection_bg: Color::Rgb(60, 60, 80),
117 search_highlight_bg: Color::Rgb(100, 80, 0),
118 error_bg: Color::Rgb(80, 30, 30),
119 success_bg: Color::Rgb(30, 80, 30),
120 }
121 }
122
123 #[must_use]
125 pub const fn dark() -> Self {
126 Self {
127 added: Color::Green,
129 removed: Color::Red,
130 modified: Color::Yellow,
131 unchanged: Color::Gray,
132
133 critical: Color::Magenta,
135 high: Color::Red,
136 medium: Color::Yellow,
137 low: Color::Cyan,
138 info: Color::Blue,
139
140 permissive: Color::Green,
142 copyleft: Color::Yellow,
143 weak_copyleft: Color::Cyan,
144 proprietary: Color::Red,
145 unknown_license: Color::DarkGray,
146
147 primary: Color::Cyan,
149 secondary: Color::Blue,
150 accent: Color::Yellow,
151 muted: Color::DarkGray,
152 border: Color::DarkGray,
153 border_focused: Color::Cyan,
154 background: Color::Reset,
155 background_alt: Color::Rgb(30, 30, 40),
156 text: Color::White,
157 text_muted: Color::Gray,
158 selection: Color::DarkGray,
159 highlight: Color::Yellow,
160
161 success: Color::Green,
163 warning: Color::Yellow,
164 error: Color::Red,
165
166 badge_fg_dark: Color::Black,
168 badge_fg_light: Color::White,
169
170 selection_bg: Color::Rgb(60, 60, 80),
172 search_highlight_bg: Color::Rgb(100, 80, 0),
173 error_bg: Color::Rgb(80, 30, 30),
174 success_bg: Color::Rgb(30, 80, 30),
175 }
176 }
177
178 #[must_use]
180 pub const fn light() -> Self {
181 Self {
182 added: Color::Rgb(0, 128, 0),
184 removed: Color::Rgb(200, 0, 0),
185 modified: Color::Rgb(180, 140, 0),
186 unchanged: Color::Rgb(100, 100, 100),
187
188 critical: Color::Rgb(128, 0, 128),
190 high: Color::Rgb(200, 0, 0),
191 medium: Color::Rgb(180, 140, 0),
192 low: Color::Rgb(0, 128, 128),
193 info: Color::Rgb(0, 0, 200),
194
195 permissive: Color::Rgb(0, 128, 0),
197 copyleft: Color::Rgb(180, 140, 0),
198 weak_copyleft: Color::Rgb(0, 128, 128),
199 proprietary: Color::Rgb(200, 0, 0),
200 unknown_license: Color::Rgb(100, 100, 100),
201
202 primary: Color::Rgb(0, 100, 150),
204 secondary: Color::Rgb(0, 0, 150),
205 accent: Color::Rgb(180, 140, 0),
206 muted: Color::Rgb(150, 150, 150),
207 border: Color::Rgb(180, 180, 180),
208 border_focused: Color::Rgb(0, 100, 150),
209 background: Color::Rgb(255, 255, 255),
210 background_alt: Color::Rgb(240, 240, 245),
211 text: Color::Rgb(30, 30, 30),
212 text_muted: Color::Rgb(100, 100, 100),
213 selection: Color::Rgb(200, 220, 240),
214 highlight: Color::Rgb(180, 140, 0),
215
216 success: Color::Rgb(0, 128, 0),
218 warning: Color::Rgb(180, 140, 0),
219 error: Color::Rgb(200, 0, 0),
220
221 badge_fg_dark: Color::Rgb(30, 30, 30),
223 badge_fg_light: Color::White,
224
225 selection_bg: Color::Rgb(200, 220, 240),
227 search_highlight_bg: Color::Rgb(255, 230, 150),
228 error_bg: Color::Rgb(255, 200, 200),
229 success_bg: Color::Rgb(200, 255, 200),
230 }
231 }
232
233 #[must_use]
235 pub const fn high_contrast() -> Self {
236 Self {
237 added: Color::Green,
239 removed: Color::LightRed,
240 modified: Color::LightYellow,
241 unchanged: Color::White,
242
243 critical: Color::LightMagenta,
245 high: Color::LightRed,
246 medium: Color::LightYellow,
247 low: Color::LightCyan,
248 info: Color::LightBlue,
249
250 permissive: Color::LightGreen,
252 copyleft: Color::LightYellow,
253 weak_copyleft: Color::LightCyan,
254 proprietary: Color::LightRed,
255 unknown_license: Color::Gray,
256
257 primary: Color::LightCyan,
259 secondary: Color::LightBlue,
260 accent: Color::LightYellow,
261 muted: Color::Gray,
262 border: Color::White,
263 border_focused: Color::LightCyan,
264 background: Color::Black,
265 background_alt: Color::Rgb(20, 20, 20),
266 text: Color::White,
267 text_muted: Color::Gray,
268 selection: Color::White,
269 highlight: Color::LightYellow,
270
271 success: Color::LightGreen,
273 warning: Color::LightYellow,
274 error: Color::LightRed,
275
276 badge_fg_dark: Color::Black,
278 badge_fg_light: Color::White,
279
280 selection_bg: Color::Rgb(50, 50, 80),
282 search_highlight_bg: Color::Rgb(120, 100, 0),
283 error_bg: Color::Rgb(100, 30, 30),
284 success_bg: Color::Rgb(30, 100, 30),
285 }
286 }
287
288 #[must_use]
290 pub fn severity_color(&self, severity: &str) -> Color {
291 match severity.to_lowercase().as_str() {
292 "critical" => self.critical,
293 "high" => self.high,
294 "medium" | "moderate" => self.medium,
295 "low" => self.low,
296 "info" | "informational" | "none" => self.info,
297 _ => self.text_muted,
298 }
299 }
300
301 #[must_use]
303 pub fn severity_bg_tint(&self, severity: &str) -> Color {
304 match severity.to_lowercase().as_str() {
305 "critical" => Color::Rgb(50, 15, 50),
306 "high" => Color::Rgb(50, 15, 15),
307 "medium" => Color::Rgb(45, 40, 10),
308 "low" => Color::Rgb(15, 35, 40),
309 _ => Color::Reset,
310 }
311 }
312
313 #[must_use]
315 pub fn change_color(&self, status: &str) -> Color {
316 match status.to_lowercase().as_str() {
317 "added" | "new" | "introduced" => self.added,
318 "removed" | "deleted" | "resolved" => self.removed,
319 "modified" | "changed" | "updated" => self.modified,
320 _ => self.unchanged,
321 }
322 }
323
324 #[must_use]
326 pub fn license_color(&self, category: &str) -> Color {
327 match category.to_lowercase().as_str() {
328 "permissive" => self.permissive,
329 "copyleft" | "strong copyleft" => self.copyleft,
330 "weak copyleft" => self.weak_copyleft,
331 "proprietary" | "commercial" => self.proprietary,
332 _ => self.unknown_license,
333 }
334 }
335
336 #[must_use]
339 pub fn severity_badge_fg(&self, severity: &str) -> Color {
340 match severity.to_lowercase().as_str() {
341 "critical" | "high" | "info" | "informational" => self.badge_fg_light,
342 _ => self.badge_fg_dark,
343 }
344 }
345
346 #[must_use]
349 pub const fn kev(&self) -> Color {
350 Color::Rgb(255, 100, 50) }
352
353 #[must_use]
355 pub const fn kev_badge_fg(&self) -> Color {
356 self.badge_fg_dark
357 }
358
359 #[must_use]
361 pub const fn direct_dep(&self) -> Color {
362 Color::Rgb(46, 160, 67) }
364
365 #[must_use]
367 pub const fn transitive_dep(&self) -> Color {
368 Color::Rgb(110, 118, 129) }
370
371 #[must_use]
374 pub const fn change_badge_fg(&self) -> Color {
375 self.badge_fg_dark
376 }
377
378 #[must_use]
380 pub fn license_badge_fg(&self, category: &str) -> Color {
381 match category.to_lowercase().as_str() {
382 "proprietary" | "commercial" => self.badge_fg_light,
383 _ => self.badge_fg_dark,
384 }
385 }
386
387 #[must_use]
389 pub const fn chart_palette(&self) -> [Color; 5] {
390 [
391 self.primary,
392 self.success,
393 self.warning,
394 self.critical,
395 self.secondary,
396 ]
397 }
398}
399
400static THEME: RwLock<Theme> = RwLock::new(Theme::dark_const());
402
403#[derive(Debug, Clone)]
405pub struct Theme {
406 pub colors: ColorScheme,
407 pub name: &'static str,
408}
409
410impl Default for Theme {
411 fn default() -> Self {
412 Self::dark()
413 }
414}
415
416impl Theme {
417 const fn dark_const() -> Self {
419 Self {
420 colors: ColorScheme::dark_const(),
421 name: "dark",
422 }
423 }
424
425 #[must_use]
426 pub const fn dark() -> Self {
427 Self {
428 colors: ColorScheme::dark(),
429 name: "dark",
430 }
431 }
432
433 #[must_use]
434 pub const fn light() -> Self {
435 Self {
436 colors: ColorScheme::light(),
437 name: "light",
438 }
439 }
440
441 #[must_use]
442 pub const fn high_contrast() -> Self {
443 Self {
444 colors: ColorScheme::high_contrast(),
445 name: "high-contrast",
446 }
447 }
448
449 #[must_use]
450 pub fn from_name(name: &str) -> Self {
451 match name.to_lowercase().as_str() {
452 "light" => Self::light(),
453 "high-contrast" | "highcontrast" | "hc" => Self::high_contrast(),
454 _ => Self::dark(),
455 }
456 }
457
458 #[must_use]
460 pub fn next(&self) -> Self {
461 match self.name {
462 "dark" => Self::light(),
463 "light" => Self::high_contrast(),
464 _ => Self::dark(),
465 }
466 }
467}
468
469pub fn current_theme_name() -> &'static str {
471 THEME.read().expect("THEME lock not poisoned").name
472}
473
474pub fn set_theme(theme: Theme) {
476 *THEME.write().expect("THEME lock not poisoned") = theme;
477}
478
479pub fn toggle_theme() -> &'static str {
481 let mut theme = THEME.write().expect("THEME lock not poisoned");
482 *theme = theme.next();
483 theme.name
484}
485
486pub fn colors() -> ColorScheme {
488 THEME.read().expect("THEME lock not poisoned").colors
489}
490
491pub struct Styles;
497
498impl Styles {
499 #[must_use]
501 pub fn header_title() -> Style {
502 Style::default().fg(colors().primary).bold()
503 }
504
505 #[must_use]
507 pub fn section_title() -> Style {
508 Style::default().fg(colors().primary).bold()
509 }
510
511 #[must_use]
513 pub fn subsection_title() -> Style {
514 Style::default().fg(colors().primary)
515 }
516
517 #[must_use]
519 pub fn text() -> Style {
520 Style::default().fg(colors().text)
521 }
522
523 #[must_use]
525 pub fn text_muted() -> Style {
526 Style::default().fg(colors().text_muted)
527 }
528
529 #[must_use]
531 pub fn label() -> Style {
532 Style::default().fg(colors().muted)
533 }
534
535 #[must_use]
537 pub fn value() -> Style {
538 Style::default().fg(colors().text).bold()
539 }
540
541 #[must_use]
543 pub fn highlight() -> Style {
544 Style::default().fg(colors().highlight).bold()
545 }
546
547 #[must_use]
549 pub fn selected() -> Style {
550 Style::default()
551 .bg(colors().selection)
552 .fg(colors().text)
553 .bold()
554 }
555
556 #[must_use]
558 pub fn border() -> Style {
559 Style::default().fg(colors().border)
560 }
561
562 #[must_use]
564 pub fn border_focused() -> Style {
565 Style::default().fg(colors().border_focused)
566 }
567
568 #[must_use]
570 pub fn status_bar() -> Style {
571 Style::default().bg(colors().background_alt)
572 }
573
574 #[must_use]
576 pub fn shortcut_key() -> Style {
577 Style::default().fg(colors().accent)
578 }
579
580 #[must_use]
582 pub fn shortcut_desc() -> Style {
583 Style::default().fg(colors().text_muted)
584 }
585
586 #[must_use]
588 pub fn success() -> Style {
589 Style::default().fg(colors().success)
590 }
591
592 #[must_use]
594 pub fn warning() -> Style {
595 Style::default().fg(colors().warning)
596 }
597
598 #[must_use]
600 pub fn error() -> Style {
601 Style::default().fg(colors().error)
602 }
603
604 #[must_use]
606 pub fn added() -> Style {
607 Style::default().fg(colors().added)
608 }
609
610 #[must_use]
612 pub fn removed() -> Style {
613 Style::default().fg(colors().removed)
614 }
615
616 #[must_use]
618 pub fn modified() -> Style {
619 Style::default().fg(colors().modified)
620 }
621
622 #[must_use]
624 pub fn critical() -> Style {
625 Style::default().fg(colors().critical).bold()
626 }
627
628 #[must_use]
630 pub fn high() -> Style {
631 Style::default().fg(colors().high).bold()
632 }
633
634 #[must_use]
636 pub fn medium() -> Style {
637 Style::default().fg(colors().medium)
638 }
639
640 #[must_use]
642 pub fn low() -> Style {
643 Style::default().fg(colors().low)
644 }
645}
646
647#[must_use]
653pub fn status_badge(status: &str) -> Span<'static> {
654 let scheme = colors();
655 let (label, color, symbol) = match status.to_lowercase().as_str() {
656 "added" | "new" | "introduced" => ("ADDED", scheme.added, "+"),
657 "removed" | "deleted" | "resolved" => ("REMOVED", scheme.removed, "-"),
658 "modified" | "changed" | "updated" => ("MODIFIED", scheme.modified, "~"),
659 _ => ("UNCHANGED", scheme.unchanged, "="),
660 };
661
662 Span::styled(
663 format!(" {symbol} {label} "),
664 Style::default()
665 .fg(scheme.change_badge_fg())
666 .bg(color)
667 .bold(),
668 )
669}
670
671#[must_use]
673pub fn severity_badge(severity: &str) -> Span<'static> {
674 let scheme = colors();
675 let (label, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
676 "critical" => ("CRITICAL", scheme.critical, false),
677 "high" => ("HIGH", scheme.high, false),
678 "medium" | "moderate" => ("MEDIUM", scheme.medium, false),
679 "low" => ("LOW", scheme.low, false),
680 "info" | "informational" => ("INFO", scheme.info, false),
681 "none" => ("NONE", scheme.muted, false),
682 _ => ("UNKNOWN", scheme.muted, true),
683 };
684 let fg_color = scheme.severity_badge_fg(severity);
685
686 let style = if is_unknown {
687 Style::default().fg(fg_color).bg(bg_color).dim()
688 } else {
689 Style::default().fg(fg_color).bg(bg_color).bold()
690 };
691
692 Span::styled(format!(" {label} "), style)
693}
694
695#[must_use]
697pub fn severity_indicator(severity: &str) -> Span<'static> {
698 let scheme = colors();
699 let (symbol, bg_color, is_unknown) = match severity.to_lowercase().as_str() {
700 "critical" => ("C", scheme.critical, false),
701 "high" => ("H", scheme.high, false),
702 "medium" | "moderate" => ("M", scheme.medium, false),
703 "low" => ("L", scheme.low, false),
704 "info" | "informational" => ("I", scheme.info, false),
705 "none" => ("-", scheme.muted, false),
706 _ => ("U", scheme.muted, true),
707 };
708 let fg_color = scheme.severity_badge_fg(severity);
709
710 let style = if is_unknown {
711 Style::default().fg(fg_color).bg(bg_color).dim()
712 } else {
713 Style::default().fg(fg_color).bg(bg_color).bold()
714 };
715
716 Span::styled(format!(" {symbol} "), style)
717}
718
719#[must_use]
721pub fn count_badge(count: usize, bg_color: Color) -> Span<'static> {
722 let scheme = colors();
723 Span::styled(
724 format!(" {count} "),
725 Style::default()
726 .fg(scheme.badge_fg_dark)
727 .bg(bg_color)
728 .bold(),
729 )
730}
731
732#[must_use]
734pub fn filter_badge(label: &str, value: &str) -> Vec<Span<'static>> {
735 let scheme = colors();
736 vec![
737 Span::styled(format!("{label}: "), Style::default().fg(scheme.text_muted)),
738 Span::styled(
739 format!(" {value} "),
740 Style::default()
741 .fg(scheme.badge_fg_dark)
742 .bg(scheme.accent)
743 .bold(),
744 ),
745 ]
746}
747
748#[must_use]
754pub fn mode_badge(mode: &str) -> Span<'static> {
755 let scheme = colors();
756 let color = match mode.to_lowercase().as_str() {
757 "diff" => scheme.modified,
758 "view" => scheme.primary,
759 "multi-diff" | "multidiff" => scheme.added,
760 "timeline" => scheme.secondary,
761 "matrix" => scheme.high,
762 _ => scheme.muted,
763 };
764
765 Span::styled(
766 format!(" {} ", mode.to_uppercase()),
767 Style::default().fg(scheme.badge_fg_dark).bg(color).bold(),
768 )
769}
770
771pub struct FooterHints;
777
778impl FooterHints {
779 #[must_use]
781 pub fn for_diff_tab(tab: &str) -> Vec<(&'static str, &'static str)> {
782 let mut hints = Self::global();
783
784 match tab.to_lowercase().as_str() {
785 "components" => {
786 hints.insert(0, ("f", "filter: All→Added→Removed→Modified"));
787 hints.insert(1, ("s", "sort: Name→Version→Ecosystem"));
788 }
789 "dependencies" => {
790 hints.insert(0, ("t", "toggle transitive"));
791 hints.insert(1, ("h", "toggle highlight"));
792 hints.insert(2, ("Enter", "expand/collapse"));
793 hints.insert(3, ("c", "go to component"));
794 }
795 "licenses" => {
796 hints.insert(0, ("g", "group: License→Component→Compat"));
797 }
798 "vulnerabilities" | "vulns" => {
799 hints.insert(0, ("f", "filter: All→Intro→Resolved→Critical→High"));
800 }
801 "sidebyside" | "side-by-side" | "diff" => {
802 hints.insert(0, ("←→/p", "switch panel"));
803 hints.insert(1, ("↑↓/jk", "scroll focused"));
804 hints.insert(2, ("J/K", "scroll both"));
805 }
806 "quality" => {
807 hints.insert(0, ("v", "view: Summary→Metrics→Recs"));
808 hints.insert(1, ("↑↓", "select recommendation"));
809 }
810 "graphchanges" | "graph" => {
811 hints.insert(0, ("↑↓/jk", "select change"));
812 hints.insert(1, ("PgUp/Dn", "page scroll"));
813 hints.insert(2, ("Home/End", "first/last"));
814 }
815 _ => {}
816 }
817
818 hints
819 }
820
821 #[must_use]
823 pub fn for_view_tab(tab: &str) -> Vec<(&'static str, &'static str)> {
824 let mut hints = Self::global();
825
826 match tab.to_lowercase().as_str() {
827 "tree" | "components" => {
828 hints.insert(0, ("g", "group: Eco→License→Vuln→Flat"));
829 hints.insert(1, ("f", "filter: All→HasVuln→Critical"));
830 hints.insert(2, ("p", "toggle panel focus"));
831 hints.insert(3, ("Enter", "expand/select"));
832 hints.insert(4, ("[ ]", "detail tabs"));
833 }
834 "vulnerabilities" | "vulns" => {
835 hints.insert(0, ("f", "filter: All→Critical→High"));
836 hints.insert(1, ("g", "group: Severity→Component→Flat"));
837 hints.insert(2, ("d", "deduplicate by CVE"));
838 hints.insert(3, ("Enter", "jump to component"));
839 }
840 "licenses" => {
841 hints.insert(0, ("g", "group: License→Category"));
842 }
843 "dependencies" => {
844 hints.insert(0, ("Enter/→", "expand"));
845 hints.insert(1, ("←", "collapse"));
846 }
847 "quality" => {
848 hints.insert(0, ("v", "view: Summary→Metrics→Recs"));
849 hints.insert(1, ("↑↓", "select recommendation"));
850 }
851 "source" => {
852 hints.insert(0, ("v", "tree/raw"));
853 hints.insert(1, ("p", "panel focus"));
854 hints.insert(2, ("H/L", "collapse/expand all"));
855 hints.insert(3, ("Enter", "expand/jump"));
856 }
857 _ => {}
858 }
859
860 hints
861 }
862
863 #[must_use]
865 pub fn global() -> Vec<(&'static str, &'static str)> {
866 vec![
867 ("Tab", "switch"),
868 ("↑↓/jk", "navigate"),
869 ("/", "search"),
870 ("e", "export"),
871 ("l", "legend"),
872 ("T", "theme"),
873 ("?", "help"),
874 ("q", "quit"),
875 ]
876 }
877}
878
879#[must_use]
881pub fn render_footer_hints(hints: &[(&str, &str)]) -> Vec<Span<'static>> {
882 let mut spans = Vec::new();
883
884 for (i, (key, desc)) in hints.iter().enumerate() {
885 if i > 0 {
886 spans.push(Span::raw(" "));
887 }
888 spans.push(Span::styled(format!("[{key}]"), Styles::shortcut_key()));
889 spans.push(Span::styled(desc.to_string(), Styles::shortcut_desc()));
890 }
891
892 spans
893}