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/// Terminal color-depth detection and 256/16-color theme adaptation.
8pub mod color_depth;
9
10#[derive(Debug, Clone, PartialEq)]
11pub enum ThemeColor {
12    Rgb(u8, u8, u8),
13    /// Terminal ANSI color index (0–15 for the standard palette, up to 255 for
14    /// 256-color mode). The actual color is determined by the user's terminal.
15    Ansi(u8),
16    /// The terminal's default foreground or background color.
17    Reset,
18}
19
20impl Serialize for ThemeColor {
21    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
22    where
23        S: Serializer,
24    {
25        match self {
26            ThemeColor::Rgb(r, g, b) => {
27                serializer.serialize_str(&format!("#{:02x}{:02x}{:02x}", r, g, b))
28            }
29            ThemeColor::Ansi(n) => serializer.serialize_str(&format!("ansi:{}", n)),
30            ThemeColor::Reset => serializer.serialize_str("reset"),
31        }
32    }
33}
34
35impl<'de> Deserialize<'de> for ThemeColor {
36    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37    where
38        D: Deserializer<'de>,
39    {
40        let s = String::deserialize(deserializer)?;
41        ThemeColor::from_string(&s).map_err(serde::de::Error::custom)
42    }
43}
44
45impl ThemeColor {
46    pub fn new(r: u8, g: u8, b: u8) -> Self {
47        ThemeColor::Rgb(r, g, b)
48    }
49
50    /// Convert to the corresponding ratatui `Color`.
51    ///
52    /// ANSI indices 0–15 map to ratatui's named color variants so they emit
53    /// the standard SGR codes (30–37 / 90–97) the terminal's palette is keyed
54    /// to, rather than the 256-color `38;5;n` form which some terminals
55    /// remap inconsistently for the low 16 slots.
56    pub fn to_ratatui(&self) -> Color {
57        match self {
58            ThemeColor::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
59            ThemeColor::Ansi(n) => match n {
60                0 => Color::Black,
61                1 => Color::Red,
62                2 => Color::Green,
63                3 => Color::Yellow,
64                4 => Color::Blue,
65                5 => Color::Magenta,
66                6 => Color::Cyan,
67                7 => Color::Gray,
68                8 => Color::DarkGray,
69                9 => Color::LightRed,
70                10 => Color::LightGreen,
71                11 => Color::LightYellow,
72                12 => Color::LightBlue,
73                13 => Color::LightMagenta,
74                14 => Color::LightCyan,
75                15 => Color::White,
76                _ => Color::Indexed(*n),
77            },
78            ThemeColor::Reset => Color::Reset,
79        }
80    }
81
82    /// Parse a color from a string in various formats:
83    /// - RGB: "rgb(255, 128, 0)"
84    /// - 3-char hex: "#abc" (expanded to #aabbcc)
85    /// - 6-char hex: "#aabbcc"
86    /// - ANSI index: "ansi:4" (0–255)
87    /// - Terminal default: "reset"
88    pub fn from_string(s: &str) -> Result<Self, String> {
89        let s = s.trim();
90
91        if s.starts_with('#') {
92            Self::from_hex(s)
93        } else if s.starts_with("rgb(") && s.ends_with(')') {
94            Self::from_rgb_string(s)
95        } else if s == "reset" {
96            Ok(ThemeColor::Reset)
97        } else if let Some(rest) = s.strip_prefix("ansi:") {
98            rest.parse::<u8>()
99                .map(ThemeColor::Ansi)
100                .map_err(|_| format!("Invalid ANSI color index: {}", rest))
101        } else {
102            Err(format!("Invalid color format: {}", s))
103        }
104    }
105
106    /// Parse hex color string (#abc or #aabbcc)
107    fn from_hex(s: &str) -> Result<Self, String> {
108        if !s.starts_with('#') {
109            return Err("Hex color must start with #".to_string());
110        }
111
112        let hex = &s[1..];
113
114        match hex.len() {
115            3 => Self::from_hex_3char(hex),
116            6 => Self::from_hex_6char(hex),
117            _ => Err(format!(
118                "Invalid hex color length: expected 3 or 6 chars, got {}",
119                hex.len()
120            )),
121        }
122    }
123
124    /// Parse 3-character hex color (e.g., "abc" -> r=0xaa, g=0xbb, b=0xcc)
125    fn from_hex_3char(hex: &str) -> Result<Self, String> {
126        if hex.len() != 3 {
127            return Err("Expected 3 hex characters".to_string());
128        }
129
130        let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
131            .map_err(|_| format!("Invalid hex character in red component: {}", &hex[0..1]))?;
132        let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
133            .map_err(|_| format!("Invalid hex character in green component: {}", &hex[1..2]))?;
134        let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
135            .map_err(|_| format!("Invalid hex character in blue component: {}", &hex[2..3]))?;
136
137        Ok(ThemeColor::Rgb(r, g, b))
138    }
139
140    /// Parse 6-character hex color (e.g., "aabbcc")
141    fn from_hex_6char(hex: &str) -> Result<Self, String> {
142        if hex.len() != 6 {
143            return Err("Expected 6 hex characters".to_string());
144        }
145
146        let r = u8::from_str_radix(&hex[0..2], 16)
147            .map_err(|_| format!("Invalid hex characters in red component: {}", &hex[0..2]))?;
148        let g = u8::from_str_radix(&hex[2..4], 16)
149            .map_err(|_| format!("Invalid hex characters in green component: {}", &hex[2..4]))?;
150        let b = u8::from_str_radix(&hex[4..6], 16)
151            .map_err(|_| format!("Invalid hex characters in blue component: {}", &hex[4..6]))?;
152
153        Ok(ThemeColor::Rgb(r, g, b))
154    }
155
156    /// Parse RGB string format (e.g., "rgb(255, 128, 0)")
157    fn from_rgb_string(s: &str) -> Result<Self, String> {
158        if !s.starts_with("rgb(") || !s.ends_with(')') {
159            return Err("RGB format must be rgb(r, g, b)".to_string());
160        }
161
162        let inner = &s[4..s.len() - 1];
163        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
164
165        if parts.len() != 3 {
166            return Err(format!("RGB format requires 3 values, got {}", parts.len()));
167        }
168
169        let r = parts[0]
170            .parse::<u8>()
171            .map_err(|_| format!("Invalid red value: {}", parts[0]))?;
172        let g = parts[1]
173            .parse::<u8>()
174            .map_err(|_| format!("Invalid green value: {}", parts[1]))?;
175        let b = parts[2]
176            .parse::<u8>()
177            .map_err(|_| format!("Invalid blue value: {}", parts[2]))?;
178
179        Ok(ThemeColor::Rgb(r, g, b))
180    }
181}
182
183impl Display for ThemeColor {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        match self {
186            ThemeColor::Rgb(r, g, b) => write!(f, "rgb({},{},{})", r, g, b),
187            ThemeColor::Ansi(n) => write!(f, "ansi:{}", n),
188            ThemeColor::Reset => write!(f, "reset"),
189        }
190    }
191}
192
193/// Theme for the TUI application.
194///
195/// Fields are named after the UI roles they fill, making it straightforward to
196/// map any popular terminal color scheme (Gruvbox, Catppuccin, Tokyo Night, …)
197/// to this struct.  Custom themes can be placed as `.toml` files in the themes
198/// config directory and will be loaded automatically at startup.
199///
200/// # Example theme file (`~/.config/kimun/themes/mytheme.toml`)
201/// ```toml
202/// name = "My Theme"
203/// bg               = "#1e1e2e"
204/// bg_hard          = "#11111b"
205/// bg_soft          = "#313244"
206/// bg_panel         = "#181825"
207/// selection_bg      = "#313244"
208/// fg               = "#cdd6f4"
209/// fg_bright        = "#f5e0dc"
210/// fg_secondary     = "#a6adc8"
211/// gray         = "#6c7086"
212/// selection_fg      = "#cdd6f4"
213/// border_dim           = "#45475a"
214/// focus_border   = "#89b4fa"
215/// accent           = "#89b4fa"
216/// cursor           = "#f5e0dc"
217/// red              = "#f38ba8"
218/// green            = "#a6e3a1"
219/// yellow           = "#f9e2af"
220/// blue             = "#89b4fa"
221/// purple           = "#cba6f7"
222/// aqua             = "#94e2d5"
223/// orange           = "#fab387"
224/// color_directory  = "#89dceb"
225/// color_journal_date = "#94e2d5"
226/// color_search_match = "#a6e3a1"
227/// color_tag        = "#fab387"
228/// blockquote_bar   = "#cba6f7"
229/// code_bg          = "#181825"
230/// ```
231///
232/// Roles introduced after a theme file was written may be omitted — they are
233/// derived from the closest sibling role (see `ThemeToml`).
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[serde(from = "ThemeToml")]
236pub struct Theme {
237    pub name: String,
238
239    // ── Backgrounds ─────────────────────────────────────────────────────────
240    /// Main/editor background.
241    pub bg: ThemeColor,
242    /// Hard-contrast background: modals and input fields.
243    pub bg_hard: ThemeColor,
244    /// Soft background: alternating rows, horizontal rules.
245    pub bg_soft: ThemeColor,
246    /// Sidebar / panel background (usually slightly offset from `bg`).
247    pub bg_panel: ThemeColor,
248    /// Background of the currently selected row in lists.
249    pub selection_bg: ThemeColor,
250
251    // ── Foreground / text ────────────────────────────────────────────────────
252    /// Primary text color.
253    pub fg: ThemeColor,
254    /// Bright/high-contrast text: titles, headings.
255    pub fg_bright: ThemeColor,
256    /// Secondary text: filenames, metadata, subdued hints.
257    pub fg_secondary: ThemeColor,
258    /// Very dim text: placeholders, separators, disabled items.
259    pub gray: ThemeColor,
260    /// Text color of a selected/highlighted row (often brighter than `fg`).
261    pub selection_fg: ThemeColor,
262
263    // ── Borders ──────────────────────────────────────────────────────────────
264    /// Default (unfocused) border color.
265    pub border_dim: ThemeColor,
266    /// Border color when the pane has keyboard focus.
267    pub focus_border: ThemeColor,
268
269    // ── Accent ───────────────────────────────────────────────────────────────
270    /// Primary accent: title bars, active markers, cursor highlights.
271    pub accent: ThemeColor,
272    /// Block-cursor color in text fields.
273    pub cursor: ThemeColor,
274
275    // ── Accent palette (semantic, theme-bound) ──────────────────────────────
276    /// Errors, destructive actions, query negation.
277    pub red: ThemeColor,
278    /// Success / OK states.
279    pub green: ThemeColor,
280    /// Warnings, field keys, keycaps.
281    pub yellow: ThemeColor,
282    /// Wikilink targets.
283    pub blue: ThemeColor,
284    /// Numbers and date literals.
285    pub purple: ThemeColor,
286    /// Tags, links, group labels.
287    pub aqua: ThemeColor,
288    /// Operators and strong accents.
289    pub orange: ThemeColor,
290
291    // ── Semantic colors for file-list entries ────────────────────────────────
292    /// Color used for directory entries in the file list.
293    pub color_directory: ThemeColor,
294    /// Color for the journal-date annotation line in journal entries.
295    pub color_journal_date: ThemeColor,
296    /// Color for highlighted search-match text.
297    pub color_search_match: ThemeColor,
298    /// Color for #hashtag label spans in the editor.
299    pub color_tag: ThemeColor,
300    /// Color of the `│` blockquote bar drawn in place of `>` markers.
301    pub blockquote_bar: ThemeColor,
302    /// Background of fenced and indented code blocks (the "code box").
303    /// Inline `code` uses `selection_bg`, not this.
304    pub code_bg: ThemeColor,
305    /// Background of text the **replace preview** is showing in place of a
306    /// match. Deliberately its own role rather than a reuse of `selection_bg`
307    /// or `color_search_match`: the preview rewrites the note on screen while
308    /// leaving the buffer untouched, so "this is not your note yet" must not
309    /// look like any committed-text style.
310    pub color_replace_preview: ThemeColor,
311}
312
313/// Deserialization shadow of [`Theme`].
314///
315/// Newer roles are optional so theme TOML files written before they existed
316/// still parse; missing roles are derived from the closest sibling role (or a
317/// chromatic ANSI slot for the accent palette, which adapts to the user's
318/// terminal palette) in [`From<ThemeToml>`].
319#[derive(Deserialize)]
320struct ThemeToml {
321    name: String,
322    bg: ThemeColor,
323    bg_hard: Option<ThemeColor>,
324    bg_soft: Option<ThemeColor>,
325    bg_panel: ThemeColor,
326    selection_bg: ThemeColor,
327    fg: ThemeColor,
328    fg_bright: Option<ThemeColor>,
329    fg_secondary: ThemeColor,
330    gray: ThemeColor,
331    selection_fg: ThemeColor,
332    border_dim: ThemeColor,
333    focus_border: ThemeColor,
334    accent: ThemeColor,
335    cursor: Option<ThemeColor>,
336    red: Option<ThemeColor>,
337    green: Option<ThemeColor>,
338    yellow: Option<ThemeColor>,
339    blue: Option<ThemeColor>,
340    purple: Option<ThemeColor>,
341    aqua: Option<ThemeColor>,
342    orange: Option<ThemeColor>,
343    color_directory: ThemeColor,
344    color_journal_date: ThemeColor,
345    color_search_match: ThemeColor,
346    color_tag: Option<ThemeColor>,
347    blockquote_bar: Option<ThemeColor>,
348    code_bg: Option<ThemeColor>,
349    color_replace_preview: Option<ThemeColor>,
350}
351
352impl From<ThemeToml> for Theme {
353    fn from(t: ThemeToml) -> Self {
354        let orange = t.orange.unwrap_or(ThemeColor::Ansi(208));
355        Theme {
356            name: t.name,
357            bg_hard: t.bg_hard.unwrap_or_else(|| t.bg_panel.clone()),
358            bg_soft: t.bg_soft.unwrap_or_else(|| t.selection_bg.clone()),
359            fg_bright: t.fg_bright.unwrap_or_else(|| t.selection_fg.clone()),
360            cursor: t.cursor.unwrap_or_else(|| t.fg.clone()),
361            red: t.red.unwrap_or(ThemeColor::Ansi(9)),
362            green: t.green.unwrap_or(ThemeColor::Ansi(10)),
363            yellow: t.yellow.unwrap_or(ThemeColor::Ansi(11)),
364            blue: t.blue.unwrap_or(ThemeColor::Ansi(12)),
365            purple: t.purple.unwrap_or(ThemeColor::Ansi(13)),
366            aqua: t.aqua.unwrap_or(ThemeColor::Ansi(14)),
367            color_tag: t.color_tag.unwrap_or_else(|| orange.clone()),
368            blockquote_bar: t.blockquote_bar.unwrap_or_else(|| t.accent.clone()),
369            code_bg: t.code_bg.unwrap_or_else(|| t.bg_panel.clone()),
370            // Themes written before the replace preview existed fall back to
371            // the accent colour, which is chromatic in every builtin — so the
372            // preview still reads as "not committed text" rather than
373            // silently collapsing into the selection or the code box.
374            color_replace_preview: t.color_replace_preview.unwrap_or_else(|| t.accent.clone()),
375            orange,
376            bg: t.bg,
377            bg_panel: t.bg_panel,
378            selection_bg: t.selection_bg,
379            fg: t.fg,
380            fg_secondary: t.fg_secondary,
381            gray: t.gray,
382            selection_fg: t.selection_fg,
383            border_dim: t.border_dim,
384            focus_border: t.focus_border,
385            accent: t.accent,
386            color_directory: t.color_directory,
387            color_journal_date: t.color_journal_date,
388            color_search_match: t.color_search_match,
389        }
390    }
391}
392
393impl Default for Theme {
394    fn default() -> Self {
395        Self::gruvbox_dark()
396    }
397}
398
399impl Theme {
400    /// Returns the appropriate border style depending on focus state.
401    pub fn border_style(&self, focused: bool) -> Style {
402        if focused {
403            Style::default().fg(self.focus_border.to_ratatui())
404        } else {
405            Style::default().fg(self.border_dim.to_ratatui())
406        }
407    }
408
409    /// Base style for most surfaces: theme fg on theme bg.
410    pub fn base_style(&self) -> Style {
411        Style::default()
412            .fg(self.fg.to_ratatui())
413            .bg(self.bg.to_ratatui())
414    }
415
416    /// Panel style for sidebars and panels: theme fg on bg_panel.
417    pub fn panel_style(&self) -> Style {
418        Style::default()
419            .fg(self.fg.to_ratatui())
420            .bg(self.bg_panel.to_ratatui())
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use ratatui::style::Style;
428
429    #[test]
430    fn test_border_style_focused() {
431        let theme = Theme::gruvbox_dark();
432        let style = theme.border_style(true);
433        assert_eq!(style, Style::default().fg(theme.focus_border.to_ratatui()));
434    }
435
436    #[test]
437    fn test_border_style_unfocused() {
438        let theme = Theme::gruvbox_dark();
439        let style = theme.border_style(false);
440        assert_eq!(style, Style::default().fg(theme.border_dim.to_ratatui()));
441    }
442
443    #[test]
444    fn test_from_hex_6char() {
445        assert_eq!(
446            ThemeColor::from_string("#ff8800").unwrap(),
447            ThemeColor::Rgb(255, 136, 0)
448        );
449    }
450
451    #[test]
452    fn test_from_hex_6char_lowercase() {
453        assert_eq!(
454            ThemeColor::from_string("#abcdef").unwrap(),
455            ThemeColor::Rgb(171, 205, 239)
456        );
457    }
458
459    #[test]
460    fn test_from_hex_6char_uppercase() {
461        assert_eq!(
462            ThemeColor::from_string("#ABCDEF").unwrap(),
463            ThemeColor::Rgb(171, 205, 239)
464        );
465    }
466
467    #[test]
468    fn test_from_hex_3char() {
469        assert_eq!(
470            ThemeColor::from_string("#f80").unwrap(),
471            ThemeColor::Rgb(255, 136, 0)
472        );
473    }
474
475    #[test]
476    fn test_from_hex_3char_expansion() {
477        assert_eq!(
478            ThemeColor::from_string("#abc").unwrap(),
479            ThemeColor::Rgb(170, 187, 204)
480        );
481    }
482
483    #[test]
484    fn test_from_hex_3char_black() {
485        assert_eq!(
486            ThemeColor::from_string("#000").unwrap(),
487            ThemeColor::Rgb(0, 0, 0)
488        );
489    }
490
491    #[test]
492    fn test_from_hex_3char_white() {
493        assert_eq!(
494            ThemeColor::from_string("#fff").unwrap(),
495            ThemeColor::Rgb(255, 255, 255)
496        );
497    }
498
499    #[test]
500    fn test_from_rgb_string() {
501        assert_eq!(
502            ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
503            ThemeColor::Rgb(255, 128, 0)
504        );
505    }
506
507    #[test]
508    fn test_from_rgb_string_no_spaces() {
509        assert_eq!(
510            ThemeColor::from_string("rgb(255,128,0)").unwrap(),
511            ThemeColor::Rgb(255, 128, 0)
512        );
513    }
514
515    #[test]
516    fn test_from_rgb_string_extra_spaces() {
517        assert_eq!(
518            ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
519            ThemeColor::Rgb(255, 128, 0)
520        );
521    }
522
523    #[test]
524    fn test_from_rgb_string_min_max() {
525        assert_eq!(
526            ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
527            ThemeColor::Rgb(0, 255, 0)
528        );
529    }
530
531    #[test]
532    fn test_from_string_with_whitespace() {
533        assert_eq!(
534            ThemeColor::from_string("  #ff8800  ").unwrap(),
535            ThemeColor::Rgb(255, 136, 0)
536        );
537    }
538
539    #[test]
540    fn test_ansi_to_ratatui() {
541        // Low 16 ANSI indices map to named ratatui variants
542        assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
543        assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
544        assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
545        assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
546        assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
547        // Indices >= 16 still use 256-color
548        assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
549        assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
550    }
551
552    #[test]
553    fn test_invalid_hex_length() {
554        let result = ThemeColor::from_string("#ff880");
555        assert!(result.is_err());
556        assert!(result.unwrap_err().contains("Invalid hex color length"));
557    }
558
559    #[test]
560    fn test_invalid_hex_chars() {
561        let result = ThemeColor::from_string("#gghhii");
562        assert!(result.is_err());
563    }
564
565    #[test]
566    fn test_missing_hash() {
567        let result = ThemeColor::from_string("ff8800");
568        assert!(result.is_err());
569        assert!(result.unwrap_err().contains("Invalid color format"));
570    }
571
572    #[test]
573    fn test_invalid_rgb_format() {
574        let result = ThemeColor::from_string("rgb(255, 128)");
575        assert!(result.is_err());
576        assert!(result.unwrap_err().contains("requires 3 values"));
577    }
578
579    #[test]
580    fn test_rgb_value_out_of_range() {
581        let result = ThemeColor::from_string("rgb(256, 128, 0)");
582        assert!(result.is_err());
583    }
584
585    #[test]
586    fn test_rgb_negative_value() {
587        let result = ThemeColor::from_string("rgb(-1, 128, 0)");
588        assert!(result.is_err());
589    }
590
591    #[test]
592    fn test_rgb_non_numeric() {
593        let result = ThemeColor::from_string("rgb(abc, 128, 0)");
594        assert!(result.is_err());
595        assert!(result.unwrap_err().contains("Invalid red value"));
596    }
597
598    #[test]
599    fn test_invalid_format() {
600        let result = ThemeColor::from_string("not a color");
601        assert!(result.is_err());
602        assert!(result.unwrap_err().contains("Invalid color format"));
603    }
604
605    #[test]
606    fn test_empty_string() {
607        let result = ThemeColor::from_string("");
608        assert!(result.is_err());
609    }
610
611    #[test]
612    fn test_new_constructor() {
613        assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
614    }
615
616    #[test]
617    fn test_to_ratatui() {
618        let color = ThemeColor::new(131, 165, 152);
619        assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
620    }
621
622    #[test]
623    fn test_theme_color_serialize() {
624        #[derive(Serialize)]
625        struct Wrapper {
626            color: ThemeColor,
627        }
628        let wrapper = Wrapper {
629            color: ThemeColor::new(59, 130, 246),
630        };
631        let serialized = toml::to_string(&wrapper).unwrap();
632        assert!(serialized.contains("color = \"#3b82f6\""));
633    }
634
635    #[test]
636    fn test_theme_color_deserialize() {
637        #[derive(Deserialize)]
638        struct Wrapper {
639            color: ThemeColor,
640        }
641        let toml_str = r###"color = "#3b82f6""###;
642        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
643        assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
644    }
645
646    #[test]
647    fn test_theme_color_roundtrip() {
648        #[derive(Serialize, Deserialize)]
649        struct Wrapper {
650            color: ThemeColor,
651        }
652        let original = Wrapper {
653            color: ThemeColor::new(239, 68, 68),
654        };
655        let serialized = toml::to_string(&original).unwrap();
656        let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
657        assert_eq!(original.color, deserialized.color);
658    }
659
660    #[test]
661    fn test_theme_serialize_to_toml() {
662        let theme = Theme::gruvbox_dark();
663        let toml_string = toml::to_string_pretty(&theme).unwrap();
664
665        assert!(toml_string.contains("name = \"Gruvbox Dark\""));
666        assert!(toml_string.contains("bg = \"#282828\""));
667        assert!(toml_string.contains("bg_panel = \"#32302f\""));
668        assert!(toml_string.contains("focus_border = \"#b8bb26\""));
669        assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
670    }
671
672    #[test]
673    fn test_theme_deserialize_from_toml() {
674        let toml_str = r###"
675            name = "Test Theme"
676            bg                 = "#282828"
677            bg_panel           = "#32302f"
678            selection_bg        = "#504945"
679            fg                 = "#ebdbb2"
680            fg_secondary       = "#a89984"
681            gray           = "#7c6f64"
682            selection_fg        = "#fbf1c7"
683            border_dim             = "#504945"
684            focus_border     = "#fabd2f"
685            accent             = "#fabd2f"
686            color_directory    = "#83a598"
687            color_journal_date = "#8ec07c"
688            color_search_match = "#b8bb26"
689            color_tag          = "#fe8019"
690        "###;
691
692        let theme: Theme = toml::from_str(toml_str).unwrap();
693        assert_eq!(theme.name, "Test Theme");
694        assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
695        assert_eq!(theme.focus_border, ThemeColor::new(0xfa, 0xbd, 0x2f));
696        assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
697    }
698
699    #[test]
700    fn test_theme_roundtrip() {
701        let original = Theme::tokyo_night();
702        let toml_string = toml::to_string_pretty(&original).unwrap();
703        let deserialized: Theme = toml::from_str(&toml_string).unwrap();
704
705        assert_eq!(original.name, deserialized.name);
706        assert_eq!(original.bg, deserialized.bg);
707        assert_eq!(original.fg, deserialized.fg);
708        assert_eq!(original.focus_border, deserialized.focus_border);
709        assert_eq!(original.color_journal_date, deserialized.color_journal_date);
710    }
711
712    #[test]
713    fn test_theme_color_serialize_lowercase_hex() {
714        #[derive(Serialize)]
715        struct Wrapper {
716            color: ThemeColor,
717        }
718        let wrapper = Wrapper {
719            color: ThemeColor::new(171, 205, 239),
720        };
721        let serialized = toml::to_string(&wrapper).unwrap();
722        assert!(serialized.contains("color = \"#abcdef\""));
723    }
724
725    #[test]
726    fn test_theme_deserialize_uppercase_hex() {
727        #[derive(Deserialize)]
728        struct Wrapper {
729            color: ThemeColor,
730        }
731        let toml_str = r###"color = "#ABCDEF""###;
732        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
733        assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
734    }
735
736    #[test]
737    fn test_theme_deserialize_3char_hex() {
738        #[derive(Deserialize)]
739        struct Wrapper {
740            color: ThemeColor,
741        }
742        let toml_str = r###"color = "#abc""###;
743        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
744        assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
745    }
746
747    #[test]
748    fn test_from_ansi_index() {
749        assert_eq!(
750            ThemeColor::from_string("ansi:4").unwrap(),
751            ThemeColor::Ansi(4)
752        );
753        assert_eq!(
754            ThemeColor::from_string("ansi:255").unwrap(),
755            ThemeColor::Ansi(255)
756        );
757    }
758
759    #[test]
760    fn test_from_reset() {
761        assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
762    }
763
764    #[test]
765    fn test_all_builtin_themes_serialize() {
766        let themes = vec![
767            Theme::ansi(),
768            Theme::gruvbox_dark(),
769            Theme::gruvbox_light(),
770            Theme::catppuccin_mocha(),
771            Theme::catppuccin_latte(),
772            Theme::tokyo_night(),
773            Theme::tokyo_night_storm(),
774            Theme::solarized_dark(),
775            Theme::solarized_light(),
776            Theme::nord(),
777        ];
778        for theme in themes {
779            let toml_string = toml::to_string_pretty(&theme).unwrap();
780            let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
781            assert_eq!(theme.name, roundtrip.name);
782            assert_eq!(theme.bg, roundtrip.bg);
783        }
784    }
785
786    #[test]
787    fn test_ansi_theme() {
788        let theme = Theme::ansi();
789        assert_eq!(theme.name, "ANSI");
790        assert_eq!(theme.bg, ThemeColor::Reset);
791        assert_eq!(theme.fg, ThemeColor::Reset);
792        assert_eq!(theme.selection_bg, ThemeColor::Ansi(4));
793        assert_eq!(theme.focus_border, ThemeColor::Ansi(10));
794        assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
795    }
796
797    #[test]
798    fn new_decoration_fields_present_and_deserialize_default() {
799        // Built-in theme exposes the fields.
800        let t = Theme::gruvbox_dark();
801        assert_eq!(
802            t.blockquote_bar,
803            ThemeColor::from_string("#fabd2f").unwrap()
804        );
805        assert_eq!(t.code_bg, ThemeColor::from_string("#32302f").unwrap());
806
807        // Old TOML without the fields still deserializes (serde defaults kick in).
808        let toml = r##"
809            name = "Old"
810            bg = "#000000"
811            bg_panel = "#111111"
812            selection_bg = "#222222"
813            fg = "#ffffff"
814            fg_secondary = "#cccccc"
815            gray = "#888888"
816            selection_fg = "#ffffff"
817            border_dim = "#333333"
818            focus_border = "#444444"
819            accent = "#55aaff"
820            color_directory = "#66ccee"
821            color_journal_date = "#77ddcc"
822            color_search_match = "#88eeaa"
823        "##;
824        let parsed: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
825        // Sibling-derived defaults.
826        assert_eq!(parsed.blockquote_bar, parsed.accent);
827        assert_eq!(parsed.code_bg, parsed.bg_panel);
828    }
829
830    #[test]
831    fn old_theme_toml_derives_new_roles_from_siblings() {
832        // A theme file written before the §1 role expansion must still parse,
833        // with the new roles derived from their closest sibling.
834        let toml = r##"
835            name = "Old"
836            bg = "#000000"
837            bg_panel = "#111111"
838            selection_bg = "#222222"
839            fg = "#ffffff"
840            fg_secondary = "#cccccc"
841            gray = "#888888"
842            selection_fg = "#eeeeee"
843            border_dim = "#333333"
844            focus_border = "#444444"
845            accent = "#55aaff"
846            color_directory = "#66ccee"
847            color_journal_date = "#77ddcc"
848            color_search_match = "#88eeaa"
849        "##;
850        let t: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
851        assert_eq!(t.bg_hard, t.bg_panel);
852        assert_eq!(t.bg_soft, t.selection_bg);
853        assert_eq!(t.fg_bright, t.selection_fg);
854        assert_eq!(t.cursor, t.fg);
855        // Accent palette falls back to chromatic ANSI slots.
856        assert_eq!(t.red, ThemeColor::Ansi(9));
857        assert_eq!(t.green, ThemeColor::Ansi(10));
858        assert_eq!(t.yellow, ThemeColor::Ansi(11));
859        assert_eq!(t.blue, ThemeColor::Ansi(12));
860        assert_eq!(t.purple, ThemeColor::Ansi(13));
861        assert_eq!(t.aqua, ThemeColor::Ansi(14));
862        assert_eq!(t.orange, ThemeColor::Ansi(208));
863        // color_tag tracks the orange accent when absent.
864        assert_eq!(t.color_tag, t.orange);
865    }
866
867    #[test]
868    fn new_roles_roundtrip_through_toml() {
869        let original = Theme::gruvbox_dark();
870        let toml_string = toml::to_string_pretty(&original).unwrap();
871        let parsed: Theme = toml::from_str(&toml_string).unwrap();
872        assert_eq!(original, parsed);
873    }
874}