Skip to main content

lattice_theme/
themes.rs

1//! Named builtin themes (T.9.b).
2//!
3//! A [`NamedTheme`] bundles a palette with an (optional) element-
4//! override set under a user-typeable name. `:colorscheme <name>`
5//! looks a theme up here and hands its `palette` + `overrides` to
6//! [`ThemeRegistry::set_theme`](crate::ThemeRegistry::set_theme),
7//! which swaps the active palette + override set atomically and marks
8//! the resolved table dirty. The renderers rebuild on the host's
9//! `ThemeChanged` signal.
10//!
11//! v1 ships two Catppuccin flavours. Both keep an empty override list
12//! — the palette indirection already re-colours every element, so a
13//! flavour swap needs no per-element overrides. A future theme that
14//! wants to restyle a specific element (not just re-tint via the
15//! palette) populates `overrides`.
16//!
17//! Design: `docs/dev/architecture/theme-system.md` §3.3, §5.1.
18
19use crate::element::{ElementName, StyleSpec};
20use crate::palette::{
21    Palette, catppuccin_latte_palette, default_palette, dracula_dark_palette,
22    dracula_light_palette, everforest_dark_palette, everforest_light_palette, gruvbox_dark_palette,
23    gruvbox_light_palette, macchiato_palette, monokai_dark_palette, monokai_light_palette,
24    nord_dark_palette, nord_light_palette, one_dark_palette, one_light_palette,
25    rosepine_dark_palette, rosepine_light_palette, solarized_dark_palette, solarized_light_palette,
26    tokyonight_dark_palette, tokyonight_light_palette,
27};
28
29/// A named theme: a palette + a (possibly empty) element-override set.
30/// The unit a `:colorscheme <name>` swap resolves to.
31pub struct NamedTheme {
32    pub name: &'static str,
33    pub palette: Palette,
34    pub overrides: Vec<(ElementName, StyleSpec)>,
35}
36
37/// Every builtin theme, by name. `:colorscheme` matches `name`
38/// case-sensitively against this list. The first entry
39/// (`catppuccin-mocha`) is the boot default — its palette equals
40/// [`default_palette`], so swapping to it is a no-op restore.
41pub fn builtin_themes() -> Vec<NamedTheme> {
42    vec![
43        NamedTheme {
44            name: "catppuccin-mocha",
45            palette: default_palette(),
46            overrides: Vec::new(),
47        },
48        NamedTheme {
49            name: "catppuccin-macchiato",
50            palette: macchiato_palette(),
51            overrides: Vec::new(),
52        },
53        // T.11.1: first LIGHT theme. The palette indirection + the
54        // canvas elements (T.11.0b) mean a swap to Latte recolours the
55        // whole surface light with no per-element overrides.
56        NamedTheme {
57            name: "catppuccin-latte",
58            palette: catppuccin_latte_palette(),
59            overrides: Vec::new(),
60        },
61        // T.11.1: the multi-theme library — 9 cross-editor families ×
62        // {dark, light}. Each palette fills the full role-key set, so an
63        // empty override list still recolours the whole surface.
64        NamedTheme {
65            name: "gruvbox-dark",
66            palette: gruvbox_dark_palette(),
67            overrides: Vec::new(),
68        },
69        NamedTheme {
70            name: "gruvbox-light",
71            palette: gruvbox_light_palette(),
72            overrides: Vec::new(),
73        },
74        NamedTheme {
75            name: "tokyonight-dark",
76            palette: tokyonight_dark_palette(),
77            overrides: Vec::new(),
78        },
79        NamedTheme {
80            name: "tokyonight-light",
81            palette: tokyonight_light_palette(),
82            overrides: Vec::new(),
83        },
84        NamedTheme {
85            name: "dracula-dark",
86            palette: dracula_dark_palette(),
87            overrides: Vec::new(),
88        },
89        NamedTheme {
90            name: "dracula-light",
91            palette: dracula_light_palette(),
92            overrides: Vec::new(),
93        },
94        NamedTheme {
95            name: "nord-dark",
96            palette: nord_dark_palette(),
97            overrides: Vec::new(),
98        },
99        NamedTheme {
100            name: "nord-light",
101            palette: nord_light_palette(),
102            overrides: Vec::new(),
103        },
104        NamedTheme {
105            name: "solarized-dark",
106            palette: solarized_dark_palette(),
107            overrides: Vec::new(),
108        },
109        NamedTheme {
110            name: "solarized-light",
111            palette: solarized_light_palette(),
112            overrides: Vec::new(),
113        },
114        NamedTheme {
115            name: "one-dark",
116            palette: one_dark_palette(),
117            overrides: Vec::new(),
118        },
119        NamedTheme {
120            name: "one-light",
121            palette: one_light_palette(),
122            overrides: Vec::new(),
123        },
124        NamedTheme {
125            name: "everforest-dark",
126            palette: everforest_dark_palette(),
127            overrides: Vec::new(),
128        },
129        NamedTheme {
130            name: "everforest-light",
131            palette: everforest_light_palette(),
132            overrides: Vec::new(),
133        },
134        NamedTheme {
135            name: "rosepine-dark",
136            palette: rosepine_dark_palette(),
137            overrides: Vec::new(),
138        },
139        NamedTheme {
140            name: "rosepine-light",
141            palette: rosepine_light_palette(),
142            overrides: Vec::new(),
143        },
144        NamedTheme {
145            name: "monokai-dark",
146            palette: monokai_dark_palette(),
147            overrides: Vec::new(),
148        },
149        NamedTheme {
150            name: "monokai-light",
151            palette: monokai_light_palette(),
152            overrides: Vec::new(),
153        },
154    ]
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::Color;
161
162    #[test]
163    fn builtin_themes_has_both_flavours() {
164        let themes = builtin_themes();
165        let names: Vec<&str> = themes.iter().map(|t| t.name).collect();
166        assert!(names.contains(&"catppuccin-mocha"));
167        assert!(names.contains(&"catppuccin-macchiato"));
168    }
169
170    #[test]
171    fn macchiato_mauve_differs_from_mocha() {
172        let themes = builtin_themes();
173        let mocha = themes
174            .iter()
175            .find(|t| t.name == "catppuccin-mocha")
176            .expect("mocha registered");
177        let mac = themes
178            .iter()
179            .find(|t| t.name == "catppuccin-macchiato")
180            .expect("macchiato registered");
181        let mauve = crate::PaletteKey::from_static("purple");
182        assert_ne!(mocha.palette.get(&mauve), mac.palette.get(&mauve));
183        assert_eq!(mac.palette.get(&mauve), Some(Color::Rgb(0xc6, 0xa0, 0xf6)));
184    }
185
186    #[test]
187    fn latte_is_a_registered_light_theme() {
188        // T.11.1: catppuccin-latte is registered and is genuinely LIGHT —
189        // its `base` (the canvas background, via `editor.background`) is a
190        // light colour, inverted from Mocha's dark base, and its `text`
191        // (the foreground, via `editor.foreground`) is dark. This is what
192        // makes the T.11.0b palette-driven canvas render light on a swap.
193        let themes = builtin_themes();
194        let latte = themes
195            .iter()
196            .find(|t| t.name == "catppuccin-latte")
197            .expect("catppuccin-latte registered");
198        let base = crate::PaletteKey::from_static("base");
199        let text = crate::PaletteKey::from_static("text");
200        assert_eq!(latte.palette.get(&base), Some(Color::Rgb(0xef, 0xf1, 0xf5)));
201        assert_eq!(latte.palette.get(&text), Some(Color::Rgb(0x4c, 0x4f, 0x69)));
202        // Light base: every channel brighter than Mocha's dark base.
203        let mocha = themes
204            .iter()
205            .find(|t| t.name == "catppuccin-mocha")
206            .unwrap();
207        let (Some(Color::Rgb(lr, lg, lb)), Some(Color::Rgb(mr, mg, mb))) =
208            (latte.palette.get(&base), mocha.palette.get(&base))
209        else {
210            panic!("both bases are rgb");
211        };
212        assert!(
213            lr > mr && lg > mg && lb > mb,
214            "Latte base must be lighter than Mocha"
215        );
216    }
217
218    #[test]
219    fn every_builtin_theme_covers_the_full_role_key_set() {
220        // T.11.1: each builtin palette MUST define every key that
221        // `default_palette` defines, or element resolution falls back to
222        // the inherit chain / hard default — a silent mis-theme. This pin
223        // guarantees no fallback for any registered theme.
224        let reference = default_palette();
225        for theme in builtin_themes() {
226            for key in reference.keys() {
227                assert!(
228                    theme.palette.get(key).is_some(),
229                    "theme `{}` is missing role key `{}`",
230                    theme.name,
231                    key.as_str()
232                );
233            }
234        }
235    }
236
237    #[test]
238    fn light_themes_are_light_and_dark_themes_are_dark() {
239        // T.11.1: a `*-light` theme's `base` (canvas background) must be
240        // brighter (sum of channels) than its `text` (foreground); a
241        // `*-dark` theme's `base` must be darker than its `text`. Catches a
242        // copy-paste that left a light palette with a dark base or vice
243        // versa.
244        fn channel_sum(c: Color) -> u32 {
245            match c {
246                Color::Rgb(r, g, b) => r as u32 + g as u32 + b as u32,
247                other => {
248                    let rgb = other.to_rgb_u32(0);
249                    ((rgb >> 16) & 0xff) + ((rgb >> 8) & 0xff) + (rgb & 0xff)
250                }
251            }
252        }
253        let base = crate::PaletteKey::from_static("base");
254        let text = crate::PaletteKey::from_static("text");
255        for theme in builtin_themes() {
256            let b = channel_sum(theme.palette.get(&base).expect("base defined"));
257            let t = channel_sum(theme.palette.get(&text).expect("text defined"));
258            if theme.name.ends_with("-light") || theme.name.ends_with("-latte") {
259                assert!(
260                    b > t,
261                    "light theme `{}`: base ({}) must be brighter than text ({})",
262                    theme.name,
263                    b,
264                    t
265                );
266            } else {
267                assert!(
268                    b < t,
269                    "dark theme `{}`: base ({}) must be darker than text ({})",
270                    theme.name,
271                    b,
272                    t
273                );
274            }
275        }
276    }
277}