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 #[serde(default = "default_blockquote_bar")]
256 pub blockquote_bar: ThemeColor,
257 #[serde(default = "default_code_bg")]
260 pub code_bg: ThemeColor,
261}
262
263fn default_color_tag() -> ThemeColor {
266 ThemeColor::from_string("#fe8019").unwrap()
267}
268
269fn default_blockquote_bar() -> ThemeColor {
271 ThemeColor::from_string("#fabd2f").unwrap()
272}
273
274fn default_code_bg() -> ThemeColor {
276 ThemeColor::from_string("#32302f").unwrap()
277}
278
279impl Default for Theme {
280 fn default() -> Self {
281 Self::gruvbox_dark()
282 }
283}
284
285impl Theme {
286 pub fn gruvbox_dark() -> Self {
289 Theme {
290 name: "Gruvbox Dark".to_string(),
291 bg: ThemeColor::from_string("#282828").unwrap(),
292 bg_panel: ThemeColor::from_string("#32302f").unwrap(),
293 bg_selected: ThemeColor::from_string("#504945").unwrap(),
294 fg: ThemeColor::from_string("#ebdbb2").unwrap(),
295 fg_secondary: ThemeColor::from_string("#a89984").unwrap(),
296 fg_muted: ThemeColor::from_string("#7c6f64").unwrap(),
297 fg_selected: ThemeColor::from_string("#fbf1c7").unwrap(),
298 border: ThemeColor::from_string("#504945").unwrap(),
299 border_focused: ThemeColor::from_string("#fabd2f").unwrap(),
300 accent: ThemeColor::from_string("#fabd2f").unwrap(),
301 color_directory: ThemeColor::from_string("#83a598").unwrap(),
302 color_journal_date: ThemeColor::from_string("#8ec07c").unwrap(),
303 color_search_match: ThemeColor::from_string("#b8bb26").unwrap(),
304 color_tag: ThemeColor::from_string("#fe8019").unwrap(),
305 blockquote_bar: ThemeColor::from_string("#fabd2f").unwrap(),
306 code_bg: ThemeColor::from_string("#32302f").unwrap(),
307 }
308 }
309
310 pub fn gruvbox_light() -> Self {
311 Theme {
312 name: "Gruvbox Light".to_string(),
313 bg: ThemeColor::from_string("#fbf1c7").unwrap(),
314 bg_panel: ThemeColor::from_string("#f2e5bc").unwrap(),
315 bg_selected: ThemeColor::from_string("#ebdbb2").unwrap(),
316 fg: ThemeColor::from_string("#3c3836").unwrap(),
317 fg_secondary: ThemeColor::from_string("#7c6f64").unwrap(),
318 fg_muted: ThemeColor::from_string("#a89984").unwrap(),
319 fg_selected: ThemeColor::from_string("#282828").unwrap(),
320 border: ThemeColor::from_string("#d5c4a1").unwrap(),
321 border_focused: ThemeColor::from_string("#d79921").unwrap(),
322 accent: ThemeColor::from_string("#d79921").unwrap(),
323 color_directory: ThemeColor::from_string("#458588").unwrap(),
324 color_journal_date: ThemeColor::from_string("#689d6a").unwrap(),
325 color_search_match: ThemeColor::from_string("#98971a").unwrap(),
326 color_tag: ThemeColor::from_string("#af3a03").unwrap(),
327 blockquote_bar: ThemeColor::from_string("#d79921").unwrap(),
328 code_bg: ThemeColor::from_string("#f2e5bc").unwrap(),
329 }
330 }
331
332 pub fn catppuccin_mocha() -> Self {
333 Theme {
334 name: "Catppuccin Mocha".to_string(),
335 bg: ThemeColor::from_string("#1e1e2e").unwrap(),
336 bg_panel: ThemeColor::from_string("#181825").unwrap(),
337 bg_selected: ThemeColor::from_string("#313244").unwrap(),
338 fg: ThemeColor::from_string("#cdd6f4").unwrap(),
339 fg_secondary: ThemeColor::from_string("#a6adc8").unwrap(),
340 fg_muted: ThemeColor::from_string("#6c7086").unwrap(),
341 fg_selected: ThemeColor::from_string("#cdd6f4").unwrap(),
342 border: ThemeColor::from_string("#45475a").unwrap(),
343 border_focused: ThemeColor::from_string("#89b4fa").unwrap(),
344 accent: ThemeColor::from_string("#cba6f7").unwrap(),
345 color_directory: ThemeColor::from_string("#89dceb").unwrap(),
346 color_journal_date: ThemeColor::from_string("#94e2d5").unwrap(),
347 color_search_match: ThemeColor::from_string("#a6e3a1").unwrap(),
348 color_tag: ThemeColor::from_string("#fab387").unwrap(),
349 blockquote_bar: ThemeColor::from_string("#cba6f7").unwrap(),
350 code_bg: ThemeColor::from_string("#181825").unwrap(),
351 }
352 }
353
354 pub fn catppuccin_latte() -> Self {
355 Theme {
356 name: "Catppuccin Latte".to_string(),
357 bg: ThemeColor::from_string("#eff1f5").unwrap(),
358 bg_panel: ThemeColor::from_string("#e6e9ef").unwrap(),
359 bg_selected: ThemeColor::from_string("#ccd0da").unwrap(),
360 fg: ThemeColor::from_string("#4c4f69").unwrap(),
361 fg_secondary: ThemeColor::from_string("#6c6f85").unwrap(),
362 fg_muted: ThemeColor::from_string("#9ca0b0").unwrap(),
363 fg_selected: ThemeColor::from_string("#4c4f69").unwrap(),
364 border: ThemeColor::from_string("#ccd0da").unwrap(),
365 border_focused: ThemeColor::from_string("#1e66f5").unwrap(),
366 accent: ThemeColor::from_string("#8839ef").unwrap(),
367 color_directory: ThemeColor::from_string("#04a5e5").unwrap(),
368 color_journal_date: ThemeColor::from_string("#179299").unwrap(),
369 color_search_match: ThemeColor::from_string("#40a02b").unwrap(),
370 color_tag: ThemeColor::from_string("#fe640b").unwrap(),
371 blockquote_bar: ThemeColor::from_string("#8839ef").unwrap(),
372 code_bg: ThemeColor::from_string("#e6e9ef").unwrap(),
373 }
374 }
375
376 pub fn tokyo_night() -> Self {
377 Theme {
378 name: "Tokyo Night".to_string(),
379 bg: ThemeColor::from_string("#1a1b26").unwrap(),
380 bg_panel: ThemeColor::from_string("#16161e").unwrap(),
381 bg_selected: ThemeColor::from_string("#292e42").unwrap(),
382 fg: ThemeColor::from_string("#c0caf5").unwrap(),
383 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
384 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
385 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
386 border: ThemeColor::from_string("#3b4261").unwrap(),
387 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
388 accent: ThemeColor::from_string("#7aa2f7").unwrap(),
389 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
390 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
391 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
392 color_tag: ThemeColor::from_string("#ff9e64").unwrap(),
393 blockquote_bar: ThemeColor::from_string("#7aa2f7").unwrap(),
394 code_bg: ThemeColor::from_string("#16161e").unwrap(),
395 }
396 }
397
398 pub fn tokyo_night_storm() -> Self {
399 Theme {
400 name: "Tokyo Night Storm".to_string(),
401 bg: ThemeColor::from_string("#24283b").unwrap(),
402 bg_panel: ThemeColor::from_string("#1f2335").unwrap(),
403 bg_selected: ThemeColor::from_string("#364a82").unwrap(),
404 fg: ThemeColor::from_string("#c0caf5").unwrap(),
405 fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
406 fg_muted: ThemeColor::from_string("#565f89").unwrap(),
407 fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
408 border: ThemeColor::from_string("#3b4261").unwrap(),
409 border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
410 accent: ThemeColor::from_string("#bb9af7").unwrap(),
411 color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
412 color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
413 color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
414 color_tag: ThemeColor::from_string("#ff9e64").unwrap(),
415 blockquote_bar: ThemeColor::from_string("#bb9af7").unwrap(),
416 code_bg: ThemeColor::from_string("#1f2335").unwrap(),
417 }
418 }
419
420 pub fn solarized_dark() -> Self {
421 Theme {
422 name: "Solarized Dark".to_string(),
423 bg: ThemeColor::from_string("#002b36").unwrap(),
424 bg_panel: ThemeColor::from_string("#073642").unwrap(),
425 bg_selected: ThemeColor::from_string("#586e75").unwrap(),
426 fg: ThemeColor::from_string("#839496").unwrap(),
427 fg_secondary: ThemeColor::from_string("#657b83").unwrap(),
428 fg_muted: ThemeColor::from_string("#586e75").unwrap(),
429 fg_selected: ThemeColor::from_string("#eee8d5").unwrap(),
430 border: ThemeColor::from_string("#073642").unwrap(),
431 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
432 accent: ThemeColor::from_string("#268bd2").unwrap(),
433 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
434 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
435 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
436 color_tag: ThemeColor::from_string("#cb4b16").unwrap(),
437 blockquote_bar: ThemeColor::from_string("#268bd2").unwrap(),
438 code_bg: ThemeColor::from_string("#073642").unwrap(),
439 }
440 }
441
442 pub fn solarized_light() -> Self {
443 Theme {
444 name: "Solarized Light".to_string(),
445 bg: ThemeColor::from_string("#fdf6e3").unwrap(),
446 bg_panel: ThemeColor::from_string("#eee8d5").unwrap(),
447 bg_selected: ThemeColor::from_string("#93a1a1").unwrap(),
448 fg: ThemeColor::from_string("#657b83").unwrap(),
449 fg_secondary: ThemeColor::from_string("#839496").unwrap(),
450 fg_muted: ThemeColor::from_string("#93a1a1").unwrap(),
451 fg_selected: ThemeColor::from_string("#073642").unwrap(),
452 border: ThemeColor::from_string("#eee8d5").unwrap(),
453 border_focused: ThemeColor::from_string("#268bd2").unwrap(),
454 accent: ThemeColor::from_string("#268bd2").unwrap(),
455 color_directory: ThemeColor::from_string("#2aa198").unwrap(),
456 color_journal_date: ThemeColor::from_string("#859900").unwrap(),
457 color_search_match: ThemeColor::from_string("#b58900").unwrap(),
458 color_tag: ThemeColor::from_string("#cb4b16").unwrap(),
459 blockquote_bar: ThemeColor::from_string("#268bd2").unwrap(),
460 code_bg: ThemeColor::from_string("#eee8d5").unwrap(),
461 }
462 }
463
464 pub fn border_style(&self, focused: bool) -> Style {
466 if focused {
467 Style::default().fg(self.border_focused.to_ratatui())
468 } else {
469 Style::default().fg(self.border.to_ratatui())
470 }
471 }
472
473 pub fn base_style(&self) -> Style {
475 Style::default()
476 .fg(self.fg.to_ratatui())
477 .bg(self.bg.to_ratatui())
478 }
479
480 pub fn panel_style(&self) -> Style {
482 Style::default()
483 .fg(self.fg.to_ratatui())
484 .bg(self.bg_panel.to_ratatui())
485 }
486
487 pub fn nord() -> Self {
488 Theme {
489 name: "Nord".to_string(),
490 bg: ThemeColor::from_string("#2e3440").unwrap(),
491 bg_panel: ThemeColor::from_string("#3b4252").unwrap(),
492 bg_selected: ThemeColor::from_string("#434c5e").unwrap(),
493 fg: ThemeColor::from_string("#eceff4").unwrap(),
494 fg_secondary: ThemeColor::from_string("#d8dee9").unwrap(),
495 fg_muted: ThemeColor::from_string("#4c566a").unwrap(),
496 fg_selected: ThemeColor::from_string("#eceff4").unwrap(),
497 border: ThemeColor::from_string("#434c5e").unwrap(),
498 border_focused: ThemeColor::from_string("#81a1c1").unwrap(),
499 accent: ThemeColor::from_string("#88c0d0").unwrap(),
500 color_directory: ThemeColor::from_string("#81a1c1").unwrap(),
501 color_journal_date: ThemeColor::from_string("#8fbcbb").unwrap(),
502 color_search_match: ThemeColor::from_string("#a3be8c").unwrap(),
503 color_tag: ThemeColor::from_string("#d08770").unwrap(),
504 blockquote_bar: ThemeColor::from_string("#88c0d0").unwrap(),
505 code_bg: ThemeColor::from_string("#3b4252").unwrap(),
506 }
507 }
508
509 pub fn ansi() -> Self {
515 Theme {
516 name: "ANSI".to_string(),
517 bg: ThemeColor::Reset,
518 bg_panel: ThemeColor::Reset,
519 bg_selected: ThemeColor::Ansi(4), fg: ThemeColor::Reset,
521 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), blockquote_bar: ThemeColor::Ansi(6), code_bg: ThemeColor::Ansi(8),
536 }
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use ratatui::style::Style;
544
545 #[test]
546 fn every_builtin_theme_has_a_visible_code_bg() {
547 for theme in [
551 Theme::gruvbox_dark(),
552 Theme::gruvbox_light(),
553 Theme::catppuccin_mocha(),
554 Theme::catppuccin_latte(),
555 Theme::tokyo_night(),
556 Theme::tokyo_night_storm(),
557 Theme::solarized_dark(),
558 Theme::solarized_light(),
559 Theme::nord(),
560 Theme::ansi(),
561 ] {
562 assert_ne!(
563 theme.code_bg,
564 ThemeColor::Reset,
565 "theme {:?} has code_bg = Reset → invisible code box",
566 theme.name
567 );
568 }
569 }
570
571 #[test]
572 fn test_border_style_focused() {
573 let theme = Theme::gruvbox_dark();
574 let style = theme.border_style(true);
575 assert_eq!(
576 style,
577 Style::default().fg(theme.border_focused.to_ratatui())
578 );
579 }
580
581 #[test]
582 fn test_border_style_unfocused() {
583 let theme = Theme::gruvbox_dark();
584 let style = theme.border_style(false);
585 assert_eq!(style, Style::default().fg(theme.border.to_ratatui()));
586 }
587
588 #[test]
589 fn test_from_hex_6char() {
590 assert_eq!(
591 ThemeColor::from_string("#ff8800").unwrap(),
592 ThemeColor::Rgb(255, 136, 0)
593 );
594 }
595
596 #[test]
597 fn test_from_hex_6char_lowercase() {
598 assert_eq!(
599 ThemeColor::from_string("#abcdef").unwrap(),
600 ThemeColor::Rgb(171, 205, 239)
601 );
602 }
603
604 #[test]
605 fn test_from_hex_6char_uppercase() {
606 assert_eq!(
607 ThemeColor::from_string("#ABCDEF").unwrap(),
608 ThemeColor::Rgb(171, 205, 239)
609 );
610 }
611
612 #[test]
613 fn test_from_hex_3char() {
614 assert_eq!(
615 ThemeColor::from_string("#f80").unwrap(),
616 ThemeColor::Rgb(255, 136, 0)
617 );
618 }
619
620 #[test]
621 fn test_from_hex_3char_expansion() {
622 assert_eq!(
623 ThemeColor::from_string("#abc").unwrap(),
624 ThemeColor::Rgb(170, 187, 204)
625 );
626 }
627
628 #[test]
629 fn test_from_hex_3char_black() {
630 assert_eq!(
631 ThemeColor::from_string("#000").unwrap(),
632 ThemeColor::Rgb(0, 0, 0)
633 );
634 }
635
636 #[test]
637 fn test_from_hex_3char_white() {
638 assert_eq!(
639 ThemeColor::from_string("#fff").unwrap(),
640 ThemeColor::Rgb(255, 255, 255)
641 );
642 }
643
644 #[test]
645 fn test_from_rgb_string() {
646 assert_eq!(
647 ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
648 ThemeColor::Rgb(255, 128, 0)
649 );
650 }
651
652 #[test]
653 fn test_from_rgb_string_no_spaces() {
654 assert_eq!(
655 ThemeColor::from_string("rgb(255,128,0)").unwrap(),
656 ThemeColor::Rgb(255, 128, 0)
657 );
658 }
659
660 #[test]
661 fn test_from_rgb_string_extra_spaces() {
662 assert_eq!(
663 ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
664 ThemeColor::Rgb(255, 128, 0)
665 );
666 }
667
668 #[test]
669 fn test_from_rgb_string_min_max() {
670 assert_eq!(
671 ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
672 ThemeColor::Rgb(0, 255, 0)
673 );
674 }
675
676 #[test]
677 fn test_from_string_with_whitespace() {
678 assert_eq!(
679 ThemeColor::from_string(" #ff8800 ").unwrap(),
680 ThemeColor::Rgb(255, 136, 0)
681 );
682 }
683
684 #[test]
685 fn test_ansi_to_ratatui() {
686 assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
688 assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
689 assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
690 assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
691 assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
692 assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
694 assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
695 }
696
697 #[test]
698 fn test_invalid_hex_length() {
699 let result = ThemeColor::from_string("#ff880");
700 assert!(result.is_err());
701 assert!(result.unwrap_err().contains("Invalid hex color length"));
702 }
703
704 #[test]
705 fn test_invalid_hex_chars() {
706 let result = ThemeColor::from_string("#gghhii");
707 assert!(result.is_err());
708 }
709
710 #[test]
711 fn test_missing_hash() {
712 let result = ThemeColor::from_string("ff8800");
713 assert!(result.is_err());
714 assert!(result.unwrap_err().contains("Invalid color format"));
715 }
716
717 #[test]
718 fn test_invalid_rgb_format() {
719 let result = ThemeColor::from_string("rgb(255, 128)");
720 assert!(result.is_err());
721 assert!(result.unwrap_err().contains("requires 3 values"));
722 }
723
724 #[test]
725 fn test_rgb_value_out_of_range() {
726 let result = ThemeColor::from_string("rgb(256, 128, 0)");
727 assert!(result.is_err());
728 }
729
730 #[test]
731 fn test_rgb_negative_value() {
732 let result = ThemeColor::from_string("rgb(-1, 128, 0)");
733 assert!(result.is_err());
734 }
735
736 #[test]
737 fn test_rgb_non_numeric() {
738 let result = ThemeColor::from_string("rgb(abc, 128, 0)");
739 assert!(result.is_err());
740 assert!(result.unwrap_err().contains("Invalid red value"));
741 }
742
743 #[test]
744 fn test_invalid_format() {
745 let result = ThemeColor::from_string("not a color");
746 assert!(result.is_err());
747 assert!(result.unwrap_err().contains("Invalid color format"));
748 }
749
750 #[test]
751 fn test_empty_string() {
752 let result = ThemeColor::from_string("");
753 assert!(result.is_err());
754 }
755
756 #[test]
757 fn test_new_constructor() {
758 assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
759 }
760
761 #[test]
762 fn test_to_ratatui() {
763 let color = ThemeColor::new(131, 165, 152);
764 assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
765 }
766
767 #[test]
768 fn test_theme_color_serialize() {
769 #[derive(Serialize)]
770 struct Wrapper {
771 color: ThemeColor,
772 }
773 let wrapper = Wrapper {
774 color: ThemeColor::new(59, 130, 246),
775 };
776 let serialized = toml::to_string(&wrapper).unwrap();
777 assert!(serialized.contains("color = \"#3b82f6\""));
778 }
779
780 #[test]
781 fn test_theme_color_deserialize() {
782 #[derive(Deserialize)]
783 struct Wrapper {
784 color: ThemeColor,
785 }
786 let toml_str = r###"color = "#3b82f6""###;
787 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
788 assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
789 }
790
791 #[test]
792 fn test_theme_color_roundtrip() {
793 #[derive(Serialize, Deserialize)]
794 struct Wrapper {
795 color: ThemeColor,
796 }
797 let original = Wrapper {
798 color: ThemeColor::new(239, 68, 68),
799 };
800 let serialized = toml::to_string(&original).unwrap();
801 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
802 assert_eq!(original.color, deserialized.color);
803 }
804
805 #[test]
806 fn test_theme_serialize_to_toml() {
807 let theme = Theme::gruvbox_dark();
808 let toml_string = toml::to_string_pretty(&theme).unwrap();
809
810 assert!(toml_string.contains("name = \"Gruvbox Dark\""));
811 assert!(toml_string.contains("bg = \"#282828\""));
812 assert!(toml_string.contains("bg_panel = \"#32302f\""));
813 assert!(toml_string.contains("border_focused = \"#fabd2f\""));
814 assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
815 }
816
817 #[test]
818 fn test_theme_deserialize_from_toml() {
819 let toml_str = r###"
820 name = "Test Theme"
821 bg = "#282828"
822 bg_panel = "#32302f"
823 bg_selected = "#504945"
824 fg = "#ebdbb2"
825 fg_secondary = "#a89984"
826 fg_muted = "#7c6f64"
827 fg_selected = "#fbf1c7"
828 border = "#504945"
829 border_focused = "#fabd2f"
830 accent = "#fabd2f"
831 color_directory = "#83a598"
832 color_journal_date = "#8ec07c"
833 color_search_match = "#b8bb26"
834 color_tag = "#fe8019"
835 "###;
836
837 let theme: Theme = toml::from_str(toml_str).unwrap();
838 assert_eq!(theme.name, "Test Theme");
839 assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
840 assert_eq!(theme.border_focused, ThemeColor::new(0xfa, 0xbd, 0x2f));
841 assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
842 }
843
844 #[test]
845 fn test_theme_roundtrip() {
846 let original = Theme::tokyo_night();
847 let toml_string = toml::to_string_pretty(&original).unwrap();
848 let deserialized: Theme = toml::from_str(&toml_string).unwrap();
849
850 assert_eq!(original.name, deserialized.name);
851 assert_eq!(original.bg, deserialized.bg);
852 assert_eq!(original.fg, deserialized.fg);
853 assert_eq!(original.border_focused, deserialized.border_focused);
854 assert_eq!(original.color_journal_date, deserialized.color_journal_date);
855 }
856
857 #[test]
858 fn test_theme_color_serialize_lowercase_hex() {
859 #[derive(Serialize)]
860 struct Wrapper {
861 color: ThemeColor,
862 }
863 let wrapper = Wrapper {
864 color: ThemeColor::new(171, 205, 239),
865 };
866 let serialized = toml::to_string(&wrapper).unwrap();
867 assert!(serialized.contains("color = \"#abcdef\""));
868 }
869
870 #[test]
871 fn test_theme_deserialize_uppercase_hex() {
872 #[derive(Deserialize)]
873 struct Wrapper {
874 color: ThemeColor,
875 }
876 let toml_str = r###"color = "#ABCDEF""###;
877 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
878 assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
879 }
880
881 #[test]
882 fn test_theme_deserialize_3char_hex() {
883 #[derive(Deserialize)]
884 struct Wrapper {
885 color: ThemeColor,
886 }
887 let toml_str = r###"color = "#abc""###;
888 let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
889 assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
890 }
891
892 #[test]
893 fn test_from_ansi_index() {
894 assert_eq!(
895 ThemeColor::from_string("ansi:4").unwrap(),
896 ThemeColor::Ansi(4)
897 );
898 assert_eq!(
899 ThemeColor::from_string("ansi:255").unwrap(),
900 ThemeColor::Ansi(255)
901 );
902 }
903
904 #[test]
905 fn test_from_reset() {
906 assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
907 }
908
909 #[test]
910 fn test_all_builtin_themes_serialize() {
911 let themes = vec![
912 Theme::ansi(),
913 Theme::gruvbox_dark(),
914 Theme::gruvbox_light(),
915 Theme::catppuccin_mocha(),
916 Theme::catppuccin_latte(),
917 Theme::tokyo_night(),
918 Theme::tokyo_night_storm(),
919 Theme::solarized_dark(),
920 Theme::solarized_light(),
921 Theme::nord(),
922 ];
923 for theme in themes {
924 let toml_string = toml::to_string_pretty(&theme).unwrap();
925 let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
926 assert_eq!(theme.name, roundtrip.name);
927 assert_eq!(theme.bg, roundtrip.bg);
928 }
929 }
930
931 #[test]
932 fn test_ansi_theme() {
933 let theme = Theme::ansi();
934 assert_eq!(theme.name, "ANSI");
935 assert_eq!(theme.bg, ThemeColor::Reset);
936 assert_eq!(theme.fg, ThemeColor::Reset);
937 assert_eq!(theme.bg_selected, ThemeColor::Ansi(4));
938 assert_eq!(theme.border_focused, ThemeColor::Ansi(6));
939 assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
940 }
941
942 #[test]
943 fn new_decoration_fields_present_and_deserialize_default() {
944 let t = Theme::gruvbox_dark();
946 assert_eq!(
947 t.blockquote_bar,
948 ThemeColor::from_string("#fabd2f").unwrap()
949 );
950 assert_eq!(t.code_bg, ThemeColor::from_string("#32302f").unwrap());
951
952 let toml = r##"
954 name = "Old"
955 bg = "#000000"
956 bg_panel = "#111111"
957 bg_selected = "#222222"
958 fg = "#ffffff"
959 fg_secondary = "#cccccc"
960 fg_muted = "#888888"
961 fg_selected = "#ffffff"
962 border = "#333333"
963 border_focused = "#444444"
964 accent = "#55aaff"
965 color_directory = "#66ccee"
966 color_journal_date = "#77ddcc"
967 color_search_match = "#88eeaa"
968 "##;
969 let parsed: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
970 assert_eq!(parsed.blockquote_bar, default_blockquote_bar());
971 assert_eq!(parsed.code_bg, default_code_bg());
972 }
973}