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/// Lightness of [`hairline`] ink, dark then light.
103const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25);
104
105/// Translucent **fill** ink for interactive states and chip plates: soft-white on
106/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
107///
108/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
109/// the tuned one — and the light value is derived. Callers keep one number and
110/// both appearances stay in the relationship the dark tuning established.
111///
112/// Fills must never rest on transparent BLACK in dark mode: fully opaque washes
113/// killed the glass and flashed dark mid-fade (user reports), so hover fades rest
114/// on `ink(0.0)`, which stays tonally correct at zero alpha.
115pub fn ink(alpha: f32) -> Hsla {
116 ink_for(current_appearance(), alpha)
117}
118
119pub(crate) fn ink_for(appearance: Appearance, alpha: f32) -> Hsla {
120 match appearance {
121 // Soft-white, not pure white: alphas are high enough to stay visible at
122 // the brightest backdrop the 0.90 glass scrim can produce.
123 Appearance::Dark => hsla(0.0, 0.0, 1.0, alpha),
124 Appearance::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
125 }
126}
127
128/// Translucent **hairline** ink for borders, dividers and rings: a light grey on
129/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never
130/// pure white or black.
131///
132/// Separate from [`ink`] because edges and fills scale in opposite directions
133/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
134/// less.
135pub fn hairline(alpha: f32) -> Hsla {
136 hairline_for(current_appearance(), alpha)
137}
138
139pub(crate) fn hairline_for(appearance: Appearance, alpha: f32) -> Hsla {
140 match appearance {
141 Appearance::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha),
142 Appearance::Light => hsla(
143 0.0,
144 0.0,
145 HAIRLINE_TONE.1,
146 (alpha * INK_HAIRLINE_SCALE).min(0.5),
147 ),
148 }
149}
150
151/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
152/// white so hover plates read as tinted glass rather than paint.
153pub fn wash(alpha: f32) -> Hsla {
154 wash_for(current_appearance(), alpha)
155}
156
157pub(crate) fn wash_for(appearance: Appearance, alpha: f32) -> Hsla {
158 match appearance {
159 Appearance::Dark => hsla(0.0, 0.0, 0.92, alpha),
160 Appearance::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
161 }
162}
163
164/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
165/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
166pub const SCRIM_ALPHA_DARK: f32 = 0.60;
167
168/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
169///
170/// Black in both appearances — a scrim's job is to darken what is behind it, and
171/// a "light scrim" of white would wash the modal out rather than seat it. What
172/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
173/// blackout, so light mode scales to roughly half.
174pub fn scrim(alpha_dark: f32) -> Hsla {
175 scrim_for(current_appearance(), alpha_dark)
176}
177
178pub(crate) fn scrim_for(appearance: Appearance, alpha_dark: f32) -> Hsla {
179 match appearance {
180 Appearance::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
181 Appearance::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
182 }
183}
184
185/// Recessed band behind a palette/picker header or footer strip.
186///
187/// A free function as well as a [`Theme`](crate::theme::Theme) field because the
188/// picker chrome that paints it is built from context-free helpers; both resolve
189/// to the same value.
190pub fn band() -> Hsla {
191 band_for(current_appearance())
192}
193
194pub(crate) fn band_for(appearance: Appearance) -> Hsla {
195 match appearance {
196 Appearance::Dark => hsla(0.0, 0.0, 0.0, 0.16),
197 // A recessed strip on white needs far less ink than on near-black; the
198 // dark 16% would read as a bruise.
199 Appearance::Light => hsla(0.0, 0.0, 0.0, 0.045),
200 }
201}
202
203/// Selected-state glass treatment (tabs, session rows, space rows):
204/// `../desktop`'s `--color-active` — white at 12% on dark, black at 6% on
205/// light, translucent enough for the vibrancy to read through (heavier flat
206/// washes blocked the glass, user request).
207///
208/// One rung over [`Theme::element_hover`](crate::theme::Theme::element_hover),
209/// carrying the ring in [`glass_selected_shadows`] on top. Selection *inside
210/// floating cards* is [`card_selected_bg`].
211pub fn glass_selected_bg() -> Hsla {
212 card_selected_bg()
213}
214
215/// The user message bubble's plate: the softened [`wash`] at 8% dark, 4%
216/// light. At the selection weight the bubble read too strong for settled
217/// content (user report), and an opaque plate before that read as a solid slab
218/// over glass.
219pub fn user_bubble_bg() -> Hsla {
220 match current_appearance() {
221 Appearance::Dark => wash(0.08),
222 Appearance::Light => wash(0.04),
223 }
224}
225
226/// Selected/keyboard-active treatment for rows and chips INSIDE a floating
227/// card (menu rows, the picker rail, segmented chips): `--color-active` again,
228/// tone-flipped by the palette convention so a card that is already the bright
229/// plane lifts with black at 6% rather than white.
230pub fn card_selected_bg() -> Hsla {
231 card_selected_bg_for(current_appearance())
232}
233
234pub(crate) fn card_selected_bg_for(appearance: Appearance) -> Hsla {
235 ink_for(appearance, if appearance.is_dark() { 0.12 } else { 0.06 })
236}
237
238/// The selected chip's bright outline, as an INSET shadow: gpui paints inset
239/// shadows ON TOP of the background, edges only — a border with zero layout
240/// cost. Drop shadows are filled rects painted BEHIND the element, and behind
241/// a 5% fill they showed straight through as an opaque dark plate with a
242/// greyed ring (user report) — nothing may paint behind a glass chip.
243///
244/// Light pins the ring at a flat 7% rather than the scaled hairline:
245/// heavier rings (the [`INK_HAIRLINE_SCALE`]d value, then 12%) outlined every
246/// selected chip in a dark box (user reports) — the ring should define the
247/// chip the way dark's 9% white ring does, not frame it.
248///
249/// There is deliberately NO drop-shadow seat under the light chip. Three
250/// recipes were tried (a tight 10% layer, a 6% contact + 5% ambient pair, a
251/// lone 4% whisper) and every one failed on sight: layers sum into a grey rim
252/// exactly where the chip meets the frost, gpui's small-radius blur reads
253/// coarse on a bright field, and the tab strip is a scroll container that
254/// clips its children vertically — any shadow escaping the chip gets cut off
255/// mid-fade. The near-opaque fill plus the ring carry selection, exactly as
256/// dark's wash plus ring does; the two appearances share one recipe now.
257pub fn glass_selected_shadows() -> Vec<BoxShadow> {
258 card_selected_shadows()
259}
260
261/// The elevation shadow a floating surface casts — `shadow_lg`'s shape, which
262/// is where it lived until the surface moved into the material. Painted cut to
263/// outside the shape, because the same drop shadow under a translucent fill is
264/// the grey plate [`card_selected_shadows`] records. `GlassSpec::shadow` says
265/// which looks carry one.
266pub fn surface_shadows() -> Vec<BoxShadow> {
267 vec![
268 BoxShadow {
269 color: hsla(0.0, 0.0, 0.0, 0.1),
270 offset: point(px(0.0), px(10.0)),
271 blur_radius: px(15.0),
272 spread_radius: px(-3.0),
273 inset: false,
274 },
275 BoxShadow {
276 color: hsla(0.0, 0.0, 0.0, 0.1),
277 offset: point(px(0.0), px(4.0)),
278 blur_radius: px(6.0),
279 spread_radius: px(-4.0),
280 inset: false,
281 },
282 ]
283}
284
285/// Centered frame shadow, with half-inset blur to leave room for its tail
286/// inside the transparent [`Theme::CLIENT_INSET`](crate::Theme::CLIENT_INSET) band.
287pub fn frame_shadows() -> Vec<BoxShadow> {
288 vec![BoxShadow {
289 color: hsla(0.0, 0.0, 0.0, 0.4),
290 offset: point(px(0.0), px(0.0)),
291 blur_radius: px(crate::Theme::CLIENT_INSET / 2.0),
292 spread_radius: px(0.0),
293 inset: false,
294 }]
295}
296
297/// The shadow `bezel::ui::window::content`'s floating card casts on the
298/// chrome around it. Its blur stays inside
299/// [`Theme::CONTENT_INSET`](crate::Theme::CONTENT_INSET), which the card's
300/// parent may clip at.
301pub fn content_shadows() -> Vec<BoxShadow> {
302 vec![BoxShadow {
303 color: hsla(0.0, 0.0, 0.0, 0.08),
304 offset: point(px(0.0), px(1.0)),
305 blur_radius: px(crate::Theme::CONTENT_INSET / 2.0),
306 spread_radius: px(0.0),
307 inset: false,
308 }]
309}
310
311/// Selection outline for rows and chips INSIDE a floating card (menu rows,
312/// the picker rail, segmented chips): the inset ring alone, in both
313/// appearances. Card rows fill with a translucent wash
314/// ([`card_selected_bg`]), and a drop shadow — a filled rect painted BEHIND
315/// the element — shows straight through a translucent fill as a grey plate
316/// (the same lesson [`glass_selected_shadows`] records for dark glass). The
317/// card already carries the elevation shadow; selection inside it only needs
318/// the edge.
319pub fn card_selected_shadows() -> Vec<BoxShadow> {
320 let color = match current_appearance() {
321 Appearance::Dark => hairline(0.09),
322 Appearance::Light => hsla(0.0, 0.0, HAIRLINE_TONE.1, 0.07),
323 };
324 vec![BoxShadow {
325 color,
326 offset: point(px(0.0), px(0.0)),
327 blur_radius: px(0.0),
328 spread_radius: px(1.0),
329 inset: true,
330 }]
331}