1use ratatui::style::{Color, Style};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::Display;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum ThemeColor {
7 Rgb(u8, u8, u8),
8 Ansi(u8),
11 Reset,
13}
14
15impl Serialize for ThemeColor {
16 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
17 where
18 S: Serializer,
19 {
20 match self {
21 ThemeColor::Rgb(r, g, b) => {
22 serializer.serialize_str(&format!("#{:02x}{:02x}{:02x}", r, g, b))
23 }
24 ThemeColor::Ansi(n) => serializer.serialize_str(&format!("ansi:{}", n)),
25 ThemeColor::Reset => serializer.serialize_str("reset"),
26 }
27 }
28}
29
30impl<'de> Deserialize<'de> for ThemeColor {
31 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
32 where
33 D: Deserializer<'de>,
34 {
35 let s = String::deserialize(deserializer)?;
36 ThemeColor::from_string(&s).map_err(serde::de::Error::custom)
37 }
38}
39
40impl ThemeColor {
41 pub fn new(r: u8, g: u8, b: u8) -> Self {
42 ThemeColor::Rgb(r, g, b)
43 }
44
45 pub fn to_ratatui(&self) -> Color {
52 match self {
53 ThemeColor::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
54 ThemeColor::Ansi(n) => match n {
55 0 => Color::Black,
56 1 => Color::Red,
57 2 => Color::Green,
58 3 => Color::Yellow,
59 4 => Color::Blue,
60 5 => Color::Magenta,
61 6 => Color::Cyan,
62 7 => Color::Gray,
63 8 => Color::DarkGray,
64 9 => Color::LightRed,
65 10 => Color::LightGreen,
66 11 => Color::LightYellow,
67 12 => Color::LightBlue,
68 13 => Color::LightMagenta,
69 14 => Color::LightCyan,
70 15 => Color::White,
71 _ => Color::Indexed(*n),
72 },
73 ThemeColor::Reset => Color::Reset,
74 }
75 }
76
77 pub fn from_string(s: &str) -> Result<Self, String> {
84 let s = s.trim();
85
86 if s.starts_with('#') {
87 Self::from_hex(s)
88 } else if s.starts_with("rgb(") && s.ends_with(')') {
89 Self::from_rgb_string(s)
90 } else if s == "reset" {
91 Ok(ThemeColor::Reset)
92 } else if let Some(rest) = s.strip_prefix("ansi:") {
93 rest.parse::<u8>()
94 .map(ThemeColor::Ansi)
95 .map_err(|_| format!("Invalid ANSI color index: {}", rest))
96 } else {
97 Err(format!("Invalid color format: {}", s))
98 }
99 }
100
101 fn from_hex(s: &str) -> Result<Self, String> {
103 if !s.starts_with('#') {
104 return Err("Hex color must start with #".to_string());
105 }
106
107 let hex = &s[1..];
108
109 match hex.len() {
110 3 => Self::from_hex_3char(hex),
111 6 => Self::from_hex_6char(hex),
112 _ => Err(format!(
113 "Invalid hex color length: expected 3 or 6 chars, got {}",
114 hex.len()
115 )),
116 }
117 }
118
119 fn from_hex_3char(hex: &str) -> Result<Self, String> {
121 if hex.len() != 3 {
122 return Err("Expected 3 hex characters".to_string());
123 }
124
125 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
126 .map_err(|_| format!("Invalid hex character in red component: {}", &hex[0..1]))?;
127 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
128 .map_err(|_| format!("Invalid hex character in green component: {}", &hex[1..2]))?;
129 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
130 .map_err(|_| format!("Invalid hex character in blue component: {}", &hex[2..3]))?;
131
132 Ok(ThemeColor::Rgb(r, g, b))
133 }
134
135 fn from_hex_6char(hex: &str) -> Result<Self, String> {
137 if hex.len() != 6 {
138 return Err("Expected 6 hex characters".to_string());
139 }
140
141 let r = u8::from_str_radix(&hex[0..2], 16)
142 .map_err(|_| format!("Invalid hex characters in red component: {}", &hex[0..2]))?;
143 let g = u8::from_str_radix(&hex[2..4], 16)
144 .map_err(|_| format!("Invalid hex characters in green component: {}", &hex[2..4]))?;
145 let b = u8::from_str_radix(&hex[4..6], 16)
146 .map_err(|_| format!("Invalid hex characters in blue component: {}", &hex[4..6]))?;
147
148 Ok(ThemeColor::Rgb(r, g, b))
149 }
150
151 fn from_rgb_string(s: &str) -> Result<Self, String> {
153 if !s.starts_with("rgb(") || !s.ends_with(')') {
154 return Err("RGB format must be rgb(r, g, b)".to_string());
155 }
156
157 let inner = &s[4..s.len() - 1];
158 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
159
160 if parts.len() != 3 {
161 return Err(format!("RGB format requires 3 values, got {}", parts.len()));
162 }
163
164 let r = parts[0]
165 .parse::<u8>()
166 .map_err(|_| format!("Invalid red value: {}", parts[0]))?;
167 let g = parts[1]
168 .parse::<u8>()
169 .map_err(|_| format!("Invalid green value: {}", parts[1]))?;
170 let b = parts[2]
171 .parse::<u8>()
172 .map_err(|_| format!("Invalid blue value: {}", parts[2]))?;
173
174 Ok(ThemeColor::Rgb(r, g, b))
175 }
176}
177
178impl Display for ThemeColor {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 ThemeColor::Rgb(r, g, b) => write!(f, "rgb({},{},{})", r, g, b),
182 ThemeColor::Ansi(n) => write!(f, "ansi:{}", n),
183 ThemeColor::Reset => write!(f, "reset"),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct Theme {
214 pub name: String,
215
216 pub bg: ThemeColor,
219 pub bg_panel: ThemeColor,
221 pub bg_selected: ThemeColor,
223
224 pub fg: ThemeColor,
227 pub fg_secondary: ThemeColor,
229 pub fg_muted: ThemeColor,
231 pub fg_selected: ThemeColor,
233
234 pub border: ThemeColor,
237 pub border_focused: ThemeColor,
239
240 pub accent: ThemeColor,
243
244 pub color_directory: ThemeColor,
247 pub color_journal_date: ThemeColor,
249 pub color_search_match: ThemeColor,
251 #[serde(default = "default_color_tag")]
253 pub color_tag: ThemeColor,
254}
255
256fn default_color_tag() -> ThemeColor {
259 ThemeColor::from_string("#fe8019").unwrap()
260}
261
262impl Default for Theme {
263 fn default() -> Self {
264 Self::gruvbox_dark()
265 }
266}
267
268impl Theme {
269 pub fn gruvbox_dark() -> Self {
272 Theme {
273 name: "Gruvbox Dark".to_string(),
274 bg: ThemeColor::from_string("#282828").unwrap(),
275 bg_panel: ThemeColor::from_string("#32302f").unwrap(),
276 bg_selected: ThemeColor::from_string("#504945").unwrap(),
277 fg: ThemeColor::from_string("#ebdbb2").unwrap(),
278 fg_secondary: ThemeColor::from_string("#a89984").unwrap(),
279 fg_muted: ThemeColor::from_string("#7c6f64").unwrap(),
280 fg_selected: ThemeColor::from_string("#fbf1c7").unwrap(),
281 border: ThemeColor::from_string("#504945").unwrap(),
282 border_focused: ThemeColor::from_string("#fabd2f").unwrap(),
283 accent: ThemeColor::from_string("#fabd2f").unwrap(),
284 color_directory: ThemeColor::from_string("#83a598").unwrap(),
285 color_journal_date: ThemeColor::from_string("#8ec07c").unwrap(),
286 color_search_match: ThemeColor::from_string("#b8bb26").unwrap(),
287 color_tag: ThemeColor::from_string("#fe8019").unwrap(),
288 }
289 }
290
291 pub fn gruvbox_light() -> Self {
292 Theme {
293 name: "Gruvbox Light".to_string(),
294 bg: ThemeColor::from_string("#fbf1c7").unwrap(),
295 bg_panel: ThemeColor::from_string("#f2e5bc").unwrap(),
296 bg_selected: ThemeColor::from_string("#ebdbb2").unwrap(),
297 fg: ThemeColor::from_string("#3c3836").unwrap(),
298 fg_secondary: ThemeColor::from_string("#7c6f64").unwrap(),
299 fg_muted: ThemeColor::from_string("#a89984").unwrap(),
300 fg_selected: ThemeColor::from_string("#282828").unwrap(),
301 border: ThemeColor::from_string("#d5c4a1").unwrap(),
302 border_focused: ThemeColor::from_string("#d79921").unwrap(),
303 accent: ThemeColor::from_string("#d79921").unwrap(),
304 color_directory: ThemeColor::from_string("#458588").unwrap(),
305 color_journal_date: ThemeColor::from_string("#689d6a").unwrap(),
306 color_search_match: ThemeColor::from_string("#98971a").unwrap(),
307 color_tag: ThemeColor::from_string("#af3a03").unwrap(),
308 }
309 }
310
311 pub fn catppuccin_mocha() -> Self {
312 Theme {
313 name: "Catppuccin Mocha".to_string(),
314 bg: ThemeColor::from_string("#1e1e2e").unwrap(),
315 bg_panel: ThemeColor::from_string("#181825").unwrap(),
316 bg_selected: ThemeColor::from_string("#313244").unwrap(),
317 fg: ThemeColor::from_string("#cdd6f4").unwrap(),
318 fg_secondary: ThemeColor::from_string("#a6adc8").unwrap(),
319 fg_muted: ThemeColor::from_string("#6c7086").unwrap(),
320 fg_selected: ThemeColor::from_string("#cdd6f4").unwrap(),
321 border: ThemeColor::from_string("#45475a").unwrap(),
322 border_focused: ThemeColor::from_string("#89b4fa").unwrap(),
323 accent: ThemeColor::from_string("#cba6f7").unwrap(),
324 color_directory: ThemeColor::from_string("#89dceb").unwrap(),
325 color_journal_date: ThemeColor::from_string("#94e2d5").unwrap(),
326 color_search_match: ThemeColor::from_string("#a6e3a1").unwrap(),
327 color_tag: ThemeColor::from_string("#fab387").unwrap(),
328 }
329 }
330
331 pub fn catppuccin_latte() -> Self {
332 Theme {
333 name: "Catppuccin Latte".to_string(),
334 bg: ThemeColor::from_string("#eff1f5").unwrap(),
335 bg_panel: ThemeColor::from_string("#e6e9ef").unwrap(),
336 bg_selected: ThemeColor::from_string("#ccd0da").unwrap(),
337 fg: ThemeColor::from_string("#4c4f69").unwrap(),
338 fg_secondary: ThemeColor::from_string("#6c6f85").unwrap(),
339 fg_muted: ThemeColor::from_string("#9ca0b0").unwrap(),
340 fg_selected: ThemeColor::from_string("#4c4f69").unwrap(),
341 border: ThemeColor::from_string("#ccd0da").unwrap(),
342 border_focused: ThemeColor::from_string("#1e66f5").unwrap(),
343 accent: ThemeColor::from_string("#8839ef").unwrap(),
344 color_directory: ThemeColor::from_string("#04a5e5").unwrap(),
345 color_journal_date: ThemeColor::from_string("#179299").unwrap(),
346 color_search_match: ThemeColor::from_string("#40a02b").unwrap(),
347 color_tag: ThemeColor::from_string("#fe640b").unwrap(),
348 }
349 }
350
351 pub fn tokyo_night() -> Self {
352 Theme {
353 name: "Tokyo Night".to_string(),
354 bg: ThemeColor::from_string("#1a1b26").unwrap(),
355 bg_panel: ThemeColor::from_string("#16161e").unwrap(),
356 bg_selected: ThemeColor::from_string("#292e42").unwrap(),
357 fg: ThemeColor::from_string("#c0caf5").unwrap(),
358 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
359 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
360 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
361 border: ThemeColor::from_string("#3b4261").unwrap(),
362 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
363 accent: ThemeColor::from_string("#7aa2f7").unwrap(),
364 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
365 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
366 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
367 color_tag: ThemeColor::from_string("#ff9e64").unwrap(),
368 }
369 }
370
371 pub fn tokyo_night_storm() -> Self {
372 Theme {
373 name: "Tokyo Night Storm".to_string(),
374 bg: ThemeColor::from_string("#24283b").unwrap(),
375 bg_panel: ThemeColor::from_string("#1f2335").unwrap(),
376 bg_selected: ThemeColor::from_string("#364a82").unwrap(),
377 fg: ThemeColor::from_string("#c0caf5").unwrap(),
378 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
379 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
380 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
381 border: ThemeColor::from_string("#3b4261").unwrap(),
382 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
383 accent: ThemeColor::from_string("#bb9af7").unwrap(),
384 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
385 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
386 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
387 color_tag: ThemeColor::from_string("#ff9e64").unwrap(),
388 }
389 }
390
391 pub fn solarized_dark() -> Self {
392 Theme {
393 name: "Solarized Dark".to_string(),
394 bg: ThemeColor::from_string("#002b36").unwrap(),
395 bg_panel: ThemeColor::from_string("#073642").unwrap(),
396 bg_selected: ThemeColor::from_string("#586e75").unwrap(),
397 fg: ThemeColor::from_string("#839496").unwrap(),
398 fg_secondary: ThemeColor::from_string("#657b83").unwrap(),
399 fg_muted: ThemeColor::from_string("#586e75").unwrap(),
400 fg_selected: ThemeColor::from_string("#eee8d5").unwrap(),
401 border: ThemeColor::from_string("#073642").unwrap(),
402 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
403 accent: ThemeColor::from_string("#268bd2").unwrap(),
404 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
405 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
406 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
407 color_tag: ThemeColor::from_string("#cb4b16").unwrap(),
408 }
409 }
410
411 pub fn solarized_light() -> Self {
412 Theme {
413 name: "Solarized Light".to_string(),
414 bg: ThemeColor::from_string("#fdf6e3").unwrap(),
415 bg_panel: ThemeColor::from_string("#eee8d5").unwrap(),
416 bg_selected: ThemeColor::from_string("#93a1a1").unwrap(),
417 fg: ThemeColor::from_string("#657b83").unwrap(),
418 fg_secondary: ThemeColor::from_string("#839496").unwrap(),
419 fg_muted: ThemeColor::from_string("#93a1a1").unwrap(),
420 fg_selected: ThemeColor::from_string("#073642").unwrap(),
421 border: ThemeColor::from_string("#eee8d5").unwrap(),
422 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
423 accent: ThemeColor::from_string("#268bd2").unwrap(),
424 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
425 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
426 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
427 color_tag: ThemeColor::from_string("#cb4b16").unwrap(),
428 }
429 }
430
431 pub fn border_style(&self, focused: bool) -> Style {
433 if focused {
434 Style::default().fg(self.border_focused.to_ratatui())
435 } else {
436 Style::default().fg(self.border.to_ratatui())
437 }
438 }
439
440 pub fn base_style(&self) -> Style {
442 Style::default()
443 .fg(self.fg.to_ratatui())
444 .bg(self.bg.to_ratatui())
445 }
446
447 pub fn panel_style(&self) -> Style {
449 Style::default()
450 .fg(self.fg.to_ratatui())
451 .bg(self.bg_panel.to_ratatui())
452 }
453
454 pub fn nord() -> Self {
455 Theme {
456 name: "Nord".to_string(),
457 bg: ThemeColor::from_string("#2e3440").unwrap(),
458 bg_panel: ThemeColor::from_string("#3b4252").unwrap(),
459 bg_selected: ThemeColor::from_string("#434c5e").unwrap(),
460 fg: ThemeColor::from_string("#eceff4").unwrap(),
461 fg_secondary: ThemeColor::from_string("#d8dee9").unwrap(),
462 fg_muted: ThemeColor::from_string("#4c566a").unwrap(),
463 fg_selected: ThemeColor::from_string("#eceff4").unwrap(),
464 border: ThemeColor::from_string("#434c5e").unwrap(),
465 border_focused: ThemeColor::from_string("#81a1c1").unwrap(),
466 accent: ThemeColor::from_string("#88c0d0").unwrap(),
467 color_directory: ThemeColor::from_string("#81a1c1").unwrap(),
468 color_journal_date: ThemeColor::from_string("#8fbcbb").unwrap(),
469 color_search_match: ThemeColor::from_string("#a3be8c").unwrap(),
470 color_tag: ThemeColor::from_string("#d08770").unwrap(),
471 }
472 }
473
474 pub fn ansi() -> Self {
480 Theme {
481 name: "ANSI".to_string(),
482 bg: ThemeColor::Reset,
483 bg_panel: ThemeColor::Reset,
484 bg_selected: ThemeColor::Ansi(4), fg: ThemeColor::Reset,
486 fg_secondary: ThemeColor::Ansi(7), fg_muted: ThemeColor::Ansi(8), fg_selected: ThemeColor::Ansi(15), border: ThemeColor::Ansi(8), border_focused: ThemeColor::Ansi(6), accent: ThemeColor::Ansi(6), color_directory: ThemeColor::Ansi(12), color_journal_date: ThemeColor::Ansi(10), color_search_match: ThemeColor::Ansi(11), color_tag: ThemeColor::Ansi(3), }
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use ratatui::style::Style;
504
505 #[test]
506 fn test_border_style_focused() {
507 let theme = Theme::gruvbox_dark();
508 let style = theme.border_style(true);
509 assert_eq!(
510 style,
511 Style::default().fg(theme.border_focused.to_ratatui())
512 );
513 }
514
515 #[test]
516 fn test_border_style_unfocused() {
517 let theme = Theme::gruvbox_dark();
518 let style = theme.border_style(false);
519 assert_eq!(style, Style::default().fg(theme.border.to_ratatui()));
520 }
521
522 #[test]
523 fn test_from_hex_6char() {
524 assert_eq!(
525 ThemeColor::from_string("#ff8800").unwrap(),
526 ThemeColor::Rgb(255, 136, 0)
527 );
528 }
529
530 #[test]
531 fn test_from_hex_6char_lowercase() {
532 assert_eq!(
533 ThemeColor::from_string("#abcdef").unwrap(),
534 ThemeColor::Rgb(171, 205, 239)
535 );
536 }
537
538 #[test]
539 fn test_from_hex_6char_uppercase() {
540 assert_eq!(
541 ThemeColor::from_string("#ABCDEF").unwrap(),
542 ThemeColor::Rgb(171, 205, 239)
543 );
544 }
545
546 #[test]
547 fn test_from_hex_3char() {
548 assert_eq!(
549 ThemeColor::from_string("#f80").unwrap(),
550 ThemeColor::Rgb(255, 136, 0)
551 );
552 }
553
554 #[test]
555 fn test_from_hex_3char_expansion() {
556 assert_eq!(
557 ThemeColor::from_string("#abc").unwrap(),
558 ThemeColor::Rgb(170, 187, 204)
559 );
560 }
561
562 #[test]
563 fn test_from_hex_3char_black() {
564 assert_eq!(
565 ThemeColor::from_string("#000").unwrap(),
566 ThemeColor::Rgb(0, 0, 0)
567 );
568 }
569
570 #[test]
571 fn test_from_hex_3char_white() {
572 assert_eq!(
573 ThemeColor::from_string("#fff").unwrap(),
574 ThemeColor::Rgb(255, 255, 255)
575 );
576 }
577
578 #[test]
579 fn test_from_rgb_string() {
580 assert_eq!(
581 ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
582 ThemeColor::Rgb(255, 128, 0)
583 );
584 }
585
586 #[test]
587 fn test_from_rgb_string_no_spaces() {
588 assert_eq!(
589 ThemeColor::from_string("rgb(255,128,0)").unwrap(),
590 ThemeColor::Rgb(255, 128, 0)
591 );
592 }
593
594 #[test]
595 fn test_from_rgb_string_extra_spaces() {
596 assert_eq!(
597 ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
598 ThemeColor::Rgb(255, 128, 0)
599 );
600 }
601
602 #[test]
603 fn test_from_rgb_string_min_max() {
604 assert_eq!(
605 ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
606 ThemeColor::Rgb(0, 255, 0)
607 );
608 }
609
610 #[test]
611 fn test_from_string_with_whitespace() {
612 assert_eq!(
613 ThemeColor::from_string(" #ff8800 ").unwrap(),
614 ThemeColor::Rgb(255, 136, 0)
615 );
616 }
617
618 #[test]
619 fn test_ansi_to_ratatui() {
620 assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
622 assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
623 assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
624 assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
625 assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
626 assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
628 assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
629 }
630
631 #[test]
632 fn test_invalid_hex_length() {
633 let result = ThemeColor::from_string("#ff880");
634 assert!(result.is_err());
635 assert!(result.unwrap_err().contains("Invalid hex color length"));
636 }
637
638 #[test]
639 fn test_invalid_hex_chars() {
640 let result = ThemeColor::from_string("#gghhii");
641 assert!(result.is_err());
642 }
643
644 #[test]
645 fn test_missing_hash() {
646 let result = ThemeColor::from_string("ff8800");
647 assert!(result.is_err());
648 assert!(result.unwrap_err().contains("Invalid color format"));
649 }
650
651 #[test]
652 fn test_invalid_rgb_format() {
653 let result = ThemeColor::from_string("rgb(255, 128)");
654 assert!(result.is_err());
655 assert!(result.unwrap_err().contains("requires 3 values"));
656 }
657
658 #[test]
659 fn test_rgb_value_out_of_range() {
660 let result = ThemeColor::from_string("rgb(256, 128, 0)");
661 assert!(result.is_err());
662 }
663
664 #[test]
665 fn test_rgb_negative_value() {
666 let result = ThemeColor::from_string("rgb(-1, 128, 0)");
667 assert!(result.is_err());
668 }
669
670 #[test]
671 fn test_rgb_non_numeric() {
672 let result = ThemeColor::from_string("rgb(abc, 128, 0)");
673 assert!(result.is_err());
674 assert!(result.unwrap_err().contains("Invalid red value"));
675 }
676
677 #[test]
678 fn test_invalid_format() {
679 let result = ThemeColor::from_string("not a color");
680 assert!(result.is_err());
681 assert!(result.unwrap_err().contains("Invalid color format"));
682 }
683
684 #[test]
685 fn test_empty_string() {
686 let result = ThemeColor::from_string("");
687 assert!(result.is_err());
688 }
689
690 #[test]
691 fn test_new_constructor() {
692 assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
693 }
694
695 #[test]
696 fn test_to_ratatui() {
697 let color = ThemeColor::new(131, 165, 152);
698 assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
699 }
700
701 #[test]
702 fn test_theme_color_serialize() {
703 #[derive(Serialize)]
704 struct Wrapper {
705 color: ThemeColor,
706 }
707 let wrapper = Wrapper {
708 color: ThemeColor::new(59, 130, 246),
709 };
710 let serialized = toml::to_string(&wrapper).unwrap();
711 assert!(serialized.contains("color = \"#3b82f6\""));
712 }
713
714 #[test]
715 fn test_theme_color_deserialize() {
716 #[derive(Deserialize)]
717 struct Wrapper {
718 color: ThemeColor,
719 }
720 let toml_str = r###"color = "#3b82f6""###;
721 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
722 assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
723 }
724
725 #[test]
726 fn test_theme_color_roundtrip() {
727 #[derive(Serialize, Deserialize)]
728 struct Wrapper {
729 color: ThemeColor,
730 }
731 let original = Wrapper {
732 color: ThemeColor::new(239, 68, 68),
733 };
734 let serialized = toml::to_string(&original).unwrap();
735 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
736 assert_eq!(original.color, deserialized.color);
737 }
738
739 #[test]
740 fn test_theme_serialize_to_toml() {
741 let theme = Theme::gruvbox_dark();
742 let toml_string = toml::to_string_pretty(&theme).unwrap();
743
744 assert!(toml_string.contains("name = \"Gruvbox Dark\""));
745 assert!(toml_string.contains("bg = \"#282828\""));
746 assert!(toml_string.contains("bg_panel = \"#32302f\""));
747 assert!(toml_string.contains("border_focused = \"#fabd2f\""));
748 assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
749 }
750
751 #[test]
752 fn test_theme_deserialize_from_toml() {
753 let toml_str = r###"
754 name = "Test Theme"
755 bg = "#282828"
756 bg_panel = "#32302f"
757 bg_selected = "#504945"
758 fg = "#ebdbb2"
759 fg_secondary = "#a89984"
760 fg_muted = "#7c6f64"
761 fg_selected = "#fbf1c7"
762 border = "#504945"
763 border_focused = "#fabd2f"
764 accent = "#fabd2f"
765 color_directory = "#83a598"
766 color_journal_date = "#8ec07c"
767 color_search_match = "#b8bb26"
768 color_tag = "#fe8019"
769 "###;
770
771 let theme: Theme = toml::from_str(toml_str).unwrap();
772 assert_eq!(theme.name, "Test Theme");
773 assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
774 assert_eq!(theme.border_focused, ThemeColor::new(0xfa, 0xbd, 0x2f));
775 assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
776 }
777
778 #[test]
779 fn test_theme_roundtrip() {
780 let original = Theme::tokyo_night();
781 let toml_string = toml::to_string_pretty(&original).unwrap();
782 let deserialized: Theme = toml::from_str(&toml_string).unwrap();
783
784 assert_eq!(original.name, deserialized.name);
785 assert_eq!(original.bg, deserialized.bg);
786 assert_eq!(original.fg, deserialized.fg);
787 assert_eq!(original.border_focused, deserialized.border_focused);
788 assert_eq!(original.color_journal_date, deserialized.color_journal_date);
789 }
790
791 #[test]
792 fn test_theme_color_serialize_lowercase_hex() {
793 #[derive(Serialize)]
794 struct Wrapper {
795 color: ThemeColor,
796 }
797 let wrapper = Wrapper {
798 color: ThemeColor::new(171, 205, 239),
799 };
800 let serialized = toml::to_string(&wrapper).unwrap();
801 assert!(serialized.contains("color = \"#abcdef\""));
802 }
803
804 #[test]
805 fn test_theme_deserialize_uppercase_hex() {
806 #[derive(Deserialize)]
807 struct Wrapper {
808 color: ThemeColor,
809 }
810 let toml_str = r###"color = "#ABCDEF""###;
811 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
812 assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
813 }
814
815 #[test]
816 fn test_theme_deserialize_3char_hex() {
817 #[derive(Deserialize)]
818 struct Wrapper {
819 color: ThemeColor,
820 }
821 let toml_str = r###"color = "#abc""###;
822 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
823 assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
824 }
825
826 #[test]
827 fn test_from_ansi_index() {
828 assert_eq!(
829 ThemeColor::from_string("ansi:4").unwrap(),
830 ThemeColor::Ansi(4)
831 );
832 assert_eq!(
833 ThemeColor::from_string("ansi:255").unwrap(),
834 ThemeColor::Ansi(255)
835 );
836 }
837
838 #[test]
839 fn test_from_reset() {
840 assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
841 }
842
843 #[test]
844 fn test_all_builtin_themes_serialize() {
845 let themes = vec![
846 Theme::ansi(),
847 Theme::gruvbox_dark(),
848 Theme::gruvbox_light(),
849 Theme::catppuccin_mocha(),
850 Theme::catppuccin_latte(),
851 Theme::tokyo_night(),
852 Theme::tokyo_night_storm(),
853 Theme::solarized_dark(),
854 Theme::solarized_light(),
855 Theme::nord(),
856 ];
857 for theme in themes {
858 let toml_string = toml::to_string_pretty(&theme).unwrap();
859 let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
860 assert_eq!(theme.name, roundtrip.name);
861 assert_eq!(theme.bg, roundtrip.bg);
862 }
863 }
864
865 #[test]
866 fn test_ansi_theme() {
867 let theme = Theme::ansi();
868 assert_eq!(theme.name, "ANSI");
869 assert_eq!(theme.bg, ThemeColor::Reset);
870 assert_eq!(theme.fg, ThemeColor::Reset);
871 assert_eq!(theme.bg_selected, ThemeColor::Ansi(4));
872 assert_eq!(theme.border_focused, ThemeColor::Ansi(6));
873 assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
874 }
875}