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