1#![allow(clippy::unreadable_literal)]
48
49use std::str::FromStr;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Color {
54 pub r: u8,
56 pub g: u8,
58 pub b: u8,
60}
61
62impl Color {
63 #[must_use]
65 pub const fn new(r: u8, g: u8, b: u8) -> Self {
66 Self { r, g, b }
67 }
68
69 #[must_use]
71 pub const fn from_hex(hex: u32) -> Self {
72 Self {
73 r: ((hex >> 16) & 0xFF) as u8,
74 g: ((hex >> 8) & 0xFF) as u8,
75 b: (hex & 0xFF) as u8,
76 }
77 }
78
79 #[must_use]
81 pub fn to_hex(&self) -> String {
82 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
83 }
84
85 #[must_use]
87 pub const fn to_rgb(&self) -> (u8, u8, u8) {
88 (self.r, self.g, self.b)
89 }
90
91 #[must_use]
93 pub fn to_ansi_fg(&self) -> String {
94 format!("\x1b[38;2;{};{};{}m", self.r, self.g, self.b)
95 }
96
97 #[must_use]
99 pub fn to_ansi_bg(&self) -> String {
100 format!("\x1b[48;2;{};{};{}m", self.r, self.g, self.b)
101 }
102
103 #[must_use]
107 pub fn luminance(&self) -> f64 {
108 fn channel_luminance(c: u8) -> f64 {
109 let c = f64::from(c) / 255.0;
110 if c <= 0.03928 {
111 c / 12.92
112 } else {
113 ((c + 0.055) / 1.055).powf(2.4)
114 }
115 }
116 0.2126 * channel_luminance(self.r)
117 + 0.7152 * channel_luminance(self.g)
118 + 0.0722 * channel_luminance(self.b)
119 }
120
121 #[must_use]
126 pub fn contrast_ratio(&self, other: &Color) -> f64 {
127 let l1 = self.luminance();
128 let l2 = other.luminance();
129 let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
130 (lighter + 0.05) / (darker + 0.05)
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ThemeIcons {
157 pub success: &'static str,
159 pub failure: &'static str,
161 pub warning: &'static str,
163 pub info: &'static str,
165 pub arrow_right: &'static str,
167 pub arrow_left: &'static str,
169 pub bullet: &'static str,
171 pub lock: &'static str,
173 pub unlock: &'static str,
175 pub http: &'static str,
177 pub loading: &'static str,
179 pub route: &'static str,
181 pub database: &'static str,
183 pub time: &'static str,
185 pub size: &'static str,
187}
188
189impl ThemeIcons {
190 #[must_use]
194 pub const fn unicode() -> Self {
195 Self {
196 success: "\u{2713}", failure: "\u{2717}", warning: "\u{26A0}", info: "\u{2139}", arrow_right: "\u{2192}", arrow_left: "\u{2190}", bullet: "\u{2022}", lock: "\u{1F512}", unlock: "\u{1F513}", http: "\u{1F310}", loading: "\u{25CF}", route: "\u{2192}", database: "\u{1F5C4}", time: "\u{23F1}", size: "\u{1F4BE}", }
212 }
213
214 #[must_use]
219 pub const fn ascii() -> Self {
220 Self {
221 success: "[OK]",
222 failure: "[X]",
223 warning: "[!]",
224 info: "[i]",
225 arrow_right: "->",
226 arrow_left: "<-",
227 bullet: "*",
228 lock: "[#]",
229 unlock: "[ ]",
230 http: "[H]",
231 loading: "...",
232 route: "->",
233 database: "[D]",
234 time: "[T]",
235 size: "[S]",
236 }
237 }
238
239 #[must_use]
243 pub const fn compact() -> Self {
244 Self {
245 success: "\u{2713}", failure: "\u{2717}", warning: "!",
248 info: "i",
249 arrow_right: ">",
250 arrow_left: "<",
251 bullet: "\u{2022}", lock: "#",
253 unlock: "o",
254 http: "@",
255 loading: ".",
256 route: "/",
257 database: "D",
258 time: "T",
259 size: "S",
260 }
261 }
262
263 #[must_use]
268 pub fn auto() -> Self {
269 if std::env::var("TERM").is_ok_and(|t| t == "dumb")
270 || std::env::var("CI").is_ok()
271 || std::env::var("CLAUDE_CODE").is_ok()
272 || std::env::var("CODEX_CLI").is_ok()
273 {
274 Self::ascii()
275 } else {
276 Self::unicode()
277 }
278 }
279}
280
281impl Default for ThemeIcons {
282 fn default() -> Self {
283 Self::unicode()
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub struct ThemeSpacing {
306 pub indent: usize,
308 pub panel_padding: usize,
310 pub table_cell_padding: usize,
312 pub section_gap: usize,
314 pub item_gap: usize,
316 pub method_width: usize,
318 pub status_width: usize,
320}
321
322impl ThemeSpacing {
323 #[must_use]
325 pub const fn default_spacing() -> Self {
326 Self {
327 indent: 2,
328 panel_padding: 1,
329 table_cell_padding: 1,
330 section_gap: 1,
331 item_gap: 0,
332 method_width: 7, status_width: 3, }
335 }
336
337 #[must_use]
339 pub const fn compact() -> Self {
340 Self {
341 indent: 1,
342 panel_padding: 0,
343 table_cell_padding: 1,
344 section_gap: 0,
345 item_gap: 0,
346 method_width: 6,
347 status_width: 3,
348 }
349 }
350
351 #[must_use]
353 pub const fn spacious() -> Self {
354 Self {
355 indent: 4,
356 panel_padding: 2,
357 table_cell_padding: 2,
358 section_gap: 2,
359 item_gap: 1,
360 method_width: 8,
361 status_width: 4,
362 }
363 }
364}
365
366impl Default for ThemeSpacing {
367 fn default() -> Self {
368 Self::default_spacing()
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub struct BoxStyle {
391 pub top_left: char,
393 pub top_right: char,
395 pub bottom_left: char,
397 pub bottom_right: char,
399 pub horizontal: char,
401 pub vertical: char,
403 pub left_tee: char,
405 pub right_tee: char,
407 pub top_tee: char,
409 pub bottom_tee: char,
411 pub cross: char,
413}
414
415impl BoxStyle {
416 #[must_use]
418 pub const fn rounded() -> Self {
419 Self {
420 top_left: '\u{256D}', top_right: '\u{256E}', bottom_left: '\u{2570}', bottom_right: '\u{256F}', horizontal: '\u{2500}', vertical: '\u{2502}', left_tee: '\u{251C}', right_tee: '\u{2524}', top_tee: '\u{252C}', bottom_tee: '\u{2534}', cross: '\u{253C}', }
432 }
433
434 #[must_use]
436 pub const fn square() -> Self {
437 Self {
438 top_left: '\u{250C}', top_right: '\u{2510}', bottom_left: '\u{2514}', bottom_right: '\u{2518}', horizontal: '\u{2500}', vertical: '\u{2502}', left_tee: '\u{251C}', right_tee: '\u{2524}', top_tee: '\u{252C}', bottom_tee: '\u{2534}', cross: '\u{253C}', }
450 }
451
452 #[must_use]
454 pub const fn heavy() -> Self {
455 Self {
456 top_left: '\u{250F}', top_right: '\u{2513}', bottom_left: '\u{2517}', bottom_right: '\u{251B}', horizontal: '\u{2501}', vertical: '\u{2503}', left_tee: '\u{2523}', right_tee: '\u{252B}', top_tee: '\u{2533}', bottom_tee: '\u{253B}', cross: '\u{254B}', }
468 }
469
470 #[must_use]
472 pub const fn double() -> Self {
473 Self {
474 top_left: '\u{2554}', top_right: '\u{2557}', bottom_left: '\u{255A}', bottom_right: '\u{255D}', horizontal: '\u{2550}', vertical: '\u{2551}', left_tee: '\u{2560}', right_tee: '\u{2563}', top_tee: '\u{2566}', bottom_tee: '\u{2569}', cross: '\u{256C}', }
486 }
487
488 #[must_use]
490 pub const fn ascii() -> Self {
491 Self {
492 top_left: '+',
493 top_right: '+',
494 bottom_left: '+',
495 bottom_right: '+',
496 horizontal: '-',
497 vertical: '|',
498 left_tee: '+',
499 right_tee: '+',
500 top_tee: '+',
501 bottom_tee: '+',
502 cross: '+',
503 }
504 }
505
506 #[must_use]
508 pub const fn none() -> Self {
509 Self {
510 top_left: ' ',
511 top_right: ' ',
512 bottom_left: ' ',
513 bottom_right: ' ',
514 horizontal: ' ',
515 vertical: ' ',
516 left_tee: ' ',
517 right_tee: ' ',
518 top_tee: ' ',
519 bottom_tee: ' ',
520 cross: ' ',
521 }
522 }
523
524 #[must_use]
526 pub fn horizontal_line(&self, width: usize) -> String {
527 std::iter::repeat_n(self.horizontal, width).collect()
528 }
529
530 #[must_use]
532 pub fn top_border(&self, width: usize) -> String {
533 format!(
534 "{}{}{}",
535 self.top_left,
536 self.horizontal_line(width.saturating_sub(2)),
537 self.top_right
538 )
539 }
540
541 #[must_use]
543 pub fn bottom_border(&self, width: usize) -> String {
544 format!(
545 "{}{}{}",
546 self.bottom_left,
547 self.horizontal_line(width.saturating_sub(2)),
548 self.bottom_right
549 )
550 }
551}
552
553impl Default for BoxStyle {
554 fn default() -> Self {
555 Self::rounded()
556 }
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum BoxStylePreset {
562 #[default]
564 Rounded,
565 Square,
567 Heavy,
569 Double,
571 Ascii,
573 None,
575}
576
577impl BoxStylePreset {
578 #[must_use]
580 pub const fn style(&self) -> BoxStyle {
581 match self {
582 Self::Rounded => BoxStyle::rounded(),
583 Self::Square => BoxStyle::square(),
584 Self::Heavy => BoxStyle::heavy(),
585 Self::Double => BoxStyle::double(),
586 Self::Ascii => BoxStyle::ascii(),
587 Self::None => BoxStyle::none(),
588 }
589 }
590}
591
592impl FromStr for BoxStylePreset {
593 type Err = BoxStyleParseError;
594
595 fn from_str(s: &str) -> Result<Self, Self::Err> {
596 match s.to_lowercase().as_str() {
597 "rounded" => Ok(Self::Rounded),
598 "square" => Ok(Self::Square),
599 "heavy" | "bold" => Ok(Self::Heavy),
600 "double" => Ok(Self::Double),
601 "ascii" | "plain" => Ok(Self::Ascii),
602 "none" | "invisible" => Ok(Self::None),
603 _ => Err(BoxStyleParseError(s.to_string())),
604 }
605 }
606}
607
608#[derive(Debug, Clone)]
610pub struct BoxStyleParseError(String);
611
612impl std::fmt::Display for BoxStyleParseError {
613 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614 write!(
615 f,
616 "unknown box style '{}', available: rounded, square, heavy, double, ascii, none",
617 self.0
618 )
619 }
620}
621
622impl std::error::Error for BoxStyleParseError {}
623
624#[must_use]
626pub fn rgb_to_hex(rgb: (u8, u8, u8)) -> String {
627 format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2)
628}
629
630#[must_use]
645pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
646 let hex = hex.trim_start_matches('#');
647 if hex.len() == 6 {
648 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
649 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
650 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
651 Some((r, g, b))
652 } else if hex.len() == 3 {
653 let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
654 let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
655 let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
656 Some((r, g, b))
657 } else {
658 None
659 }
660}
661
662#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct FastApiTheme {
672 pub primary: Color,
675 pub secondary: Color,
677 pub accent: Color,
679
680 pub success: Color,
683 pub warning: Color,
685 pub error: Color,
687 pub info: Color,
689
690 pub http_get: Color,
693 pub http_post: Color,
695 pub http_put: Color,
697 pub http_delete: Color,
699 pub http_patch: Color,
701 pub http_options: Color,
703 pub http_head: Color,
705
706 pub status_1xx: Color,
709 pub status_2xx: Color,
711 pub status_3xx: Color,
713 pub status_4xx: Color,
715 pub status_5xx: Color,
717
718 pub border: Color,
721 pub header: Color,
723 pub muted: Color,
725 pub highlight_bg: Color,
727}
728
729impl FastApiTheme {
730 #[must_use]
732 pub fn from_preset(preset: ThemePreset) -> Self {
733 match preset {
734 ThemePreset::FastApi | ThemePreset::Default => Self::fastapi(),
735 ThemePreset::Neon => Self::neon(),
736 ThemePreset::Minimal => Self::minimal(),
737 ThemePreset::Monokai => Self::monokai(),
738 ThemePreset::Light => Self::light(),
739 ThemePreset::Accessible => Self::accessible(),
740 }
741 }
742
743 #[must_use]
748 pub fn fastapi() -> Self {
749 Self {
750 primary: Color::from_hex(0x009688), secondary: Color::from_hex(0x4CAF50), accent: Color::from_hex(0xFF9800), success: Color::from_hex(0x4CAF50), warning: Color::from_hex(0xFF9800), error: Color::from_hex(0xF44336), info: Color::from_hex(0x2196F3), http_get: Color::from_hex(0x61AFFE), http_post: Color::from_hex(0x49CC90), http_put: Color::from_hex(0xFCA130), http_delete: Color::from_hex(0xF93E3E), http_patch: Color::from_hex(0x50E3C2), http_options: Color::from_hex(0x808080), http_head: Color::from_hex(0x9370DB), status_1xx: Color::from_hex(0x808080), status_2xx: Color::from_hex(0x4CAF50), status_3xx: Color::from_hex(0x00BCD4), status_4xx: Color::from_hex(0xFFC107), status_5xx: Color::from_hex(0xF44336), border: Color::from_hex(0x9E9E9E), header: Color::from_hex(0x009688), muted: Color::from_hex(0x757575), highlight_bg: Color::from_hex(0x263238), }
783 }
784
785 #[must_use]
787 pub fn neon() -> Self {
788 Self {
789 primary: Color::from_hex(0x00FFFF), secondary: Color::from_hex(0xFF00FF), accent: Color::from_hex(0xFFFF00), success: Color::from_hex(0x00FF80), warning: Color::from_hex(0xFFFF00), error: Color::from_hex(0xFF0040), info: Color::from_hex(0x0080FF), http_get: Color::from_hex(0x00FFFF),
799 http_post: Color::from_hex(0x00FF80),
800 http_put: Color::from_hex(0xFFA500),
801 http_delete: Color::from_hex(0xFF0040),
802 http_patch: Color::from_hex(0xFF00FF),
803 http_options: Color::from_hex(0x808080),
804 http_head: Color::from_hex(0x9400D3),
805
806 status_1xx: Color::from_hex(0x808080),
807 status_2xx: Color::from_hex(0x00FF80),
808 status_3xx: Color::from_hex(0x00FFFF),
809 status_4xx: Color::from_hex(0xFFFF00),
810 status_5xx: Color::from_hex(0xFF0040),
811
812 border: Color::from_hex(0x00FFFF),
813 header: Color::from_hex(0xFF00FF),
814 muted: Color::from_hex(0x646464),
815 highlight_bg: Color::from_hex(0x141428),
816 }
817 }
818
819 #[must_use]
821 pub fn minimal() -> Self {
822 Self {
823 primary: Color::from_hex(0xC8C8C8),
824 secondary: Color::from_hex(0xB4B4B4),
825 accent: Color::from_hex(0xFF9800),
826
827 success: Color::from_hex(0x64C864),
828 warning: Color::from_hex(0xFFB400),
829 error: Color::from_hex(0xFF6464),
830 info: Color::from_hex(0x6496FF),
831
832 http_get: Color::from_hex(0x9696C8),
833 http_post: Color::from_hex(0x96C896),
834 http_put: Color::from_hex(0xC8B464),
835 http_delete: Color::from_hex(0xC86464),
836 http_patch: Color::from_hex(0x64C8C8),
837 http_options: Color::from_hex(0x808080),
838 http_head: Color::from_hex(0xB496C8),
839
840 status_1xx: Color::from_hex(0x808080),
841 status_2xx: Color::from_hex(0x64C864),
842 status_3xx: Color::from_hex(0x64C8C8),
843 status_4xx: Color::from_hex(0xC8B464),
844 status_5xx: Color::from_hex(0xC86464),
845
846 border: Color::from_hex(0x646464),
847 header: Color::from_hex(0xDCDCDC),
848 muted: Color::from_hex(0x505050),
849 highlight_bg: Color::from_hex(0x1E1E1E),
850 }
851 }
852
853 #[must_use]
855 pub fn monokai() -> Self {
856 Self {
857 primary: Color::from_hex(0xA6E22E), secondary: Color::from_hex(0x66D9EF), accent: Color::from_hex(0xFD971F), success: Color::from_hex(0xA6E22E),
862 warning: Color::from_hex(0xFD971F),
863 error: Color::from_hex(0xF92672), info: Color::from_hex(0x66D9EF),
865
866 http_get: Color::from_hex(0x66D9EF),
867 http_post: Color::from_hex(0xA6E22E),
868 http_put: Color::from_hex(0xFD971F),
869 http_delete: Color::from_hex(0xF92672),
870 http_patch: Color::from_hex(0xAE81FF), http_options: Color::from_hex(0x75715E),
872 http_head: Color::from_hex(0xAE81FF),
873
874 status_1xx: Color::from_hex(0x75715E),
875 status_2xx: Color::from_hex(0xA6E22E),
876 status_3xx: Color::from_hex(0x66D9EF),
877 status_4xx: Color::from_hex(0xFD971F),
878 status_5xx: Color::from_hex(0xF92672),
879
880 border: Color::from_hex(0x75715E),
881 header: Color::from_hex(0xF8F8F2),
882 muted: Color::from_hex(0x75715E),
883 highlight_bg: Color::from_hex(0x272822),
884 }
885 }
886
887 #[must_use]
892 pub fn light() -> Self {
893 Self {
894 primary: Color::from_hex(0x00796B), secondary: Color::from_hex(0x388E3C), accent: Color::from_hex(0xE65100), success: Color::from_hex(0x2E7D32), warning: Color::from_hex(0xE65100), error: Color::from_hex(0xC62828), info: Color::from_hex(0x1565C0), http_get: Color::from_hex(0x1976D2), http_post: Color::from_hex(0x2E7D32), http_put: Color::from_hex(0xE65100), http_delete: Color::from_hex(0xC62828), http_patch: Color::from_hex(0x00838F), http_options: Color::from_hex(0x616161), http_head: Color::from_hex(0x6A1B9A), status_1xx: Color::from_hex(0x616161), status_2xx: Color::from_hex(0x2E7D32), status_3xx: Color::from_hex(0x00838F), status_4xx: Color::from_hex(0xE65100), status_5xx: Color::from_hex(0xC62828), border: Color::from_hex(0x9E9E9E), header: Color::from_hex(0x212121), muted: Color::from_hex(0x757575), highlight_bg: Color::from_hex(0xE3F2FD), }
927 }
928
929 #[must_use]
941 pub fn accessible() -> Self {
942 Self {
943 primary: Color::from_hex(0x00FFFF), secondary: Color::from_hex(0x00FF00), accent: Color::from_hex(0xFFFF00), success: Color::from_hex(0x00FF00), warning: Color::from_hex(0xFFFF00), error: Color::from_hex(0xFF0000), info: Color::from_hex(0x00FFFF), http_get: Color::from_hex(0x00FFFF), http_post: Color::from_hex(0x00FF00), http_put: Color::from_hex(0xFFFF00), http_delete: Color::from_hex(0xFF0000), http_patch: Color::from_hex(0xFF00FF), http_options: Color::from_hex(0xFFFFFF), http_head: Color::from_hex(0xFF00FF), status_1xx: Color::from_hex(0xFFFFFF), status_2xx: Color::from_hex(0x00FF00), status_3xx: Color::from_hex(0x00FFFF), status_4xx: Color::from_hex(0xFFFF00), status_5xx: Color::from_hex(0xFF0000), border: Color::from_hex(0xFFFFFF), header: Color::from_hex(0xFFFFFF), muted: Color::from_hex(0xC0C0C0), highlight_bg: Color::from_hex(0x000080), }
976 }
977
978 #[must_use]
992 pub fn http_method_color(&self, method: &str) -> Color {
993 match method.to_uppercase().as_str() {
994 "GET" => self.http_get,
995 "POST" => self.http_post,
996 "PUT" => self.http_put,
997 "DELETE" => self.http_delete,
998 "PATCH" => self.http_patch,
999 "OPTIONS" => self.http_options,
1000 "HEAD" => self.http_head,
1001 _ => self.muted,
1002 }
1003 }
1004
1005 #[must_use]
1017 pub fn status_code_color(&self, code: u16) -> Color {
1018 match code {
1019 100..=199 => self.status_1xx,
1020 200..=299 => self.status_2xx,
1021 300..=399 => self.status_3xx,
1022 400..=499 => self.status_4xx,
1023 500..=599 => self.status_5xx,
1024 _ => self.muted,
1025 }
1026 }
1027
1028 #[must_use]
1032 pub fn primary_hex(&self) -> String {
1033 self.primary.to_hex()
1034 }
1035
1036 #[must_use]
1038 pub fn success_hex(&self) -> String {
1039 self.success.to_hex()
1040 }
1041
1042 #[must_use]
1044 pub fn error_hex(&self) -> String {
1045 self.error.to_hex()
1046 }
1047
1048 #[must_use]
1050 pub fn warning_hex(&self) -> String {
1051 self.warning.to_hex()
1052 }
1053
1054 #[must_use]
1056 pub fn info_hex(&self) -> String {
1057 self.info.to_hex()
1058 }
1059
1060 #[must_use]
1062 pub fn accent_hex(&self) -> String {
1063 self.accent.to_hex()
1064 }
1065
1066 #[must_use]
1068 pub fn http_method_hex(&self, method: &str) -> String {
1069 self.http_method_color(method).to_hex()
1070 }
1071
1072 #[must_use]
1074 pub fn status_code_hex(&self, code: u16) -> String {
1075 self.status_code_color(code).to_hex()
1076 }
1077}
1078
1079impl Default for FastApiTheme {
1080 fn default() -> Self {
1081 Self::fastapi()
1082 }
1083}
1084
1085#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1089pub enum ThemePreset {
1090 #[default]
1092 Default,
1093 FastApi,
1095 Neon,
1097 Minimal,
1099 Monokai,
1101 Light,
1103 Accessible,
1105}
1106
1107impl ThemePreset {
1108 #[must_use]
1110 pub fn theme(&self) -> FastApiTheme {
1111 FastApiTheme::from_preset(*self)
1112 }
1113
1114 #[must_use]
1116 pub fn available_presets() -> &'static [&'static str] {
1117 &[
1118 "default",
1119 "fastapi",
1120 "neon",
1121 "minimal",
1122 "monokai",
1123 "light",
1124 "accessible",
1125 ]
1126 }
1127}
1128
1129impl std::fmt::Display for ThemePreset {
1130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131 match self {
1132 Self::Default => write!(f, "default"),
1133 Self::FastApi => write!(f, "fastapi"),
1134 Self::Neon => write!(f, "neon"),
1135 Self::Minimal => write!(f, "minimal"),
1136 Self::Monokai => write!(f, "monokai"),
1137 Self::Light => write!(f, "light"),
1138 Self::Accessible => write!(f, "accessible"),
1139 }
1140 }
1141}
1142
1143impl FromStr for ThemePreset {
1144 type Err = ThemePresetParseError;
1145
1146 fn from_str(s: &str) -> Result<Self, Self::Err> {
1147 match s.to_lowercase().as_str() {
1148 "default" | "fastapi" => Ok(Self::FastApi),
1149 "neon" | "cyberpunk" => Ok(Self::Neon),
1150 "minimal" | "gray" | "grey" => Ok(Self::Minimal),
1151 "monokai" | "dark" => Ok(Self::Monokai),
1152 "light" => Ok(Self::Light),
1153 "accessible" | "a11y" => Ok(Self::Accessible),
1154 _ => Err(ThemePresetParseError(s.to_string())),
1155 }
1156 }
1157}
1158
1159#[derive(Debug, Clone)]
1161pub struct ThemePresetParseError(String);
1162
1163impl ThemePresetParseError {
1164 #[must_use]
1166 pub fn invalid_name(&self) -> &str {
1167 &self.0
1168 }
1169}
1170
1171impl std::fmt::Display for ThemePresetParseError {
1172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173 write!(
1174 f,
1175 "unknown theme preset '{}', available: {}",
1176 self.0,
1177 ThemePreset::available_presets().join(", ")
1178 )
1179 }
1180}
1181
1182impl std::error::Error for ThemePresetParseError {}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 fn is_not_black(c: Color) -> bool {
1189 c.r > 0 || c.g > 0 || c.b > 0
1190 }
1191
1192 #[test]
1195 fn test_color_from_hex() {
1196 let color = Color::from_hex(0xFF5500);
1197 assert_eq!(color.r, 0xFF);
1198 assert_eq!(color.g, 0x55);
1199 assert_eq!(color.b, 0x00);
1200 }
1201
1202 #[test]
1203 fn test_color_to_hex() {
1204 let color = Color::new(255, 85, 0);
1205 assert_eq!(color.to_hex(), "#ff5500");
1206 }
1207
1208 #[test]
1209 fn test_color_to_rgb() {
1210 let color = Color::new(100, 150, 200);
1211 assert_eq!(color.to_rgb(), (100, 150, 200));
1212 }
1213
1214 #[test]
1215 fn test_color_to_ansi() {
1216 let color = Color::new(255, 128, 64);
1217 assert_eq!(color.to_ansi_fg(), "\x1b[38;2;255;128;64m");
1218 assert_eq!(color.to_ansi_bg(), "\x1b[48;2;255;128;64m");
1219 }
1220
1221 #[test]
1224 fn test_rgb_to_hex() {
1225 assert_eq!(rgb_to_hex((0, 150, 136)), "#009688");
1226 assert_eq!(rgb_to_hex((255, 255, 255)), "#ffffff");
1227 assert_eq!(rgb_to_hex((0, 0, 0)), "#000000");
1228 }
1229
1230 #[test]
1231 fn test_hex_to_rgb_6_digit() {
1232 assert_eq!(hex_to_rgb("#009688"), Some((0, 150, 136)));
1233 assert_eq!(hex_to_rgb("009688"), Some((0, 150, 136)));
1234 assert_eq!(hex_to_rgb("#FF5500"), Some((255, 85, 0)));
1235 assert_eq!(hex_to_rgb("#ffffff"), Some((255, 255, 255)));
1236 }
1237
1238 #[test]
1239 fn test_hex_to_rgb_3_digit() {
1240 assert_eq!(hex_to_rgb("#F00"), Some((255, 0, 0)));
1241 assert_eq!(hex_to_rgb("0F0"), Some((0, 255, 0)));
1242 assert_eq!(hex_to_rgb("#FFF"), Some((255, 255, 255)));
1243 }
1244
1245 #[test]
1246 fn test_hex_to_rgb_invalid() {
1247 assert_eq!(hex_to_rgb("invalid"), None);
1248 assert_eq!(hex_to_rgb("#12345"), None);
1249 assert_eq!(hex_to_rgb(""), None);
1250 assert_eq!(hex_to_rgb("#GGG"), None);
1251 }
1252
1253 #[test]
1256 fn test_theme_default_has_all_colors() {
1257 let theme = FastApiTheme::default();
1258
1259 assert!(is_not_black(theme.primary));
1261 assert!(is_not_black(theme.secondary));
1262 assert!(is_not_black(theme.accent));
1263
1264 assert!(is_not_black(theme.success));
1266 assert!(is_not_black(theme.warning));
1267 assert!(is_not_black(theme.error));
1268 assert!(is_not_black(theme.info));
1269
1270 assert!(is_not_black(theme.http_get));
1272 assert!(is_not_black(theme.http_post));
1273 assert!(is_not_black(theme.http_put));
1274 assert!(is_not_black(theme.http_delete));
1275 }
1276
1277 #[test]
1278 fn test_theme_presets_differ() {
1279 let fastapi = FastApiTheme::fastapi();
1280 let neon = FastApiTheme::neon();
1281 let minimal = FastApiTheme::minimal();
1282 let monokai = FastApiTheme::monokai();
1283 let light = FastApiTheme::light();
1284 let accessible = FastApiTheme::accessible();
1285
1286 assert_ne!(fastapi, neon);
1287 assert_ne!(fastapi, minimal);
1288 assert_ne!(fastapi, monokai);
1289 assert_ne!(fastapi, light);
1290 assert_ne!(fastapi, accessible);
1291 assert_ne!(neon, minimal);
1292 assert_ne!(neon, monokai);
1293 assert_ne!(neon, light);
1294 assert_ne!(neon, accessible);
1295 assert_ne!(minimal, monokai);
1296 assert_ne!(minimal, light);
1297 assert_ne!(minimal, accessible);
1298 assert_ne!(monokai, light);
1299 assert_ne!(monokai, accessible);
1300 assert_ne!(light, accessible);
1301 }
1302
1303 #[test]
1304 fn test_theme_from_preset() {
1305 assert_eq!(
1306 FastApiTheme::from_preset(ThemePreset::Default),
1307 FastApiTheme::fastapi()
1308 );
1309 assert_eq!(
1310 FastApiTheme::from_preset(ThemePreset::FastApi),
1311 FastApiTheme::fastapi()
1312 );
1313 assert_eq!(
1314 FastApiTheme::from_preset(ThemePreset::Neon),
1315 FastApiTheme::neon()
1316 );
1317 assert_eq!(
1318 FastApiTheme::from_preset(ThemePreset::Minimal),
1319 FastApiTheme::minimal()
1320 );
1321 assert_eq!(
1322 FastApiTheme::from_preset(ThemePreset::Monokai),
1323 FastApiTheme::monokai()
1324 );
1325 assert_eq!(
1326 FastApiTheme::from_preset(ThemePreset::Light),
1327 FastApiTheme::light()
1328 );
1329 assert_eq!(
1330 FastApiTheme::from_preset(ThemePreset::Accessible),
1331 FastApiTheme::accessible()
1332 );
1333 }
1334
1335 #[test]
1338 fn test_http_method_colors() {
1339 let theme = FastApiTheme::default();
1340
1341 assert_eq!(theme.http_method_color("GET"), theme.http_get);
1342 assert_eq!(theme.http_method_color("get"), theme.http_get);
1343 assert_eq!(theme.http_method_color("POST"), theme.http_post);
1344 assert_eq!(theme.http_method_color("PUT"), theme.http_put);
1345 assert_eq!(theme.http_method_color("DELETE"), theme.http_delete);
1346 assert_eq!(theme.http_method_color("PATCH"), theme.http_patch);
1347 assert_eq!(theme.http_method_color("OPTIONS"), theme.http_options);
1348 assert_eq!(theme.http_method_color("HEAD"), theme.http_head);
1349 assert_eq!(theme.http_method_color("UNKNOWN"), theme.muted);
1350 }
1351
1352 #[test]
1353 fn test_http_method_hex() {
1354 let theme = FastApiTheme::default();
1355 assert_eq!(theme.http_method_hex("GET"), theme.http_get.to_hex());
1356 assert_eq!(theme.http_method_hex("POST"), theme.http_post.to_hex());
1357 }
1358
1359 #[test]
1362 fn test_status_code_colors() {
1363 let theme = FastApiTheme::default();
1364
1365 assert_eq!(theme.status_code_color(100), theme.status_1xx);
1366 assert_eq!(theme.status_code_color(199), theme.status_1xx);
1367 assert_eq!(theme.status_code_color(200), theme.status_2xx);
1368 assert_eq!(theme.status_code_color(201), theme.status_2xx);
1369 assert_eq!(theme.status_code_color(301), theme.status_3xx);
1370 assert_eq!(theme.status_code_color(404), theme.status_4xx);
1371 assert_eq!(theme.status_code_color(500), theme.status_5xx);
1372 assert_eq!(theme.status_code_color(503), theme.status_5xx);
1373 assert_eq!(theme.status_code_color(600), theme.muted);
1374 assert_eq!(theme.status_code_color(99), theme.muted);
1375 }
1376
1377 #[test]
1378 fn test_status_code_hex() {
1379 let theme = FastApiTheme::default();
1380 assert_eq!(theme.status_code_hex(200), theme.status_2xx.to_hex());
1381 assert_eq!(theme.status_code_hex(500), theme.status_5xx.to_hex());
1382 }
1383
1384 #[test]
1387 fn test_hex_helpers() {
1388 let theme = FastApiTheme::default();
1389
1390 assert_eq!(theme.primary_hex(), theme.primary.to_hex());
1391 assert_eq!(theme.success_hex(), theme.success.to_hex());
1392 assert_eq!(theme.error_hex(), theme.error.to_hex());
1393 assert_eq!(theme.warning_hex(), theme.warning.to_hex());
1394 assert_eq!(theme.info_hex(), theme.info.to_hex());
1395 assert_eq!(theme.accent_hex(), theme.accent.to_hex());
1396 }
1397
1398 #[test]
1401 fn test_theme_preset_display() {
1402 assert_eq!(ThemePreset::Default.to_string(), "default");
1403 assert_eq!(ThemePreset::FastApi.to_string(), "fastapi");
1404 assert_eq!(ThemePreset::Neon.to_string(), "neon");
1405 assert_eq!(ThemePreset::Minimal.to_string(), "minimal");
1406 assert_eq!(ThemePreset::Monokai.to_string(), "monokai");
1407 assert_eq!(ThemePreset::Light.to_string(), "light");
1408 assert_eq!(ThemePreset::Accessible.to_string(), "accessible");
1409 }
1410
1411 #[test]
1412 fn test_theme_preset_from_str() {
1413 assert_eq!(
1414 "default".parse::<ThemePreset>().unwrap(),
1415 ThemePreset::FastApi
1416 );
1417 assert_eq!(
1418 "fastapi".parse::<ThemePreset>().unwrap(),
1419 ThemePreset::FastApi
1420 );
1421 assert_eq!(
1422 "FASTAPI".parse::<ThemePreset>().unwrap(),
1423 ThemePreset::FastApi
1424 );
1425 assert_eq!("neon".parse::<ThemePreset>().unwrap(), ThemePreset::Neon);
1426 assert_eq!(
1427 "cyberpunk".parse::<ThemePreset>().unwrap(),
1428 ThemePreset::Neon
1429 );
1430 assert_eq!(
1431 "minimal".parse::<ThemePreset>().unwrap(),
1432 ThemePreset::Minimal
1433 );
1434 assert_eq!("gray".parse::<ThemePreset>().unwrap(), ThemePreset::Minimal);
1435 assert_eq!("grey".parse::<ThemePreset>().unwrap(), ThemePreset::Minimal);
1436 assert_eq!(
1437 "monokai".parse::<ThemePreset>().unwrap(),
1438 ThemePreset::Monokai
1439 );
1440 assert_eq!("dark".parse::<ThemePreset>().unwrap(), ThemePreset::Monokai);
1441 assert_eq!("light".parse::<ThemePreset>().unwrap(), ThemePreset::Light);
1442 assert_eq!(
1443 "accessible".parse::<ThemePreset>().unwrap(),
1444 ThemePreset::Accessible
1445 );
1446 assert_eq!(
1447 "a11y".parse::<ThemePreset>().unwrap(),
1448 ThemePreset::Accessible
1449 );
1450 }
1451
1452 #[test]
1453 fn test_theme_preset_from_str_invalid() {
1454 let err = "invalid".parse::<ThemePreset>().unwrap_err();
1455 assert_eq!(err.invalid_name(), "invalid");
1456 assert!(err.to_string().contains("invalid"));
1457 assert!(err.to_string().contains("available"));
1458 }
1459
1460 #[test]
1461 fn test_theme_preset_theme() {
1462 assert_eq!(ThemePreset::FastApi.theme(), FastApiTheme::fastapi());
1463 assert_eq!(ThemePreset::Neon.theme(), FastApiTheme::neon());
1464 assert_eq!(ThemePreset::Light.theme(), FastApiTheme::light());
1465 assert_eq!(ThemePreset::Accessible.theme(), FastApiTheme::accessible());
1466 }
1467
1468 #[test]
1469 fn test_available_presets() {
1470 let presets = ThemePreset::available_presets();
1471 assert!(presets.contains(&"default"));
1472 assert!(presets.contains(&"fastapi"));
1473 assert!(presets.contains(&"neon"));
1474 assert!(presets.contains(&"minimal"));
1475 assert!(presets.contains(&"monokai"));
1476 assert!(presets.contains(&"light"));
1477 assert!(presets.contains(&"accessible"));
1478 }
1479
1480 #[test]
1483 fn test_theme_icons_unicode() {
1484 let icons = ThemeIcons::unicode();
1485 assert!(!icons.success.is_empty());
1486 assert!(!icons.failure.is_empty());
1487 assert!(!icons.warning.is_empty());
1488 assert!(!icons.info.is_empty());
1489 }
1490
1491 #[test]
1492 fn test_theme_icons_ascii() {
1493 let icons = ThemeIcons::ascii();
1494 assert!(icons.success.is_ascii());
1495 assert!(icons.failure.is_ascii());
1496 assert!(icons.warning.is_ascii());
1497 assert!(icons.info.is_ascii());
1498 }
1499
1500 #[test]
1501 fn test_theme_icons_compact() {
1502 let icons = ThemeIcons::compact();
1503 assert!(!icons.success.is_empty());
1504 assert!(!icons.arrow_right.is_empty());
1505 }
1506
1507 #[test]
1510 fn test_theme_spacing_default() {
1511 let spacing = ThemeSpacing::default();
1512 assert!(spacing.indent > 0);
1513 assert!(spacing.method_width >= 7); }
1515
1516 #[test]
1517 fn test_theme_spacing_compact() {
1518 let compact = ThemeSpacing::compact();
1519 let default = ThemeSpacing::default();
1520 assert!(compact.indent <= default.indent);
1521 }
1522
1523 #[test]
1524 fn test_theme_spacing_spacious() {
1525 let spacious = ThemeSpacing::spacious();
1526 let default = ThemeSpacing::default();
1527 assert!(spacious.indent >= default.indent);
1528 }
1529
1530 #[test]
1533 fn test_box_style_rounded() {
1534 let style = BoxStyle::rounded();
1535 assert_ne!(style.top_left, style.horizontal);
1536 assert_ne!(style.vertical, style.horizontal);
1537 }
1538
1539 #[test]
1540 fn test_box_style_ascii() {
1541 let style = BoxStyle::ascii();
1542 assert_eq!(style.top_left, '+');
1543 assert_eq!(style.horizontal, '-');
1544 assert_eq!(style.vertical, '|');
1545 }
1546
1547 #[test]
1548 fn test_box_style_horizontal_line() {
1549 let style = BoxStyle::rounded();
1550 let line = style.horizontal_line(5);
1551 assert_eq!(line.chars().count(), 5);
1552 }
1553
1554 #[test]
1555 fn test_box_style_borders() {
1556 let style = BoxStyle::rounded();
1557 let top = style.top_border(10);
1558 let bottom = style.bottom_border(10);
1559 assert!(top.starts_with(style.top_left));
1560 assert!(top.ends_with(style.top_right));
1561 assert!(bottom.starts_with(style.bottom_left));
1562 assert!(bottom.ends_with(style.bottom_right));
1563 }
1564
1565 #[test]
1566 fn test_box_style_preset_parse() {
1567 assert_eq!(
1568 "rounded".parse::<BoxStylePreset>().unwrap(),
1569 BoxStylePreset::Rounded
1570 );
1571 assert_eq!(
1572 "heavy".parse::<BoxStylePreset>().unwrap(),
1573 BoxStylePreset::Heavy
1574 );
1575 assert_eq!(
1576 "ascii".parse::<BoxStylePreset>().unwrap(),
1577 BoxStylePreset::Ascii
1578 );
1579 assert!("invalid".parse::<BoxStylePreset>().is_err());
1580 }
1581
1582 #[test]
1585 fn test_color_luminance() {
1586 let black = Color::new(0, 0, 0);
1587 let white = Color::new(255, 255, 255);
1588 assert!(black.luminance() < 0.01);
1589 assert!(white.luminance() > 0.99);
1590 }
1591
1592 #[test]
1593 fn test_color_contrast_ratio() {
1594 let black = Color::new(0, 0, 0);
1595 let white = Color::new(255, 255, 255);
1596 let ratio = black.contrast_ratio(&white);
1597 assert!(ratio > 20.0);
1599 assert!(ratio <= 21.0);
1600 }
1601
1602 #[test]
1603 fn test_accessible_theme_high_contrast() {
1604 let accessible = FastApiTheme::accessible();
1605 let black = Color::new(0, 0, 0);
1606
1607 assert!(accessible.success.contrast_ratio(&black) >= 4.5);
1609 assert!(accessible.error.contrast_ratio(&black) >= 4.5);
1610 assert!(accessible.warning.contrast_ratio(&black) >= 4.5);
1611 assert!(accessible.info.contrast_ratio(&black) >= 4.5);
1612 }
1613}