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//! One global as the single source of truth for colors, spacing, radius and
5//! typography, with semantic colors derived from the active light/dark
6//! [`ColorScheme`].
7
8mod color;
9mod css;
10mod json;
11mod palette;
12mod presets;
13mod tokens;
14
15pub use color::Color;
16pub use css::{css, hsl, hsla, rgb, rgba, CssColorError};
17pub use json::ThemeJsonError;
18pub use palette::{open_color, ColorName, Palette, Shades};
19pub use presets::PRESET_NAMES;
20pub use tokens::{Scale, Size};
21
22use gpui::{App, Global, SharedString};
23
24/// Light or dark surface treatment. Drives every semantic color.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ColorScheme {
27    Light,
28    Dark,
29}
30
31impl ColorScheme {
32    pub fn is_dark(self) -> bool {
33        matches!(self, ColorScheme::Dark)
34    }
35
36    /// The opposite scheme.
37    pub fn toggled(self) -> Self {
38        match self {
39            ColorScheme::Light => ColorScheme::Dark,
40            ColorScheme::Dark => ColorScheme::Light,
41        }
42    }
43}
44
45/// The active theme. Install once with [`Theme::init`], read with [`theme`].
46#[derive(Debug, Clone)]
47pub struct Theme {
48    pub scheme: ColorScheme,
49    pub palette: Palette,
50    /// The color used for default-variant filled controls.
51    pub primary_color: ColorName,
52    /// Shade index of the primary color in light / dark mode.
53    pub primary_shade_light: usize,
54    pub primary_shade_dark: usize,
55    pub white: Color,
56    pub black: Color,
57    pub spacing: Scale,
58    pub radius: Scale,
59    pub font_size: Scale,
60    /// Default corner radius for components that don't specify one.
61    pub default_radius: Size,
62    pub line_height: f32,
63    pub font_family: SharedString,
64    /// Optional CSS-color overrides for the scheme-derived semantic colors and
65    /// the primary accent. When set, the matching getter returns the override.
66    /// Set them with the `with_*` builders (e.g. `Theme::dark().with_body(..)`).
67    pub overrides: Overrides,
68}
69
70/// Per-theme semantic color overrides (opaque). `None` means "use the
71/// scheme-derived default". Populated via the `Theme::with_*` builders.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct Overrides {
74    pub primary: Option<Color>,
75    pub body: Option<Color>,
76    pub surface: Option<Color>,
77    pub surface_hover: Option<Color>,
78    pub text: Option<Color>,
79    pub dimmed: Option<Color>,
80    pub border: Option<Color>,
81    pub success: Option<Color>,
82    pub warning: Option<Color>,
83    pub danger: Option<Color>,
84    pub info: Option<Color>,
85}
86
87impl Global for Theme {}
88
89impl Default for Theme {
90    fn default() -> Self {
91        Theme::light()
92    }
93}
94
95impl Theme {
96    pub fn light() -> Self {
97        Theme {
98            scheme: ColorScheme::Light,
99            palette: open_color(),
100            primary_color: ColorName::Blue,
101            primary_shade_light: 6,
102            primary_shade_dark: 8,
103            white: Color::hex("#ffffff"),
104            black: Color::hex("#000000"),
105            spacing: Scale::spacing(),
106            radius: Scale::radius(),
107            font_size: Scale::font_size(),
108            default_radius: Size::Sm,
109            line_height: 1.55,
110            font_family: SharedString::new_static("Helvetica"),
111            overrides: Overrides::default(),
112        }
113    }
114
115    pub fn dark() -> Self {
116        Theme {
117            scheme: ColorScheme::Dark,
118            ..Theme::light()
119        }
120    }
121
122    /// Install the theme as the app-global, replacing any existing one.
123    pub fn init(self, cx: &mut App) {
124        cx.set_global(self);
125    }
126
127    // --- CSS-color overrides (builder) -------------------------------------
128    //
129    // Each accepts anything convertible to an `Hsla` — notably the `color!`
130    // macro and `css(..)` output, but also a palette `Color`. Alpha is dropped
131    // (semantic colors are opaque). Named `with_*` to avoid clashing with the
132    // same-named getters.
133
134    /// Override the primary accent color.
135    pub fn with_primary(mut self, color: impl Into<gpui::Hsla>) -> Self {
136        self.overrides.primary = Some(Color::from_hsla(color.into()));
137        self
138    }
139
140    /// Override the app/window background.
141    pub fn with_body(mut self, color: impl Into<gpui::Hsla>) -> Self {
142        self.overrides.body = Some(Color::from_hsla(color.into()));
143        self
144    }
145
146    /// Override the raised-surface background.
147    pub fn with_surface(mut self, color: impl Into<gpui::Hsla>) -> Self {
148        self.overrides.surface = Some(Color::from_hsla(color.into()));
149        self
150    }
151
152    /// Override the subtle hover/recessed fill.
153    pub fn with_surface_hover(mut self, color: impl Into<gpui::Hsla>) -> Self {
154        self.overrides.surface_hover = Some(Color::from_hsla(color.into()));
155        self
156    }
157
158    /// Override the primary body-text color.
159    pub fn with_text(mut self, color: impl Into<gpui::Hsla>) -> Self {
160        self.overrides.text = Some(Color::from_hsla(color.into()));
161        self
162    }
163
164    /// Override the secondary/dimmed text color.
165    pub fn with_dimmed(mut self, color: impl Into<gpui::Hsla>) -> Self {
166        self.overrides.dimmed = Some(Color::from_hsla(color.into()));
167        self
168    }
169
170    /// Override the default border/divider color.
171    pub fn with_border(mut self, color: impl Into<gpui::Hsla>) -> Self {
172        self.overrides.border = Some(Color::from_hsla(color.into()));
173        self
174    }
175
176    /// Override the success/positive accent.
177    pub fn with_success(mut self, color: impl Into<gpui::Hsla>) -> Self {
178        self.overrides.success = Some(Color::from_hsla(color.into()));
179        self
180    }
181
182    /// Override the warning accent.
183    pub fn with_warning(mut self, color: impl Into<gpui::Hsla>) -> Self {
184        self.overrides.warning = Some(Color::from_hsla(color.into()));
185        self
186    }
187
188    /// Override the danger/destructive accent.
189    pub fn with_danger(mut self, color: impl Into<gpui::Hsla>) -> Self {
190        self.overrides.danger = Some(Color::from_hsla(color.into()));
191        self
192    }
193
194    /// Override the informational accent.
195    pub fn with_info(mut self, color: impl Into<gpui::Hsla>) -> Self {
196        self.overrides.info = Some(Color::from_hsla(color.into()));
197        self
198    }
199
200    // --- Token lookups -----------------------------------------------------
201
202    pub fn spacing(&self, size: Size) -> f32 {
203        self.spacing.get(size)
204    }
205
206    pub fn radius(&self, size: Size) -> f32 {
207        self.radius.get(size)
208    }
209
210    pub fn font_size(&self, size: Size) -> f32 {
211        self.font_size.get(size)
212    }
213
214    // --- Color resolution --------------------------------------------------
215
216    /// A single shade of a named color.
217    pub fn color(&self, name: ColorName, shade: usize) -> Color {
218        self.palette.get(name, shade)
219    }
220
221    /// The active primary-color shade for the current scheme.
222    pub fn primary_shade(&self) -> usize {
223        match self.scheme {
224            ColorScheme::Light => self.primary_shade_light,
225            ColorScheme::Dark => self.primary_shade_dark,
226        }
227    }
228
229    /// The primary color at its scheme-appropriate shade.
230    pub fn primary(&self) -> Color {
231        self.overrides
232            .primary
233            .unwrap_or_else(|| self.color(self.primary_color, self.primary_shade()))
234    }
235
236    // --- Semantic colors (scheme-aware) ------------------------------------
237
238    /// The app/window background.
239    pub fn body(&self) -> Color {
240        self.overrides.body.unwrap_or_else(|| match self.scheme {
241            ColorScheme::Light => self.white,
242            ColorScheme::Dark => self.color(ColorName::Dark, 7),
243        })
244    }
245
246    /// Raised surface background (Paper, Card, Menu, ...).
247    pub fn surface(&self) -> Color {
248        self.overrides.surface.unwrap_or_else(|| match self.scheme {
249            ColorScheme::Light => self.white,
250            ColorScheme::Dark => self.color(ColorName::Dark, 6),
251        })
252    }
253
254    /// A subtly recessed/hover fill.
255    pub fn surface_hover(&self) -> Color {
256        self.overrides
257            .surface_hover
258            .unwrap_or_else(|| match self.scheme {
259                ColorScheme::Light => self.color(ColorName::Gray, 0),
260                ColorScheme::Dark => self.color(ColorName::Dark, 5),
261            })
262    }
263
264    /// Primary body text.
265    pub fn text(&self) -> Color {
266        self.overrides.text.unwrap_or_else(|| match self.scheme {
267            ColorScheme::Light => self.color(ColorName::Dark, 9),
268            ColorScheme::Dark => self.color(ColorName::Dark, 0),
269        })
270    }
271
272    /// Secondary / dimmed text.
273    pub fn dimmed(&self) -> Color {
274        self.overrides.dimmed.unwrap_or_else(|| match self.scheme {
275            ColorScheme::Light => self.color(ColorName::Gray, 6),
276            ColorScheme::Dark => self.color(ColorName::Dark, 2),
277        })
278    }
279
280    /// Default border / divider color.
281    pub fn border(&self) -> Color {
282        self.overrides.border.unwrap_or_else(|| match self.scheme {
283            ColorScheme::Light => self.color(ColorName::Gray, 3),
284            ColorScheme::Dark => self.color(ColorName::Dark, 4),
285        })
286    }
287
288    /// The wash behind selected text. Every field and editor paints the same
289    /// one, so it lives here rather than as a `primary().alpha(..)` open-coded
290    /// per component — which is how the two that existed had already drifted
291    /// apart.
292    pub fn selection(&self) -> gpui::Hsla {
293        self.primary().alpha(0.30)
294    }
295
296    // --- Feedback accents (scheme-aware) ------------------------------------
297
298    /// Success / positive accent (confirmations, valid states).
299    pub fn success(&self) -> Color {
300        self.overrides
301            .success
302            .unwrap_or_else(|| self.color(ColorName::Green, self.primary_shade()))
303    }
304
305    /// Warning accent (caution states).
306    pub fn warning(&self) -> Color {
307        self.overrides
308            .warning
309            .unwrap_or_else(|| self.color(ColorName::Yellow, self.primary_shade()))
310    }
311
312    /// Danger / destructive accent (errors, deletes).
313    pub fn danger(&self) -> Color {
314        self.overrides
315            .danger
316            .unwrap_or_else(|| self.color(ColorName::Red, self.primary_shade()))
317    }
318
319    /// Informational accent (notices, hints).
320    pub fn info(&self) -> Color {
321        self.overrides
322            .info
323            .unwrap_or_else(|| self.color(ColorName::Cyan, self.primary_shade()))
324    }
325}
326
327/// Read the active theme. Panics if [`Theme::init`] was never called.
328pub fn theme(cx: &App) -> &Theme {
329    cx.global::<Theme>()
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn semantic_override_replaces_scheme_default() {
338        let base = Theme::light();
339        let themed = Theme::light()
340            .with_primary(css::rgb(200, 10, 10))
341            .with_body(css::css("#0b0b0f").unwrap());
342
343        assert!(themed.overrides.primary.is_some());
344        assert_ne!(themed.primary(), base.primary());
345        assert_ne!(themed.body(), base.body());
346        // Untouched semantics still fall back to the scheme default.
347        assert_eq!(themed.text(), base.text());
348    }
349}