Skip to main content

guise/theme/
mod.rs

1//! The theme: palette + sizing tokens + color scheme, exposed as a gpui
2//! `Global` so any component can resolve themed values during render.
3//!
4//! Mirrors Mantine's `MantineProvider` model — a single source of truth for
5//! colors, spacing, radius and typography, with semantic colors derived from
6//! the active light/dark [`ColorScheme`].
7
8mod color;
9mod css;
10mod palette;
11mod tokens;
12
13pub use color::Color;
14pub use css::{css, hsl, hsla, rgb, rgba, CssColorError};
15pub use palette::{mantine, ColorName, Palette, Shades};
16pub use tokens::{Scale, Size};
17
18use gpui::{App, Global, SharedString};
19
20/// Light or dark surface treatment. Drives every semantic color.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ColorScheme {
23    Light,
24    Dark,
25}
26
27impl ColorScheme {
28    pub fn is_dark(self) -> bool {
29        matches!(self, ColorScheme::Dark)
30    }
31
32    /// The opposite scheme.
33    pub fn toggled(self) -> Self {
34        match self {
35            ColorScheme::Light => ColorScheme::Dark,
36            ColorScheme::Dark => ColorScheme::Light,
37        }
38    }
39}
40
41/// The active theme. Install once with [`Theme::init`], read with [`theme`].
42#[derive(Debug, Clone)]
43pub struct Theme {
44    pub scheme: ColorScheme,
45    pub palette: Palette,
46    /// The color used for default-variant filled controls.
47    pub primary_color: ColorName,
48    /// Shade index of the primary color in light / dark mode.
49    pub primary_shade_light: usize,
50    pub primary_shade_dark: usize,
51    pub white: Color,
52    pub black: Color,
53    pub spacing: Scale,
54    pub radius: Scale,
55    pub font_size: Scale,
56    /// Default corner radius for components that don't specify one.
57    pub default_radius: Size,
58    pub line_height: f32,
59    pub font_family: SharedString,
60    /// Optional CSS-color overrides for the scheme-derived semantic colors and
61    /// the primary accent. When set, the matching getter returns the override.
62    /// Set them with the `with_*` builders (e.g. `Theme::dark().with_body(..)`).
63    pub overrides: Overrides,
64}
65
66/// Per-theme semantic color overrides (opaque). `None` means "use the
67/// scheme-derived default". Populated via the `Theme::with_*` builders.
68#[derive(Debug, Clone, Copy, Default)]
69pub struct Overrides {
70    pub primary: Option<Color>,
71    pub body: Option<Color>,
72    pub surface: Option<Color>,
73    pub surface_hover: Option<Color>,
74    pub text: Option<Color>,
75    pub dimmed: Option<Color>,
76    pub border: Option<Color>,
77}
78
79impl Global for Theme {}
80
81impl Default for Theme {
82    fn default() -> Self {
83        Theme::light()
84    }
85}
86
87impl Theme {
88    pub fn light() -> Self {
89        Theme {
90            scheme: ColorScheme::Light,
91            palette: mantine(),
92            primary_color: ColorName::Blue,
93            primary_shade_light: 6,
94            primary_shade_dark: 8,
95            white: Color::hex("#ffffff"),
96            black: Color::hex("#000000"),
97            spacing: Scale::spacing(),
98            radius: Scale::radius(),
99            font_size: Scale::font_size(),
100            default_radius: Size::Sm,
101            line_height: 1.55,
102            font_family: SharedString::new_static("Helvetica"),
103            overrides: Overrides::default(),
104        }
105    }
106
107    pub fn dark() -> Self {
108        Theme {
109            scheme: ColorScheme::Dark,
110            ..Theme::light()
111        }
112    }
113
114    /// Install the theme as the app-global, replacing any existing one.
115    pub fn init(self, cx: &mut App) {
116        cx.set_global(self);
117    }
118
119    // --- CSS-color overrides (builder) -------------------------------------
120    //
121    // Each accepts anything convertible to an `Hsla` — notably the `color!`
122    // macro and `css(..)` output, but also a palette `Color`. Alpha is dropped
123    // (semantic colors are opaque). Named `with_*` to avoid clashing with the
124    // same-named getters.
125
126    /// Override the primary accent color.
127    pub fn with_primary(mut self, color: impl Into<gpui::Hsla>) -> Self {
128        self.overrides.primary = Some(Color::from_hsla(color.into()));
129        self
130    }
131
132    /// Override the app/window background.
133    pub fn with_body(mut self, color: impl Into<gpui::Hsla>) -> Self {
134        self.overrides.body = Some(Color::from_hsla(color.into()));
135        self
136    }
137
138    /// Override the raised-surface background.
139    pub fn with_surface(mut self, color: impl Into<gpui::Hsla>) -> Self {
140        self.overrides.surface = Some(Color::from_hsla(color.into()));
141        self
142    }
143
144    /// Override the subtle hover/recessed fill.
145    pub fn with_surface_hover(mut self, color: impl Into<gpui::Hsla>) -> Self {
146        self.overrides.surface_hover = Some(Color::from_hsla(color.into()));
147        self
148    }
149
150    /// Override the primary body-text color.
151    pub fn with_text(mut self, color: impl Into<gpui::Hsla>) -> Self {
152        self.overrides.text = Some(Color::from_hsla(color.into()));
153        self
154    }
155
156    /// Override the secondary/dimmed text color.
157    pub fn with_dimmed(mut self, color: impl Into<gpui::Hsla>) -> Self {
158        self.overrides.dimmed = Some(Color::from_hsla(color.into()));
159        self
160    }
161
162    /// Override the default border/divider color.
163    pub fn with_border(mut self, color: impl Into<gpui::Hsla>) -> Self {
164        self.overrides.border = Some(Color::from_hsla(color.into()));
165        self
166    }
167
168    // --- Token lookups -----------------------------------------------------
169
170    pub fn spacing(&self, size: Size) -> f32 {
171        self.spacing.get(size)
172    }
173
174    pub fn radius(&self, size: Size) -> f32 {
175        self.radius.get(size)
176    }
177
178    pub fn font_size(&self, size: Size) -> f32 {
179        self.font_size.get(size)
180    }
181
182    // --- Color resolution --------------------------------------------------
183
184    /// A single shade of a named color.
185    pub fn color(&self, name: ColorName, shade: usize) -> Color {
186        self.palette.get(name, shade)
187    }
188
189    /// The active primary-color shade for the current scheme.
190    pub fn primary_shade(&self) -> usize {
191        match self.scheme {
192            ColorScheme::Light => self.primary_shade_light,
193            ColorScheme::Dark => self.primary_shade_dark,
194        }
195    }
196
197    /// The primary color at its scheme-appropriate shade.
198    pub fn primary(&self) -> Color {
199        self.overrides
200            .primary
201            .unwrap_or_else(|| self.color(self.primary_color, self.primary_shade()))
202    }
203
204    // --- Semantic colors (scheme-aware) ------------------------------------
205
206    /// The app/window background.
207    pub fn body(&self) -> Color {
208        self.overrides.body.unwrap_or_else(|| match self.scheme {
209            ColorScheme::Light => self.white,
210            ColorScheme::Dark => self.color(ColorName::Dark, 7),
211        })
212    }
213
214    /// Raised surface background (Paper, Card, Menu, ...).
215    pub fn surface(&self) -> Color {
216        self.overrides.surface.unwrap_or_else(|| match self.scheme {
217            ColorScheme::Light => self.white,
218            ColorScheme::Dark => self.color(ColorName::Dark, 6),
219        })
220    }
221
222    /// A subtly recessed/hover fill.
223    pub fn surface_hover(&self) -> Color {
224        self.overrides
225            .surface_hover
226            .unwrap_or_else(|| match self.scheme {
227                ColorScheme::Light => self.color(ColorName::Gray, 0),
228                ColorScheme::Dark => self.color(ColorName::Dark, 5),
229            })
230    }
231
232    /// Primary body text.
233    pub fn text(&self) -> Color {
234        self.overrides.text.unwrap_or_else(|| match self.scheme {
235            ColorScheme::Light => self.color(ColorName::Dark, 9),
236            ColorScheme::Dark => self.color(ColorName::Dark, 0),
237        })
238    }
239
240    /// Secondary / dimmed text.
241    pub fn dimmed(&self) -> Color {
242        self.overrides.dimmed.unwrap_or_else(|| match self.scheme {
243            ColorScheme::Light => self.color(ColorName::Gray, 6),
244            ColorScheme::Dark => self.color(ColorName::Dark, 2),
245        })
246    }
247
248    /// Default border / divider color.
249    pub fn border(&self) -> Color {
250        self.overrides.border.unwrap_or_else(|| match self.scheme {
251            ColorScheme::Light => self.color(ColorName::Gray, 3),
252            ColorScheme::Dark => self.color(ColorName::Dark, 4),
253        })
254    }
255}
256
257/// Read the active theme. Panics if [`Theme::init`] was never called.
258pub fn theme(cx: &App) -> &Theme {
259    cx.global::<Theme>()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn semantic_override_replaces_scheme_default() {
268        let base = Theme::light();
269        let themed = Theme::light()
270            .with_primary(css::rgb(200, 10, 10))
271            .with_body(css::css("#0b0b0f").unwrap());
272
273        assert!(themed.overrides.primary.is_some());
274        assert_ne!(themed.primary(), base.primary());
275        assert_ne!(themed.body(), base.body());
276        // Untouched semantics still fall back to the scheme default.
277        assert_eq!(themed.text(), base.text());
278    }
279}