1use ratatui::style::{Color, Style};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::Display;
4
5#[derive(Debug, Clone, PartialEq)]
6pub struct ThemeColor {
7 r: u8,
8 g: u8,
9 b: u8,
10}
11
12impl Serialize for ThemeColor {
13 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
14 where
15 S: Serializer,
16 {
17 let hex = format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b);
18 serializer.serialize_str(&hex)
19 }
20}
21
22impl<'de> Deserialize<'de> for ThemeColor {
23 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
24 where
25 D: Deserializer<'de>,
26 {
27 let s = String::deserialize(deserializer)?;
28 ThemeColor::from_string(&s).map_err(serde::de::Error::custom)
29 }
30}
31
32impl ThemeColor {
33 pub fn new(r: u8, g: u8, b: u8) -> Self {
34 ThemeColor { r, g, b }
35 }
36
37 pub fn to_ratatui(&self) -> Color {
39 Color::Rgb(self.r, self.g, self.b)
40 }
41
42 pub fn from_string(s: &str) -> Result<Self, String> {
47 let s = s.trim();
48
49 if s.starts_with('#') {
50 Self::from_hex(s)
51 } else if s.starts_with("rgb(") && s.ends_with(')') {
52 Self::from_rgb_string(s)
53 } else {
54 Err(format!("Invalid color format: {}", s))
55 }
56 }
57
58 fn from_hex(s: &str) -> Result<Self, String> {
60 if !s.starts_with('#') {
61 return Err("Hex color must start with #".to_string());
62 }
63
64 let hex = &s[1..];
65
66 match hex.len() {
67 3 => Self::from_hex_3char(hex),
68 6 => Self::from_hex_6char(hex),
69 _ => Err(format!(
70 "Invalid hex color length: expected 3 or 6 chars, got {}",
71 hex.len()
72 )),
73 }
74 }
75
76 fn from_hex_3char(hex: &str) -> Result<Self, String> {
78 if hex.len() != 3 {
79 return Err("Expected 3 hex characters".to_string());
80 }
81
82 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
83 .map_err(|_| format!("Invalid hex character in red component: {}", &hex[0..1]))?;
84 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
85 .map_err(|_| format!("Invalid hex character in green component: {}", &hex[1..2]))?;
86 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
87 .map_err(|_| format!("Invalid hex character in blue component: {}", &hex[2..3]))?;
88
89 Ok(ThemeColor { r, g, b })
90 }
91
92 fn from_hex_6char(hex: &str) -> Result<Self, String> {
94 if hex.len() != 6 {
95 return Err("Expected 6 hex characters".to_string());
96 }
97
98 let r = u8::from_str_radix(&hex[0..2], 16)
99 .map_err(|_| format!("Invalid hex characters in red component: {}", &hex[0..2]))?;
100 let g = u8::from_str_radix(&hex[2..4], 16)
101 .map_err(|_| format!("Invalid hex characters in green component: {}", &hex[2..4]))?;
102 let b = u8::from_str_radix(&hex[4..6], 16)
103 .map_err(|_| format!("Invalid hex characters in blue component: {}", &hex[4..6]))?;
104
105 Ok(ThemeColor { r, g, b })
106 }
107
108 fn from_rgb_string(s: &str) -> Result<Self, String> {
110 if !s.starts_with("rgb(") || !s.ends_with(')') {
111 return Err("RGB format must be rgb(r, g, b)".to_string());
112 }
113
114 let inner = &s[4..s.len() - 1];
115 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
116
117 if parts.len() != 3 {
118 return Err(format!("RGB format requires 3 values, got {}", parts.len()));
119 }
120
121 let r = parts[0]
122 .parse::<u8>()
123 .map_err(|_| format!("Invalid red value: {}", parts[0]))?;
124 let g = parts[1]
125 .parse::<u8>()
126 .map_err(|_| format!("Invalid green value: {}", parts[1]))?;
127 let b = parts[2]
128 .parse::<u8>()
129 .map_err(|_| format!("Invalid blue value: {}", parts[2]))?;
130
131 Ok(ThemeColor { r, g, b })
132 }
133}
134
135impl Display for ThemeColor {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 write!(f, "rgb({},{},{})", self.r, self.g, self.b)
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct Theme {
167 pub name: String,
168
169 pub bg: ThemeColor,
172 pub bg_panel: ThemeColor,
174 pub bg_selected: ThemeColor,
176
177 pub fg: ThemeColor,
180 pub fg_secondary: ThemeColor,
182 pub fg_muted: ThemeColor,
184 pub fg_selected: ThemeColor,
186
187 pub border: ThemeColor,
190 pub border_focused: ThemeColor,
192
193 pub accent: ThemeColor,
196
197 pub color_directory: ThemeColor,
200 pub color_journal_date: ThemeColor,
202 pub color_search_match: ThemeColor,
204}
205
206impl Default for Theme {
207 fn default() -> Self {
208 Self::gruvbox_dark()
209 }
210}
211
212impl Theme {
213 pub fn gruvbox_dark() -> Self {
216 Theme {
217 name: "Gruvbox Dark".to_string(),
218 bg: ThemeColor::from_string("#282828").unwrap(),
219 bg_panel: ThemeColor::from_string("#32302f").unwrap(),
220 bg_selected: ThemeColor::from_string("#504945").unwrap(),
221 fg: ThemeColor::from_string("#ebdbb2").unwrap(),
222 fg_secondary: ThemeColor::from_string("#a89984").unwrap(),
223 fg_muted: ThemeColor::from_string("#7c6f64").unwrap(),
224 fg_selected: ThemeColor::from_string("#fbf1c7").unwrap(),
225 border: ThemeColor::from_string("#504945").unwrap(),
226 border_focused: ThemeColor::from_string("#fabd2f").unwrap(),
227 accent: ThemeColor::from_string("#fabd2f").unwrap(),
228 color_directory: ThemeColor::from_string("#83a598").unwrap(),
229 color_journal_date: ThemeColor::from_string("#8ec07c").unwrap(),
230 color_search_match: ThemeColor::from_string("#b8bb26").unwrap(),
231 }
232 }
233
234 pub fn gruvbox_light() -> Self {
235 Theme {
236 name: "Gruvbox Light".to_string(),
237 bg: ThemeColor::from_string("#fbf1c7").unwrap(),
238 bg_panel: ThemeColor::from_string("#f2e5bc").unwrap(),
239 bg_selected: ThemeColor::from_string("#ebdbb2").unwrap(),
240 fg: ThemeColor::from_string("#3c3836").unwrap(),
241 fg_secondary: ThemeColor::from_string("#7c6f64").unwrap(),
242 fg_muted: ThemeColor::from_string("#a89984").unwrap(),
243 fg_selected: ThemeColor::from_string("#282828").unwrap(),
244 border: ThemeColor::from_string("#d5c4a1").unwrap(),
245 border_focused: ThemeColor::from_string("#d79921").unwrap(),
246 accent: ThemeColor::from_string("#d79921").unwrap(),
247 color_directory: ThemeColor::from_string("#458588").unwrap(),
248 color_journal_date: ThemeColor::from_string("#689d6a").unwrap(),
249 color_search_match: ThemeColor::from_string("#98971a").unwrap(),
250 }
251 }
252
253 pub fn catppuccin_mocha() -> Self {
254 Theme {
255 name: "Catppuccin Mocha".to_string(),
256 bg: ThemeColor::from_string("#1e1e2e").unwrap(),
257 bg_panel: ThemeColor::from_string("#181825").unwrap(),
258 bg_selected: ThemeColor::from_string("#313244").unwrap(),
259 fg: ThemeColor::from_string("#cdd6f4").unwrap(),
260 fg_secondary: ThemeColor::from_string("#a6adc8").unwrap(),
261 fg_muted: ThemeColor::from_string("#6c7086").unwrap(),
262 fg_selected: ThemeColor::from_string("#cdd6f4").unwrap(),
263 border: ThemeColor::from_string("#45475a").unwrap(),
264 border_focused: ThemeColor::from_string("#89b4fa").unwrap(),
265 accent: ThemeColor::from_string("#cba6f7").unwrap(),
266 color_directory: ThemeColor::from_string("#89dceb").unwrap(),
267 color_journal_date: ThemeColor::from_string("#94e2d5").unwrap(),
268 color_search_match: ThemeColor::from_string("#a6e3a1").unwrap(),
269 }
270 }
271
272 pub fn catppuccin_latte() -> Self {
273 Theme {
274 name: "Catppuccin Latte".to_string(),
275 bg: ThemeColor::from_string("#eff1f5").unwrap(),
276 bg_panel: ThemeColor::from_string("#e6e9ef").unwrap(),
277 bg_selected: ThemeColor::from_string("#ccd0da").unwrap(),
278 fg: ThemeColor::from_string("#4c4f69").unwrap(),
279 fg_secondary: ThemeColor::from_string("#6c6f85").unwrap(),
280 fg_muted: ThemeColor::from_string("#9ca0b0").unwrap(),
281 fg_selected: ThemeColor::from_string("#4c4f69").unwrap(),
282 border: ThemeColor::from_string("#ccd0da").unwrap(),
283 border_focused: ThemeColor::from_string("#1e66f5").unwrap(),
284 accent: ThemeColor::from_string("#8839ef").unwrap(),
285 color_directory: ThemeColor::from_string("#04a5e5").unwrap(),
286 color_journal_date: ThemeColor::from_string("#179299").unwrap(),
287 color_search_match: ThemeColor::from_string("#40a02b").unwrap(),
288 }
289 }
290
291 pub fn tokyo_night() -> Self {
292 Theme {
293 name: "Tokyo Night".to_string(),
294 bg: ThemeColor::from_string("#1a1b26").unwrap(),
295 bg_panel: ThemeColor::from_string("#16161e").unwrap(),
296 bg_selected: ThemeColor::from_string("#292e42").unwrap(),
297 fg: ThemeColor::from_string("#c0caf5").unwrap(),
298 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
299 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
300 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
301 border: ThemeColor::from_string("#3b4261").unwrap(),
302 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
303 accent: ThemeColor::from_string("#7aa2f7").unwrap(),
304 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
305 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
306 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
307 }
308 }
309
310 pub fn tokyo_night_storm() -> Self {
311 Theme {
312 name: "Tokyo Night Storm".to_string(),
313 bg: ThemeColor::from_string("#24283b").unwrap(),
314 bg_panel: ThemeColor::from_string("#1f2335").unwrap(),
315 bg_selected: ThemeColor::from_string("#364a82").unwrap(),
316 fg: ThemeColor::from_string("#c0caf5").unwrap(),
317 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
318 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
319 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
320 border: ThemeColor::from_string("#3b4261").unwrap(),
321 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
322 accent: ThemeColor::from_string("#bb9af7").unwrap(),
323 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
324 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
325 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
326 }
327 }
328
329 pub fn solarized_dark() -> Self {
330 Theme {
331 name: "Solarized Dark".to_string(),
332 bg: ThemeColor::from_string("#002b36").unwrap(),
333 bg_panel: ThemeColor::from_string("#073642").unwrap(),
334 bg_selected: ThemeColor::from_string("#586e75").unwrap(),
335 fg: ThemeColor::from_string("#839496").unwrap(),
336 fg_secondary: ThemeColor::from_string("#657b83").unwrap(),
337 fg_muted: ThemeColor::from_string("#586e75").unwrap(),
338 fg_selected: ThemeColor::from_string("#eee8d5").unwrap(),
339 border: ThemeColor::from_string("#073642").unwrap(),
340 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
341 accent: ThemeColor::from_string("#268bd2").unwrap(),
342 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
343 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
344 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
345 }
346 }
347
348 pub fn solarized_light() -> Self {
349 Theme {
350 name: "Solarized Light".to_string(),
351 bg: ThemeColor::from_string("#fdf6e3").unwrap(),
352 bg_panel: ThemeColor::from_string("#eee8d5").unwrap(),
353 bg_selected: ThemeColor::from_string("#93a1a1").unwrap(),
354 fg: ThemeColor::from_string("#657b83").unwrap(),
355 fg_secondary: ThemeColor::from_string("#839496").unwrap(),
356 fg_muted: ThemeColor::from_string("#93a1a1").unwrap(),
357 fg_selected: ThemeColor::from_string("#073642").unwrap(),
358 border: ThemeColor::from_string("#eee8d5").unwrap(),
359 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
360 accent: ThemeColor::from_string("#268bd2").unwrap(),
361 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
362 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
363 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
364 }
365 }
366
367 pub fn border_style(&self, focused: bool) -> Style {
369 if focused {
370 Style::default().fg(self.border_focused.to_ratatui())
371 } else {
372 Style::default().fg(self.border.to_ratatui())
373 }
374 }
375
376 pub fn base_style(&self) -> Style {
378 Style::default()
379 .fg(self.fg.to_ratatui())
380 .bg(self.bg.to_ratatui())
381 }
382
383 pub fn panel_style(&self) -> Style {
385 Style::default()
386 .fg(self.fg.to_ratatui())
387 .bg(self.bg_panel.to_ratatui())
388 }
389
390 pub fn nord() -> Self {
391 Theme {
392 name: "Nord".to_string(),
393 bg: ThemeColor::from_string("#2e3440").unwrap(),
394 bg_panel: ThemeColor::from_string("#3b4252").unwrap(),
395 bg_selected: ThemeColor::from_string("#434c5e").unwrap(),
396 fg: ThemeColor::from_string("#eceff4").unwrap(),
397 fg_secondary: ThemeColor::from_string("#d8dee9").unwrap(),
398 fg_muted: ThemeColor::from_string("#4c566a").unwrap(),
399 fg_selected: ThemeColor::from_string("#eceff4").unwrap(),
400 border: ThemeColor::from_string("#434c5e").unwrap(),
401 border_focused: ThemeColor::from_string("#81a1c1").unwrap(),
402 accent: ThemeColor::from_string("#88c0d0").unwrap(),
403 color_directory: ThemeColor::from_string("#81a1c1").unwrap(),
404 color_journal_date: ThemeColor::from_string("#8fbcbb").unwrap(),
405 color_search_match: ThemeColor::from_string("#a3be8c").unwrap(),
406 }
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use ratatui::style::Style;
414
415 #[test]
416 fn test_border_style_focused() {
417 let theme = Theme::gruvbox_dark();
418 let style = theme.border_style(true);
419 assert_eq!(
420 style,
421 Style::default().fg(theme.border_focused.to_ratatui())
422 );
423 }
424
425 #[test]
426 fn test_border_style_unfocused() {
427 let theme = Theme::gruvbox_dark();
428 let style = theme.border_style(false);
429 assert_eq!(style, Style::default().fg(theme.border.to_ratatui()));
430 }
431
432 #[test]
433 fn test_from_hex_6char() {
434 let color = ThemeColor::from_string("#ff8800").unwrap();
435 assert_eq!(color.r, 255);
436 assert_eq!(color.g, 136);
437 assert_eq!(color.b, 0);
438 }
439
440 #[test]
441 fn test_from_hex_6char_lowercase() {
442 let color = ThemeColor::from_string("#abcdef").unwrap();
443 assert_eq!(color.r, 171);
444 assert_eq!(color.g, 205);
445 assert_eq!(color.b, 239);
446 }
447
448 #[test]
449 fn test_from_hex_6char_uppercase() {
450 let color = ThemeColor::from_string("#ABCDEF").unwrap();
451 assert_eq!(color.r, 171);
452 assert_eq!(color.g, 205);
453 assert_eq!(color.b, 239);
454 }
455
456 #[test]
457 fn test_from_hex_3char() {
458 let color = ThemeColor::from_string("#f80").unwrap();
459 assert_eq!(color.r, 255);
460 assert_eq!(color.g, 136);
461 assert_eq!(color.b, 0);
462 }
463
464 #[test]
465 fn test_from_hex_3char_expansion() {
466 let color = ThemeColor::from_string("#abc").unwrap();
467 assert_eq!(color.r, 170);
468 assert_eq!(color.g, 187);
469 assert_eq!(color.b, 204);
470 }
471
472 #[test]
473 fn test_from_hex_3char_black() {
474 let color = ThemeColor::from_string("#000").unwrap();
475 assert_eq!(color.r, 0);
476 assert_eq!(color.g, 0);
477 assert_eq!(color.b, 0);
478 }
479
480 #[test]
481 fn test_from_hex_3char_white() {
482 let color = ThemeColor::from_string("#fff").unwrap();
483 assert_eq!(color.r, 255);
484 assert_eq!(color.g, 255);
485 assert_eq!(color.b, 255);
486 }
487
488 #[test]
489 fn test_from_rgb_string() {
490 let color = ThemeColor::from_string("rgb(255, 128, 0)").unwrap();
491 assert_eq!(color.r, 255);
492 assert_eq!(color.g, 128);
493 assert_eq!(color.b, 0);
494 }
495
496 #[test]
497 fn test_from_rgb_string_no_spaces() {
498 let color = ThemeColor::from_string("rgb(255,128,0)").unwrap();
499 assert_eq!(color.r, 255);
500 assert_eq!(color.g, 128);
501 assert_eq!(color.b, 0);
502 }
503
504 #[test]
505 fn test_from_rgb_string_extra_spaces() {
506 let color = ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap();
507 assert_eq!(color.r, 255);
508 assert_eq!(color.g, 128);
509 assert_eq!(color.b, 0);
510 }
511
512 #[test]
513 fn test_from_rgb_string_min_max() {
514 let color = ThemeColor::from_string("rgb(0, 255, 0)").unwrap();
515 assert_eq!(color.r, 0);
516 assert_eq!(color.g, 255);
517 assert_eq!(color.b, 0);
518 }
519
520 #[test]
521 fn test_from_string_with_whitespace() {
522 let color = ThemeColor::from_string(" #ff8800 ").unwrap();
523 assert_eq!(color.r, 255);
524 assert_eq!(color.g, 136);
525 assert_eq!(color.b, 0);
526 }
527
528 #[test]
529 fn test_invalid_hex_length() {
530 let result = ThemeColor::from_string("#ff880");
531 assert!(result.is_err());
532 assert!(result.unwrap_err().contains("Invalid hex color length"));
533 }
534
535 #[test]
536 fn test_invalid_hex_chars() {
537 let result = ThemeColor::from_string("#gghhii");
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn test_missing_hash() {
543 let result = ThemeColor::from_string("ff8800");
544 assert!(result.is_err());
545 assert!(result.unwrap_err().contains("Invalid color format"));
546 }
547
548 #[test]
549 fn test_invalid_rgb_format() {
550 let result = ThemeColor::from_string("rgb(255, 128)");
551 assert!(result.is_err());
552 assert!(result.unwrap_err().contains("requires 3 values"));
553 }
554
555 #[test]
556 fn test_rgb_value_out_of_range() {
557 let result = ThemeColor::from_string("rgb(256, 128, 0)");
558 assert!(result.is_err());
559 }
560
561 #[test]
562 fn test_rgb_negative_value() {
563 let result = ThemeColor::from_string("rgb(-1, 128, 0)");
564 assert!(result.is_err());
565 }
566
567 #[test]
568 fn test_rgb_non_numeric() {
569 let result = ThemeColor::from_string("rgb(abc, 128, 0)");
570 assert!(result.is_err());
571 assert!(result.unwrap_err().contains("Invalid red value"));
572 }
573
574 #[test]
575 fn test_invalid_format() {
576 let result = ThemeColor::from_string("not a color");
577 assert!(result.is_err());
578 assert!(result.unwrap_err().contains("Invalid color format"));
579 }
580
581 #[test]
582 fn test_empty_string() {
583 let result = ThemeColor::from_string("");
584 assert!(result.is_err());
585 }
586
587 #[test]
588 fn test_new_constructor() {
589 let color = ThemeColor::new(255, 128, 0);
590 assert_eq!(color.r, 255);
591 assert_eq!(color.g, 128);
592 assert_eq!(color.b, 0);
593 }
594
595 #[test]
596 fn test_to_ratatui() {
597 let color = ThemeColor::new(131, 165, 152);
598 assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
599 }
600
601 #[test]
602 fn test_theme_color_serialize() {
603 #[derive(Serialize)]
604 struct Wrapper {
605 color: ThemeColor,
606 }
607 let wrapper = Wrapper {
608 color: ThemeColor::new(59, 130, 246),
609 };
610 let serialized = toml::to_string(&wrapper).unwrap();
611 assert!(serialized.contains("color = \"#3b82f6\""));
612 }
613
614 #[test]
615 fn test_theme_color_deserialize() {
616 #[derive(Deserialize)]
617 struct Wrapper {
618 color: ThemeColor,
619 }
620 let toml_str = r###"color = "#3b82f6""###;
621 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
622 assert_eq!(wrapper.color.r, 59);
623 assert_eq!(wrapper.color.g, 130);
624 assert_eq!(wrapper.color.b, 246);
625 }
626
627 #[test]
628 fn test_theme_color_roundtrip() {
629 #[derive(Serialize, Deserialize)]
630 struct Wrapper {
631 color: ThemeColor,
632 }
633 let original = Wrapper {
634 color: ThemeColor::new(239, 68, 68),
635 };
636 let serialized = toml::to_string(&original).unwrap();
637 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
638 assert_eq!(original.color, deserialized.color);
639 }
640
641 #[test]
642 fn test_theme_serialize_to_toml() {
643 let theme = Theme::gruvbox_dark();
644 let toml_string = toml::to_string_pretty(&theme).unwrap();
645
646 assert!(toml_string.contains("name = \"Gruvbox Dark\""));
647 assert!(toml_string.contains("bg = \"#282828\""));
648 assert!(toml_string.contains("bg_panel = \"#32302f\""));
649 assert!(toml_string.contains("border_focused = \"#fabd2f\""));
650 assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
651 }
652
653 #[test]
654 fn test_theme_deserialize_from_toml() {
655 let toml_str = r###"
656 name = "Test Theme"
657 bg = "#282828"
658 bg_panel = "#32302f"
659 bg_selected = "#504945"
660 fg = "#ebdbb2"
661 fg_secondary = "#a89984"
662 fg_muted = "#7c6f64"
663 fg_selected = "#fbf1c7"
664 border = "#504945"
665 border_focused = "#fabd2f"
666 accent = "#fabd2f"
667 color_directory = "#83a598"
668 color_journal_date = "#8ec07c"
669 color_search_match = "#b8bb26"
670 "###;
671
672 let theme: Theme = toml::from_str(toml_str).unwrap();
673 assert_eq!(theme.name, "Test Theme");
674 assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
675 assert_eq!(theme.border_focused, ThemeColor::new(0xfa, 0xbd, 0x2f));
676 assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
677 }
678
679 #[test]
680 fn test_theme_roundtrip() {
681 let original = Theme::tokyo_night();
682 let toml_string = toml::to_string_pretty(&original).unwrap();
683 let deserialized: Theme = toml::from_str(&toml_string).unwrap();
684
685 assert_eq!(original.name, deserialized.name);
686 assert_eq!(original.bg, deserialized.bg);
687 assert_eq!(original.fg, deserialized.fg);
688 assert_eq!(original.border_focused, deserialized.border_focused);
689 assert_eq!(original.color_journal_date, deserialized.color_journal_date);
690 }
691
692 #[test]
693 fn test_theme_color_serialize_lowercase_hex() {
694 #[derive(Serialize)]
695 struct Wrapper {
696 color: ThemeColor,
697 }
698 let wrapper = Wrapper {
699 color: ThemeColor::new(171, 205, 239),
700 };
701 let serialized = toml::to_string(&wrapper).unwrap();
702 assert!(serialized.contains("color = \"#abcdef\""));
703 }
704
705 #[test]
706 fn test_theme_deserialize_uppercase_hex() {
707 #[derive(Deserialize)]
708 struct Wrapper {
709 color: ThemeColor,
710 }
711 let toml_str = r###"color = "#ABCDEF""###;
712 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
713 assert_eq!(wrapper.color.r, 171);
714 assert_eq!(wrapper.color.g, 205);
715 assert_eq!(wrapper.color.b, 239);
716 }
717
718 #[test]
719 fn test_theme_deserialize_3char_hex() {
720 #[derive(Deserialize)]
721 struct Wrapper {
722 color: ThemeColor,
723 }
724 let toml_str = r###"color = "#abc""###;
725 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
726 assert_eq!(wrapper.color.r, 170);
727 assert_eq!(wrapper.color.g, 187);
728 assert_eq!(wrapper.color.b, 204);
729 }
730
731 #[test]
732 fn test_all_builtin_themes_serialize() {
733 let themes = vec![
734 Theme::gruvbox_dark(),
735 Theme::gruvbox_light(),
736 Theme::catppuccin_mocha(),
737 Theme::catppuccin_latte(),
738 Theme::tokyo_night(),
739 Theme::tokyo_night_storm(),
740 Theme::solarized_dark(),
741 Theme::solarized_light(),
742 Theme::nord(),
743 ];
744 for theme in themes {
745 let toml_string = toml::to_string_pretty(&theme).unwrap();
746 let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
747 assert_eq!(theme.name, roundtrip.name);
748 assert_eq!(theme.bg, roundtrip.bg);
749 }
750 }
751}