1use ratatui::style::{Color, Style};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::Display;
4
5mod builtin;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum ThemeColor {
10 Rgb(u8, u8, u8),
11 Ansi(u8),
14 Reset,
16}
17
18impl Serialize for ThemeColor {
19 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
20 where
21 S: Serializer,
22 {
23 match self {
24 ThemeColor::Rgb(r, g, b) => {
25 serializer.serialize_str(&format!("#{:02x}{:02x}{:02x}", r, g, b))
26 }
27 ThemeColor::Ansi(n) => serializer.serialize_str(&format!("ansi:{}", n)),
28 ThemeColor::Reset => serializer.serialize_str("reset"),
29 }
30 }
31}
32
33impl<'de> Deserialize<'de> for ThemeColor {
34 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
35 where
36 D: Deserializer<'de>,
37 {
38 let s = String::deserialize(deserializer)?;
39 ThemeColor::from_string(&s).map_err(serde::de::Error::custom)
40 }
41}
42
43impl ThemeColor {
44 pub fn new(r: u8, g: u8, b: u8) -> Self {
45 ThemeColor::Rgb(r, g, b)
46 }
47
48 pub fn to_ratatui(&self) -> Color {
55 match self {
56 ThemeColor::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
57 ThemeColor::Ansi(n) => match n {
58 0 => Color::Black,
59 1 => Color::Red,
60 2 => Color::Green,
61 3 => Color::Yellow,
62 4 => Color::Blue,
63 5 => Color::Magenta,
64 6 => Color::Cyan,
65 7 => Color::Gray,
66 8 => Color::DarkGray,
67 9 => Color::LightRed,
68 10 => Color::LightGreen,
69 11 => Color::LightYellow,
70 12 => Color::LightBlue,
71 13 => Color::LightMagenta,
72 14 => Color::LightCyan,
73 15 => Color::White,
74 _ => Color::Indexed(*n),
75 },
76 ThemeColor::Reset => Color::Reset,
77 }
78 }
79
80 pub fn from_string(s: &str) -> Result<Self, String> {
87 let s = s.trim();
88
89 if s.starts_with('#') {
90 Self::from_hex(s)
91 } else if s.starts_with("rgb(") && s.ends_with(')') {
92 Self::from_rgb_string(s)
93 } else if s == "reset" {
94 Ok(ThemeColor::Reset)
95 } else if let Some(rest) = s.strip_prefix("ansi:") {
96 rest.parse::<u8>()
97 .map(ThemeColor::Ansi)
98 .map_err(|_| format!("Invalid ANSI color index: {}", rest))
99 } else {
100 Err(format!("Invalid color format: {}", s))
101 }
102 }
103
104 fn from_hex(s: &str) -> Result<Self, String> {
106 if !s.starts_with('#') {
107 return Err("Hex color must start with #".to_string());
108 }
109
110 let hex = &s[1..];
111
112 match hex.len() {
113 3 => Self::from_hex_3char(hex),
114 6 => Self::from_hex_6char(hex),
115 _ => Err(format!(
116 "Invalid hex color length: expected 3 or 6 chars, got {}",
117 hex.len()
118 )),
119 }
120 }
121
122 fn from_hex_3char(hex: &str) -> Result<Self, String> {
124 if hex.len() != 3 {
125 return Err("Expected 3 hex characters".to_string());
126 }
127
128 let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
129 .map_err(|_| format!("Invalid hex character in red component: {}", &hex[0..1]))?;
130 let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
131 .map_err(|_| format!("Invalid hex character in green component: {}", &hex[1..2]))?;
132 let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
133 .map_err(|_| format!("Invalid hex character in blue component: {}", &hex[2..3]))?;
134
135 Ok(ThemeColor::Rgb(r, g, b))
136 }
137
138 fn from_hex_6char(hex: &str) -> Result<Self, String> {
140 if hex.len() != 6 {
141 return Err("Expected 6 hex characters".to_string());
142 }
143
144 let r = u8::from_str_radix(&hex[0..2], 16)
145 .map_err(|_| format!("Invalid hex characters in red component: {}", &hex[0..2]))?;
146 let g = u8::from_str_radix(&hex[2..4], 16)
147 .map_err(|_| format!("Invalid hex characters in green component: {}", &hex[2..4]))?;
148 let b = u8::from_str_radix(&hex[4..6], 16)
149 .map_err(|_| format!("Invalid hex characters in blue component: {}", &hex[4..6]))?;
150
151 Ok(ThemeColor::Rgb(r, g, b))
152 }
153
154 fn from_rgb_string(s: &str) -> Result<Self, String> {
156 if !s.starts_with("rgb(") || !s.ends_with(')') {
157 return Err("RGB format must be rgb(r, g, b)".to_string());
158 }
159
160 let inner = &s[4..s.len() - 1];
161 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
162
163 if parts.len() != 3 {
164 return Err(format!("RGB format requires 3 values, got {}", parts.len()));
165 }
166
167 let r = parts[0]
168 .parse::<u8>()
169 .map_err(|_| format!("Invalid red value: {}", parts[0]))?;
170 let g = parts[1]
171 .parse::<u8>()
172 .map_err(|_| format!("Invalid green value: {}", parts[1]))?;
173 let b = parts[2]
174 .parse::<u8>()
175 .map_err(|_| format!("Invalid blue value: {}", parts[2]))?;
176
177 Ok(ThemeColor::Rgb(r, g, b))
178 }
179}
180
181impl Display for ThemeColor {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 match self {
184 ThemeColor::Rgb(r, g, b) => write!(f, "rgb({},{},{})", r, g, b),
185 ThemeColor::Ansi(n) => write!(f, "ansi:{}", n),
186 ThemeColor::Reset => write!(f, "reset"),
187 }
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct Theme {
217 pub name: String,
218
219 pub bg: ThemeColor,
222 pub bg_panel: ThemeColor,
224 pub bg_selected: ThemeColor,
226
227 pub fg: ThemeColor,
230 pub fg_secondary: ThemeColor,
232 pub fg_muted: ThemeColor,
234 pub fg_selected: ThemeColor,
236
237 pub border: ThemeColor,
240 pub border_focused: ThemeColor,
242
243 pub accent: ThemeColor,
246
247 pub color_directory: ThemeColor,
250 pub color_journal_date: ThemeColor,
252 pub color_search_match: ThemeColor,
254 #[serde(default = "default_color_tag")]
256 pub color_tag: ThemeColor,
257 #[serde(default = "default_blockquote_bar")]
259 pub blockquote_bar: ThemeColor,
260 #[serde(default = "default_code_bg")]
263 pub code_bg: ThemeColor,
264}
265
266fn default_color_tag() -> ThemeColor {
269 ThemeColor::from_string("#fe8019").unwrap()
270}
271
272fn default_blockquote_bar() -> ThemeColor {
274 ThemeColor::from_string("#fabd2f").unwrap()
275}
276
277fn default_code_bg() -> ThemeColor {
279 ThemeColor::from_string("#32302f").unwrap()
280}
281
282impl Default for Theme {
283 fn default() -> Self {
284 Self::gruvbox_dark()
285 }
286}
287
288impl Theme {
289 pub fn border_style(&self, focused: bool) -> Style {
291 if focused {
292 Style::default().fg(self.border_focused.to_ratatui())
293 } else {
294 Style::default().fg(self.border.to_ratatui())
295 }
296 }
297
298 pub fn base_style(&self) -> Style {
300 Style::default()
301 .fg(self.fg.to_ratatui())
302 .bg(self.bg.to_ratatui())
303 }
304
305 pub fn panel_style(&self) -> Style {
307 Style::default()
308 .fg(self.fg.to_ratatui())
309 .bg(self.bg_panel.to_ratatui())
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use ratatui::style::Style;
317
318 #[test]
319 fn test_border_style_focused() {
320 let theme = Theme::gruvbox_dark();
321 let style = theme.border_style(true);
322 assert_eq!(
323 style,
324 Style::default().fg(theme.border_focused.to_ratatui())
325 );
326 }
327
328 #[test]
329 fn test_border_style_unfocused() {
330 let theme = Theme::gruvbox_dark();
331 let style = theme.border_style(false);
332 assert_eq!(style, Style::default().fg(theme.border.to_ratatui()));
333 }
334
335 #[test]
336 fn test_from_hex_6char() {
337 assert_eq!(
338 ThemeColor::from_string("#ff8800").unwrap(),
339 ThemeColor::Rgb(255, 136, 0)
340 );
341 }
342
343 #[test]
344 fn test_from_hex_6char_lowercase() {
345 assert_eq!(
346 ThemeColor::from_string("#abcdef").unwrap(),
347 ThemeColor::Rgb(171, 205, 239)
348 );
349 }
350
351 #[test]
352 fn test_from_hex_6char_uppercase() {
353 assert_eq!(
354 ThemeColor::from_string("#ABCDEF").unwrap(),
355 ThemeColor::Rgb(171, 205, 239)
356 );
357 }
358
359 #[test]
360 fn test_from_hex_3char() {
361 assert_eq!(
362 ThemeColor::from_string("#f80").unwrap(),
363 ThemeColor::Rgb(255, 136, 0)
364 );
365 }
366
367 #[test]
368 fn test_from_hex_3char_expansion() {
369 assert_eq!(
370 ThemeColor::from_string("#abc").unwrap(),
371 ThemeColor::Rgb(170, 187, 204)
372 );
373 }
374
375 #[test]
376 fn test_from_hex_3char_black() {
377 assert_eq!(
378 ThemeColor::from_string("#000").unwrap(),
379 ThemeColor::Rgb(0, 0, 0)
380 );
381 }
382
383 #[test]
384 fn test_from_hex_3char_white() {
385 assert_eq!(
386 ThemeColor::from_string("#fff").unwrap(),
387 ThemeColor::Rgb(255, 255, 255)
388 );
389 }
390
391 #[test]
392 fn test_from_rgb_string() {
393 assert_eq!(
394 ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
395 ThemeColor::Rgb(255, 128, 0)
396 );
397 }
398
399 #[test]
400 fn test_from_rgb_string_no_spaces() {
401 assert_eq!(
402 ThemeColor::from_string("rgb(255,128,0)").unwrap(),
403 ThemeColor::Rgb(255, 128, 0)
404 );
405 }
406
407 #[test]
408 fn test_from_rgb_string_extra_spaces() {
409 assert_eq!(
410 ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
411 ThemeColor::Rgb(255, 128, 0)
412 );
413 }
414
415 #[test]
416 fn test_from_rgb_string_min_max() {
417 assert_eq!(
418 ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
419 ThemeColor::Rgb(0, 255, 0)
420 );
421 }
422
423 #[test]
424 fn test_from_string_with_whitespace() {
425 assert_eq!(
426 ThemeColor::from_string(" #ff8800 ").unwrap(),
427 ThemeColor::Rgb(255, 136, 0)
428 );
429 }
430
431 #[test]
432 fn test_ansi_to_ratatui() {
433 assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
435 assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
436 assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
437 assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
438 assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
439 assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
441 assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
442 }
443
444 #[test]
445 fn test_invalid_hex_length() {
446 let result = ThemeColor::from_string("#ff880");
447 assert!(result.is_err());
448 assert!(result.unwrap_err().contains("Invalid hex color length"));
449 }
450
451 #[test]
452 fn test_invalid_hex_chars() {
453 let result = ThemeColor::from_string("#gghhii");
454 assert!(result.is_err());
455 }
456
457 #[test]
458 fn test_missing_hash() {
459 let result = ThemeColor::from_string("ff8800");
460 assert!(result.is_err());
461 assert!(result.unwrap_err().contains("Invalid color format"));
462 }
463
464 #[test]
465 fn test_invalid_rgb_format() {
466 let result = ThemeColor::from_string("rgb(255, 128)");
467 assert!(result.is_err());
468 assert!(result.unwrap_err().contains("requires 3 values"));
469 }
470
471 #[test]
472 fn test_rgb_value_out_of_range() {
473 let result = ThemeColor::from_string("rgb(256, 128, 0)");
474 assert!(result.is_err());
475 }
476
477 #[test]
478 fn test_rgb_negative_value() {
479 let result = ThemeColor::from_string("rgb(-1, 128, 0)");
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn test_rgb_non_numeric() {
485 let result = ThemeColor::from_string("rgb(abc, 128, 0)");
486 assert!(result.is_err());
487 assert!(result.unwrap_err().contains("Invalid red value"));
488 }
489
490 #[test]
491 fn test_invalid_format() {
492 let result = ThemeColor::from_string("not a color");
493 assert!(result.is_err());
494 assert!(result.unwrap_err().contains("Invalid color format"));
495 }
496
497 #[test]
498 fn test_empty_string() {
499 let result = ThemeColor::from_string("");
500 assert!(result.is_err());
501 }
502
503 #[test]
504 fn test_new_constructor() {
505 assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
506 }
507
508 #[test]
509 fn test_to_ratatui() {
510 let color = ThemeColor::new(131, 165, 152);
511 assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
512 }
513
514 #[test]
515 fn test_theme_color_serialize() {
516 #[derive(Serialize)]
517 struct Wrapper {
518 color: ThemeColor,
519 }
520 let wrapper = Wrapper {
521 color: ThemeColor::new(59, 130, 246),
522 };
523 let serialized = toml::to_string(&wrapper).unwrap();
524 assert!(serialized.contains("color = \"#3b82f6\""));
525 }
526
527 #[test]
528 fn test_theme_color_deserialize() {
529 #[derive(Deserialize)]
530 struct Wrapper {
531 color: ThemeColor,
532 }
533 let toml_str = r###"color = "#3b82f6""###;
534 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
535 assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
536 }
537
538 #[test]
539 fn test_theme_color_roundtrip() {
540 #[derive(Serialize, Deserialize)]
541 struct Wrapper {
542 color: ThemeColor,
543 }
544 let original = Wrapper {
545 color: ThemeColor::new(239, 68, 68),
546 };
547 let serialized = toml::to_string(&original).unwrap();
548 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
549 assert_eq!(original.color, deserialized.color);
550 }
551
552 #[test]
553 fn test_theme_serialize_to_toml() {
554 let theme = Theme::gruvbox_dark();
555 let toml_string = toml::to_string_pretty(&theme).unwrap();
556
557 assert!(toml_string.contains("name = \"Gruvbox Dark\""));
558 assert!(toml_string.contains("bg = \"#282828\""));
559 assert!(toml_string.contains("bg_panel = \"#32302f\""));
560 assert!(toml_string.contains("border_focused = \"#fabd2f\""));
561 assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
562 }
563
564 #[test]
565 fn test_theme_deserialize_from_toml() {
566 let toml_str = r###"
567 name = "Test Theme"
568 bg = "#282828"
569 bg_panel = "#32302f"
570 bg_selected = "#504945"
571 fg = "#ebdbb2"
572 fg_secondary = "#a89984"
573 fg_muted = "#7c6f64"
574 fg_selected = "#fbf1c7"
575 border = "#504945"
576 border_focused = "#fabd2f"
577 accent = "#fabd2f"
578 color_directory = "#83a598"
579 color_journal_date = "#8ec07c"
580 color_search_match = "#b8bb26"
581 color_tag = "#fe8019"
582 "###;
583
584 let theme: Theme = toml::from_str(toml_str).unwrap();
585 assert_eq!(theme.name, "Test Theme");
586 assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
587 assert_eq!(theme.border_focused, ThemeColor::new(0xfa, 0xbd, 0x2f));
588 assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
589 }
590
591 #[test]
592 fn test_theme_roundtrip() {
593 let original = Theme::tokyo_night();
594 let toml_string = toml::to_string_pretty(&original).unwrap();
595 let deserialized: Theme = toml::from_str(&toml_string).unwrap();
596
597 assert_eq!(original.name, deserialized.name);
598 assert_eq!(original.bg, deserialized.bg);
599 assert_eq!(original.fg, deserialized.fg);
600 assert_eq!(original.border_focused, deserialized.border_focused);
601 assert_eq!(original.color_journal_date, deserialized.color_journal_date);
602 }
603
604 #[test]
605 fn test_theme_color_serialize_lowercase_hex() {
606 #[derive(Serialize)]
607 struct Wrapper {
608 color: ThemeColor,
609 }
610 let wrapper = Wrapper {
611 color: ThemeColor::new(171, 205, 239),
612 };
613 let serialized = toml::to_string(&wrapper).unwrap();
614 assert!(serialized.contains("color = \"#abcdef\""));
615 }
616
617 #[test]
618 fn test_theme_deserialize_uppercase_hex() {
619 #[derive(Deserialize)]
620 struct Wrapper {
621 color: ThemeColor,
622 }
623 let toml_str = r###"color = "#ABCDEF""###;
624 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
625 assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
626 }
627
628 #[test]
629 fn test_theme_deserialize_3char_hex() {
630 #[derive(Deserialize)]
631 struct Wrapper {
632 color: ThemeColor,
633 }
634 let toml_str = r###"color = "#abc""###;
635 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
636 assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
637 }
638
639 #[test]
640 fn test_from_ansi_index() {
641 assert_eq!(
642 ThemeColor::from_string("ansi:4").unwrap(),
643 ThemeColor::Ansi(4)
644 );
645 assert_eq!(
646 ThemeColor::from_string("ansi:255").unwrap(),
647 ThemeColor::Ansi(255)
648 );
649 }
650
651 #[test]
652 fn test_from_reset() {
653 assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
654 }
655
656 #[test]
657 fn test_all_builtin_themes_serialize() {
658 let themes = vec![
659 Theme::ansi(),
660 Theme::gruvbox_dark(),
661 Theme::gruvbox_light(),
662 Theme::catppuccin_mocha(),
663 Theme::catppuccin_latte(),
664 Theme::tokyo_night(),
665 Theme::tokyo_night_storm(),
666 Theme::solarized_dark(),
667 Theme::solarized_light(),
668 Theme::nord(),
669 ];
670 for theme in themes {
671 let toml_string = toml::to_string_pretty(&theme).unwrap();
672 let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
673 assert_eq!(theme.name, roundtrip.name);
674 assert_eq!(theme.bg, roundtrip.bg);
675 }
676 }
677
678 #[test]
679 fn test_ansi_theme() {
680 let theme = Theme::ansi();
681 assert_eq!(theme.name, "ANSI");
682 assert_eq!(theme.bg, ThemeColor::Reset);
683 assert_eq!(theme.fg, ThemeColor::Reset);
684 assert_eq!(theme.bg_selected, ThemeColor::Ansi(4));
685 assert_eq!(theme.border_focused, ThemeColor::Ansi(6));
686 assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
687 }
688
689 #[test]
690 fn new_decoration_fields_present_and_deserialize_default() {
691 let t = Theme::gruvbox_dark();
693 assert_eq!(
694 t.blockquote_bar,
695 ThemeColor::from_string("#fabd2f").unwrap()
696 );
697 assert_eq!(t.code_bg, ThemeColor::from_string("#32302f").unwrap());
698
699 let toml = r##"
701 name = "Old"
702 bg = "#000000"
703 bg_panel = "#111111"
704 bg_selected = "#222222"
705 fg = "#ffffff"
706 fg_secondary = "#cccccc"
707 fg_muted = "#888888"
708 fg_selected = "#ffffff"
709 border = "#333333"
710 border_focused = "#444444"
711 accent = "#55aaff"
712 color_directory = "#66ccee"
713 color_journal_date = "#77ddcc"
714 color_search_match = "#88eeaa"
715 "##;
716 let parsed: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
717 assert_eq!(parsed.blockquote_bar, default_blockquote_bar());
718 assert_eq!(parsed.code_bg, default_code_bg());
719 }
720}