Skip to main content

basalt_tui/config/
theme.rs

1use std::{collections::HashMap, fs::read_to_string, str::FromStr};
2
3use etcetera::{choose_base_strategy, BaseStrategy};
4use ratatui::{style::Color, widgets, widgets::Borders};
5use serde::Deserialize;
6
7/// Semantic colour roles for the whole UI. Every hard-coded colour in the
8/// renderer resolves through one of these, so swapping a [`Theme`] re-skins
9/// the application. [`Theme::default`] reproduces the original palette, so the
10/// default appearance is unchanged.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct Theme {
13    /// Primary foreground (terminal default).
14    pub text: Color,
15    /// Base background painted across the whole UI (terminal default by default).
16    pub background: Color,
17    /// Secondary text: markers, indentation, bullets, badges.
18    pub muted: Color,
19    /// Accent: raw-mode markers and other highlights.
20    pub accent: Color,
21    /// Default border colours and line type for every pane (each pane may
22    /// override its own in a `[pane]` section).
23    pub border: Color,
24    pub border_active: Color,
25    pub border_type: Option<BorderKind>,
26    pub border_edges: Edges,
27    pub heading_1: Color,
28    pub heading_2: Color,
29    pub heading_3: Color,
30    pub heading_4: Color,
31    pub heading_5: Color,
32    pub heading_6: Color,
33    /// Background for fenced code blocks.
34    pub code_bg: Color,
35    /// Block-quote bar and text.
36    pub blockquote: Color,
37    /// List bullets and ordered-list numbers.
38    pub list_marker: Color,
39    /// Task check-box marker.
40    pub task: Color,
41    /// Editor mode indicator: insert / edit (writing).
42    pub mode_insert: Color,
43    /// Editor mode indicator: vim normal.
44    pub mode_normal: Color,
45    /// Editor mode indicator: read-only.
46    pub mode_read: Color,
47    pub success: Color,
48    pub info: Color,
49    pub warning: Color,
50    pub error: Color,
51    /// Per-pane background and border overrides.
52    pub explorer: Pane,
53    pub note_editor: Pane,
54    pub outline: Pane,
55    pub status_bar: StatusBar,
56}
57
58/// Background and border styling for a single bordered pane. Colours default to
59/// the theme's global background / terminal default; `border_type` defers to the
60/// [`Symbols`](super::Symbols) preset when unset.
61#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct Pane {
63    pub background: Color,
64    pub border: Color,
65    pub border_active: Color,
66    pub border_type: Option<BorderKind>,
67    pub border_edges: Edges,
68}
69
70/// Which sides of a pane draw a border. Lets a theme keep only the dividers
71/// between panes (e.g. explorer `right`, outline `left`, editor `none`).
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Default)]
73#[serde(rename_all = "kebab-case")]
74pub enum Edges {
75    #[default]
76    All,
77    None,
78    Top,
79    Bottom,
80    Left,
81    Right,
82    /// Left and right.
83    Vertical,
84    /// Top and bottom.
85    Horizontal,
86}
87
88impl Edges {
89    pub fn to_borders(self) -> Borders {
90        match self {
91            Edges::All => Borders::ALL,
92            Edges::None => Borders::NONE,
93            Edges::Top => Borders::TOP,
94            Edges::Bottom => Borders::BOTTOM,
95            Edges::Left => Borders::LEFT,
96            Edges::Right => Borders::RIGHT,
97            Edges::Vertical => Borders::LEFT | Borders::RIGHT,
98            Edges::Horizontal => Borders::TOP | Borders::BOTTOM,
99        }
100    }
101}
102
103impl Pane {
104    /// Border colour by focus state.
105    pub fn border(&self, active: bool) -> Color {
106        if active {
107            self.border_active
108        } else {
109            self.border
110        }
111    }
112
113    /// Effective border line: the theme's `border-type` when set, otherwise the
114    /// given fallback (from the [`Symbols`](super::Symbols) preset). `None` means
115    /// the border is removed.
116    pub fn border_line(&self, fallback: widgets::BorderType) -> Option<widgets::BorderType> {
117        match self.border_type {
118            Some(kind) => kind.line(),
119            None => Some(fallback),
120        }
121    }
122
123    /// Borders for a folded pane: a full-box theme keeps the 3-sided `strip`; a
124    /// divider theme keeps only its own edge so the folded pane connects to its
125    /// neighbour instead of floating as a box.
126    pub fn collapsed_borders(&self, strip: Borders) -> Borders {
127        match self.border_edges {
128            Edges::All => strip,
129            edges => edges.to_borders(),
130        }
131    }
132}
133
134/// Background and foreground for the status bar (which has no border).
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct StatusBar {
137    pub background: Color,
138    pub foreground: Color,
139}
140
141/// Border line style for a pane. `None` removes the border entirely.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
143#[serde(rename_all = "kebab-case")]
144pub enum BorderKind {
145    None,
146    Plain,
147    Rounded,
148    Thick,
149    Double,
150}
151
152impl BorderKind {
153    /// Ratatui line type, or `None` when the border should not be drawn.
154    pub fn line(self) -> Option<widgets::BorderType> {
155        match self {
156            BorderKind::None => None,
157            BorderKind::Plain => Some(widgets::BorderType::Plain),
158            BorderKind::Rounded => Some(widgets::BorderType::Rounded),
159            BorderKind::Thick => Some(widgets::BorderType::Thick),
160            BorderKind::Double => Some(widgets::BorderType::Double),
161        }
162    }
163}
164
165impl Default for Theme {
166    fn default() -> Self {
167        let pane = Pane {
168            background: Color::Reset,
169            border: Color::Reset,
170            border_active: Color::Reset,
171            border_type: None,
172            border_edges: Edges::All,
173        };
174        Self {
175            text: Color::Reset,
176            background: Color::Reset,
177            muted: Color::DarkGray,
178            accent: Color::Magenta,
179            border: Color::Reset,
180            border_active: Color::Reset,
181            border_type: None,
182            border_edges: Edges::All,
183            heading_1: Color::Reset,
184            heading_2: Color::Yellow,
185            heading_3: Color::Cyan,
186            heading_4: Color::Magenta,
187            heading_5: Color::Reset,
188            heading_6: Color::Reset,
189            code_bg: Color::Black,
190            blockquote: Color::Magenta,
191            list_marker: Color::DarkGray,
192            task: Color::Magenta,
193            mode_insert: Color::Green,
194            mode_normal: Color::Gray,
195            mode_read: Color::Gray,
196            success: Color::Green,
197            info: Color::Blue,
198            warning: Color::Yellow,
199            error: Color::Red,
200            explorer: pane,
201            note_editor: pane,
202            outline: pane,
203            status_bar: StatusBar {
204                background: Color::Reset,
205                foreground: Color::Reset,
206            },
207        }
208    }
209}
210
211impl Theme {
212    /// Heading colour for a level (`1..=6`); out-of-range falls back to [`text`].
213    pub fn heading(&self, level: usize) -> Color {
214        match level {
215            1 => self.heading_1,
216            2 => self.heading_2,
217            3 => self.heading_3,
218            4 => self.heading_4,
219            5 => self.heading_5,
220            6 => self.heading_6,
221            _ => self.text,
222        }
223    }
224}
225
226/// A theme as written in a TOML file: a `[palette]` of named colours, a value
227/// per global role, and optional `[explorer]`, `[note-editor]`, `[outline]`
228/// and `[status-bar]` tables. A colour value is either a palette key or a
229/// literal (`#rrggbb` or an ANSI name). Unset roles keep the [`Theme::default`]
230/// colour; unset pane backgrounds inherit the theme background.
231#[derive(Clone, Debug, Default, Deserialize)]
232#[serde(rename_all = "kebab-case")]
233struct TomlTheme {
234    #[serde(default)]
235    palette: HashMap<String, String>,
236    text: Option<String>,
237    background: Option<String>,
238    muted: Option<String>,
239    accent: Option<String>,
240    border: Option<String>,
241    border_active: Option<String>,
242    border_type: Option<BorderKind>,
243    border_edges: Option<Edges>,
244    heading_1: Option<String>,
245    heading_2: Option<String>,
246    heading_3: Option<String>,
247    heading_4: Option<String>,
248    heading_5: Option<String>,
249    heading_6: Option<String>,
250    code_bg: Option<String>,
251    blockquote: Option<String>,
252    list_marker: Option<String>,
253    task: Option<String>,
254    mode_insert: Option<String>,
255    mode_normal: Option<String>,
256    mode_read: Option<String>,
257    success: Option<String>,
258    info: Option<String>,
259    warning: Option<String>,
260    error: Option<String>,
261    #[serde(default)]
262    explorer: TomlPane,
263    #[serde(default)]
264    note_editor: TomlPane,
265    #[serde(default)]
266    outline: TomlPane,
267    #[serde(default)]
268    status_bar: TomlStatusBar,
269}
270
271#[derive(Clone, Debug, Default, Deserialize)]
272#[serde(rename_all = "kebab-case")]
273struct TomlPane {
274    background: Option<String>,
275    border: Option<String>,
276    border_active: Option<String>,
277    border_type: Option<BorderKind>,
278    border_edges: Option<Edges>,
279}
280
281#[derive(Clone, Debug, Default, Deserialize)]
282#[serde(rename_all = "kebab-case")]
283struct TomlStatusBar {
284    background: Option<String>,
285    foreground: Option<String>,
286}
287
288/// Resolves a role value to a colour: a `[palette]` key, else a literal
289/// (`#rrggbb` or ANSI name), else the fallback.
290fn resolve(palette: &HashMap<String, String>, role: Option<String>, fallback: Color) -> Color {
291    role.map(|color| {
292        let literal = palette.get(&color).unwrap_or(&color);
293        Color::from_str(literal).unwrap_or(fallback)
294    })
295    .unwrap_or(fallback)
296}
297
298fn resolve_pane(palette: &HashMap<String, String>, toml: TomlPane, default: Pane) -> Pane {
299    Pane {
300        background: resolve(palette, toml.background, default.background),
301        border: resolve(palette, toml.border, default.border),
302        border_active: resolve(palette, toml.border_active, default.border_active),
303        border_type: toml.border_type.or(default.border_type),
304        border_edges: toml.border_edges.unwrap_or(default.border_edges),
305    }
306}
307
308impl From<TomlTheme> for Theme {
309    fn from(value: TomlTheme) -> Self {
310        let default = Theme::default();
311        let palette = &value.palette;
312        let color = |role, fallback| resolve(palette, role, fallback);
313
314        // Panes and the status bar inherit the global background unless they set
315        // their own, so a single `background` tints the whole UI consistently.
316        let background = color(value.background, default.background);
317        let pane_default = Pane {
318            background,
319            border: color(value.border, default.border),
320            border_active: color(value.border_active, default.border_active),
321            border_type: value.border_type.or(default.border_type),
322            border_edges: value.border_edges.unwrap_or(default.border_edges),
323        };
324
325        Self {
326            text: color(value.text, default.text),
327            background,
328            muted: color(value.muted, default.muted),
329            accent: color(value.accent, default.accent),
330            border: pane_default.border,
331            border_active: pane_default.border_active,
332            border_type: pane_default.border_type,
333            border_edges: pane_default.border_edges,
334            heading_1: color(value.heading_1, default.heading_1),
335            heading_2: color(value.heading_2, default.heading_2),
336            heading_3: color(value.heading_3, default.heading_3),
337            heading_4: color(value.heading_4, default.heading_4),
338            heading_5: color(value.heading_5, default.heading_5),
339            heading_6: color(value.heading_6, default.heading_6),
340            code_bg: color(value.code_bg, default.code_bg),
341            blockquote: color(value.blockquote, default.blockquote),
342            list_marker: color(value.list_marker, default.list_marker),
343            task: color(value.task, default.task),
344            mode_insert: color(value.mode_insert, default.mode_insert),
345            mode_normal: color(value.mode_normal, default.mode_normal),
346            mode_read: color(value.mode_read, default.mode_read),
347            success: color(value.success, default.success),
348            info: color(value.info, default.info),
349            warning: color(value.warning, default.warning),
350            error: color(value.error, default.error),
351            explorer: resolve_pane(palette, value.explorer, pane_default),
352            note_editor: resolve_pane(palette, value.note_editor, pane_default),
353            outline: resolve_pane(palette, value.outline, pane_default),
354            status_bar: StatusBar {
355                background: resolve(palette, value.status_bar.background, background),
356                foreground: resolve(
357                    palette,
358                    value.status_bar.foreground,
359                    default.status_bar.foreground,
360                ),
361            },
362        }
363    }
364}
365
366fn parse_theme(toml: &str) -> Theme {
367    toml::from_str::<TomlTheme>(toml)
368        .map(Theme::from)
369        .unwrap_or_default()
370}
371
372/// Built-in themes embedded at compile time, in display order.
373const BUILTIN_THEMES: &[(&str, &str)] = &[
374    (
375        "default",
376        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/default.toml")),
377    ),
378    (
379        "causeway-dark",
380        include_str!(concat!(
381            env!("CARGO_MANIFEST_DIR"),
382            "/themes/causeway-dark.toml"
383        )),
384    ),
385    (
386        "causeway-light",
387        include_str!(concat!(
388            env!("CARGO_MANIFEST_DIR"),
389            "/themes/causeway-light.toml"
390        )),
391    ),
392    (
393        "gruvbox-dark",
394        include_str!(concat!(
395            env!("CARGO_MANIFEST_DIR"),
396            "/themes/gruvbox-dark.toml"
397        )),
398    ),
399    (
400        "gruvbox-light",
401        include_str!(concat!(
402            env!("CARGO_MANIFEST_DIR"),
403            "/themes/gruvbox-light.toml"
404        )),
405    ),
406    (
407        "nord",
408        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/nord.toml")),
409    ),
410    (
411        "dracula",
412        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/dracula.toml")),
413    ),
414    (
415        "catppuccin-latte",
416        include_str!(concat!(
417            env!("CARGO_MANIFEST_DIR"),
418            "/themes/catppuccin-latte.toml"
419        )),
420    ),
421    (
422        "catppuccin-frappe",
423        include_str!(concat!(
424            env!("CARGO_MANIFEST_DIR"),
425            "/themes/catppuccin-frappe.toml"
426        )),
427    ),
428    (
429        "catppuccin-macchiato",
430        include_str!(concat!(
431            env!("CARGO_MANIFEST_DIR"),
432            "/themes/catppuccin-macchiato.toml"
433        )),
434    ),
435    (
436        "catppuccin-mocha",
437        include_str!(concat!(
438            env!("CARGO_MANIFEST_DIR"),
439            "/themes/catppuccin-mocha.toml"
440        )),
441    ),
442    (
443        "everforest-dark",
444        include_str!(concat!(
445            env!("CARGO_MANIFEST_DIR"),
446            "/themes/everforest-dark.toml"
447        )),
448    ),
449    (
450        "everforest-light",
451        include_str!(concat!(
452            env!("CARGO_MANIFEST_DIR"),
453            "/themes/everforest-light.toml"
454        )),
455    ),
456    (
457        "minimal",
458        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/minimal.toml")),
459    ),
460];
461
462/// User themes live in `$config/basalt/themes/*.toml`.
463fn user_themes_dir() -> Option<std::path::PathBuf> {
464    choose_base_strategy()
465        .ok()
466        .map(|strategy| strategy.config_dir().join("basalt/themes"))
467}
468
469fn user_themes() -> Vec<(String, Theme)> {
470    let Some(dir) = user_themes_dir() else {
471        return vec![];
472    };
473    let Ok(entries) = std::fs::read_dir(dir) else {
474        return vec![];
475    };
476
477    entries
478        .flatten()
479        .map(|entry| entry.path())
480        .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
481        .filter_map(|path| {
482            let name = path.file_stem()?.to_string_lossy().into_owned();
483            let theme = parse_theme(&read_to_string(&path).ok()?);
484            Some((name, theme))
485        })
486        .collect()
487}
488
489/// All available themes, in picker order: built-ins first, then user themes
490/// from `$config/basalt/themes/`. A user theme with a built-in's name overrides it.
491pub fn load_themes() -> Vec<(String, Theme)> {
492    let mut themes: Vec<(String, Theme)> = BUILTIN_THEMES
493        .iter()
494        .map(|(name, toml)| (name.to_string(), parse_theme(toml)))
495        .collect();
496
497    for (name, theme) in user_themes() {
498        match themes.iter_mut().find(|(existing, _)| *existing == name) {
499            Some((_, existing)) => *existing = theme,
500            None => themes.push((name, theme)),
501        }
502    }
503
504    themes
505}
506
507/// Resolves a theme by name, falling back to the default theme when unknown.
508pub fn theme_by_name(name: &str) -> Theme {
509    load_themes()
510        .into_iter()
511        .find(|(theme_name, _)| theme_name == name)
512        .map(|(_, theme)| theme)
513        .unwrap_or_default()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn builtin_default_matches_struct_default() {
522        let (_, default) = load_themes()
523            .into_iter()
524            .find(|(name, _)| name == "default")
525            .unwrap();
526        assert_eq!(default, Theme::default());
527    }
528
529    #[test]
530    fn resolves_palette_and_literals() {
531        let toml = r##"
532            accent = "red"
533            muted = "#102030"
534            error = "green"
535
536            [palette]
537            red = "#ff0000"
538        "##;
539        let theme = parse_theme(toml);
540        assert_eq!(theme.accent, Color::Rgb(255, 0, 0));
541        assert_eq!(theme.muted, Color::Rgb(16, 32, 48));
542        assert_eq!(theme.error, Color::Green);
543    }
544
545    #[test]
546    fn unset_roles_fall_back_to_default() {
547        let theme = parse_theme("accent = \"#abcdef\"");
548        assert_eq!(theme.accent, Color::Rgb(0xab, 0xcd, 0xef));
549        assert_eq!(theme.muted, Theme::default().muted);
550        assert_eq!(theme.heading_2, Theme::default().heading_2);
551    }
552
553    #[test]
554    fn resolves_pane_sections() {
555        let theme = parse_theme(
556            r##"
557            background = "#000000"
558
559            [explorer]
560            background = "surface"
561            border = "#111111"
562            border-active = "#00ff00"
563            border-type = "none"
564
565            [palette]
566            surface = "#101010"
567        "##,
568        );
569        assert_eq!(theme.explorer.background, Color::Rgb(16, 16, 16));
570        assert_eq!(theme.explorer.border, Color::Rgb(0x11, 0x11, 0x11));
571        assert_eq!(theme.explorer.border(true), Color::Rgb(0, 255, 0));
572        assert_eq!(theme.explorer.border_type, Some(BorderKind::None));
573        // Unset pane inherits the global background and defers its border type.
574        assert_eq!(theme.note_editor.background, Color::Rgb(0, 0, 0));
575        assert_eq!(theme.note_editor.border_type, None);
576    }
577
578    #[test]
579    fn resolves_border_edges() {
580        let theme = parse_theme(
581            r##"
582            [explorer]
583            border-edges = "right"
584
585            [outline]
586            border-edges = "left"
587        "##,
588        );
589        assert_eq!(theme.explorer.border_edges, Edges::Right);
590        assert_eq!(theme.outline.border_edges, Edges::Left);
591        assert_eq!(theme.explorer.border_edges.to_borders(), Borders::RIGHT);
592        // Unset panes keep the default (all edges).
593        assert_eq!(theme.note_editor.border_edges, Edges::All);
594    }
595
596    #[test]
597    fn status_bar_section() {
598        let theme = parse_theme(
599            r##"
600            [status-bar]
601            background = "#222222"
602            foreground = "#eeeeee"
603        "##,
604        );
605        assert_eq!(theme.status_bar.background, Color::Rgb(0x22, 0x22, 0x22));
606        assert_eq!(theme.status_bar.foreground, Color::Rgb(0xee, 0xee, 0xee));
607    }
608
609    #[test]
610    fn all_builtins_parse() {
611        let themes = load_themes();
612        for name in [
613            "causeway-dark",
614            "causeway-light",
615            "gruvbox-dark",
616            "gruvbox-light",
617            "nord",
618            "dracula",
619            "catppuccin-latte",
620            "catppuccin-frappe",
621            "catppuccin-macchiato",
622            "catppuccin-mocha",
623            "everforest-dark",
624            "everforest-light",
625            "minimal",
626        ] {
627            assert!(
628                themes.iter().any(|(theme, _)| theme == name),
629                "missing {name}"
630            );
631        }
632    }
633}