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}
306
307/// Deserialization shadow of [`Theme`].
308///
309/// Newer roles are optional so theme TOML files written before they existed
310/// still parse; missing roles are derived from the closest sibling role (or a
311/// chromatic ANSI slot for the accent palette, which adapts to the user's
312/// terminal palette) in [`From<ThemeToml>`].
313#[derive(Deserialize)]
314struct ThemeToml {
315    name: String,
316    bg: ThemeColor,
317    bg_hard: Option<ThemeColor>,
318    bg_soft: Option<ThemeColor>,
319    bg_panel: ThemeColor,
320    selection_bg: ThemeColor,
321    fg: ThemeColor,
322    fg_bright: Option<ThemeColor>,
323    fg_secondary: ThemeColor,
324    gray: ThemeColor,
325    selection_fg: ThemeColor,
326    border_dim: ThemeColor,
327    focus_border: ThemeColor,
328    accent: ThemeColor,
329    cursor: Option<ThemeColor>,
330    red: Option<ThemeColor>,
331    green: Option<ThemeColor>,
332    yellow: Option<ThemeColor>,
333    blue: Option<ThemeColor>,
334    purple: Option<ThemeColor>,
335    aqua: Option<ThemeColor>,
336    orange: Option<ThemeColor>,
337    color_directory: ThemeColor,
338    color_journal_date: ThemeColor,
339    color_search_match: ThemeColor,
340    color_tag: Option<ThemeColor>,
341    blockquote_bar: Option<ThemeColor>,
342    code_bg: Option<ThemeColor>,
343}
344
345impl From<ThemeToml> for Theme {
346    fn from(t: ThemeToml) -> Self {
347        let orange = t.orange.unwrap_or(ThemeColor::Ansi(208));
348        Theme {
349            name: t.name,
350            bg_hard: t.bg_hard.unwrap_or_else(|| t.bg_panel.clone()),
351            bg_soft: t.bg_soft.unwrap_or_else(|| t.selection_bg.clone()),
352            fg_bright: t.fg_bright.unwrap_or_else(|| t.selection_fg.clone()),
353            cursor: t.cursor.unwrap_or_else(|| t.fg.clone()),
354            red: t.red.unwrap_or(ThemeColor::Ansi(9)),
355            green: t.green.unwrap_or(ThemeColor::Ansi(10)),
356            yellow: t.yellow.unwrap_or(ThemeColor::Ansi(11)),
357            blue: t.blue.unwrap_or(ThemeColor::Ansi(12)),
358            purple: t.purple.unwrap_or(ThemeColor::Ansi(13)),
359            aqua: t.aqua.unwrap_or(ThemeColor::Ansi(14)),
360            color_tag: t.color_tag.unwrap_or_else(|| orange.clone()),
361            blockquote_bar: t.blockquote_bar.unwrap_or_else(|| t.accent.clone()),
362            code_bg: t.code_bg.unwrap_or_else(|| t.bg_panel.clone()),
363            orange,
364            bg: t.bg,
365            bg_panel: t.bg_panel,
366            selection_bg: t.selection_bg,
367            fg: t.fg,
368            fg_secondary: t.fg_secondary,
369            gray: t.gray,
370            selection_fg: t.selection_fg,
371            border_dim: t.border_dim,
372            focus_border: t.focus_border,
373            accent: t.accent,
374            color_directory: t.color_directory,
375            color_journal_date: t.color_journal_date,
376            color_search_match: t.color_search_match,
377        }
378    }
379}
380
381impl Default for Theme {
382    fn default() -> Self {
383        Self::gruvbox_dark()
384    }
385}
386
387impl Theme {
388    /// Returns the appropriate border style depending on focus state.
389    pub fn border_style(&self, focused: bool) -> Style {
390        if focused {
391            Style::default().fg(self.focus_border.to_ratatui())
392        } else {
393            Style::default().fg(self.border_dim.to_ratatui())
394        }
395    }
396
397    /// Base style for most surfaces: theme fg on theme bg.
398    pub fn base_style(&self) -> Style {
399        Style::default()
400            .fg(self.fg.to_ratatui())
401            .bg(self.bg.to_ratatui())
402    }
403
404    /// Panel style for sidebars and panels: theme fg on bg_panel.
405    pub fn panel_style(&self) -> Style {
406        Style::default()
407            .fg(self.fg.to_ratatui())
408            .bg(self.bg_panel.to_ratatui())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use ratatui::style::Style;
416
417    #[test]
418    fn test_border_style_focused() {
419        let theme = Theme::gruvbox_dark();
420        let style = theme.border_style(true);
421        assert_eq!(style, Style::default().fg(theme.focus_border.to_ratatui()));
422    }
423
424    #[test]
425    fn test_border_style_unfocused() {
426        let theme = Theme::gruvbox_dark();
427        let style = theme.border_style(false);
428        assert_eq!(style, Style::default().fg(theme.border_dim.to_ratatui()));
429    }
430
431    #[test]
432    fn test_from_hex_6char() {
433        assert_eq!(
434            ThemeColor::from_string("#ff8800").unwrap(),
435            ThemeColor::Rgb(255, 136, 0)
436        );
437    }
438
439    #[test]
440    fn test_from_hex_6char_lowercase() {
441        assert_eq!(
442            ThemeColor::from_string("#abcdef").unwrap(),
443            ThemeColor::Rgb(171, 205, 239)
444        );
445    }
446
447    #[test]
448    fn test_from_hex_6char_uppercase() {
449        assert_eq!(
450            ThemeColor::from_string("#ABCDEF").unwrap(),
451            ThemeColor::Rgb(171, 205, 239)
452        );
453    }
454
455    #[test]
456    fn test_from_hex_3char() {
457        assert_eq!(
458            ThemeColor::from_string("#f80").unwrap(),
459            ThemeColor::Rgb(255, 136, 0)
460        );
461    }
462
463    #[test]
464    fn test_from_hex_3char_expansion() {
465        assert_eq!(
466            ThemeColor::from_string("#abc").unwrap(),
467            ThemeColor::Rgb(170, 187, 204)
468        );
469    }
470
471    #[test]
472    fn test_from_hex_3char_black() {
473        assert_eq!(
474            ThemeColor::from_string("#000").unwrap(),
475            ThemeColor::Rgb(0, 0, 0)
476        );
477    }
478
479    #[test]
480    fn test_from_hex_3char_white() {
481        assert_eq!(
482            ThemeColor::from_string("#fff").unwrap(),
483            ThemeColor::Rgb(255, 255, 255)
484        );
485    }
486
487    #[test]
488    fn test_from_rgb_string() {
489        assert_eq!(
490            ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
491            ThemeColor::Rgb(255, 128, 0)
492        );
493    }
494
495    #[test]
496    fn test_from_rgb_string_no_spaces() {
497        assert_eq!(
498            ThemeColor::from_string("rgb(255,128,0)").unwrap(),
499            ThemeColor::Rgb(255, 128, 0)
500        );
501    }
502
503    #[test]
504    fn test_from_rgb_string_extra_spaces() {
505        assert_eq!(
506            ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
507            ThemeColor::Rgb(255, 128, 0)
508        );
509    }
510
511    #[test]
512    fn test_from_rgb_string_min_max() {
513        assert_eq!(
514            ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
515            ThemeColor::Rgb(0, 255, 0)
516        );
517    }
518
519    #[test]
520    fn test_from_string_with_whitespace() {
521        assert_eq!(
522            ThemeColor::from_string("  #ff8800  ").unwrap(),
523            ThemeColor::Rgb(255, 136, 0)
524        );
525    }
526
527    #[test]
528    fn test_ansi_to_ratatui() {
529        // Low 16 ANSI indices map to named ratatui variants
530        assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
531        assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
532        assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
533        assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
534        assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
535        // Indices >= 16 still use 256-color
536        assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
537        assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
538    }
539
540    #[test]
541    fn test_invalid_hex_length() {
542        let result = ThemeColor::from_string("#ff880");
543        assert!(result.is_err());
544        assert!(result.unwrap_err().contains("Invalid hex color length"));
545    }
546
547    #[test]
548    fn test_invalid_hex_chars() {
549        let result = ThemeColor::from_string("#gghhii");
550        assert!(result.is_err());
551    }
552
553    #[test]
554    fn test_missing_hash() {
555        let result = ThemeColor::from_string("ff8800");
556        assert!(result.is_err());
557        assert!(result.unwrap_err().contains("Invalid color format"));
558    }
559
560    #[test]
561    fn test_invalid_rgb_format() {
562        let result = ThemeColor::from_string("rgb(255, 128)");
563        assert!(result.is_err());
564        assert!(result.unwrap_err().contains("requires 3 values"));
565    }
566
567    #[test]
568    fn test_rgb_value_out_of_range() {
569        let result = ThemeColor::from_string("rgb(256, 128, 0)");
570        assert!(result.is_err());
571    }
572
573    #[test]
574    fn test_rgb_negative_value() {
575        let result = ThemeColor::from_string("rgb(-1, 128, 0)");
576        assert!(result.is_err());
577    }
578
579    #[test]
580    fn test_rgb_non_numeric() {
581        let result = ThemeColor::from_string("rgb(abc, 128, 0)");
582        assert!(result.is_err());
583        assert!(result.unwrap_err().contains("Invalid red value"));
584    }
585
586    #[test]
587    fn test_invalid_format() {
588        let result = ThemeColor::from_string("not a color");
589        assert!(result.is_err());
590        assert!(result.unwrap_err().contains("Invalid color format"));
591    }
592
593    #[test]
594    fn test_empty_string() {
595        let result = ThemeColor::from_string("");
596        assert!(result.is_err());
597    }
598
599    #[test]
600    fn test_new_constructor() {
601        assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
602    }
603
604    #[test]
605    fn test_to_ratatui() {
606        let color = ThemeColor::new(131, 165, 152);
607        assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
608    }
609
610    #[test]
611    fn test_theme_color_serialize() {
612        #[derive(Serialize)]
613        struct Wrapper {
614            color: ThemeColor,
615        }
616        let wrapper = Wrapper {
617            color: ThemeColor::new(59, 130, 246),
618        };
619        let serialized = toml::to_string(&wrapper).unwrap();
620        assert!(serialized.contains("color = \"#3b82f6\""));
621    }
622
623    #[test]
624    fn test_theme_color_deserialize() {
625        #[derive(Deserialize)]
626        struct Wrapper {
627            color: ThemeColor,
628        }
629        let toml_str = r###"color = "#3b82f6""###;
630        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
631        assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
632    }
633
634    #[test]
635    fn test_theme_color_roundtrip() {
636        #[derive(Serialize, Deserialize)]
637        struct Wrapper {
638            color: ThemeColor,
639        }
640        let original = Wrapper {
641            color: ThemeColor::new(239, 68, 68),
642        };
643        let serialized = toml::to_string(&original).unwrap();
644        let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
645        assert_eq!(original.color, deserialized.color);
646    }
647
648    #[test]
649    fn test_theme_serialize_to_toml() {
650        let theme = Theme::gruvbox_dark();
651        let toml_string = toml::to_string_pretty(&theme).unwrap();
652
653        assert!(toml_string.contains("name = \"Gruvbox Dark\""));
654        assert!(toml_string.contains("bg = \"#282828\""));
655        assert!(toml_string.contains("bg_panel = \"#32302f\""));
656        assert!(toml_string.contains("focus_border = \"#b8bb26\""));
657        assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
658    }
659
660    #[test]
661    fn test_theme_deserialize_from_toml() {
662        let toml_str = r###"
663            name = "Test Theme"
664            bg                 = "#282828"
665            bg_panel           = "#32302f"
666            selection_bg        = "#504945"
667            fg                 = "#ebdbb2"
668            fg_secondary       = "#a89984"
669            gray           = "#7c6f64"
670            selection_fg        = "#fbf1c7"
671            border_dim             = "#504945"
672            focus_border     = "#fabd2f"
673            accent             = "#fabd2f"
674            color_directory    = "#83a598"
675            color_journal_date = "#8ec07c"
676            color_search_match = "#b8bb26"
677            color_tag          = "#fe8019"
678        "###;
679
680        let theme: Theme = toml::from_str(toml_str).unwrap();
681        assert_eq!(theme.name, "Test Theme");
682        assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
683        assert_eq!(theme.focus_border, ThemeColor::new(0xfa, 0xbd, 0x2f));
684        assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
685    }
686
687    #[test]
688    fn test_theme_roundtrip() {
689        let original = Theme::tokyo_night();
690        let toml_string = toml::to_string_pretty(&original).unwrap();
691        let deserialized: Theme = toml::from_str(&toml_string).unwrap();
692
693        assert_eq!(original.name, deserialized.name);
694        assert_eq!(original.bg, deserialized.bg);
695        assert_eq!(original.fg, deserialized.fg);
696        assert_eq!(original.focus_border, deserialized.focus_border);
697        assert_eq!(original.color_journal_date, deserialized.color_journal_date);
698    }
699
700    #[test]
701    fn test_theme_color_serialize_lowercase_hex() {
702        #[derive(Serialize)]
703        struct Wrapper {
704            color: ThemeColor,
705        }
706        let wrapper = Wrapper {
707            color: ThemeColor::new(171, 205, 239),
708        };
709        let serialized = toml::to_string(&wrapper).unwrap();
710        assert!(serialized.contains("color = \"#abcdef\""));
711    }
712
713    #[test]
714    fn test_theme_deserialize_uppercase_hex() {
715        #[derive(Deserialize)]
716        struct Wrapper {
717            color: ThemeColor,
718        }
719        let toml_str = r###"color = "#ABCDEF""###;
720        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
721        assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
722    }
723
724    #[test]
725    fn test_theme_deserialize_3char_hex() {
726        #[derive(Deserialize)]
727        struct Wrapper {
728            color: ThemeColor,
729        }
730        let toml_str = r###"color = "#abc""###;
731        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
732        assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
733    }
734
735    #[test]
736    fn test_from_ansi_index() {
737        assert_eq!(
738            ThemeColor::from_string("ansi:4").unwrap(),
739            ThemeColor::Ansi(4)
740        );
741        assert_eq!(
742            ThemeColor::from_string("ansi:255").unwrap(),
743            ThemeColor::Ansi(255)
744        );
745    }
746
747    #[test]
748    fn test_from_reset() {
749        assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
750    }
751
752    #[test]
753    fn test_all_builtin_themes_serialize() {
754        let themes = vec![
755            Theme::ansi(),
756            Theme::gruvbox_dark(),
757            Theme::gruvbox_light(),
758            Theme::catppuccin_mocha(),
759            Theme::catppuccin_latte(),
760            Theme::tokyo_night(),
761            Theme::tokyo_night_storm(),
762            Theme::solarized_dark(),
763            Theme::solarized_light(),
764            Theme::nord(),
765        ];
766        for theme in themes {
767            let toml_string = toml::to_string_pretty(&theme).unwrap();
768            let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
769            assert_eq!(theme.name, roundtrip.name);
770            assert_eq!(theme.bg, roundtrip.bg);
771        }
772    }
773
774    #[test]
775    fn test_ansi_theme() {
776        let theme = Theme::ansi();
777        assert_eq!(theme.name, "ANSI");
778        assert_eq!(theme.bg, ThemeColor::Reset);
779        assert_eq!(theme.fg, ThemeColor::Reset);
780        assert_eq!(theme.selection_bg, ThemeColor::Ansi(4));
781        assert_eq!(theme.focus_border, ThemeColor::Ansi(10));
782        assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
783    }
784
785    #[test]
786    fn new_decoration_fields_present_and_deserialize_default() {
787        // Built-in theme exposes the fields.
788        let t = Theme::gruvbox_dark();
789        assert_eq!(
790            t.blockquote_bar,
791            ThemeColor::from_string("#fabd2f").unwrap()
792        );
793        assert_eq!(t.code_bg, ThemeColor::from_string("#32302f").unwrap());
794
795        // Old TOML without the fields still deserializes (serde defaults kick in).
796        let toml = r##"
797            name = "Old"
798            bg = "#000000"
799            bg_panel = "#111111"
800            selection_bg = "#222222"
801            fg = "#ffffff"
802            fg_secondary = "#cccccc"
803            gray = "#888888"
804            selection_fg = "#ffffff"
805            border_dim = "#333333"
806            focus_border = "#444444"
807            accent = "#55aaff"
808            color_directory = "#66ccee"
809            color_journal_date = "#77ddcc"
810            color_search_match = "#88eeaa"
811        "##;
812        let parsed: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
813        // Sibling-derived defaults.
814        assert_eq!(parsed.blockquote_bar, parsed.accent);
815        assert_eq!(parsed.code_bg, parsed.bg_panel);
816    }
817
818    #[test]
819    fn old_theme_toml_derives_new_roles_from_siblings() {
820        // A theme file written before the §1 role expansion must still parse,
821        // with the new roles derived from their closest sibling.
822        let toml = r##"
823            name = "Old"
824            bg = "#000000"
825            bg_panel = "#111111"
826            selection_bg = "#222222"
827            fg = "#ffffff"
828            fg_secondary = "#cccccc"
829            gray = "#888888"
830            selection_fg = "#eeeeee"
831            border_dim = "#333333"
832            focus_border = "#444444"
833            accent = "#55aaff"
834            color_directory = "#66ccee"
835            color_journal_date = "#77ddcc"
836            color_search_match = "#88eeaa"
837        "##;
838        let t: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
839        assert_eq!(t.bg_hard, t.bg_panel);
840        assert_eq!(t.bg_soft, t.selection_bg);
841        assert_eq!(t.fg_bright, t.selection_fg);
842        assert_eq!(t.cursor, t.fg);
843        // Accent palette falls back to chromatic ANSI slots.
844        assert_eq!(t.red, ThemeColor::Ansi(9));
845        assert_eq!(t.green, ThemeColor::Ansi(10));
846        assert_eq!(t.yellow, ThemeColor::Ansi(11));
847        assert_eq!(t.blue, ThemeColor::Ansi(12));
848        assert_eq!(t.purple, ThemeColor::Ansi(13));
849        assert_eq!(t.aqua, ThemeColor::Ansi(14));
850        assert_eq!(t.orange, ThemeColor::Ansi(208));
851        // color_tag tracks the orange accent when absent.
852        assert_eq!(t.color_tag, t.orange);
853    }
854
855    #[test]
856    fn new_roles_roundtrip_through_toml() {
857        let original = Theme::gruvbox_dark();
858        let toml_string = toml::to_string_pretty(&original).unwrap();
859        let parsed: Theme = toml::from_str(&toml_string).unwrap();
860        assert_eq!(original, parsed);
861    }
862}