Skip to main content

urushi/theme/
preset.rs

1//! Built-in terminal theme palettes mapped to Urushi semantic roles.
2
3use std::fmt;
4
5use crate::Color;
6
7use super::{ColorScheme, SemanticTokens, Theme};
8
9/// One named built-in terminal theme.
10///
11/// The catalog is generated from the terminal palette files in the pinned
12/// iTerm2-Color-Schemes snapshot documented in
13/// `urushi/THIRD_PARTY_NOTICES.md`. Every source theme is present; Urushi does
14/// not select a preferred subset.
15///
16/// A terminal palette describes indexed colors, while an Urushi theme
17/// describes semantic UI roles. The conversion uses terminal
18/// foreground/background for `text`/`background`, ANSI red/green/yellow/blue
19/// for the corresponding semantic colors, neutral foreground/background
20/// blends for `surface`, `text_muted`, and `border`, and the background for
21/// `accent_text`.
22///
23/// ```
24/// use urushi::{ColorScheme, ThemePreset, ThemeSet};
25///
26/// let light = ThemePreset::get("Catppuccin Latte").unwrap();
27/// let dark = ThemePreset::get("Catppuccin Mocha").unwrap();
28/// assert_eq!(light.scheme(), ColorScheme::Light);
29/// assert_eq!(dark.scheme(), ColorScheme::Dark);
30/// let themes = ThemeSet::new(light.theme(), dark.theme());
31/// assert_ne!(themes.light().tokens().background, themes.dark().tokens().background);
32/// ```
33#[derive(Clone, Copy, PartialEq, Eq, Hash)]
34pub struct ThemePreset(usize);
35
36impl ThemePreset {
37    /// Returns every built-in theme in name order.
38    pub const fn all() -> &'static [Self] {
39        &generated::THEME_PRESETS
40    }
41
42    /// Finds a built-in theme by its exact name.
43    pub fn get(name: &str) -> Option<Self> {
44        generated::THEME_DATA
45            .binary_search_by(|data| data.name.cmp(name))
46            .ok()
47            .map(Self)
48    }
49
50    /// Returns the source theme's name.
51    pub const fn name(self) -> &'static str {
52        generated::THEME_DATA[self.0].name
53    }
54
55    /// Returns whether this preset has a light or dark background.
56    pub const fn scheme(self) -> ColorScheme {
57        generated::THEME_DATA[self.0].scheme
58    }
59
60    /// Builds an Urushi theme from this terminal palette.
61    pub fn theme(self) -> Theme {
62        generated::THEME_DATA[self.0].palette.theme()
63    }
64}
65
66impl fmt::Debug for ThemePreset {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter
69            .debug_struct("ThemePreset")
70            .field("name", &self.name())
71            .field("scheme", &self.scheme())
72            .finish()
73    }
74}
75
76impl fmt::Display for ThemePreset {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter.write_str(self.name())
79    }
80}
81
82#[derive(Clone, Copy)]
83struct ThemeData {
84    name: &'static str,
85    scheme: ColorScheme,
86    palette: PaletteTheme,
87}
88
89#[derive(Clone, Copy)]
90struct PaletteTheme {
91    foreground: Rgb,
92    background: Rgb,
93    red: Rgb,
94    green: Rgb,
95    yellow: Rgb,
96    blue: Rgb,
97}
98
99impl PaletteTheme {
100    fn theme(self) -> Theme {
101        Theme::from_tokens(SemanticTokens {
102            text: self.foreground.color(),
103            text_muted: mix(self.background, self.foreground, 55).color(),
104            background: self.background.color(),
105            surface: mix(self.background, self.foreground, 8).color(),
106            accent: self.blue.color(),
107            accent_text: self.background.color(),
108            success: self.green.color(),
109            warning: self.yellow.color(),
110            error: self.red.color(),
111            border: mix(self.background, self.foreground, 25).color(),
112        })
113    }
114}
115
116#[derive(Clone, Copy)]
117struct Rgb(u8, u8, u8);
118
119impl Rgb {
120    const fn color(self) -> Color {
121        Color::Rgb(self.0, self.1, self.2)
122    }
123}
124
125fn mix(background: Rgb, foreground: Rgb, foreground_percent: u16) -> Rgb {
126    fn channel(background: u8, foreground: u8, foreground_percent: u16) -> u8 {
127        let background_percent = 100 - foreground_percent;
128        let mixed =
129            u16::from(background) * background_percent + u16::from(foreground) * foreground_percent;
130        ((mixed + 50) / 100) as u8
131    }
132
133    Rgb(
134        channel(background.0, foreground.0, foreground_percent),
135        channel(background.1, foreground.1, foreground_percent),
136        channel(background.2, foreground.2, foreground_percent),
137    )
138}
139
140mod generated {
141    use super::{ColorScheme, PaletteTheme, Rgb, ThemeData, ThemePreset};
142
143    include!("generated_presets.rs");
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn catalog_contains_the_complete_pinned_snapshot() {
152        assert_eq!(ThemePreset::all().len(), 463);
153        assert_eq!(
154            ThemePreset::all()
155                .iter()
156                .filter(|preset| preset.scheme() == ColorScheme::Light)
157                .count(),
158            78
159        );
160        assert_eq!(
161            ThemePreset::all()
162                .iter()
163                .filter(|preset| preset.scheme() == ColorScheme::Dark)
164                .count(),
165            385
166        );
167    }
168
169    #[test]
170    fn catalog_names_are_sorted_and_unique() {
171        for presets in ThemePreset::all().windows(2) {
172            assert!(presets[0].name() < presets[1].name());
173        }
174    }
175
176    #[test]
177    fn exact_name_lookup_returns_the_catalog_value() {
178        let preset = ThemePreset::get("Catppuccin Mocha").expect("preset exists");
179        assert_eq!(preset.name(), "Catppuccin Mocha");
180        assert_eq!(preset.scheme(), ColorScheme::Dark);
181        assert_eq!(
182            preset.theme().tokens().background,
183            Color::Rgb(0x1e, 0x1e, 0x2e)
184        );
185        assert!(ThemePreset::get("catppuccin mocha").is_none());
186    }
187
188    #[test]
189    fn every_preset_builds_a_semantic_theme() {
190        for preset in ThemePreset::all() {
191            let tokens = preset.theme().tokens().to_owned();
192            assert_ne!(tokens.text, tokens.background, "{preset}");
193            assert_ne!(tokens.surface, tokens.background, "{preset}");
194        }
195    }
196}