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