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    /// At-rest style for anything clickable (chips, buttons, status-bar
424    /// segments, the sort label): one colour, no decoration, so click
425    /// targets are recognisable without moving the mouse. See
426    /// `components::clickable`.
427    pub fn action(&self) -> Style {
428        Style::default().fg(self.blue.to_ratatui())
429    }
430
431    /// The click target under the pointer: the action colour turned into a
432    /// background. Distinct from the list selection so a hover never reads
433    /// as "selected".
434    pub fn hover(&self) -> Style {
435        Style::default()
436            .fg(self.bg_hard.to_ratatui())
437            .bg(self.blue.to_ratatui())
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use ratatui::style::Style;
445
446    #[test]
447    fn test_border_style_focused() {
448        let theme = Theme::gruvbox_dark();
449        let style = theme.border_style(true);
450        assert_eq!(style, Style::default().fg(theme.focus_border.to_ratatui()));
451    }
452
453    #[test]
454    fn test_border_style_unfocused() {
455        let theme = Theme::gruvbox_dark();
456        let style = theme.border_style(false);
457        assert_eq!(style, Style::default().fg(theme.border_dim.to_ratatui()));
458    }
459
460    #[test]
461    fn test_from_hex_6char() {
462        assert_eq!(
463            ThemeColor::from_string("#ff8800").unwrap(),
464            ThemeColor::Rgb(255, 136, 0)
465        );
466    }
467
468    #[test]
469    fn test_from_hex_6char_lowercase() {
470        assert_eq!(
471            ThemeColor::from_string("#abcdef").unwrap(),
472            ThemeColor::Rgb(171, 205, 239)
473        );
474    }
475
476    #[test]
477    fn test_from_hex_6char_uppercase() {
478        assert_eq!(
479            ThemeColor::from_string("#ABCDEF").unwrap(),
480            ThemeColor::Rgb(171, 205, 239)
481        );
482    }
483
484    #[test]
485    fn test_from_hex_3char() {
486        assert_eq!(
487            ThemeColor::from_string("#f80").unwrap(),
488            ThemeColor::Rgb(255, 136, 0)
489        );
490    }
491
492    #[test]
493    fn test_from_hex_3char_expansion() {
494        assert_eq!(
495            ThemeColor::from_string("#abc").unwrap(),
496            ThemeColor::Rgb(170, 187, 204)
497        );
498    }
499
500    #[test]
501    fn test_from_hex_3char_black() {
502        assert_eq!(
503            ThemeColor::from_string("#000").unwrap(),
504            ThemeColor::Rgb(0, 0, 0)
505        );
506    }
507
508    #[test]
509    fn test_from_hex_3char_white() {
510        assert_eq!(
511            ThemeColor::from_string("#fff").unwrap(),
512            ThemeColor::Rgb(255, 255, 255)
513        );
514    }
515
516    #[test]
517    fn test_from_rgb_string() {
518        assert_eq!(
519            ThemeColor::from_string("rgb(255, 128, 0)").unwrap(),
520            ThemeColor::Rgb(255, 128, 0)
521        );
522    }
523
524    #[test]
525    fn test_from_rgb_string_no_spaces() {
526        assert_eq!(
527            ThemeColor::from_string("rgb(255,128,0)").unwrap(),
528            ThemeColor::Rgb(255, 128, 0)
529        );
530    }
531
532    #[test]
533    fn test_from_rgb_string_extra_spaces() {
534        assert_eq!(
535            ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap(),
536            ThemeColor::Rgb(255, 128, 0)
537        );
538    }
539
540    #[test]
541    fn test_from_rgb_string_min_max() {
542        assert_eq!(
543            ThemeColor::from_string("rgb(0, 255, 0)").unwrap(),
544            ThemeColor::Rgb(0, 255, 0)
545        );
546    }
547
548    #[test]
549    fn test_from_string_with_whitespace() {
550        assert_eq!(
551            ThemeColor::from_string("  #ff8800  ").unwrap(),
552            ThemeColor::Rgb(255, 136, 0)
553        );
554    }
555
556    #[test]
557    fn test_ansi_to_ratatui() {
558        // Low 16 ANSI indices map to named ratatui variants
559        assert_eq!(ThemeColor::Ansi(0).to_ratatui(), Color::Black);
560        assert_eq!(ThemeColor::Ansi(4).to_ratatui(), Color::Blue);
561        assert_eq!(ThemeColor::Ansi(7).to_ratatui(), Color::Gray);
562        assert_eq!(ThemeColor::Ansi(8).to_ratatui(), Color::DarkGray);
563        assert_eq!(ThemeColor::Ansi(15).to_ratatui(), Color::White);
564        // Indices >= 16 still use 256-color
565        assert_eq!(ThemeColor::Ansi(42).to_ratatui(), Color::Indexed(42));
566        assert_eq!(ThemeColor::Reset.to_ratatui(), Color::Reset);
567    }
568
569    #[test]
570    fn test_invalid_hex_length() {
571        let result = ThemeColor::from_string("#ff880");
572        assert!(result.is_err());
573        assert!(result.unwrap_err().contains("Invalid hex color length"));
574    }
575
576    #[test]
577    fn test_invalid_hex_chars() {
578        let result = ThemeColor::from_string("#gghhii");
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn test_missing_hash() {
584        let result = ThemeColor::from_string("ff8800");
585        assert!(result.is_err());
586        assert!(result.unwrap_err().contains("Invalid color format"));
587    }
588
589    #[test]
590    fn test_invalid_rgb_format() {
591        let result = ThemeColor::from_string("rgb(255, 128)");
592        assert!(result.is_err());
593        assert!(result.unwrap_err().contains("requires 3 values"));
594    }
595
596    #[test]
597    fn test_rgb_value_out_of_range() {
598        let result = ThemeColor::from_string("rgb(256, 128, 0)");
599        assert!(result.is_err());
600    }
601
602    #[test]
603    fn test_rgb_negative_value() {
604        let result = ThemeColor::from_string("rgb(-1, 128, 0)");
605        assert!(result.is_err());
606    }
607
608    #[test]
609    fn test_rgb_non_numeric() {
610        let result = ThemeColor::from_string("rgb(abc, 128, 0)");
611        assert!(result.is_err());
612        assert!(result.unwrap_err().contains("Invalid red value"));
613    }
614
615    #[test]
616    fn test_invalid_format() {
617        let result = ThemeColor::from_string("not a color");
618        assert!(result.is_err());
619        assert!(result.unwrap_err().contains("Invalid color format"));
620    }
621
622    #[test]
623    fn test_empty_string() {
624        let result = ThemeColor::from_string("");
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn test_new_constructor() {
630        assert_eq!(ThemeColor::new(255, 128, 0), ThemeColor::Rgb(255, 128, 0));
631    }
632
633    #[test]
634    fn test_to_ratatui() {
635        let color = ThemeColor::new(131, 165, 152);
636        assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
637    }
638
639    #[test]
640    fn test_theme_color_serialize() {
641        #[derive(Serialize)]
642        struct Wrapper {
643            color: ThemeColor,
644        }
645        let wrapper = Wrapper {
646            color: ThemeColor::new(59, 130, 246),
647        };
648        let serialized = toml::to_string(&wrapper).unwrap();
649        assert!(serialized.contains("color = \"#3b82f6\""));
650    }
651
652    #[test]
653    fn test_theme_color_deserialize() {
654        #[derive(Deserialize)]
655        struct Wrapper {
656            color: ThemeColor,
657        }
658        let toml_str = r###"color = "#3b82f6""###;
659        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
660        assert_eq!(wrapper.color, ThemeColor::Rgb(59, 130, 246));
661    }
662
663    #[test]
664    fn test_theme_color_roundtrip() {
665        #[derive(Serialize, Deserialize)]
666        struct Wrapper {
667            color: ThemeColor,
668        }
669        let original = Wrapper {
670            color: ThemeColor::new(239, 68, 68),
671        };
672        let serialized = toml::to_string(&original).unwrap();
673        let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
674        assert_eq!(original.color, deserialized.color);
675    }
676
677    #[test]
678    fn test_theme_serialize_to_toml() {
679        let theme = Theme::gruvbox_dark();
680        let toml_string = toml::to_string_pretty(&theme).unwrap();
681
682        assert!(toml_string.contains("name = \"Gruvbox Dark\""));
683        assert!(toml_string.contains("bg = \"#282828\""));
684        assert!(toml_string.contains("bg_panel = \"#32302f\""));
685        assert!(toml_string.contains("focus_border = \"#b8bb26\""));
686        assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
687    }
688
689    #[test]
690    fn test_theme_deserialize_from_toml() {
691        let toml_str = r###"
692            name = "Test Theme"
693            bg                 = "#282828"
694            bg_panel           = "#32302f"
695            selection_bg        = "#504945"
696            fg                 = "#ebdbb2"
697            fg_secondary       = "#a89984"
698            gray           = "#7c6f64"
699            selection_fg        = "#fbf1c7"
700            border_dim             = "#504945"
701            focus_border     = "#fabd2f"
702            accent             = "#fabd2f"
703            color_directory    = "#83a598"
704            color_journal_date = "#8ec07c"
705            color_search_match = "#b8bb26"
706            color_tag          = "#fe8019"
707        "###;
708
709        let theme: Theme = toml::from_str(toml_str).unwrap();
710        assert_eq!(theme.name, "Test Theme");
711        assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
712        assert_eq!(theme.focus_border, ThemeColor::new(0xfa, 0xbd, 0x2f));
713        assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
714    }
715
716    #[test]
717    fn test_theme_roundtrip() {
718        let original = Theme::tokyo_night();
719        let toml_string = toml::to_string_pretty(&original).unwrap();
720        let deserialized: Theme = toml::from_str(&toml_string).unwrap();
721
722        assert_eq!(original.name, deserialized.name);
723        assert_eq!(original.bg, deserialized.bg);
724        assert_eq!(original.fg, deserialized.fg);
725        assert_eq!(original.focus_border, deserialized.focus_border);
726        assert_eq!(original.color_journal_date, deserialized.color_journal_date);
727    }
728
729    #[test]
730    fn test_theme_color_serialize_lowercase_hex() {
731        #[derive(Serialize)]
732        struct Wrapper {
733            color: ThemeColor,
734        }
735        let wrapper = Wrapper {
736            color: ThemeColor::new(171, 205, 239),
737        };
738        let serialized = toml::to_string(&wrapper).unwrap();
739        assert!(serialized.contains("color = \"#abcdef\""));
740    }
741
742    #[test]
743    fn test_theme_deserialize_uppercase_hex() {
744        #[derive(Deserialize)]
745        struct Wrapper {
746            color: ThemeColor,
747        }
748        let toml_str = r###"color = "#ABCDEF""###;
749        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
750        assert_eq!(wrapper.color, ThemeColor::Rgb(171, 205, 239));
751    }
752
753    #[test]
754    fn test_theme_deserialize_3char_hex() {
755        #[derive(Deserialize)]
756        struct Wrapper {
757            color: ThemeColor,
758        }
759        let toml_str = r###"color = "#abc""###;
760        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
761        assert_eq!(wrapper.color, ThemeColor::Rgb(170, 187, 204));
762    }
763
764    #[test]
765    fn test_from_ansi_index() {
766        assert_eq!(
767            ThemeColor::from_string("ansi:4").unwrap(),
768            ThemeColor::Ansi(4)
769        );
770        assert_eq!(
771            ThemeColor::from_string("ansi:255").unwrap(),
772            ThemeColor::Ansi(255)
773        );
774    }
775
776    #[test]
777    fn test_from_reset() {
778        assert_eq!(ThemeColor::from_string("reset").unwrap(), ThemeColor::Reset);
779    }
780
781    #[test]
782    fn test_all_builtin_themes_serialize() {
783        let themes = vec![
784            Theme::ansi(),
785            Theme::gruvbox_dark(),
786            Theme::gruvbox_light(),
787            Theme::catppuccin_mocha(),
788            Theme::catppuccin_latte(),
789            Theme::tokyo_night(),
790            Theme::tokyo_night_storm(),
791            Theme::solarized_dark(),
792            Theme::solarized_light(),
793            Theme::nord(),
794        ];
795        for theme in themes {
796            let toml_string = toml::to_string_pretty(&theme).unwrap();
797            let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
798            assert_eq!(theme.name, roundtrip.name);
799            assert_eq!(theme.bg, roundtrip.bg);
800        }
801    }
802
803    #[test]
804    fn test_ansi_theme() {
805        let theme = Theme::ansi();
806        assert_eq!(theme.name, "ANSI");
807        assert_eq!(theme.bg, ThemeColor::Reset);
808        assert_eq!(theme.fg, ThemeColor::Reset);
809        assert_eq!(theme.selection_bg, ThemeColor::Ansi(4));
810        assert_eq!(theme.focus_border, ThemeColor::Ansi(10));
811        assert_eq!(theme.color_directory, ThemeColor::Ansi(12));
812    }
813
814    #[test]
815    fn new_decoration_fields_present_and_deserialize_default() {
816        // Built-in theme exposes the fields.
817        let t = Theme::gruvbox_dark();
818        assert_eq!(
819            t.blockquote_bar,
820            ThemeColor::from_string("#fabd2f").unwrap()
821        );
822        assert_eq!(t.code_bg, ThemeColor::from_string("#32302f").unwrap());
823
824        // Old TOML without the fields still deserializes (serde defaults kick in).
825        let toml = r##"
826            name = "Old"
827            bg = "#000000"
828            bg_panel = "#111111"
829            selection_bg = "#222222"
830            fg = "#ffffff"
831            fg_secondary = "#cccccc"
832            gray = "#888888"
833            selection_fg = "#ffffff"
834            border_dim = "#333333"
835            focus_border = "#444444"
836            accent = "#55aaff"
837            color_directory = "#66ccee"
838            color_journal_date = "#77ddcc"
839            color_search_match = "#88eeaa"
840        "##;
841        let parsed: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
842        // Sibling-derived defaults.
843        assert_eq!(parsed.blockquote_bar, parsed.accent);
844        assert_eq!(parsed.code_bg, parsed.bg_panel);
845    }
846
847    #[test]
848    fn old_theme_toml_derives_new_roles_from_siblings() {
849        // A theme file written before the role expansion must still parse,
850        // with the new roles derived from their closest sibling.
851        let toml = r##"
852            name = "Old"
853            bg = "#000000"
854            bg_panel = "#111111"
855            selection_bg = "#222222"
856            fg = "#ffffff"
857            fg_secondary = "#cccccc"
858            gray = "#888888"
859            selection_fg = "#eeeeee"
860            border_dim = "#333333"
861            focus_border = "#444444"
862            accent = "#55aaff"
863            color_directory = "#66ccee"
864            color_journal_date = "#77ddcc"
865            color_search_match = "#88eeaa"
866        "##;
867        let t: Theme = toml::from_str(toml).expect("old theme TOML must still parse");
868        assert_eq!(t.bg_hard, t.bg_panel);
869        assert_eq!(t.bg_soft, t.selection_bg);
870        assert_eq!(t.fg_bright, t.selection_fg);
871        assert_eq!(t.cursor, t.fg);
872        // Accent palette falls back to chromatic ANSI slots.
873        assert_eq!(t.red, ThemeColor::Ansi(9));
874        assert_eq!(t.green, ThemeColor::Ansi(10));
875        assert_eq!(t.yellow, ThemeColor::Ansi(11));
876        assert_eq!(t.blue, ThemeColor::Ansi(12));
877        assert_eq!(t.purple, ThemeColor::Ansi(13));
878        assert_eq!(t.aqua, ThemeColor::Ansi(14));
879        assert_eq!(t.orange, ThemeColor::Ansi(208));
880        // color_tag tracks the orange accent when absent.
881        assert_eq!(t.color_tag, t.orange);
882    }
883
884    #[test]
885    fn new_roles_roundtrip_through_toml() {
886        let original = Theme::gruvbox_dark();
887        let toml_string = toml::to_string_pretty(&original).unwrap();
888        let parsed: Theme = toml::from_str(&toml_string).unwrap();
889        assert_eq!(original, parsed);
890    }
891}