Skip to main content

theme/
brand.rs

1//! Brand: one hue for the greys, one for the accent, one radius, and the
2//! text ink.
3//!
4//! Apart from the text ink, a brand moves hue alone: every other lightness in
5//! `palettes.rs` stays as the palette set it.
6
7use gpui::{App, Global, Hsla};
8
9use crate::{Appearance, color, theme::Theme};
10
11/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
12/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
13#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub struct Tint {
15    /// oklch hue, in degrees.
16    pub hue: f32,
17    /// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
18    pub chroma: f32,
19}
20
21impl Tint {
22    pub const NONE: Self = Self {
23        hue: 0.0,
24        chroma: 0.0,
25    };
26
27    pub const fn new(hue: f32, chroma: f32) -> Self {
28        Self { hue, chroma }
29    }
30}
31
32/// The greys a UI is built on, as oklch hue and chroma.
33///
34/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors,
35/// read 2026-08-24) — the same list shadcn offers as its base colour, and the
36/// reason these are quoted rather than invented: a neutral that carries hue is
37/// a judgement someone else has already made five times.
38pub const BASE_COLORS: [(&str, Tint); 5] = [
39    ("Neutral", Tint::NONE),
40    ("Stone", Tint::new(58.071, 0.013)),
41    ("Zinc", Tint::new(285.938, 0.016)),
42    ("Gray", Tint::new(264.364, 0.027)),
43    ("Slate", Tint::new(257.417, 0.046)),
44];
45
46/// The alpha of each text role over the appearance's ink — white in dark,
47/// black in light.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct TextInk {
50    /// [`Theme::text`].
51    pub text: f32,
52    /// [`Theme::text_muted`].
53    pub muted: f32,
54    /// [`Theme::text_faint`].
55    pub faint: f32,
56}
57
58impl TextInk {
59    /// The role at `alpha` over `appearance`'s ink.
60    pub fn color(appearance: Appearance, alpha: f32) -> Hsla {
61        match appearance {
62            Appearance::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
63            Appearance::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
64        }
65    }
66
67    /// Writes [`Theme::text`], [`Theme::text_muted`] and [`Theme::text_faint`]
68    /// for the theme's appearance.
69    pub fn paint(&self, theme: &mut Theme) {
70        let appearance = theme.appearance;
71        theme.text = Self::color(appearance, self.text);
72        theme.text_muted = Self::color(appearance, self.muted);
73        theme.text_faint = Self::color(appearance, self.faint);
74    }
75}
76
77/// Text ink for both appearances.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct Ink {
80    pub dark: TextInk,
81    pub light: TextInk,
82}
83
84impl Ink {
85    /// AppKit's label, secondary and tertiary label colours. Muted is under
86    /// 4.5:1 on white, and faint is under 3:1 in both appearances.
87    pub const APPKIT: Self = Self {
88        dark: TextInk {
89            text: 0.85,
90            muted: 0.55,
91            faint: 0.25,
92        },
93        light: TextInk {
94            text: 0.85,
95            muted: 0.50,
96            faint: 0.26,
97        },
98    };
99
100    /// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
101    /// [`Theme::bg`] and [`Theme::surface`] in both appearances.
102    pub const ACCESSIBLE: Self = Self {
103        dark: TextInk {
104            text: 0.85,
105            muted: 0.52,
106            faint: 0.40,
107        },
108        light: TextInk {
109            text: 0.85,
110            muted: 0.56,
111            faint: 0.43,
112        },
113    };
114
115    pub fn for_appearance(&self, appearance: Appearance) -> TextInk {
116        match appearance {
117            Appearance::Dark => self.dark,
118            Appearance::Light => self.light,
119        }
120    }
121}
122
123/// Whether the window composites translucent — AppKit's vibrancy, and Mica.
124///
125/// Three-valued rather than a bool because the honest answer depends on the
126/// appearance, and the appearance moves under the app: the OS switches at
127/// sunset. A bool would have to be rewritten by whoever noticed, and nothing
128/// outside this crate is told when a palette is installed.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum Vibrancy {
131    /// Frost where the palette is built for it, which is dark alone.
132    ///
133    /// Light's glass tokens sit near 85% opacity — see [`Theme::glass_overlay`],
134    /// [`Theme::card_glass_bg`] and [`Theme::input_glass_bg`], each tuned there
135    /// because a translucent white tint left text ghosting over whatever sat
136    /// behind it. Light pays for the blur and shows almost none of it, so it is
137    /// not asked for unless an app says so.
138    #[default]
139    Auto,
140    /// Frost in both appearances. What an app asks for when it has tuned its
141    /// own light palette to carry one.
142    On,
143    /// Opaque in both. What a Reduce-transparency switch sets.
144    Off,
145}
146
147impl Vibrancy {
148    /// Whether to composite translucent for `appearance`.
149    ///
150    /// The platform gate rides on [`Vibrancy::Auto`] rather than on the caller:
151    /// it asks for a frost only where the compositor paints one behind the
152    /// window ([`frosted_window`](crate::frosted_window)), since a window that is merely transparent
153    /// shows raw desktop through the sidebar. An app that knows its compositor
154    /// says [`Vibrancy::On`] and gets it anywhere.
155    pub fn on(self, appearance: Appearance) -> bool {
156        match self {
157            Self::Auto => crate::frosted_window() && matches!(appearance, Appearance::Dark),
158            Self::On => true,
159            Self::Off => false,
160        }
161    }
162}
163
164/// What an app changes about the shipped palette without redesigning it.
165///
166/// Installed as a gpui [`Global`]; [`Theme::install`] applies it to whatever
167/// palette is registered, so it survives a light/dark switch and composes with
168/// [`crate::AppExt::set_palette`](crate::AppExt::set_palette) rather than competing with it.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub struct Brand {
171    /// The hue every grey in the palette carries.
172    pub tint: Tint,
173    /// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
174    /// any other grey — which is what the shipped palette already is.
175    pub accent: Tint,
176    /// The base corner radius; every other corner is a ratio of it. See
177    /// [`Theme::BASE_RADIUS`].
178    pub radius: f32,
179    /// How opaque the tint over the blurred window is. See
180    /// [`Theme::VIBRANCY_ALPHA`], where it starts, and [`Theme::vibrancy_tint`].
181    pub vibrancy_alpha: f32,
182    /// The blur behind the translucent window. See [`Theme::window_blur`].
183    pub window_blur: f32,
184    /// Whether the window composites translucent, which is a question about
185    /// the appearance as much as about the app — see [`Vibrancy`].
186    pub vibrancy: Vibrancy,
187    /// Whether components paint glass. Separate from [`Self::vibrancy`]:
188    /// SwiftUI's material blends within the window, so an opaque window can
189    /// still carry glass — which is what a Reduce-transparency setting asks
190    /// for and the system's own does not do. The converse is a platform
191    /// answer: a Mica window carries no glass, since the blur a card lays over
192    /// the content it covers is [`LENSED`](crate::LENSED) and Mica is not.
193    pub glass: bool,
194    /// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
195    pub ink: Ink,
196}
197
198impl Global for Brand {}
199
200impl Default for Brand {
201    fn default() -> Self {
202        Self {
203            tint: Tint::NONE,
204            accent: Tint::NONE,
205            radius: Theme::BASE_RADIUS,
206            vibrancy_alpha: Theme::VIBRANCY_ALPHA,
207            window_blur: Theme::WINDOW_BLUR,
208            vibrancy: Vibrancy::Auto,
209            glass: crate::LENSED,
210            ink: Ink::APPKIT,
211        }
212    }
213}
214
215/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
216/// the two steps `palettes.rs` picked so an accent clears WCAG AA on its own
217/// background rather than glowing on one and vanishing on the other.
218const ACCENT_L: (f32, f32) = (0.673, 0.511);
219
220/// The lightness of a *plate* carrying [`Theme::on_accent`], taken from
221/// `danger_strong` — the palette's existing chromatic plate, already tuned to
222/// hold a label in both appearances.
223const PLATE_L: (f32, f32) = (0.58, 0.51);
224
225impl Theme {
226    /// The shipped palette for an appearance, rotated onto a brand. What
227    /// [`Theme::install`] builds, without installing it — for previewing the
228    /// appearance you are not currently painting.
229    pub fn branded(brand: &Brand, appearance: Appearance) -> Self {
230        let mut theme = Self::for_appearance(appearance);
231        brand.apply(&mut theme);
232        theme
233    }
234}
235
236impl Brand {
237    /// Rotate a palette onto this brand's hues.
238    pub fn apply(&self, theme: &mut Theme) {
239        // The one place the appearance and the brand are both in hand, and it
240        // already runs on every install — which is every light/dark switch,
241        // from the OS or from a person. Resolving here is what keeps vibrancy
242        // right across a sunset without anyone outside watching for one.
243        theme.vibrancy = self.vibrancy.on(theme.appearance);
244        // Left where the brand put it, even when the window is opaque: every
245        // reader of the tint is behind a `vibrancy` gate already
246        // ([`Theme::window_bg`]), and an app that turns the frost back on
247        // wants the coverage it tuned, not a value that was overwritten while
248        // nothing was looking at it.
249        theme.vibrancy_alpha = self.vibrancy_alpha;
250        theme.window_blur = self.window_blur;
251        theme.glass = self.glass;
252        self.ink.for_appearance(theme.appearance).paint(theme);
253        // Every colour token, with the rule doing the choosing: a token that is
254        // already grey takes the tint, and one that already carries a hue —
255        // danger, warning, success — is semantic and keeps it. Translucent ink
256        // is skipped because it paints over whatever is beneath it, which is
257        // tinted already.
258        let tokens: [&mut Hsla; 39] = [
259            &mut theme.bg,
260            &mut theme.surface,
261            &mut theme.surface_raised,
262            &mut theme.surface_card,
263            &mut theme.surface_dialog,
264            &mut theme.surface_overlay,
265            &mut theme.element_hover,
266            &mut theme.element_active,
267            &mut theme.border,
268            &mut theme.border_strong,
269            &mut theme.text,
270            &mut theme.text_muted,
271            &mut theme.text_faint,
272            &mut theme.text_dim,
273            &mut theme.solid,
274            &mut theme.on_solid,
275            &mut theme.accent,
276            &mut theme.accent_strong,
277            &mut theme.on_accent,
278            &mut theme.danger,
279            &mut theme.danger_muted,
280            &mut theme.warning,
281            &mut theme.warning_muted,
282            &mut theme.success,
283            &mut theme.busy,
284            &mut theme.success_muted,
285            &mut theme.surface_raised_hover,
286            &mut theme.band,
287            &mut theme.input_bg,
288            &mut theme.selection,
289            &mut theme.cursor,
290            &mut theme.caret,
291            &mut theme.ring,
292            &mut theme.danger_strong,
293            &mut theme.code_text,
294            &mut theme.code_wash,
295            &mut theme.diff_add,
296            &mut theme.diff_del,
297            &mut theme.diff_hunk_bg,
298        ];
299        let syntax: [&mut Hsla; 24] = [
300            &mut theme.syntax.comment,
301            &mut theme.syntax.keyword,
302            &mut theme.syntax.string,
303            &mut theme.syntax.string_special,
304            &mut theme.syntax.escape,
305            &mut theme.syntax.number,
306            &mut theme.syntax.boolean,
307            &mut theme.syntax.type_name,
308            &mut theme.syntax.type_builtin,
309            &mut theme.syntax.constructor,
310            &mut theme.syntax.function,
311            &mut theme.syntax.function_builtin,
312            &mut theme.syntax.macro_name,
313            &mut theme.syntax.property,
314            &mut theme.syntax.constant,
315            &mut theme.syntax.variable,
316            &mut theme.syntax.variable_special,
317            &mut theme.syntax.parameter,
318            &mut theme.syntax.operator,
319            &mut theme.syntax.punctuation,
320            &mut theme.syntax.tag,
321            &mut theme.syntax.attribute,
322            &mut theme.syntax.label,
323            &mut theme.syntax.invalid,
324        ];
325        for slot in tokens.into_iter().chain(syntax) {
326            if slot.a == 1.0 && slot.s <= f32::EPSILON {
327                *slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
328            }
329        }
330
331        if self.accent.chroma > 0.0 {
332            let light = theme.appearance == Appearance::Light;
333            let (accent_l, plate_l) = if light {
334                (ACCENT_L.1, PLATE_L.1)
335            } else {
336                (ACCENT_L.0, PLATE_L.0)
337            };
338            theme.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
339            theme.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
340            // Whichever label the plate can actually hold. The shipped accent is
341            // the maximum-contrast neutral, where the answer is always the
342            // inverse; a chromatic plate at a yellow hue is bright enough that
343            // the inverse would be the unreadable one.
344            theme.on_accent = label_on(theme.accent_strong, theme);
345        }
346    }
347}
348
349/// Whichever of the palette's two extremes the plate can actually hold.
350fn label_on(plate: Hsla, theme: &Theme) -> Hsla {
351    let (a, b) = (theme.solid, theme.on_solid);
352    if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
353        a
354    } else {
355        b
356    }
357}
358
359/// Read the installed brand (the default before one is set).
360pub(crate) fn brand(cx: &App) -> Brand {
361    cx.try_global::<Brand>().copied().unwrap_or_default()
362}
363
364/// Install a brand and repaint every window with it.
365///
366/// Colours are read imperatively at paint time, so nothing observes the theme
367/// global — the same reason [`appearance::apply`](crate::appearance::apply)
368/// refreshes rather than notifies.
369pub(crate) fn set_brand(brand: Brand, cx: &mut App) {
370    cx.set_global(brand);
371    Theme::install(crate::paint::current_appearance(), cx);
372    // Crossing 1.0 is what puts the `NSVisualEffectView` in or takes it out,
373    // and nothing else does it — a repaint alone leaves a window that was
374    // opaque at boot opaque forever.
375    crate::appearance::reapply_window_background(cx);
376    cx.refresh_windows();
377}