Skip to main content

theme/theme/
mod.rs

1//! The app theme: one token set, two concrete instances.
2
3use gpui::{Global, Hsla, SharedString};
4
5use crate::{Appearance, paint};
6
7mod family;
8mod glass;
9pub(crate) mod install;
10mod layout;
11mod palettes;
12mod syntax;
13pub(crate) mod typography;
14
15pub use family::{Seed, ThemeFamily, Variant};
16pub use layout::{ControlSize, Sizing};
17pub use syntax::{HighlightKind, SyntaxPalette};
18pub use typography::{Metrics, TextStyle, Typeset, base_text_size};
19
20/// The two shipped glasses — SwiftUI's `Glass.regular` and `Glass.clear`. A
21/// closed variant rather than knobs: Apple exposes no numbers on glass either,
22/// only the variant and a tint.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Glass {
25    /// The everyday material: it blurs what it covers and dims it hard.
26    Regular,
27    /// Near-transparent — the backdrop reads through, bent only at the rim.
28    Clear,
29}
30
31/// SwiftUI's frost scale. Measured 2026-08-31: the five thicknesses are ONE
32/// material at five opacities — the tone implied by `tint / (1 - gain)` holds
33/// to within 9% across the scale, and the sigma does not move at all
34/// (23.1/20.2/21.0pt). So this is a knob, not five looks.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Material {
37    UltraThin,
38    Thin,
39    Regular,
40    Thick,
41    UltraThick,
42}
43
44impl Material {
45    /// How much of the backdrop the material covers. Measured off SwiftUI in
46    /// dark; the steps come out even to within a point.
47    pub const fn opacity(self) -> f32 {
48        match self {
49            Material::UltraThin => 0.440,
50            Material::Thin => 0.543,
51            Material::Regular => 0.638,
52            Material::Thick => 0.737,
53            Material::UltraThick => 0.825,
54        }
55    }
56}
57
58/// Which surface a caller names. Material and glass are different things with
59/// different vocabularies — a material has thickness, a glass has a variant —
60/// and they meet only at the numbers they resolve to.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SurfaceStyle {
63    Material(Material),
64    Glass(Glass),
65}
66
67/// The frost material, before a thickness picks its opacity.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct MaterialSpec {
70    /// The material's own tone, at full coverage.
71    pub tone: Hsla,
72    /// Its chroma push, as [`SurfaceSpec::saturation`].
73    pub saturation: f32,
74    /// Its sigma, which does not move with thickness.
75    pub blur: f32,
76    /// SwiftUI's frost has no lit rim at all — measured +0 at the boundary.
77    /// What is here is bezel's own hairline, the one the popover card used to
78    /// draw, moved to the surface that owes it.
79    pub edge: f32,
80    pub edge_width: f32,
81    pub edge_aa: f32,
82}
83
84impl MaterialSpec {
85    /// This material at one thickness.
86    pub fn at(&self, thickness: Material) -> SurfaceSpec {
87        let opacity = thickness.opacity();
88        SurfaceSpec {
89            gain: 1.0 - opacity,
90            saturation: self.saturation,
91            tint: self.tone.opacity(opacity),
92            blur: self.blur,
93            rim: 0.0,
94            reach: 0.0,
95            edge: self.edge,
96            edge_width: self.edge_width,
97            edge_aa: self.edge_aa,
98            // A wash has nothing to bend, so it needs one to lift off the page.
99            shadow: true,
100        }
101    }
102}
103
104/// One surface's numbers. `out = gain * saturated(backdrop) + tint`, over a
105/// backdrop blurred at `blur`.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct SurfaceSpec {
108    /// Slope of the transfer line. Below 1 compresses contrast toward
109    /// `tint`; above 1 it brightens and slightly expands, which is what light
110    /// `Clear` measures. Named for what it is, not for one of its directions.
111    pub gain: f32,
112    /// How far the backdrop's chroma is pushed from its own grey, before the
113    /// gain drops the level. A gain alone moves level and colour together, so
114    /// this is the only way a surface goes dark and keeps its colours. 1 is
115    /// the pass-through `Clear` measures.
116    pub saturation: f32,
117    /// Its offset — the look's own tone.
118    pub tint: Hsla,
119    /// Gaussian sigma under the lens, in logical pixels. It stands in for the
120    /// average attenuation of fine detail, which the real material gets from
121    /// snapshotting its backdrop coarsely.
122    pub blur: f32,
123    /// How far in from the rim the lens bends the backdrop, in logical pixels.
124    /// A length rather than a share of the box: the displacement curve measured
125    /// 2026-08-30 is one curve, the same from a 96pt box to a 320pt one and
126    /// from r24 to r84. Past it the backdrop is untouched.
127    pub rim: f32,
128    /// How far the outermost pixel drags what it samples, in logical pixels —
129    /// the far end of that same curve, measured at ~47pt.
130    pub reach: f32,
131    /// How much white the lit rim adds at the edge itself, 0..1.
132    pub edge: f32,
133    /// How far in that light falls off to nothing, in logical pixels. Even the
134    /// whole way round: measured 2026-08-30, the real rim reads the same on all
135    /// four sides.
136    pub edge_width: f32,
137    /// The coverage ramp at the shape's own boundary, in logical pixels. 0.5
138    /// is one device pixel at 2x — the plain rasteriser's answer; 0 is the hard
139    /// edge it replaced.
140    pub edge_aa: f32,
141    /// Whether the surface casts a shadow. A lens separates itself from the
142    /// page by bending it, and reads as a slab under one; a wash has nothing
143    /// to bend and needs it.
144    pub shadow: bool,
145}
146
147impl SurfaceSpec {
148    /// The one backdrop this surface returns unchanged — `tint / (1 - gain)`,
149    /// opaque. What a renderer with no backdrop blur paints in its place: the
150    /// tint alone is a coverage over a blur, and with no blur under it the
151    /// backdrop reads through as itself.
152    ///
153    /// `None` where the line has no such point — a `gain` of 1 or above
154    /// brightens whatever it covers, and a `tint` carrying more light than the
155    /// surface covers exceeds white.
156    ///
157    /// `tint` is [`Self::tint`] unless a caller substituted its own.
158    pub fn flat(&self, tint: Hsla) -> Option<Hsla> {
159        let coverage = 1.0 - self.gain;
160        let l = tint.l * tint.a / coverage;
161        (coverage > 0.0 && l <= 1.0).then_some(Hsla { l, a: 1.0, ..tint })
162    }
163}
164
165impl SurfaceStyle {
166    /// The numbers this style resolves to against a theme.
167    pub fn spec(self, theme: &Theme) -> SurfaceSpec {
168        match self {
169            SurfaceStyle::Material(thickness) => theme.material.at(thickness),
170            SurfaceStyle::Glass(Glass::Regular) => theme.glass_regular,
171            SurfaceStyle::Glass(Glass::Clear) => theme.glass_clear,
172        }
173    }
174}
175
176/// The app theme. Two concrete instances — [`Theme::dark`] and [`Theme::light`].
177#[derive(Debug, Clone)]
178pub struct Theme {
179    /// Which appearance these tokens were built for.
180    pub appearance: Appearance,
181    /// The [`ThemeFamily`] these tokens came from, by name. `None` is the
182    /// shipped palette, the only one a [`Brand`](crate::Brand)'s tint rotates.
183    pub family: Option<SharedString>,
184
185    // ---- paint: neutral surfaces ----
186    /// Main content panel. Dark: the deepest plane (#060606). Light: pure white —
187    /// long-form content reads best on an unbroken white field.
188    pub bg: Hsla,
189    /// Shell / sidebar surface. Dark: one step *up* from `bg`. Light: one step
190    /// *down* (grey) — chrome recedes from the content plane in both, which is
191    /// the direction a naive invert gets backwards.
192    pub surface: Hsla,
193    /// Raised surface: opaque pills and chips that sit proud of the panel.
194    /// Dark: lighter than `surface`. Light: white, separated by `border` +
195    /// shadow rather than by lightness.
196    pub surface_raised: Hsla,
197
198    // ---- paint: elevation ladder ----
199    //
200    // Dark mode distinguishes floating planes by lightness, and the steps are
201    // *small* (#0e → #10 → #16 → #1e). They are not interchangeable: collapsing
202    // them onto one token visibly lifts popovers off their intended plane.
203    //
204    // Light mode cannot use the same trick, because the content plane is already
205    // white and there is nothing lighter to climb to. All three land on white and
206    // let `border` + shadow carry the separation instead — the standard light-UI
207    // answer, and the reason this is a ladder of tokens rather than an arithmetic
208    // offset applied to one.
209    /// Inline card resting on the main panel (auth gate, empty-state cards).
210    pub surface_card: Hsla,
211    /// Modal dialog, floating over a [`Theme::scrim`].
212    pub surface_dialog: Hsla,
213    /// Popover, menu and command-palette surface — the highest plane.
214    pub surface_overlay: Hsla,
215    /// Hover wash for interactive rows and buttons, on glass and off it alike:
216    /// `../desktop`'s `--color-hover`.
217    pub element_hover: Hsla,
218    /// Active/selected wash, one rung over the hover — `--color-active`.
219    pub element_active: Hsla,
220    /// Faintest hairline: dividers inside a card, nested frames.
221    pub border_faint: Hsla,
222    /// Hairline border.
223    pub border: Hsla,
224    /// Stronger border for focused/raised edges.
225    pub border_strong: Hsla,
226
227    // ---- paint: text ----
228    /// Primary text. Translucent, like `text_muted` and `text_faint`: the
229    /// appearance's ink at the alpha [`Brand::ink`](crate::Brand::ink) sets.
230    pub text: Hsla,
231    /// Muted text: timestamps, secondary labels.
232    pub text_muted: Hsla,
233    /// Faint text: placeholders, disabled.
234    pub text_faint: Hsla,
235    /// One notch below `text_muted` — the diff file-path tone. It exists as its
236    /// own token rather than being folded into `text_muted` because the dark
237    /// value was sampled (#989898) and folding it would shift that label, which
238    /// is a palette change dressed up as a refactor.
239    pub text_dim: Hsla,
240
241    // ---- paint: high-contrast solid (primary buttons) ----
242    /// The maximum-contrast solid fill: near-white on dark, near-black on light.
243    /// This is the primary button plate.
244    pub solid: Hsla,
245    /// Label/icon color on top of [`Self::solid`] — its inverse.
246    pub on_solid: Hsla,
247
248    // ---- paint: accents ----
249    /// Accent — the emphasis weight for text and icons.
250    ///
251    /// **Neutral by default, and deliberately.** A component library that ships
252    /// a hue puts that hue in every app that installs it, and bezel had an
253    /// indigo running through spinners, pagination, date selection and list
254    /// markers before anyone chose it. The default is now the same lightness
255    /// with the chroma at zero.
256    ///
257    /// This is the token to brand:
258    ///
259    /// ```ignore
260    /// let mut theme = Theme::for_appearance(appearance);
261    /// theme.accent = my_brand_accent(appearance);
262    /// ```
263    ///
264    /// See [`crate::AppExt::set_palette`](crate::AppExt::set_palette), which is what
265    /// makes an override survive an appearance switch.
266    pub accent: Hsla,
267    /// Stronger accent for fills that carry [`Self::on_accent`] text. Neutral by
268    /// default, it is the maximum-contrast plate — a mid grey would not carry a
269    /// label the way the indigo it replaced did.
270    pub accent_strong: Hsla,
271    /// Label color on top of [`Self::accent_strong`].
272    pub on_accent: Hsla,
273    /// Danger — red (errors, stop button).
274    pub danger: Hsla,
275    /// Softer danger for secondary/inline error copy.
276    pub danger_muted: Hsla,
277    /// Warning — amber (offline notices, awaiting-input).
278    pub warning: Hsla,
279    /// Softer warning for secondary copy.
280    pub warning_muted: Hsla,
281    /// Success / online — emerald.
282    pub success: Hsla,
283    /// Working / streaming indicator — pink.
284    pub busy: Hsla,
285    /// Softer success for text on a success-tinted chip.
286    pub success_muted: Hsla,
287
288    // ---- paint: components ----
289    /// Hover tone for an *opaque* raised pill. Hover must brighten the plate in
290    /// dark mode, never swap it for a translucent wash (that made pills go
291    /// see-through — user-reported); in light mode it darkens instead, same idea.
292    pub surface_raised_hover: Hsla,
293    /// Recessed band behind a palette/picker header or footer strip. Translucent
294    /// so the glass still reads through.
295    pub band: Hsla,
296    /// The composer pill and other input plates.
297    ///
298    /// Its own token because "lifted" inverts between appearances. On dark, a
299    /// faint *white* wash over near-black reads as raised. The literal light
300    /// translation — a faint *black* wash on white — reads as **recessed**, a dent
301    /// rather than a plate, which is why the prompt looked like bare text on a
302    /// smudge. Light mode lifts the way light UIs actually do: pure white, with
303    /// the border and shadow carrying the elevation.
304    pub input_bg: Hsla,
305    /// Text-selection highlight in the composer and inputs.
306    ///
307    /// Opaque, painted under the text. Measured macOS 26, 2026-09-27, default
308    /// accent: `NSColor.selectedTextBackgroundColor` is `#B3D7FF` light and
309    /// `#3F638B` dark.
310    pub selection: Hsla,
311    /// Terminal block cursor. Opaque: the glyph under it is drawn in the
312    /// cell's background colour.
313    pub cursor: Hsla,
314    /// Text caret — [`Self::accent`]'s lightness.
315    ///
316    /// Measured macOS 26, 2026-08-31: `NSColor.textInsertionPointColor` is the
317    /// accent in both appearances, where `NSColor.textColor` is white and
318    /// black. So a caret is not the next glyph before you type it, which is
319    /// what this used to carry; the platform gives it its own role, and a brand
320    /// tints it here the way the system accent tints it there.
321    pub caret: Hsla,
322    /// Keyboard focus ring — a hairline, so it marks the control without
323    /// restating the label inside it.
324    pub ring: Hsla,
325    /// Where a dragged block would land in the block editor.
326    pub drop_line: Hsla,
327    /// Fill of the region a dragged pane would dock into, where no material
328    /// paints it. Translucent: the pane under it stays visible.
329    pub drop_target: Hsla,
330    /// Destructive-action button fill (danger plate, carries [`Self::on_accent`]).
331    pub danger_strong: Hsla,
332
333    // ---- paint: code & diff ----
334    /// Inline-code text. Neutral: code is already set apart by the mono face
335    /// and its wash, and a hue on top reads as a link rather than as code.
336    pub code_text: Hsla,
337    /// Inline-code wash behind [`Self::code_text`].
338    pub code_wash: Hsla,
339    /// Shared paint-only syntax palette.
340    pub syntax: SyntaxPalette,
341    /// Diff: added lines.
342    pub diff_add: Hsla,
343    /// Diff: deleted lines.
344    pub diff_del: Hsla,
345    /// Diff: hunk-header wash (bluish grey).
346    pub diff_hunk_bg: Hsla,
347
348    // ---- paint: terminal ----
349    /// The terminal grid's background, behind cells that paint none.
350    pub terminal_bg: Hsla,
351    /// The 16 named ANSI colours: 0-7 normal, 8-15 bright. Indexes 16-255 —
352    /// the xterm cube and grey ramp — are not part of a palette.
353    pub terminal_ansi: [Hsla; 16],
354
355    // ---- glass ----
356    //
357    // Numbers, so they flow with the appearance the way every other token
358    // does. `Glass::glass_effect` reads them off the theme it is handed;
359    // nothing here is a parameter on a component.
360    /// How opaque the tint over the blurred window is.
361    pub vibrancy_alpha: f32,
362    /// The fill of a floating content card over a translucent window, at full
363    /// coverage — see [`Self::content_bg`].
364    pub content_background: Hsla,
365    /// How much of the chrome [`Self::content_bg`] covers.
366    pub content_opacity: f32,
367    /// The tint over the blurred window, at full coverage — see
368    /// [`Self::vibrancy_tint`].
369    pub vibrancy_tone: Hsla,
370    /// The blur behind a translucent window, in native filter pixels with no
371    /// scale conversion. Zero leaves the window transparent and unblurred.
372    /// macOS alone; other platforms ignore it.
373    pub window_blur: f32,
374    /// Whether the window composites translucent, so the desktop reaches what
375    /// is painted over it — AppKit's vibrancy.
376    pub vibrancy: bool,
377    /// Whether components paint glass — translucent popovers and cards, and
378    /// the lens. An opaque window can still carry it.
379    pub glass: bool,
380    /// The surfaces this theme can paint. Blur belongs to the look, not to
381    /// the caller: Apple exposes no blur parameter on either family, only the
382    /// thickness or the variant.
383    pub material: MaterialSpec,
384    pub glass_regular: SurfaceSpec,
385    pub glass_clear: SurfaceSpec,
386    /// What the popover surfaces — menus, dialogs, sheets, tooltips — mount
387    /// on. They take no theme of their own, so this is where the choice
388    /// lives; a component that owns its surface names its own style instead.
389    pub popover_surface: SurfaceStyle,
390    /// What a docking drop preview paints on where [`LENSED`](crate::LENSED)
391    /// and [`Theme::glass`] hold; elsewhere it is a [`Theme::drop_target`]
392    /// wash.
393    pub drop_preview: SurfaceStyle,
394    /// What a dragged item's floating copy paints on — see
395    /// `ui::drag::Carried::ghost`.
396    pub carried_surface: SurfaceStyle,
397    /// Lens displacement amplitude, signed; negative inverts it.
398    pub glass_magnify: f32,
399    /// Per-channel spread of that displacement — the chromatic fringe.
400    pub glass_dispersion: f32,
401
402    // ---- fonts ----
403    /// UI font family — the name the text system resolves, not the bytes.
404    /// Defaults to the platform's own UI face; point it at your own family
405    /// once you have registered that font with the text system.
406    pub font_sans: SharedString,
407    /// The family prose is set in — what `markdown` shapes a document's body
408    /// with. Defaults to [`Self::font_sans`]; an app that sets it apart gives
409    /// documents a reading face while the chrome keeps the UI one.
410    pub font_body: SharedString,
411    /// Monospace family for code/terminal.
412    pub font_mono: SharedString,
413}
414
415impl Theme {
416    /// Selection wash resolved against this palette, including local previews.
417    pub fn card_selected_bg(&self) -> Hsla {
418        paint::card_selected_bg_for(self.appearance)
419    }
420
421    /// A list row's text at rest: [`Self::text`] selected, else
422    /// [`Self::text_faint`] archived, else [`Self::text_muted`].
423    pub fn row_text(&self, selected: bool, archived: bool) -> Hsla {
424        match (selected, archived) {
425            (true, _) => self.text,
426            (false, true) => self.text_faint,
427            (false, false) => self.text_muted,
428        }
429    }
430
431    /// Overlay ink at `alpha` — see [`ink`](crate::paint::ink).
432    pub fn ink(&self, alpha: f32) -> Hsla {
433        paint::ink_for(self.appearance, alpha)
434    }
435
436    /// The colour at `index` of the categorical set — a chart's series, a
437    /// label's tint: the accent, then the status colours, then hues between
438    /// them. Any index is valid; the set wraps.
439    pub fn categorical(&self, index: usize) -> Hsla {
440        let hue = |color: Hsla, degrees: f32| Hsla {
441            h: degrees / 360.0,
442            ..color
443        };
444        let palette = [
445            self.accent,
446            self.success,
447            self.warning,
448            self.danger,
449            hue(self.success, 185.0),
450            hue(self.accent, 270.0),
451            hue(self.danger, 330.0),
452            hue(self.warning, 50.0),
453        ];
454        palette[index % palette.len()]
455    }
456
457    /// Hairline ink at `alpha` — see [`hairline`](crate::paint::hairline).
458    pub fn hairline(&self, alpha: f32) -> Hsla {
459        paint::hairline_for(self.appearance, alpha)
460    }
461
462    /// State wash at `alpha` — see [`wash`](crate::paint::wash).
463    pub fn wash(&self, alpha: f32) -> Hsla {
464        paint::wash_for(self.appearance, alpha)
465    }
466}
467
468impl Default for Theme {
469    fn default() -> Self {
470        Self::dark()
471    }
472}
473
474impl Global for Theme {}