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
232      .overrides
233      .primary
234      .unwrap_or_else(|| self.color(self.primary_color, self.primary_shade()))
235  }
236
237  // --- Semantic colors (scheme-aware) ------------------------------------
238
239  /// The app/window background.
240  pub fn body(&self) -> Color {
241    self.overrides.body.unwrap_or_else(|| match self.scheme {
242      ColorScheme::Light => self.white,
243      ColorScheme::Dark => self.color(ColorName::Dark, 7),
244    })
245  }
246
247  /// Raised surface background (Paper, Card, Menu, ...).
248  pub fn surface(&self) -> Color {
249    self.overrides.surface.unwrap_or_else(|| match self.scheme {
250      ColorScheme::Light => self.white,
251      ColorScheme::Dark => self.color(ColorName::Dark, 6),
252    })
253  }
254
255  /// A subtly recessed/hover fill.
256  pub fn surface_hover(&self) -> Color {
257    self
258      .overrides
259      .surface_hover
260      .unwrap_or_else(|| match self.scheme {
261        ColorScheme::Light => self.color(ColorName::Gray, 0),
262        ColorScheme::Dark => self.color(ColorName::Dark, 5),
263      })
264  }
265
266  /// Primary body text.
267  pub fn text(&self) -> Color {
268    self.overrides.text.unwrap_or_else(|| match self.scheme {
269      ColorScheme::Light => self.color(ColorName::Dark, 9),
270      ColorScheme::Dark => self.color(ColorName::Dark, 0),
271    })
272  }
273
274  /// Secondary / dimmed text.
275  pub fn dimmed(&self) -> Color {
276    self.overrides.dimmed.unwrap_or_else(|| match self.scheme {
277      ColorScheme::Light => self.color(ColorName::Gray, 6),
278      ColorScheme::Dark => self.color(ColorName::Dark, 2),
279    })
280  }
281
282  /// Default border / divider color.
283  pub fn border(&self) -> Color {
284    self.overrides.border.unwrap_or_else(|| match self.scheme {
285      ColorScheme::Light => self.color(ColorName::Gray, 3),
286      ColorScheme::Dark => self.color(ColorName::Dark, 4),
287    })
288  }
289
290  /// The wash behind selected text. Every field and editor paints the same
291  /// one, so it lives here rather than as a `primary().alpha(..)` open-coded
292  /// per component — which is how the two that existed had already drifted
293  /// apart.
294  pub fn selection(&self) -> gpui::Hsla {
295    self.primary().alpha(0.30)
296  }
297
298  // --- Feedback accents (scheme-aware) ------------------------------------
299
300  /// Success / positive accent (confirmations, valid states).
301  pub fn success(&self) -> Color {
302    self
303      .overrides
304      .success
305      .unwrap_or_else(|| self.color(ColorName::Green, self.primary_shade()))
306  }
307
308  /// Warning accent (caution states).
309  pub fn warning(&self) -> Color {
310    self
311      .overrides
312      .warning
313      .unwrap_or_else(|| self.color(ColorName::Yellow, self.primary_shade()))
314  }
315
316  /// Danger / destructive accent (errors, deletes).
317  pub fn danger(&self) -> Color {
318    self
319      .overrides
320      .danger
321      .unwrap_or_else(|| self.color(ColorName::Red, self.primary_shade()))
322  }
323
324  /// Informational accent (notices, hints).
325  pub fn info(&self) -> Color {
326    self
327      .overrides
328      .info
329      .unwrap_or_else(|| self.color(ColorName::Cyan, self.primary_shade()))
330  }
331}
332
333/// Read the active theme. Panics if [`Theme::init`] was never called.
334pub fn theme(cx: &App) -> &Theme {
335  cx.global::<Theme>()
336}
337
338#[cfg(test)]
339mod tests {
340  use super::*;
341
342  #[test]
343  fn semantic_override_replaces_scheme_default() {
344    let base = Theme::light();
345    let themed = Theme::light()
346      .with_primary(css::rgb(200, 10, 10))
347      .with_body(css::css("#0b0b0f").unwrap());
348
349    assert!(themed.overrides.primary.is_some());
350    assert_ne!(themed.primary(), base.primary());
351    assert_ne!(themed.body(), base.body());
352    // Untouched semantics still fall back to the scheme default.
353    assert_eq!(themed.text(), base.text());
354  }
355}