Skip to main content

kimun_notes/settings/themes/
mod.rs

1use ratatui::style::{Color, Style};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::Display;
4
5/// Built-in theme definitions (`Theme::gruvbox_dark()`, `Theme::nord()`, …).
6mod builtin;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum ThemeColor {
10    Rgb(u8, u8, u8),
11    /// Terminal ANSI color index (0–15 for the standard palette, up to 255 for
12    /// 256-color mode). The actual color is determined by the user's terminal.
13    Ansi(u8),
14    /// The terminal's default foreground or background color.
15    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    /// Convert to the corresponding ratatui `Color`.
49    ///
50    /// ANSI indices 0–15 map to ratatui's named color variants so they emit
51    /// the standard SGR codes (30–37 / 90–97) the terminal's palette is keyed
52    /// to, rather than the 256-color `38;5;n` form which some terminals
53    /// remap inconsistently for the low 16 slots.
54    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    /// Parse a color from a string in various formats:
81    /// - RGB: "rgb(255, 128, 0)"
82    /// - 3-char hex: "#abc" (expanded to #aabbcc)
83    /// - 6-char hex: "#aabbcc"
84    /// - ANSI index: "ansi:4" (0–255)
85    /// - Terminal default: "reset"
86    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    /// Parse hex color string (#abc or #aabbcc)
105    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    /// Parse 3-character hex color (e.g., "abc" -> r=0xaa, g=0xbb, b=0xcc)
123    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    /// Parse 6-character hex color (e.g., "aabbcc")
139    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    /// Parse RGB string format (e.g., "rgb(255, 128, 0)")
155    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/// Theme for the TUI application.
192///
193/// Fields are named after the UI roles they fill, making it straightforward to
194/// map any popular terminal color scheme (Gruvbox, Catppuccin, Tokyo Night, …)
195/// to this struct.  Custom themes can be placed as `.toml` files in the themes
196/// config directory and will be loaded automatically at startup.
197///
198/// # Example theme file (`~/.config/kimun/themes/mytheme.toml`)
199/// ```toml
200/// name = "My Theme"
201/// bg               = "#1e1e2e"
202/// bg_panel         = "#181825"
203/// bg_selected      = "#313244"
204/// fg               = "#cdd6f4"
205/// fg_secondary     = "#a6adc8"
206/// fg_muted         = "#6c7086"
207/// fg_selected      = "#cdd6f4"
208/// border           = "#45475a"
209/// border_focused   = "#89b4fa"
210/// accent           = "#89b4fa"
211/// color_directory  = "#89dceb"
212/// color_journal_date = "#94e2d5"
213/// color_search_match = "#a6e3a1"
214/// ```
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct Theme {
217    pub name: String,
218
219    // ── Backgrounds ─────────────────────────────────────────────────────────
220    /// Main/editor background.
221    pub bg: ThemeColor,
222    /// Sidebar / panel background (usually slightly offset from `bg`).
223    pub bg_panel: ThemeColor,
224    /// Background of the currently selected row in lists.
225    pub bg_selected: ThemeColor,
226
227    // ── Foreground / text ────────────────────────────────────────────────────
228    /// Primary text color.
229    pub fg: ThemeColor,
230    /// Secondary text: filenames, metadata, subdued hints.
231    pub fg_secondary: ThemeColor,
232    /// Very dim text: placeholders, separators, disabled items.
233    pub fg_muted: ThemeColor,
234    /// Text color of a selected/highlighted row (often brighter than `fg`).
235    pub fg_selected: ThemeColor,
236
237    // ── Borders ──────────────────────────────────────────────────────────────
238    /// Default (unfocused) border color.
239    pub border: ThemeColor,
240    /// Border color when the pane has keyboard focus.
241    pub border_focused: ThemeColor,
242
243    // ── Accent ───────────────────────────────────────────────────────────────
244    /// Primary accent: title bars, active markers, cursor highlights.
245    pub accent: ThemeColor,
246
247    // ── Semantic colors for file-list entries ────────────────────────────────
248    /// Color used for directory entries in the file list.
249    pub color_directory: ThemeColor,
250    /// Color for the journal-date annotation line in journal entries.
251    pub color_journal_date: ThemeColor,
252    /// Color for highlighted search-match text.
253    pub color_search_match: ThemeColor,
254    /// Color for #hashtag label spans in the editor.
255    #[serde(default = "default_color_tag")]
256    pub color_tag: ThemeColor,
257    /// Color of the `│` blockquote bar drawn in place of `>` markers.
258    #[serde(default = "default_blockquote_bar")]
259    pub blockquote_bar: ThemeColor,
260    /// Background of fenced and indented code blocks (the "code box").
261    /// Inline `code` uses `bg_selected`, not this.
262    #[serde(default = "default_code_bg")]
263    pub code_bg: ThemeColor,
264}
265
266/// Serde default for `color_tag` — used when deserializing older theme TOML
267/// files that do not contain the field. Falls back to Gruvbox Dark's orange.
268fn default_color_tag() -> ThemeColor {
269    ThemeColor::from_string("#fe8019").unwrap()
270}
271
272/// Serde default for `blockquote_bar` — Gruvbox Dark's accent yellow.
273fn default_blockquote_bar() -> ThemeColor {
274    ThemeColor::from_string("#fabd2f").unwrap()
275}
276
277/// Serde default for `code_bg` — Gruvbox Dark's panel background.
278fn 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    /// Returns the appropriate border style depending on focus state.
290    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    /// Base style for most surfaces: theme fg on theme bg.
299    pub fn base_style(&self) -> Style {
300        Style::default()
301            .fg(self.fg.to_ratatui())
302            .bg(self.bg.to_ratatui())
303    }
304
305    /// Panel style for sidebars and panels: theme fg on bg_panel.
306    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        // Low 16 ANSI indices map to named ratatui variants
434        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        // Indices >= 16 still use 256-color
440        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        // Built-in theme exposes the fields.
692        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        // Old TOML without the fields still deserializes (serde defaults kick in).
700        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}