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