Skip to main content

theme/
paint.rs

1//! Context-free paint helpers: the process-wide appearance mirror and the
2//! free functions ([`ink`], [`hairline`], [`wash`], …) that element builders
3//! call without a `cx` in scope.
4
5use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
6
7use gpui::{BoxShadow, Hsla, hsla, point, px};
8
9use crate::Appearance;
10
11/// Process-wide mirror of the installed theme's appearance.
12///
13/// The paint helpers ([`ink`], [`hairline`], [`wash`], …) are free functions
14/// called from deep inside element builders that have no `cx` in scope, so they
15/// read the appearance from here instead of the gpui global. Appearance is
16/// genuinely process-wide — one setting for every window — so a single mirror is
17/// sound; [`Theme::install`](crate::theme::Theme::install) is the only writer
18/// outside tests.
19static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
20
21/// Bumped every time a palette is installed — an appearance switch, and equally
22/// a brand change, which moves every token while the appearance stands still.
23///
24/// Anything that caches *resolved colors* — most importantly the markdown
25/// renderer's cross-frame `TextRun` cache, which bakes an `Hsla` into every run —
26/// is only valid for the palette that produced it. Those caches were written when
27/// the theme was a compile-time constant, so their validity keys cover content
28/// only. Rather than thread the palette through every key, they compare this
29/// counter and drop everything when it moves.
30static THEME_GENERATION: AtomicU32 = AtomicU32::new(0);
31
32/// The appearance the context-free paint helpers are painting for.
33pub fn current_appearance() -> Appearance {
34    match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
35        1 => Appearance::Light,
36        _ => Appearance::Dark,
37    }
38}
39
40/// Monotonic id of the current palette.
41pub fn theme_generation() -> u32 {
42    THEME_GENERATION.load(Ordering::Relaxed)
43}
44
45/// [`CURRENT_APPEARANCE`] is process-wide, so under the parallel test runner
46/// any test that flips it — or asserts on the output of a helper that reads it
47/// ([`ink`], [`hairline`], [`wash`], …) — must hold this lock. Crate-visible
48/// because such tests exist outside this module too (see motion's tests).
49/// Tests that flip the appearance restore Dark before releasing the guard.
50/// Compiled unconditionally (not `cfg(test)`) so downstream crates' tests can
51/// use it too; it is not part of the public API.
52#[doc(hidden)]
53pub fn lock_appearance() -> std::sync::MutexGuard<'static, ()> {
54    static APPEARANCE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
55    APPEARANCE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
56}
57
58/// Mark the installed palette as a new one, so anything caching resolved
59/// colors drops what it has. A brand change moves every token without moving
60/// the appearance, which is why this is separate from
61/// [`set_current_appearance`].
62pub(crate) fn bump_generation() {
63    THEME_GENERATION.fetch_add(1, Ordering::Relaxed);
64}
65
66/// Point the context-free paint helpers at an appearance. Called by
67/// [`Theme::install`](crate::theme::Theme::install); exposed for tests that
68/// build a theme without an `App`.
69pub fn set_current_appearance(appearance: Appearance) {
70    let encoded = match appearance {
71        Appearance::Dark => 0,
72        Appearance::Light => 1,
73    };
74    if CURRENT_APPEARANCE.swap(encoded, Ordering::Relaxed) != encoded {
75        THEME_GENERATION.fetch_add(1, Ordering::Relaxed);
76    }
77}
78
79/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill
80/// backgrounds).
81///
82/// This was 0.5 on the theory that dark ink on a bright field reads heavier and
83/// should be scaled back. That theory is right for a *large* wash and badly wrong
84/// for everything else: this palette leans on very low alphas for its subtle
85/// fills — the composer plate is `ink(0.03)`, key caps are `ink(0.05)` — and
86/// halving those produced 1.5% black on white, which is nothing. The composer
87/// lost its background entirely and selected tabs stopped reading as selected.
88///
89/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%,
90/// selected ≈ 14% black — which is where the dark palette's white alphas already
91/// sit. So the honest multiplier is 1: the same number in both appearances, with
92/// only the *tone* flipping. Any per-state correction belongs in that state's
93/// token, not in a blanket multiplier.
94pub const INK_FILL_SCALE: f32 = 1.0;
95
96/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings).
97/// Opposite of fills: a 1px edge has to hold its own against a bright surround,
98/// and the dark palette's white hairlines are deliberately faint. Scaling up
99/// keeps separators legible instead of dissolving into the panel.
100pub const INK_HAIRLINE_SCALE: f32 = 1.35;
101
102/// Translucent **fill** ink for interactive states and chip plates: soft-white on
103/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
104///
105/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
106/// the tuned one — and the light value is derived. Callers keep one number and
107/// both appearances stay in the relationship the dark tuning established.
108///
109/// Fills must never rest on transparent BLACK in dark mode: fully opaque washes
110/// killed the glass and flashed dark mid-fade (user reports), so hover fades rest
111/// on `ink(0.0)`, which stays tonally correct at zero alpha.
112pub fn ink(alpha: f32) -> Hsla {
113    ink_for(current_appearance(), alpha)
114}
115
116pub(crate) fn ink_for(appearance: Appearance, alpha: f32) -> Hsla {
117    match appearance {
118        // Soft-white, not pure white: alphas are high enough to stay visible at
119        // the brightest backdrop the 0.90 glass scrim can produce.
120        Appearance::Dark => hsla(0.0, 0.0, 1.0, alpha),
121        Appearance::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
122    }
123}
124
125/// Translucent **hairline** ink for borders, dividers and rings: white on dark,
126/// black on light at [`INK_HAIRLINE_SCALE`] of the alpha.
127///
128/// Separate from [`ink`] because edges and fills scale in opposite directions
129/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
130/// less.
131pub fn hairline(alpha: f32) -> Hsla {
132    hairline_for(current_appearance(), alpha)
133}
134
135pub(crate) fn hairline_for(appearance: Appearance, alpha: f32) -> Hsla {
136    match appearance {
137        Appearance::Dark => hsla(0.0, 0.0, 1.0, alpha),
138        Appearance::Light => hsla(0.0, 0.0, 0.0, (alpha * INK_HAIRLINE_SCALE).min(0.5)),
139    }
140}
141
142/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
143/// white so hover plates read as tinted glass rather than paint.
144pub fn wash(alpha: f32) -> Hsla {
145    wash_for(current_appearance(), alpha)
146}
147
148pub(crate) fn wash_for(appearance: Appearance, alpha: f32) -> Hsla {
149    match appearance {
150        Appearance::Dark => hsla(0.0, 0.0, 0.92, alpha),
151        Appearance::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
152    }
153}
154
155/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
156/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
157pub const SCRIM_ALPHA_DARK: f32 = 0.60;
158
159/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
160///
161/// Black in both appearances — a scrim's job is to darken what is behind it, and
162/// a "light scrim" of white would wash the modal out rather than seat it. What
163/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
164/// blackout, so light mode scales to roughly half.
165pub fn scrim(alpha_dark: f32) -> Hsla {
166    scrim_for(current_appearance(), alpha_dark)
167}
168
169pub(crate) fn scrim_for(appearance: Appearance, alpha_dark: f32) -> Hsla {
170    match appearance {
171        Appearance::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
172        Appearance::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
173    }
174}
175
176/// Recessed band behind a palette/picker header or footer strip.
177///
178/// A free function as well as a [`Theme`](crate::theme::Theme) field because the
179/// picker chrome that paints it is built from context-free helpers; both resolve
180/// to the same value.
181pub fn band() -> Hsla {
182    band_for(current_appearance())
183}
184
185pub(crate) fn band_for(appearance: Appearance) -> Hsla {
186    match appearance {
187        Appearance::Dark => hsla(0.0, 0.0, 0.0, 0.16),
188        // A recessed strip on white needs far less ink than on near-black; the
189        // dark 16% would read as a bruise.
190        Appearance::Light => hsla(0.0, 0.0, 0.0, 0.045),
191    }
192}
193
194/// Selected-state glass treatment (tabs, session rows, space rows):
195/// `../desktop`'s `--color-active` — white at 12% on dark, black at 6% on
196/// light, translucent enough for the vibrancy to read through (heavier flat
197/// washes blocked the glass, user request).
198///
199/// One rung over [`Theme::element_hover`](crate::theme::Theme::element_hover),
200/// carrying the ring in [`glass_selected_shadows`] on top. Selection *inside
201/// floating cards* is [`card_selected_bg`].
202pub fn glass_selected_bg() -> Hsla {
203    match current_appearance() {
204        Appearance::Dark => ink(0.12),
205        Appearance::Light => ink(0.06),
206    }
207}
208
209/// The user message bubble's plate: the softened [`wash`] at 8% dark, 4%
210/// light. At the selection weight the bubble read too strong for settled
211/// content (user report), and an opaque plate before that read as a solid slab
212/// over glass.
213pub fn user_bubble_bg() -> Hsla {
214    match current_appearance() {
215        Appearance::Dark => wash(0.08),
216        Appearance::Light => wash(0.04),
217    }
218}
219
220/// Selected/keyboard-active treatment for rows and chips INSIDE a floating
221/// card (menu rows, the picker rail, segmented chips): `--color-active` again,
222/// tone-flipped by the palette convention so a card that is already the bright
223/// plane lifts with black at 6% rather than white.
224pub fn card_selected_bg() -> Hsla {
225    match current_appearance() {
226        Appearance::Dark => ink(0.12),
227        Appearance::Light => ink(0.06),
228    }
229}
230
231/// The selected chip's bright outline, as an INSET shadow: gpui paints inset
232/// shadows ON TOP of the background, edges only — a border with zero layout
233/// cost. Drop shadows are filled rects painted BEHIND the element, and behind
234/// a 5% fill they showed straight through as an opaque dark plate with a
235/// greyed ring (user report) — nothing may paint behind a glass chip.
236///
237/// Light pins the ring at a flat 7% black rather than the scaled hairline:
238/// heavier rings (the [`INK_HAIRLINE_SCALE`]d value, then 12%) outlined every
239/// selected chip in a dark box (user reports) — the ring should define the
240/// chip the way dark's 9% white ring does, not frame it.
241///
242/// There is deliberately NO drop-shadow seat under the light chip. Three
243/// recipes were tried (a tight 10% layer, a 6% contact + 5% ambient pair, a
244/// lone 4% whisper) and every one failed on sight: layers sum into a grey rim
245/// exactly where the chip meets the frost, gpui's small-radius blur reads
246/// coarse on a bright field, and the tab strip is a scroll container that
247/// clips its children vertically — any shadow escaping the chip gets cut off
248/// mid-fade. The near-opaque fill plus the ring carry selection, exactly as
249/// dark's wash plus ring does; the two appearances share one recipe now.
250pub fn glass_selected_shadows() -> Vec<BoxShadow> {
251    card_selected_shadows()
252}
253
254/// The elevation shadow a floating surface casts — `shadow_lg`'s shape, which
255/// is where it lived until the surface moved into the material. Painted cut to
256/// outside the shape, because the same drop shadow under a translucent fill is
257/// the grey plate [`card_selected_shadows`] records. `GlassSpec::shadow` says
258/// which looks carry one.
259pub fn surface_shadows() -> Vec<BoxShadow> {
260    vec![
261        BoxShadow {
262            color: hsla(0.0, 0.0, 0.0, 0.1),
263            offset: point(px(0.0), px(10.0)),
264            blur_radius: px(15.0),
265            spread_radius: px(-3.0),
266            inset: false,
267        },
268        BoxShadow {
269            color: hsla(0.0, 0.0, 0.0, 0.1),
270            offset: point(px(0.0), px(4.0)),
271            blur_radius: px(6.0),
272            spread_radius: px(-4.0),
273            inset: false,
274        },
275    ]
276}
277
278/// Selection outline for rows and chips INSIDE a floating card (menu rows,
279/// the picker rail, segmented chips): the inset ring alone, in both
280/// appearances. Card rows fill with a translucent wash
281/// ([`card_selected_bg`]), and a drop shadow — a filled rect painted BEHIND
282/// the element — shows straight through a translucent fill as a grey plate
283/// (the same lesson [`glass_selected_shadows`] records for dark glass). The
284/// card already carries the elevation shadow; selection inside it only needs
285/// the edge.
286pub fn card_selected_shadows() -> Vec<BoxShadow> {
287    let color = match current_appearance() {
288        Appearance::Dark => hairline(0.09),
289        Appearance::Light => hsla(0.0, 0.0, 0.0, 0.07),
290    };
291    vec![BoxShadow {
292        color,
293        offset: point(px(0.0), px(0.0)),
294        blur_radius: px(0.0),
295        spread_radius: px(1.0),
296        inset: true,
297    }]
298}