192 lines
7.3 KiB
Rust
192 lines
7.3 KiB
Rust
//! Context-free paint helpers, ported from bezel
|
||
//! (`crates/theme/src/paint.rs`, MIT License, Copyright (c) 2026 bezel
|
||
//! contributors): the process-wide appearance mirror and the free functions
|
||
//! ([`ink`], [`hairline`], [`wash`], …) that element builders call without a
|
||
//! `cx` in scope.
|
||
|
||
use std::sync::atomic::{AtomicU8, Ordering};
|
||
|
||
use gpui::{BoxShadow, Hsla, hsla, point, px};
|
||
|
||
use crate::ThemeMode;
|
||
|
||
/// Process-wide mirror of the installed theme's appearance.
|
||
///
|
||
/// The paint helpers are free functions called from deep inside element
|
||
/// builders that have no `cx` in scope, so they read the appearance from here
|
||
/// instead of the gpui global. Appearance is genuinely process-wide — one
|
||
/// setting for every window — so a single mirror is sound; [`Theme::change`]
|
||
/// is the only writer outside tests.
|
||
static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
|
||
|
||
/// The appearance the context-free paint helpers are painting for.
|
||
pub fn current_appearance() -> ThemeMode {
|
||
match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
|
||
1 => ThemeMode::Light,
|
||
_ => ThemeMode::Dark,
|
||
}
|
||
}
|
||
|
||
/// Point the context-free paint helpers at an appearance. Called by
|
||
/// [`Theme::change`]; exposed for tests that build a palette without an `App`.
|
||
pub fn set_current_appearance(appearance: ThemeMode) {
|
||
let encoded = match appearance {
|
||
ThemeMode::Dark => 0,
|
||
ThemeMode::Light => 1,
|
||
};
|
||
CURRENT_APPEARANCE.store(encoded, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill
|
||
/// backgrounds).
|
||
///
|
||
/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%,
|
||
/// selected ≈ 14% black — which is where the dark palette's white alphas already
|
||
/// sit. So the honest multiplier is 1: the same number in both appearances, with
|
||
/// only the *tone* flipping. Any per-state correction belongs in that state's
|
||
/// token, not in a blanket multiplier.
|
||
pub const INK_FILL_SCALE: f32 = 1.0;
|
||
|
||
/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings).
|
||
/// Opposite of fills: a 1px edge has to hold its own against a bright surround,
|
||
/// and the dark palette's white hairlines are deliberately faint. Scaling up
|
||
/// keeps separators legible instead of dissolving into the panel.
|
||
pub const INK_HAIRLINE_SCALE: f32 = 1.35;
|
||
|
||
/// Lightness of [`hairline`] ink, dark then light.
|
||
const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25);
|
||
|
||
/// Translucent **fill** ink for interactive states and chip plates: soft-white on
|
||
/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
|
||
///
|
||
/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
|
||
/// the tuned one — and the light value is derived. Callers keep one number and
|
||
/// both appearances stay in the relationship the dark tuning established.
|
||
pub fn ink(alpha: f32) -> Hsla {
|
||
ink_for(current_appearance(), alpha)
|
||
}
|
||
|
||
pub(crate) fn ink_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||
match appearance {
|
||
ThemeMode::Dark => hsla(0.0, 0.0, 1.0, alpha),
|
||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
|
||
}
|
||
}
|
||
|
||
/// Translucent **hairline** ink for borders, dividers and rings: a light grey on
|
||
/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never
|
||
/// pure white or black.
|
||
///
|
||
/// Separate from [`ink`] because edges and fills scale in opposite directions
|
||
/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
|
||
/// less.
|
||
pub fn hairline(alpha: f32) -> Hsla {
|
||
hairline_for(current_appearance(), alpha)
|
||
}
|
||
|
||
pub(crate) fn hairline_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||
match appearance {
|
||
ThemeMode::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha),
|
||
ThemeMode::Light => hsla(
|
||
0.0,
|
||
0.0,
|
||
HAIRLINE_TONE.1,
|
||
(alpha * INK_HAIRLINE_SCALE).min(0.5),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
|
||
/// white so hover plates read as tinted glass rather than paint.
|
||
pub fn wash(alpha: f32) -> Hsla {
|
||
wash_for(current_appearance(), alpha)
|
||
}
|
||
|
||
pub(crate) fn wash_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||
match appearance {
|
||
ThemeMode::Dark => hsla(0.0, 0.0, 0.92, alpha),
|
||
ThemeMode::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
|
||
}
|
||
}
|
||
|
||
/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
|
||
/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
|
||
pub const SCRIM_ALPHA_DARK: f32 = 0.60;
|
||
|
||
/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
|
||
///
|
||
/// Black in both appearances — a scrim's job is to darken what is behind it, and
|
||
/// a "light scrim" of white would wash the modal out rather than seat it. What
|
||
/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
|
||
/// blackout, so light mode scales to roughly half.
|
||
pub fn scrim(alpha_dark: f32) -> Hsla {
|
||
scrim_for(current_appearance(), alpha_dark)
|
||
}
|
||
|
||
pub(crate) fn scrim_for(appearance: ThemeMode, alpha_dark: f32) -> Hsla {
|
||
match appearance {
|
||
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
|
||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
|
||
}
|
||
}
|
||
|
||
/// Recessed band behind a picker header or footer strip.
|
||
///
|
||
/// A free function as well as a token so the chrome that paints it can be built
|
||
/// from context-free helpers; both resolve to the same value.
|
||
pub fn band() -> Hsla {
|
||
band_for(current_appearance())
|
||
}
|
||
|
||
pub(crate) fn band_for(appearance: ThemeMode) -> Hsla {
|
||
match appearance {
|
||
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, 0.16),
|
||
// A recessed strip on white needs far less ink than on near-black; the
|
||
// dark 16% would read as a bruise.
|
||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.045),
|
||
}
|
||
}
|
||
|
||
/// The elevation shadow a floating surface casts. Painted cut to outside the
|
||
/// shape, because the same drop shadow under a translucent fill reads as a grey
|
||
/// plate.
|
||
pub fn surface_shadows() -> Vec<BoxShadow> {
|
||
vec![
|
||
BoxShadow {
|
||
color: hsla(0.0, 0.0, 0.0, 0.1),
|
||
offset: point(px(0.0), px(10.0)),
|
||
blur_radius: px(15.0),
|
||
spread_radius: px(-3.0),
|
||
inset: false,
|
||
},
|
||
BoxShadow {
|
||
color: hsla(0.0, 0.0, 0.0, 0.1),
|
||
offset: point(px(0.0), px(4.0)),
|
||
blur_radius: px(6.0),
|
||
spread_radius: px(-4.0),
|
||
inset: false,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// Hover over an opaque plate: brighten the plate in dark mode, darken it in
|
||
/// light — never swap an opaque plate for a translucent wash.
|
||
pub(crate) fn plate_hover_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
|
||
crate::color::flatten(ink_for(appearance, 0.10), plate)
|
||
}
|
||
|
||
/// Pressed over an opaque plate, one rung above [`plate_hover_for`].
|
||
pub(crate) fn plate_active_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
|
||
crate::color::flatten(ink_for(appearance, 0.16), plate)
|
||
}
|
||
|
||
/// Hover over an opaque plate, for call sites outside this crate.
|
||
pub fn plate_hover(plate: gpui::Hsla) -> gpui::Hsla {
|
||
plate_hover_for(current_appearance(), plate)
|
||
}
|
||
|
||
/// Pressed over an opaque plate, for call sites outside this crate.
|
||
pub fn plate_active(plate: gpui::Hsla) -> gpui::Hsla {
|
||
plate_active_for(current_appearance(), plate)
|
||
}
|