Skip to main content

theme/
brand.rs

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