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