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