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/// What an app changes about the shipped palette without redesigning it.
49///
50/// Installed as a gpui [`Global`]; [`Theme::install`] applies it to whatever
51/// palette is registered, so it survives a light/dark switch and composes with
52/// [`set_palette`](crate::set_palette) rather than competing with it.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Brand {
55 /// The hue every grey in the palette carries.
56 pub tint: Tint,
57 /// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
58 /// any other grey — which is what the shipped palette already is.
59 pub accent: Tint,
60 /// The base corner radius; every other corner is a ratio of it. See
61 /// [`Theme::BASE_RADIUS`].
62 pub radius: f32,
63 /// How opaque the frost over the blurred window is — `1.0` is opaque, and
64 /// turns glass off entirely. See [`Theme::GLASS_ALPHA`], which is where it
65 /// starts, and [`Theme::glass`].
66 pub glass: f32,
67}
68
69impl Global for Brand {}
70
71impl Default for Brand {
72 fn default() -> Self {
73 Self {
74 tint: Tint::NONE,
75 accent: Tint::NONE,
76 radius: Theme::BASE_RADIUS,
77 glass: Theme::GLASS_ALPHA,
78 }
79 }
80}
81
82/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
83/// the two steps `palettes.rs` picked so an accent clears WCAG AA on its own
84/// background rather than glowing on one and vanishing on the other.
85const ACCENT_L: (f32, f32) = (0.673, 0.511);
86
87/// The lightness of a *plate* carrying [`Theme::on_accent`], taken from
88/// `danger_strong` — the palette's existing chromatic plate, already tuned to
89/// hold a label in both appearances.
90const PLATE_L: (f32, f32) = (0.58, 0.51);
91
92impl Theme {
93 /// The shipped palette for an appearance, rotated onto a brand. What
94 /// [`Theme::install`] builds, without installing it — for previewing the
95 /// appearance you are not currently painting.
96 pub fn branded(brand: &Brand, appearance: Appearance) -> Self {
97 let mut theme = Self::for_appearance(appearance);
98 brand.apply(&mut theme);
99 theme
100 }
101}
102
103impl Brand {
104 /// Rotate a palette onto this brand's hues.
105 pub fn apply(&self, theme: &mut Theme) {
106 // Every colour token, with the rule doing the choosing: a token that is
107 // already grey takes the tint, and one that already carries a hue —
108 // danger, warning, success — is semantic and keeps it. Translucent ink
109 // is skipped because it paints over whatever is beneath it, which is
110 // tinted already.
111 let tokens: [&mut Hsla; 39] = [
112 &mut theme.bg,
113 &mut theme.surface,
114 &mut theme.surface_raised,
115 &mut theme.surface_card,
116 &mut theme.surface_dialog,
117 &mut theme.surface_overlay,
118 &mut theme.element_hover,
119 &mut theme.element_active,
120 &mut theme.border,
121 &mut theme.border_strong,
122 &mut theme.text,
123 &mut theme.text_muted,
124 &mut theme.text_faint,
125 &mut theme.text_dim,
126 &mut theme.solid,
127 &mut theme.on_solid,
128 &mut theme.accent,
129 &mut theme.accent_strong,
130 &mut theme.on_accent,
131 &mut theme.danger,
132 &mut theme.danger_muted,
133 &mut theme.warning,
134 &mut theme.warning_muted,
135 &mut theme.success,
136 &mut theme.busy,
137 &mut theme.success_muted,
138 &mut theme.surface_raised_hover,
139 &mut theme.band,
140 &mut theme.input_bg,
141 &mut theme.selection,
142 &mut theme.cursor,
143 &mut theme.caret,
144 &mut theme.ring,
145 &mut theme.danger_strong,
146 &mut theme.code_text,
147 &mut theme.code_wash,
148 &mut theme.diff_add,
149 &mut theme.diff_del,
150 &mut theme.diff_hunk_bg,
151 ];
152 let syntax: [&mut Hsla; 24] = [
153 &mut theme.syntax.comment,
154 &mut theme.syntax.keyword,
155 &mut theme.syntax.string,
156 &mut theme.syntax.string_special,
157 &mut theme.syntax.escape,
158 &mut theme.syntax.number,
159 &mut theme.syntax.boolean,
160 &mut theme.syntax.type_name,
161 &mut theme.syntax.type_builtin,
162 &mut theme.syntax.constructor,
163 &mut theme.syntax.function,
164 &mut theme.syntax.function_builtin,
165 &mut theme.syntax.macro_name,
166 &mut theme.syntax.property,
167 &mut theme.syntax.constant,
168 &mut theme.syntax.variable,
169 &mut theme.syntax.variable_special,
170 &mut theme.syntax.parameter,
171 &mut theme.syntax.operator,
172 &mut theme.syntax.punctuation,
173 &mut theme.syntax.tag,
174 &mut theme.syntax.attribute,
175 &mut theme.syntax.label,
176 &mut theme.syntax.invalid,
177 ];
178 for slot in tokens.into_iter().chain(syntax) {
179 if slot.a == 1.0 && slot.s <= f32::EPSILON {
180 *slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
181 }
182 }
183
184 if self.accent.chroma > 0.0 {
185 let light = theme.appearance == Appearance::Light;
186 let (accent_l, plate_l) = if light {
187 (ACCENT_L.1, PLATE_L.1)
188 } else {
189 (ACCENT_L.0, PLATE_L.0)
190 };
191 theme.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
192 theme.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
193 // Whichever label the plate can actually hold. The shipped accent is
194 // the maximum-contrast neutral, where the answer is always the
195 // inverse; a chromatic plate at a yellow hue is bright enough that
196 // the inverse would be the unreadable one.
197 theme.on_accent = label_on(theme.accent_strong, theme);
198 }
199 }
200}
201
202/// Whichever of the palette's two extremes the plate can actually hold.
203fn label_on(plate: Hsla, theme: &Theme) -> Hsla {
204 let (a, b) = (theme.solid, theme.on_solid);
205 if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
206 a
207 } else {
208 b
209 }
210}
211
212/// Read the installed brand (the default before one is set).
213pub fn brand(cx: &App) -> Brand {
214 cx.try_global::<Brand>().copied().unwrap_or_default()
215}
216
217/// Install a brand and repaint every window with it.
218///
219/// Colours are read imperatively at paint time, so nothing observes the theme
220/// global — the same reason [`appearance::apply`](crate::appearance::apply)
221/// refreshes rather than notifies.
222pub fn set_brand(brand: Brand, cx: &mut App) {
223 cx.set_global(brand);
224 Theme::install(crate::paint::current_appearance(), cx);
225 // Crossing 1.0 is what puts the `NSVisualEffectView` in or takes it out,
226 // and nothing else does it — a repaint alone leaves a window that was
227 // opaque at boot opaque forever.
228 crate::appearance::reapply_window_background(cx);
229 cx.refresh_windows();
230}