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    card_selected_bg()
204}
205
206/// The user message bubble's plate: the softened [`wash`] at 8% dark, 4%
207/// light. At the selection weight the bubble read too strong for settled
208/// content (user report), and an opaque plate before that read as a solid slab
209/// over glass.
210pub fn user_bubble_bg() -> Hsla {
211    match current_appearance() {
212        Appearance::Dark => wash(0.08),
213        Appearance::Light => wash(0.04),
214    }
215}
216
217/// Selected/keyboard-active treatment for rows and chips INSIDE a floating
218/// card (menu rows, the picker rail, segmented chips): `--color-active` again,
219/// tone-flipped by the palette convention so a card that is already the bright
220/// plane lifts with black at 6% rather than white.
221pub fn card_selected_bg() -> Hsla {
222    card_selected_bg_for(current_appearance())
223}
224
225pub(crate) fn card_selected_bg_for(appearance: Appearance) -> Hsla {
226    ink_for(appearance, if appearance.is_dark() { 0.12 } else { 0.06 })
227}
228
229/// The selected chip's bright outline, as an INSET shadow: gpui paints inset
230/// shadows ON TOP of the background, edges only — a border with zero layout
231/// cost. Drop shadows are filled rects painted BEHIND the element, and behind
232/// a 5% fill they showed straight through as an opaque dark plate with a
233/// greyed ring (user report) — nothing may paint behind a glass chip.
234///
235/// Light pins the ring at a flat 7% black rather than the scaled hairline:
236/// heavier rings (the [`INK_HAIRLINE_SCALE`]d value, then 12%) outlined every
237/// selected chip in a dark box (user reports) — the ring should define the
238/// chip the way dark's 9% white ring does, not frame it.
239///
240/// There is deliberately NO drop-shadow seat under the light chip. Three
241/// recipes were tried (a tight 10% layer, a 6% contact + 5% ambient pair, a
242/// lone 4% whisper) and every one failed on sight: layers sum into a grey rim
243/// exactly where the chip meets the frost, gpui's small-radius blur reads
244/// coarse on a bright field, and the tab strip is a scroll container that
245/// clips its children vertically — any shadow escaping the chip gets cut off
246/// mid-fade. The near-opaque fill plus the ring carry selection, exactly as
247/// dark's wash plus ring does; the two appearances share one recipe now.
248pub fn glass_selected_shadows() -> Vec<BoxShadow> {
249    card_selected_shadows()
250}
251
252/// The elevation shadow a floating surface casts — `shadow_lg`'s shape, which
253/// is where it lived until the surface moved into the material. Painted cut to
254/// outside the shape, because the same drop shadow under a translucent fill is
255/// the grey plate [`card_selected_shadows`] records. `GlassSpec::shadow` says
256/// which looks carry one.
257pub fn surface_shadows() -> Vec<BoxShadow> {
258    vec![
259        BoxShadow {
260            color: hsla(0.0, 0.0, 0.0, 0.1),
261            offset: point(px(0.0), px(10.0)),
262            blur_radius: px(15.0),
263            spread_radius: px(-3.0),
264            inset: false,
265        },
266        BoxShadow {
267            color: hsla(0.0, 0.0, 0.0, 0.1),
268            offset: point(px(0.0), px(4.0)),
269            blur_radius: px(6.0),
270            spread_radius: px(-4.0),
271            inset: false,
272        },
273    ]
274}
275
276/// Selection outline for rows and chips INSIDE a floating card (menu rows,
277/// the picker rail, segmented chips): the inset ring alone, in both
278/// appearances. Card rows fill with a translucent wash
279/// ([`card_selected_bg`]), and a drop shadow — a filled rect painted BEHIND
280/// the element — shows straight through a translucent fill as a grey plate
281/// (the same lesson [`glass_selected_shadows`] records for dark glass). The
282/// card already carries the elevation shadow; selection inside it only needs
283/// the edge.
284pub fn card_selected_shadows() -> Vec<BoxShadow> {
285    let color = match current_appearance() {
286        Appearance::Dark => hairline(0.09),
287        Appearance::Light => hsla(0.0, 0.0, 0.0, 0.07),
288    };
289    vec![BoxShadow {
290        color,
291        offset: point(px(0.0), px(0.0)),
292        blur_radius: px(0.0),
293        spread_radius: px(1.0),
294        inset: true,
295    }]
296}