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