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