Skip to main content

kimun_notes/settings/
themes.rs

1use ratatui::style::{Color, Style};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::Display;
4
5#[derive(Debug, Clone, PartialEq)]
6pub struct ThemeColor {
7    r: u8,
8    g: u8,
9    b: u8,
10}
11
12impl Serialize for ThemeColor {
13    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
14    where
15        S: Serializer,
16    {
17        let hex = format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b);
18        serializer.serialize_str(&hex)
19    }
20}
21
22impl<'de> Deserialize<'de> for ThemeColor {
23    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
24    where
25        D: Deserializer<'de>,
26    {
27        let s = String::deserialize(deserializer)?;
28        ThemeColor::from_string(&s).map_err(serde::de::Error::custom)
29    }
30}
31
32impl ThemeColor {
33    pub fn new(r: u8, g: u8, b: u8) -> Self {
34        ThemeColor { r, g, b }
35    }
36
37    /// Convert to a ratatui `Color::Rgb` value for use in widget styles.
38    pub fn to_ratatui(&self) -> Color {
39        Color::Rgb(self.r, self.g, self.b)
40    }
41
42    /// Parse a color from a string in various formats:
43    /// - RGB: "rgb(255, 128, 0)"
44    /// - 3-char hex: "#abc" (expanded to #aabbcc)
45    /// - 6-char hex: "#aabbcc"
46    pub fn from_string(s: &str) -> Result<Self, String> {
47        let s = s.trim();
48
49        if s.starts_with('#') {
50            Self::from_hex(s)
51        } else if s.starts_with("rgb(") && s.ends_with(')') {
52            Self::from_rgb_string(s)
53        } else {
54            Err(format!("Invalid color format: {}", s))
55        }
56    }
57
58    /// Parse hex color string (#abc or #aabbcc)
59    fn from_hex(s: &str) -> Result<Self, String> {
60        if !s.starts_with('#') {
61            return Err("Hex color must start with #".to_string());
62        }
63
64        let hex = &s[1..];
65
66        match hex.len() {
67            3 => Self::from_hex_3char(hex),
68            6 => Self::from_hex_6char(hex),
69            _ => Err(format!(
70                "Invalid hex color length: expected 3 or 6 chars, got {}",
71                hex.len()
72            )),
73        }
74    }
75
76    /// Parse 3-character hex color (e.g., "abc" -> r=0xaa, g=0xbb, b=0xcc)
77    fn from_hex_3char(hex: &str) -> Result<Self, String> {
78        if hex.len() != 3 {
79            return Err("Expected 3 hex characters".to_string());
80        }
81
82        let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
83            .map_err(|_| format!("Invalid hex character in red component: {}", &hex[0..1]))?;
84        let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
85            .map_err(|_| format!("Invalid hex character in green component: {}", &hex[1..2]))?;
86        let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
87            .map_err(|_| format!("Invalid hex character in blue component: {}", &hex[2..3]))?;
88
89        Ok(ThemeColor { r, g, b })
90    }
91
92    /// Parse 6-character hex color (e.g., "aabbcc")
93    fn from_hex_6char(hex: &str) -> Result<Self, String> {
94        if hex.len() != 6 {
95            return Err("Expected 6 hex characters".to_string());
96        }
97
98        let r = u8::from_str_radix(&hex[0..2], 16)
99            .map_err(|_| format!("Invalid hex characters in red component: {}", &hex[0..2]))?;
100        let g = u8::from_str_radix(&hex[2..4], 16)
101            .map_err(|_| format!("Invalid hex characters in green component: {}", &hex[2..4]))?;
102        let b = u8::from_str_radix(&hex[4..6], 16)
103            .map_err(|_| format!("Invalid hex characters in blue component: {}", &hex[4..6]))?;
104
105        Ok(ThemeColor { r, g, b })
106    }
107
108    /// Parse RGB string format (e.g., "rgb(255, 128, 0)")
109    fn from_rgb_string(s: &str) -> Result<Self, String> {
110        if !s.starts_with("rgb(") || !s.ends_with(')') {
111            return Err("RGB format must be rgb(r, g, b)".to_string());
112        }
113
114        let inner = &s[4..s.len() - 1];
115        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
116
117        if parts.len() != 3 {
118            return Err(format!("RGB format requires 3 values, got {}", parts.len()));
119        }
120
121        let r = parts[0]
122            .parse::<u8>()
123            .map_err(|_| format!("Invalid red value: {}", parts[0]))?;
124        let g = parts[1]
125            .parse::<u8>()
126            .map_err(|_| format!("Invalid green value: {}", parts[1]))?;
127        let b = parts[2]
128            .parse::<u8>()
129            .map_err(|_| format!("Invalid blue value: {}", parts[2]))?;
130
131        Ok(ThemeColor { r, g, b })
132    }
133}
134
135impl Display for ThemeColor {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        write!(f, "rgb({},{},{})", self.r, self.g, self.b)
138    }
139}
140
141/// Theme for the TUI application.
142///
143/// Fields are named after the UI roles they fill, making it straightforward to
144/// map any popular terminal color scheme (Gruvbox, Catppuccin, Tokyo Night, …)
145/// to this struct.  Custom themes can be placed as `.toml` files in the themes
146/// config directory and will be loaded automatically at startup.
147///
148/// # Example theme file (`~/.config/kimun/themes/mytheme.toml`)
149/// ```toml
150/// name = "My Theme"
151/// bg               = "#1e1e2e"
152/// bg_panel         = "#181825"
153/// bg_selected      = "#313244"
154/// fg               = "#cdd6f4"
155/// fg_secondary     = "#a6adc8"
156/// fg_muted         = "#6c7086"
157/// fg_selected      = "#cdd6f4"
158/// border           = "#45475a"
159/// border_focused   = "#89b4fa"
160/// accent           = "#89b4fa"
161/// color_directory  = "#89dceb"
162/// color_journal_date = "#94e2d5"
163/// color_search_match = "#a6e3a1"
164/// ```
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct Theme {
167    pub name: String,
168
169    // ── Backgrounds ─────────────────────────────────────────────────────────
170    /// Main/editor background.
171    pub bg: ThemeColor,
172    /// Sidebar / panel background (usually slightly offset from `bg`).
173    pub bg_panel: ThemeColor,
174    /// Background of the currently selected row in lists.
175    pub bg_selected: ThemeColor,
176
177    // ── Foreground / text ────────────────────────────────────────────────────
178    /// Primary text color.
179    pub fg: ThemeColor,
180    /// Secondary text: filenames, metadata, subdued hints.
181    pub fg_secondary: ThemeColor,
182    /// Very dim text: placeholders, separators, disabled items.
183    pub fg_muted: ThemeColor,
184    /// Text color of a selected/highlighted row (often brighter than `fg`).
185    pub fg_selected: ThemeColor,
186
187    // ── Borders ──────────────────────────────────────────────────────────────
188    /// Default (unfocused) border color.
189    pub border: ThemeColor,
190    /// Border color when the pane has keyboard focus.
191    pub border_focused: ThemeColor,
192
193    // ── Accent ───────────────────────────────────────────────────────────────
194    /// Primary accent: title bars, active markers, cursor highlights.
195    pub accent: ThemeColor,
196
197    // ── Semantic colors for file-list entries ────────────────────────────────
198    /// Color used for directory entries in the file list.
199    pub color_directory: ThemeColor,
200    /// Color for the journal-date annotation line in journal entries.
201    pub color_journal_date: ThemeColor,
202    /// Color for highlighted search-match text.
203    pub color_search_match: ThemeColor,
204}
205
206impl Default for Theme {
207    fn default() -> Self {
208        Self::gruvbox_dark()
209    }
210}
211
212impl Theme {
213    // ── Built-in themes ──────────────────────────────────────────────────────
214
215    pub fn gruvbox_dark() -> Self {
216        Theme {
217            name: "Gruvbox Dark".to_string(),
218            bg: ThemeColor::from_string("#282828").unwrap(),
219            bg_panel: ThemeColor::from_string("#32302f").unwrap(),
220            bg_selected: ThemeColor::from_string("#504945").unwrap(),
221            fg: ThemeColor::from_string("#ebdbb2").unwrap(),
222            fg_secondary: ThemeColor::from_string("#a89984").unwrap(),
223            fg_muted: ThemeColor::from_string("#7c6f64").unwrap(),
224            fg_selected: ThemeColor::from_string("#fbf1c7").unwrap(),
225            border: ThemeColor::from_string("#504945").unwrap(),
226            border_focused: ThemeColor::from_string("#fabd2f").unwrap(),
227            accent: ThemeColor::from_string("#fabd2f").unwrap(),
228            color_directory: ThemeColor::from_string("#83a598").unwrap(),
229            color_journal_date: ThemeColor::from_string("#8ec07c").unwrap(),
230            color_search_match: ThemeColor::from_string("#b8bb26").unwrap(),
231        }
232    }
233
234    pub fn gruvbox_light() -> Self {
235        Theme {
236            name: "Gruvbox Light".to_string(),
237            bg: ThemeColor::from_string("#fbf1c7").unwrap(),
238            bg_panel: ThemeColor::from_string("#f2e5bc").unwrap(),
239            bg_selected: ThemeColor::from_string("#ebdbb2").unwrap(),
240            fg: ThemeColor::from_string("#3c3836").unwrap(),
241            fg_secondary: ThemeColor::from_string("#7c6f64").unwrap(),
242            fg_muted: ThemeColor::from_string("#a89984").unwrap(),
243            fg_selected: ThemeColor::from_string("#282828").unwrap(),
244            border: ThemeColor::from_string("#d5c4a1").unwrap(),
245            border_focused: ThemeColor::from_string("#d79921").unwrap(),
246            accent: ThemeColor::from_string("#d79921").unwrap(),
247            color_directory: ThemeColor::from_string("#458588").unwrap(),
248            color_journal_date: ThemeColor::from_string("#689d6a").unwrap(),
249            color_search_match: ThemeColor::from_string("#98971a").unwrap(),
250        }
251    }
252
253    pub fn catppuccin_mocha() -> Self {
254        Theme {
255            name: "Catppuccin Mocha".to_string(),
256            bg: ThemeColor::from_string("#1e1e2e").unwrap(),
257            bg_panel: ThemeColor::from_string("#181825").unwrap(),
258            bg_selected: ThemeColor::from_string("#313244").unwrap(),
259            fg: ThemeColor::from_string("#cdd6f4").unwrap(),
260            fg_secondary: ThemeColor::from_string("#a6adc8").unwrap(),
261            fg_muted: ThemeColor::from_string("#6c7086").unwrap(),
262            fg_selected: ThemeColor::from_string("#cdd6f4").unwrap(),
263            border: ThemeColor::from_string("#45475a").unwrap(),
264            border_focused: ThemeColor::from_string("#89b4fa").unwrap(),
265            accent: ThemeColor::from_string("#cba6f7").unwrap(),
266            color_directory: ThemeColor::from_string("#89dceb").unwrap(),
267            color_journal_date: ThemeColor::from_string("#94e2d5").unwrap(),
268            color_search_match: ThemeColor::from_string("#a6e3a1").unwrap(),
269        }
270    }
271
272    pub fn catppuccin_latte() -> Self {
273        Theme {
274            name: "Catppuccin Latte".to_string(),
275            bg: ThemeColor::from_string("#eff1f5").unwrap(),
276            bg_panel: ThemeColor::from_string("#e6e9ef").unwrap(),
277            bg_selected: ThemeColor::from_string("#ccd0da").unwrap(),
278            fg: ThemeColor::from_string("#4c4f69").unwrap(),
279            fg_secondary: ThemeColor::from_string("#6c6f85").unwrap(),
280            fg_muted: ThemeColor::from_string("#9ca0b0").unwrap(),
281            fg_selected: ThemeColor::from_string("#4c4f69").unwrap(),
282            border: ThemeColor::from_string("#ccd0da").unwrap(),
283            border_focused: ThemeColor::from_string("#1e66f5").unwrap(),
284            accent: ThemeColor::from_string("#8839ef").unwrap(),
285            color_directory: ThemeColor::from_string("#04a5e5").unwrap(),
286            color_journal_date: ThemeColor::from_string("#179299").unwrap(),
287            color_search_match: ThemeColor::from_string("#40a02b").unwrap(),
288        }
289    }
290
291    pub fn tokyo_night() -> Self {
292        Theme {
293            name: "Tokyo Night".to_string(),
294            bg: ThemeColor::from_string("#1a1b26").unwrap(),
295            bg_panel: ThemeColor::from_string("#16161e").unwrap(),
296            bg_selected: ThemeColor::from_string("#292e42").unwrap(),
297            fg: ThemeColor::from_string("#c0caf5").unwrap(),
298            fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
299            fg_muted: ThemeColor::from_string("#565f89").unwrap(),
300            fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
301            border: ThemeColor::from_string("#3b4261").unwrap(),
302            border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
303            accent: ThemeColor::from_string("#7aa2f7").unwrap(),
304            color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
305            color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
306            color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
307        }
308    }
309
310    pub fn tokyo_night_storm() -> Self {
311        Theme {
312            name: "Tokyo Night Storm".to_string(),
313            bg: ThemeColor::from_string("#24283b").unwrap(),
314            bg_panel: ThemeColor::from_string("#1f2335").unwrap(),
315            bg_selected: ThemeColor::from_string("#364a82").unwrap(),
316            fg: ThemeColor::from_string("#c0caf5").unwrap(),
317            fg_secondary: ThemeColor::from_string("#a9b1d6").unwrap(),
318            fg_muted: ThemeColor::from_string("#565f89").unwrap(),
319            fg_selected: ThemeColor::from_string("#c0caf5").unwrap(),
320            border: ThemeColor::from_string("#3b4261").unwrap(),
321            border_focused: ThemeColor::from_string("#7aa2f7").unwrap(),
322            accent: ThemeColor::from_string("#bb9af7").unwrap(),
323            color_directory: ThemeColor::from_string("#7dcfff").unwrap(),
324            color_journal_date: ThemeColor::from_string("#73daca").unwrap(),
325            color_search_match: ThemeColor::from_string("#9ece6a").unwrap(),
326        }
327    }
328
329    pub fn solarized_dark() -> Self {
330        Theme {
331            name: "Solarized Dark".to_string(),
332            bg: ThemeColor::from_string("#002b36").unwrap(),
333            bg_panel: ThemeColor::from_string("#073642").unwrap(),
334            bg_selected: ThemeColor::from_string("#586e75").unwrap(),
335            fg: ThemeColor::from_string("#839496").unwrap(),
336            fg_secondary: ThemeColor::from_string("#657b83").unwrap(),
337            fg_muted: ThemeColor::from_string("#586e75").unwrap(),
338            fg_selected: ThemeColor::from_string("#eee8d5").unwrap(),
339            border: ThemeColor::from_string("#073642").unwrap(),
340            border_focused: ThemeColor::from_string("#268bd2").unwrap(),
341            accent: ThemeColor::from_string("#268bd2").unwrap(),
342            color_directory: ThemeColor::from_string("#2aa198").unwrap(),
343            color_journal_date: ThemeColor::from_string("#859900").unwrap(),
344            color_search_match: ThemeColor::from_string("#b58900").unwrap(),
345        }
346    }
347
348    pub fn solarized_light() -> Self {
349        Theme {
350            name: "Solarized Light".to_string(),
351            bg: ThemeColor::from_string("#fdf6e3").unwrap(),
352            bg_panel: ThemeColor::from_string("#eee8d5").unwrap(),
353            bg_selected: ThemeColor::from_string("#93a1a1").unwrap(),
354            fg: ThemeColor::from_string("#657b83").unwrap(),
355            fg_secondary: ThemeColor::from_string("#839496").unwrap(),
356            fg_muted: ThemeColor::from_string("#93a1a1").unwrap(),
357            fg_selected: ThemeColor::from_string("#073642").unwrap(),
358            border: ThemeColor::from_string("#eee8d5").unwrap(),
359            border_focused: ThemeColor::from_string("#268bd2").unwrap(),
360            accent: ThemeColor::from_string("#268bd2").unwrap(),
361            color_directory: ThemeColor::from_string("#2aa198").unwrap(),
362            color_journal_date: ThemeColor::from_string("#859900").unwrap(),
363            color_search_match: ThemeColor::from_string("#b58900").unwrap(),
364        }
365    }
366
367    /// Returns the appropriate border style depending on focus state.
368    pub fn border_style(&self, focused: bool) -> Style {
369        if focused {
370            Style::default().fg(self.border_focused.to_ratatui())
371        } else {
372            Style::default().fg(self.border.to_ratatui())
373        }
374    }
375
376    /// Base style for most surfaces: theme fg on theme bg.
377    pub fn base_style(&self) -> Style {
378        Style::default()
379            .fg(self.fg.to_ratatui())
380            .bg(self.bg.to_ratatui())
381    }
382
383    /// Panel style for sidebars and panels: theme fg on bg_panel.
384    pub fn panel_style(&self) -> Style {
385        Style::default()
386            .fg(self.fg.to_ratatui())
387            .bg(self.bg_panel.to_ratatui())
388    }
389
390    pub fn nord() -> Self {
391        Theme {
392            name: "Nord".to_string(),
393            bg: ThemeColor::from_string("#2e3440").unwrap(),
394            bg_panel: ThemeColor::from_string("#3b4252").unwrap(),
395            bg_selected: ThemeColor::from_string("#434c5e").unwrap(),
396            fg: ThemeColor::from_string("#eceff4").unwrap(),
397            fg_secondary: ThemeColor::from_string("#d8dee9").unwrap(),
398            fg_muted: ThemeColor::from_string("#4c566a").unwrap(),
399            fg_selected: ThemeColor::from_string("#eceff4").unwrap(),
400            border: ThemeColor::from_string("#434c5e").unwrap(),
401            border_focused: ThemeColor::from_string("#81a1c1").unwrap(),
402            accent: ThemeColor::from_string("#88c0d0").unwrap(),
403            color_directory: ThemeColor::from_string("#81a1c1").unwrap(),
404            color_journal_date: ThemeColor::from_string("#8fbcbb").unwrap(),
405            color_search_match: ThemeColor::from_string("#a3be8c").unwrap(),
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use ratatui::style::Style;
414
415    #[test]
416    fn test_border_style_focused() {
417        let theme = Theme::gruvbox_dark();
418        let style = theme.border_style(true);
419        assert_eq!(
420            style,
421            Style::default().fg(theme.border_focused.to_ratatui())
422        );
423    }
424
425    #[test]
426    fn test_border_style_unfocused() {
427        let theme = Theme::gruvbox_dark();
428        let style = theme.border_style(false);
429        assert_eq!(style, Style::default().fg(theme.border.to_ratatui()));
430    }
431
432    #[test]
433    fn test_from_hex_6char() {
434        let color = ThemeColor::from_string("#ff8800").unwrap();
435        assert_eq!(color.r, 255);
436        assert_eq!(color.g, 136);
437        assert_eq!(color.b, 0);
438    }
439
440    #[test]
441    fn test_from_hex_6char_lowercase() {
442        let color = ThemeColor::from_string("#abcdef").unwrap();
443        assert_eq!(color.r, 171);
444        assert_eq!(color.g, 205);
445        assert_eq!(color.b, 239);
446    }
447
448    #[test]
449    fn test_from_hex_6char_uppercase() {
450        let color = ThemeColor::from_string("#ABCDEF").unwrap();
451        assert_eq!(color.r, 171);
452        assert_eq!(color.g, 205);
453        assert_eq!(color.b, 239);
454    }
455
456    #[test]
457    fn test_from_hex_3char() {
458        let color = ThemeColor::from_string("#f80").unwrap();
459        assert_eq!(color.r, 255);
460        assert_eq!(color.g, 136);
461        assert_eq!(color.b, 0);
462    }
463
464    #[test]
465    fn test_from_hex_3char_expansion() {
466        let color = ThemeColor::from_string("#abc").unwrap();
467        assert_eq!(color.r, 170);
468        assert_eq!(color.g, 187);
469        assert_eq!(color.b, 204);
470    }
471
472    #[test]
473    fn test_from_hex_3char_black() {
474        let color = ThemeColor::from_string("#000").unwrap();
475        assert_eq!(color.r, 0);
476        assert_eq!(color.g, 0);
477        assert_eq!(color.b, 0);
478    }
479
480    #[test]
481    fn test_from_hex_3char_white() {
482        let color = ThemeColor::from_string("#fff").unwrap();
483        assert_eq!(color.r, 255);
484        assert_eq!(color.g, 255);
485        assert_eq!(color.b, 255);
486    }
487
488    #[test]
489    fn test_from_rgb_string() {
490        let color = ThemeColor::from_string("rgb(255, 128, 0)").unwrap();
491        assert_eq!(color.r, 255);
492        assert_eq!(color.g, 128);
493        assert_eq!(color.b, 0);
494    }
495
496    #[test]
497    fn test_from_rgb_string_no_spaces() {
498        let color = ThemeColor::from_string("rgb(255,128,0)").unwrap();
499        assert_eq!(color.r, 255);
500        assert_eq!(color.g, 128);
501        assert_eq!(color.b, 0);
502    }
503
504    #[test]
505    fn test_from_rgb_string_extra_spaces() {
506        let color = ThemeColor::from_string("rgb( 255 , 128 , 0 )").unwrap();
507        assert_eq!(color.r, 255);
508        assert_eq!(color.g, 128);
509        assert_eq!(color.b, 0);
510    }
511
512    #[test]
513    fn test_from_rgb_string_min_max() {
514        let color = ThemeColor::from_string("rgb(0, 255, 0)").unwrap();
515        assert_eq!(color.r, 0);
516        assert_eq!(color.g, 255);
517        assert_eq!(color.b, 0);
518    }
519
520    #[test]
521    fn test_from_string_with_whitespace() {
522        let color = ThemeColor::from_string("  #ff8800  ").unwrap();
523        assert_eq!(color.r, 255);
524        assert_eq!(color.g, 136);
525        assert_eq!(color.b, 0);
526    }
527
528    #[test]
529    fn test_invalid_hex_length() {
530        let result = ThemeColor::from_string("#ff880");
531        assert!(result.is_err());
532        assert!(result.unwrap_err().contains("Invalid hex color length"));
533    }
534
535    #[test]
536    fn test_invalid_hex_chars() {
537        let result = ThemeColor::from_string("#gghhii");
538        assert!(result.is_err());
539    }
540
541    #[test]
542    fn test_missing_hash() {
543        let result = ThemeColor::from_string("ff8800");
544        assert!(result.is_err());
545        assert!(result.unwrap_err().contains("Invalid color format"));
546    }
547
548    #[test]
549    fn test_invalid_rgb_format() {
550        let result = ThemeColor::from_string("rgb(255, 128)");
551        assert!(result.is_err());
552        assert!(result.unwrap_err().contains("requires 3 values"));
553    }
554
555    #[test]
556    fn test_rgb_value_out_of_range() {
557        let result = ThemeColor::from_string("rgb(256, 128, 0)");
558        assert!(result.is_err());
559    }
560
561    #[test]
562    fn test_rgb_negative_value() {
563        let result = ThemeColor::from_string("rgb(-1, 128, 0)");
564        assert!(result.is_err());
565    }
566
567    #[test]
568    fn test_rgb_non_numeric() {
569        let result = ThemeColor::from_string("rgb(abc, 128, 0)");
570        assert!(result.is_err());
571        assert!(result.unwrap_err().contains("Invalid red value"));
572    }
573
574    #[test]
575    fn test_invalid_format() {
576        let result = ThemeColor::from_string("not a color");
577        assert!(result.is_err());
578        assert!(result.unwrap_err().contains("Invalid color format"));
579    }
580
581    #[test]
582    fn test_empty_string() {
583        let result = ThemeColor::from_string("");
584        assert!(result.is_err());
585    }
586
587    #[test]
588    fn test_new_constructor() {
589        let color = ThemeColor::new(255, 128, 0);
590        assert_eq!(color.r, 255);
591        assert_eq!(color.g, 128);
592        assert_eq!(color.b, 0);
593    }
594
595    #[test]
596    fn test_to_ratatui() {
597        let color = ThemeColor::new(131, 165, 152);
598        assert_eq!(color.to_ratatui(), Color::Rgb(131, 165, 152));
599    }
600
601    #[test]
602    fn test_theme_color_serialize() {
603        #[derive(Serialize)]
604        struct Wrapper {
605            color: ThemeColor,
606        }
607        let wrapper = Wrapper {
608            color: ThemeColor::new(59, 130, 246),
609        };
610        let serialized = toml::to_string(&wrapper).unwrap();
611        assert!(serialized.contains("color = \"#3b82f6\""));
612    }
613
614    #[test]
615    fn test_theme_color_deserialize() {
616        #[derive(Deserialize)]
617        struct Wrapper {
618            color: ThemeColor,
619        }
620        let toml_str = r###"color = "#3b82f6""###;
621        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
622        assert_eq!(wrapper.color.r, 59);
623        assert_eq!(wrapper.color.g, 130);
624        assert_eq!(wrapper.color.b, 246);
625    }
626
627    #[test]
628    fn test_theme_color_roundtrip() {
629        #[derive(Serialize, Deserialize)]
630        struct Wrapper {
631            color: ThemeColor,
632        }
633        let original = Wrapper {
634            color: ThemeColor::new(239, 68, 68),
635        };
636        let serialized = toml::to_string(&original).unwrap();
637        let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
638        assert_eq!(original.color, deserialized.color);
639    }
640
641    #[test]
642    fn test_theme_serialize_to_toml() {
643        let theme = Theme::gruvbox_dark();
644        let toml_string = toml::to_string_pretty(&theme).unwrap();
645
646        assert!(toml_string.contains("name = \"Gruvbox Dark\""));
647        assert!(toml_string.contains("bg = \"#282828\""));
648        assert!(toml_string.contains("bg_panel = \"#32302f\""));
649        assert!(toml_string.contains("border_focused = \"#fabd2f\""));
650        assert!(toml_string.contains("color_journal_date = \"#8ec07c\""));
651    }
652
653    #[test]
654    fn test_theme_deserialize_from_toml() {
655        let toml_str = r###"
656            name = "Test Theme"
657            bg                 = "#282828"
658            bg_panel           = "#32302f"
659            bg_selected        = "#504945"
660            fg                 = "#ebdbb2"
661            fg_secondary       = "#a89984"
662            fg_muted           = "#7c6f64"
663            fg_selected        = "#fbf1c7"
664            border             = "#504945"
665            border_focused     = "#fabd2f"
666            accent             = "#fabd2f"
667            color_directory    = "#83a598"
668            color_journal_date = "#8ec07c"
669            color_search_match = "#b8bb26"
670        "###;
671
672        let theme: Theme = toml::from_str(toml_str).unwrap();
673        assert_eq!(theme.name, "Test Theme");
674        assert_eq!(theme.bg, ThemeColor::new(0x28, 0x28, 0x28));
675        assert_eq!(theme.border_focused, ThemeColor::new(0xfa, 0xbd, 0x2f));
676        assert_eq!(theme.color_journal_date, ThemeColor::new(0x8e, 0xc0, 0x7c));
677    }
678
679    #[test]
680    fn test_theme_roundtrip() {
681        let original = Theme::tokyo_night();
682        let toml_string = toml::to_string_pretty(&original).unwrap();
683        let deserialized: Theme = toml::from_str(&toml_string).unwrap();
684
685        assert_eq!(original.name, deserialized.name);
686        assert_eq!(original.bg, deserialized.bg);
687        assert_eq!(original.fg, deserialized.fg);
688        assert_eq!(original.border_focused, deserialized.border_focused);
689        assert_eq!(original.color_journal_date, deserialized.color_journal_date);
690    }
691
692    #[test]
693    fn test_theme_color_serialize_lowercase_hex() {
694        #[derive(Serialize)]
695        struct Wrapper {
696            color: ThemeColor,
697        }
698        let wrapper = Wrapper {
699            color: ThemeColor::new(171, 205, 239),
700        };
701        let serialized = toml::to_string(&wrapper).unwrap();
702        assert!(serialized.contains("color = \"#abcdef\""));
703    }
704
705    #[test]
706    fn test_theme_deserialize_uppercase_hex() {
707        #[derive(Deserialize)]
708        struct Wrapper {
709            color: ThemeColor,
710        }
711        let toml_str = r###"color = "#ABCDEF""###;
712        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
713        assert_eq!(wrapper.color.r, 171);
714        assert_eq!(wrapper.color.g, 205);
715        assert_eq!(wrapper.color.b, 239);
716    }
717
718    #[test]
719    fn test_theme_deserialize_3char_hex() {
720        #[derive(Deserialize)]
721        struct Wrapper {
722            color: ThemeColor,
723        }
724        let toml_str = r###"color = "#abc""###;
725        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
726        assert_eq!(wrapper.color.r, 170);
727        assert_eq!(wrapper.color.g, 187);
728        assert_eq!(wrapper.color.b, 204);
729    }
730
731    #[test]
732    fn test_all_builtin_themes_serialize() {
733        let themes = vec![
734            Theme::gruvbox_dark(),
735            Theme::gruvbox_light(),
736            Theme::catppuccin_mocha(),
737            Theme::catppuccin_latte(),
738            Theme::tokyo_night(),
739            Theme::tokyo_night_storm(),
740            Theme::solarized_dark(),
741            Theme::solarized_light(),
742            Theme::nord(),
743        ];
744        for theme in themes {
745            let toml_string = toml::to_string_pretty(&theme).unwrap();
746            let roundtrip: Theme = toml::from_str(&toml_string).unwrap();
747            assert_eq!(theme.name, roundtrip.name);
748            assert_eq!(theme.bg, roundtrip.bg);
749        }
750    }
751}