port bezel theme system into the theme crate
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//! Light/dark switching, adapted from bezel
|
||||
//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors): what the user asked for, what the OS reports, and the
|
||||
//! plumbing that turns a change in either into a repaint.
|
||||
|
||||
use gpui::{App, Global, Subscription, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Theme, ThemeMode};
|
||||
|
||||
/// The user's appearance preference. Serde-serializable so callers can persist
|
||||
/// it wherever their settings live; this crate never touches disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the OS. The default — matches every other native app on the
|
||||
/// machine, including when the OS is set to switch at sunset.
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppearanceMode {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
|
||||
|
||||
/// Menu/label text.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state behind the current theme: what the user chose, and what the OS
|
||||
/// last said. Kept separate from [`Theme`] itself so that flipping the OS
|
||||
/// appearance while the user has pinned Light still records the new system
|
||||
/// value (and takes effect the moment they switch back to `System`).
|
||||
pub struct AppearanceState {
|
||||
pub mode: AppearanceMode,
|
||||
pub system: ThemeMode,
|
||||
}
|
||||
|
||||
impl Global for AppearanceState {}
|
||||
|
||||
/// Combine the user's choice with the OS state.
|
||||
pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
AppearanceMode::System => system,
|
||||
AppearanceMode::Light => ThemeMode::Light,
|
||||
AppearanceMode::Dark => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the appearance globals and the matching theme. Call once at boot,
|
||||
/// before any window opens, so the first frame is already the right palette
|
||||
/// (installing later produces a visible dark-to-light flash).
|
||||
pub fn init(mode: AppearanceMode, cx: &mut App) {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.set_global(AppearanceState { mode, system });
|
||||
Theme::change(resolve(mode, system), None, cx);
|
||||
}
|
||||
|
||||
/// The mode currently in effect (defaults to `System` before [`init`]).
|
||||
pub fn mode(cx: &App) -> AppearanceMode {
|
||||
cx.try_global::<AppearanceState>()
|
||||
.map(|state| state.mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the user's preference and repaint if that changed the palette.
|
||||
/// Persisting the choice is the caller's job.
|
||||
pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode == mode {
|
||||
return;
|
||||
}
|
||||
state.mode = mode;
|
||||
// Coming back to `System`, re-read the OS: a pinned mode holds an
|
||||
// appearance override, so `system` may be stale from before the pin.
|
||||
if mode == AppearanceMode::System {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.global_mut::<AppearanceState>().system = system;
|
||||
}
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
|
||||
/// must outlive the window — callers typically `.detach()` it.
|
||||
///
|
||||
/// The notification is *per window*, but the appearance it reports is a system
|
||||
/// setting, so any one window is enough to learn about the change; re-applying
|
||||
/// is idempotent when several fire.
|
||||
pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
|
||||
// Reconcile against the *window's* appearance before subscribing: `init`
|
||||
// runs before any window exists and can only ask the app, which on some
|
||||
// platforms is not reliably populated that early in launch.
|
||||
record_system(window.appearance().into(), cx);
|
||||
window.observe_window_appearance(move |window, cx| {
|
||||
record_system(window.appearance().into(), cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. `window`, when given,
|
||||
/// is the more authoritative source for what the OS currently reports.
|
||||
pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) {
|
||||
let appearance = match window {
|
||||
Some(window) => window.appearance(),
|
||||
None => cx.window_appearance(),
|
||||
};
|
||||
record_system(ThemeMode::from_window(appearance), cx);
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. Only recorded while the
|
||||
/// user's mode is `System` — a pinned mode ignores the OS anyway, and
|
||||
/// recording an override read back would overwrite what the OS said with what
|
||||
/// we asked for.
|
||||
fn record_system(system: ThemeMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode != AppearanceMode::System || state.system == system {
|
||||
return;
|
||||
}
|
||||
state.system = system;
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Re-resolve the palette and, if it moved, swap the theme and force a full
|
||||
/// repaint. A no-op when the resolved appearance is unchanged — the OS fires
|
||||
/// the appearance notification for other changes too, and repainting every
|
||||
/// window for those would be a visible hitch for nothing.
|
||||
pub fn apply(cx: &mut App) {
|
||||
let Some(state) = cx.try_global::<AppearanceState>() else {
|
||||
return;
|
||||
};
|
||||
let wanted = resolve(state.mode, state.system);
|
||||
let changed = !cx
|
||||
.try_global::<Theme>()
|
||||
.is_some_and(|theme| theme.mode == wanted);
|
||||
if changed {
|
||||
Theme::change(wanted, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Brand, ported from bezel (`crates/theme/src/brand.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): one hue for the greys, one for the
|
||||
//! accent, one radius, and the text ink.
|
||||
//!
|
||||
//! A brand moves hue alone: every lightness in `palette.rs` stays as the
|
||||
//! palette set it.
|
||||
|
||||
use gpui::{App, Global, Hsla};
|
||||
|
||||
use crate::{ThemeColors, ThemeMode, color, layout};
|
||||
|
||||
/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
|
||||
/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Tint {
|
||||
/// oklch hue, in degrees.
|
||||
pub hue: f32,
|
||||
/// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
|
||||
pub chroma: f32,
|
||||
}
|
||||
|
||||
impl Tint {
|
||||
pub const NONE: Self = Self {
|
||||
hue: 0.0,
|
||||
chroma: 0.0,
|
||||
};
|
||||
|
||||
pub const fn new(hue: f32, chroma: f32) -> Self {
|
||||
Self { hue, chroma }
|
||||
}
|
||||
}
|
||||
|
||||
/// The greys a UI is built on, as oklch hue and chroma.
|
||||
///
|
||||
/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors)
|
||||
/// — the same list shadcn offers as its base colour, and the reason these are
|
||||
/// quoted rather than invented: a neutral that carries hue is a judgement
|
||||
/// someone else has already made five times.
|
||||
pub const BASE_COLORS: [(&str, Tint); 5] = [
|
||||
("Neutral", Tint::NONE),
|
||||
("Stone", Tint::new(58.071, 0.013)),
|
||||
("Zinc", Tint::new(285.938, 0.016)),
|
||||
("Gray", Tint::new(264.364, 0.027)),
|
||||
("Slate", Tint::new(257.417, 0.046)),
|
||||
];
|
||||
|
||||
/// The alpha of each text role over the appearance's ink — white in dark,
|
||||
/// black in light.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TextInk {
|
||||
/// [`ThemeColors::text`].
|
||||
pub text: f32,
|
||||
/// [`ThemeColors::text_muted`].
|
||||
pub muted: f32,
|
||||
/// [`ThemeColors::text_faint`].
|
||||
pub faint: f32,
|
||||
}
|
||||
|
||||
impl TextInk {
|
||||
/// The role at `alpha` over `appearance`'s ink.
|
||||
pub fn color(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
|
||||
ThemeMode::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [`ThemeColors::text`], [`ThemeColors::text_muted`] and
|
||||
/// [`ThemeColors::text_faint`] for the appearance.
|
||||
pub fn paint(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
colors.text = Self::color(appearance, self.text);
|
||||
colors.text_muted = Self::color(appearance, self.muted);
|
||||
colors.text_faint = Self::color(appearance, self.faint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text ink for both appearances.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Ink {
|
||||
pub dark: TextInk,
|
||||
pub light: TextInk,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
|
||||
/// [`ThemeColors::bg`] and [`ThemeColors::surface`] in both appearances.
|
||||
pub const ACCESSIBLE: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.52,
|
||||
faint: 0.40,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.56,
|
||||
faint: 0.43,
|
||||
},
|
||||
};
|
||||
/// AppKit's label, secondary and tertiary label colours. Muted is under
|
||||
/// 4.5:1 on white, and faint is under 3:1 in both appearances.
|
||||
pub const APPKIT: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.55,
|
||||
faint: 0.25,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.50,
|
||||
faint: 0.26,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn for_appearance(&self, appearance: ThemeMode) -> TextInk {
|
||||
match appearance {
|
||||
ThemeMode::Dark => self.dark,
|
||||
ThemeMode::Light => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an app changes about the shipped palette without redesigning it.
|
||||
///
|
||||
/// Installed as a gpui [`Global`] via [`crate::AppExt::set_brand`]; every
|
||||
/// palette install applies it, so it survives a light/dark switch and composes
|
||||
/// with [`crate::AppExt::set_palette`] rather than competing with it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Brand {
|
||||
/// The hue every grey in the palette carries.
|
||||
pub tint: Tint,
|
||||
/// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
|
||||
/// any other grey — which is what the shipped palette already is.
|
||||
pub accent: Tint,
|
||||
/// The base corner radius; every other corner is a ratio of it. See
|
||||
/// [`crate::BASE_RADIUS`].
|
||||
pub radius: f32,
|
||||
/// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
|
||||
pub ink: Ink,
|
||||
}
|
||||
|
||||
impl Global for Brand {}
|
||||
|
||||
impl Default for Brand {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tint: Tint::NONE,
|
||||
accent: Tint::NONE,
|
||||
radius: layout::BASE_RADIUS,
|
||||
ink: Ink::APPKIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brand {
|
||||
/// Whether applying this brand would change nothing — the shipped palette
|
||||
/// already carries these values, so a JSON theme's own text colors must be
|
||||
/// left alone.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The shipped palette for an appearance, rotated onto this brand.
|
||||
pub fn branded(&self, appearance: ThemeMode) -> ThemeColors {
|
||||
let mut colors = ThemeColors::for_appearance(appearance);
|
||||
self.apply(appearance, &mut colors);
|
||||
colors
|
||||
}
|
||||
|
||||
/// Rotate a palette onto this brand's hues.
|
||||
pub fn apply(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
self.ink
|
||||
.for_appearance(appearance)
|
||||
.paint(appearance, colors);
|
||||
// Every colour token, with the rule doing the choosing: a token that is
|
||||
// already grey takes the tint, and one that already carries a hue —
|
||||
// danger, warning, success — is semantic and keeps it. Translucent ink
|
||||
// is skipped because it paints over whatever is beneath it, which is
|
||||
// tinted already.
|
||||
let tokens: [&mut Hsla; 41] = [
|
||||
&mut colors.bg,
|
||||
&mut colors.surface,
|
||||
&mut colors.surface_raised,
|
||||
&mut colors.surface_card,
|
||||
&mut colors.surface_dialog,
|
||||
&mut colors.surface_overlay,
|
||||
&mut colors.element_hover,
|
||||
&mut colors.element_active,
|
||||
&mut colors.border_faint,
|
||||
&mut colors.border,
|
||||
&mut colors.border_strong,
|
||||
&mut colors.text,
|
||||
&mut colors.text_muted,
|
||||
&mut colors.text_faint,
|
||||
&mut colors.text_dim,
|
||||
&mut colors.solid,
|
||||
&mut colors.on_solid,
|
||||
&mut colors.accent,
|
||||
&mut colors.accent_strong,
|
||||
&mut colors.on_accent,
|
||||
&mut colors.danger,
|
||||
&mut colors.danger_muted,
|
||||
&mut colors.warning,
|
||||
&mut colors.warning_muted,
|
||||
&mut colors.success,
|
||||
&mut colors.busy,
|
||||
&mut colors.success_muted,
|
||||
&mut colors.surface_raised_hover,
|
||||
&mut colors.band,
|
||||
&mut colors.input_bg,
|
||||
&mut colors.selection,
|
||||
&mut colors.cursor,
|
||||
&mut colors.caret,
|
||||
&mut colors.ring,
|
||||
&mut colors.drop_target,
|
||||
&mut colors.danger_strong,
|
||||
&mut colors.code_text,
|
||||
&mut colors.code_wash,
|
||||
&mut colors.diff_add,
|
||||
&mut colors.diff_del,
|
||||
&mut colors.diff_hunk_bg,
|
||||
];
|
||||
let syntax: [&mut Hsla; 24] = [
|
||||
&mut colors.syntax.comment,
|
||||
&mut colors.syntax.keyword,
|
||||
&mut colors.syntax.string,
|
||||
&mut colors.syntax.string_special,
|
||||
&mut colors.syntax.escape,
|
||||
&mut colors.syntax.number,
|
||||
&mut colors.syntax.boolean,
|
||||
&mut colors.syntax.type_name,
|
||||
&mut colors.syntax.type_builtin,
|
||||
&mut colors.syntax.constructor,
|
||||
&mut colors.syntax.function,
|
||||
&mut colors.syntax.function_builtin,
|
||||
&mut colors.syntax.macro_name,
|
||||
&mut colors.syntax.property,
|
||||
&mut colors.syntax.constant,
|
||||
&mut colors.syntax.variable,
|
||||
&mut colors.syntax.variable_special,
|
||||
&mut colors.syntax.parameter,
|
||||
&mut colors.syntax.operator,
|
||||
&mut colors.syntax.punctuation,
|
||||
&mut colors.syntax.tag,
|
||||
&mut colors.syntax.attribute,
|
||||
&mut colors.syntax.label,
|
||||
&mut colors.syntax.invalid,
|
||||
];
|
||||
for slot in tokens.into_iter().chain(syntax) {
|
||||
if slot.a == 1.0 && slot.s <= f32::EPSILON {
|
||||
*slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
|
||||
}
|
||||
}
|
||||
|
||||
if self.accent.chroma > 0.0 {
|
||||
let (accent_l, plate_l) = if appearance.is_light() {
|
||||
(ACCENT_L.1, PLATE_L.1)
|
||||
} else {
|
||||
(ACCENT_L.0, PLATE_L.0)
|
||||
};
|
||||
colors.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
|
||||
colors.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
|
||||
// Whichever label the plate can actually hold. The shipped accent is
|
||||
// the maximum-contrast neutral, where the answer is always the
|
||||
// inverse; a chromatic plate at a yellow hue is bright enough that
|
||||
// the inverse would be the unreadable one.
|
||||
colors.on_accent = label_on(colors.accent_strong, colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
|
||||
/// the two steps `palette.rs` picked so an accent clears WCAG AA on its own
|
||||
/// background rather than glowing on one and vanishing on the other.
|
||||
const ACCENT_L: (f32, f32) = (0.673, 0.511);
|
||||
|
||||
/// The lightness of a *plate* carrying [`ThemeColors::on_accent`], taken from
|
||||
/// `danger_strong` — the palette's existing chromatic plate, already tuned to
|
||||
/// hold a label in both appearances.
|
||||
const PLATE_L: (f32, f32) = (0.58, 0.51);
|
||||
|
||||
/// Whichever of the palette's two extremes the plate can actually hold.
|
||||
fn label_on(plate: Hsla, colors: &ThemeColors) -> Hsla {
|
||||
let (a, b) = (colors.solid, colors.on_solid);
|
||||
if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the installed brand (the default before one is set).
|
||||
pub fn installed(cx: &App) -> Brand {
|
||||
cx.try_global::<Brand>().copied().unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters
|
||||
//! behind the token constructors, WCAG contrast for the palette tests, and
|
||||
//! small paint helpers.
|
||||
|
||||
use gpui::{Hsla, hsla};
|
||||
|
||||
/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
|
||||
/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
|
||||
/// float-noise saturation).
|
||||
pub fn neutral(lightness: f32) -> Hsla {
|
||||
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
|
||||
hsla(0.0, 0.0, v, 1.0)
|
||||
}
|
||||
|
||||
/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
|
||||
/// — for surfaces matched against reference samples.
|
||||
pub fn grey(value: u8) -> Hsla {
|
||||
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
|
||||
}
|
||||
|
||||
/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
|
||||
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
|
||||
let [r, g, b] = oklch_to_srgb(l, c, h_deg);
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
|
||||
/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
|
||||
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let [r, g, b] = oklch_to_linear(l, c, h_deg);
|
||||
[gamma_encode(r), gamma_encode(g), gamma_encode(b)]
|
||||
}
|
||||
|
||||
/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
|
||||
/// display cannot make, which is what [`fit_chroma`] tests for.
|
||||
fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let h = h_deg.to_radians();
|
||||
let a = c * h.cos();
|
||||
let b = c * h.sin();
|
||||
|
||||
// OKLab → LMS (cube roots undone)
|
||||
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
|
||||
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
|
||||
// LMS → linear sRGB
|
||||
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
|
||||
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
|
||||
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
|
||||
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
fn gamma_encode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.003_130_8 {
|
||||
12.92 * x
|
||||
} else {
|
||||
1.055 * x.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
fn gamma_decode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.040_449_936 {
|
||||
x / 12.92
|
||||
} else {
|
||||
((x + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
|
||||
///
|
||||
/// Only greys round-trip through this. A grey's three channels are equal, and
|
||||
/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
|
||||
/// collapses to one cube root — no inverse matrix needed.
|
||||
pub fn lightness(color: Hsla) -> f32 {
|
||||
gamma_decode(color.l).cbrt()
|
||||
}
|
||||
|
||||
/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
|
||||
///
|
||||
/// Near black and near white the gamut is a needle: asking for a mid-ramp
|
||||
/// chroma there produces an out-of-range component, and clamping it per channel
|
||||
/// shifts the hue instead of dropping the saturation.
|
||||
fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
|
||||
let fits = |c: f32| {
|
||||
oklch_to_linear(l, c, hue)
|
||||
.iter()
|
||||
.all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
|
||||
};
|
||||
if fits(chroma) {
|
||||
return chroma;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, chroma);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if fits(mid) { lo = mid } else { hi = mid }
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
|
||||
/// lightness can hold. Alpha rides through untouched.
|
||||
pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
|
||||
if chroma <= 0.0 {
|
||||
return color;
|
||||
}
|
||||
let l = lightness(color);
|
||||
let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
|
||||
out.a = color.a;
|
||||
out
|
||||
}
|
||||
|
||||
/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
|
||||
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let delta = max - min;
|
||||
if delta < f32::EPSILON {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let s = if l > 0.5 {
|
||||
delta / (2.0 - max - min)
|
||||
} else {
|
||||
delta / (max + min)
|
||||
};
|
||||
let h = if (max - r).abs() < f32::EPSILON {
|
||||
((g - b) / delta).rem_euclid(6.0)
|
||||
} else if (max - g).abs() < f32::EPSILON {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
} / 6.0;
|
||||
(h, s, l)
|
||||
}
|
||||
|
||||
/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
|
||||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
if s <= f32::EPSILON {
|
||||
return [l, l, l];
|
||||
}
|
||||
let q = if l < 0.5 {
|
||||
l * (1.0 + s)
|
||||
} else {
|
||||
l + s - l * s
|
||||
};
|
||||
let p = 2.0 * l - q;
|
||||
let hue = |mut t: f32| {
|
||||
t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// WCAG 2.1 relative luminance of an opaque color.
|
||||
pub fn relative_luminance(color: Hsla) -> f32 {
|
||||
let lin = |c: f32| {
|
||||
if c <= 0.040_45 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
|
||||
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
|
||||
///
|
||||
/// Used by the palette tests to prove each light token reproduces the contrast
|
||||
/// its dark counterpart had, rather than merely looking plausible.
|
||||
pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
|
||||
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
|
||||
/// opaque result — the color the eye actually receives.
|
||||
pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
|
||||
let a = fg.a.clamp(0.0, 1.0);
|
||||
let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
|
||||
let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
|
||||
let (h, s, l) = rgb_to_hsl(
|
||||
fr * a + br * (1.0 - a),
|
||||
fg_ * a + bg_ * (1.0 - a),
|
||||
fb * a + bb * (1.0 - a),
|
||||
);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// Linear per-component mix of two colors.
|
||||
pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let lerp = |x: f32, y: f32| x + (y - x) * t;
|
||||
hsla(
|
||||
lerp(a.h, b.h),
|
||||
lerp(a.s, b.s),
|
||||
lerp(a.l, b.l),
|
||||
lerp(a.a, b.a),
|
||||
)
|
||||
}
|
||||
+355
-453
@@ -1,10 +1,21 @@
|
||||
use gpui::{Hsla, Rgba, hsla};
|
||||
use gpui::Hsla;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::scale::{ColorScale, ColorScaleSet};
|
||||
use crate::paint::{ink_for, plate_active_for, plate_hover_for, wash_for};
|
||||
use crate::syntax::SyntaxPalette;
|
||||
use crate::{ThemeMode, palette};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
|
||||
/// The full token set.
|
||||
///
|
||||
/// The first groups are the legacy coop tokens; they stay until every call
|
||||
/// site has moved to the bezel tokens (see docs/ui-theme-bezel-migration.md),
|
||||
/// and are derived from the tokens by [`ThemeColors::sync_legacy`]. The bezel
|
||||
/// token groups follow. Deserialization refines over the built-in dark
|
||||
/// palette, so a JSON that names only some fields still yields a complete
|
||||
/// palette.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeColors {
|
||||
// Surface colors
|
||||
pub background: Hsla,
|
||||
@@ -96,488 +107,379 @@ pub struct ThemeColors {
|
||||
pub drop_target_background: Hsla,
|
||||
pub cursor: Hsla,
|
||||
pub selection: Hsla,
|
||||
|
||||
// Bezel tokens: neutral surfaces
|
||||
/// Main content panel.
|
||||
pub bg: Hsla,
|
||||
/// Shell / sidebar surface.
|
||||
pub surface: Hsla,
|
||||
/// Raised surface: opaque pills and chips that sit proud of the panel.
|
||||
pub surface_raised: Hsla,
|
||||
/// Inline card resting on the main panel.
|
||||
pub surface_card: Hsla,
|
||||
/// Modal dialog, floating over the scrim.
|
||||
pub surface_dialog: Hsla,
|
||||
/// Popover, menu and command-palette surface — the highest plane.
|
||||
pub surface_overlay: Hsla,
|
||||
|
||||
// Bezel tokens: hairlines
|
||||
/// Faintest hairline: dividers inside a card, nested frames.
|
||||
pub border_faint: Hsla,
|
||||
/// Stronger border for focused/raised edges.
|
||||
pub border_strong: Hsla,
|
||||
|
||||
// Bezel tokens: text
|
||||
/// Faint text: placeholders, disabled.
|
||||
pub text_faint: Hsla,
|
||||
/// One notch below `text_muted` — the diff file-path tone.
|
||||
pub text_dim: Hsla,
|
||||
|
||||
// Bezel tokens: high-contrast solid
|
||||
/// The maximum-contrast solid fill: the primary button plate.
|
||||
pub solid: Hsla,
|
||||
/// Label/icon color on top of [`ThemeColors::solid`] — its inverse.
|
||||
pub on_solid: Hsla,
|
||||
|
||||
// Bezel tokens: accents
|
||||
/// Accent — the emphasis weight for text and icons. Neutral by default.
|
||||
pub accent: Hsla,
|
||||
/// Stronger accent for fills that carry [`ThemeColors::on_accent`].
|
||||
pub accent_strong: Hsla,
|
||||
/// Label color on top of [`ThemeColors::accent_strong`].
|
||||
pub on_accent: Hsla,
|
||||
|
||||
// Bezel tokens: semantic
|
||||
/// Danger — red (errors, stop button).
|
||||
pub danger: Hsla,
|
||||
/// Softer danger for secondary/inline error copy.
|
||||
pub danger_muted: Hsla,
|
||||
/// Destructive-action button fill (danger plate, carries `on_accent`).
|
||||
pub danger_strong: Hsla,
|
||||
/// Warning — amber.
|
||||
pub warning: Hsla,
|
||||
/// Softer warning for secondary copy.
|
||||
pub warning_muted: Hsla,
|
||||
/// Success / online — emerald.
|
||||
pub success: Hsla,
|
||||
/// Softer success for text on a success-tinted chip.
|
||||
pub success_muted: Hsla,
|
||||
/// Working / streaming indicator — pink.
|
||||
pub busy: Hsla,
|
||||
|
||||
// Bezel tokens: components
|
||||
/// Hover tone for an *opaque* raised pill.
|
||||
pub surface_raised_hover: Hsla,
|
||||
/// Recessed band behind a header or footer strip.
|
||||
pub band: Hsla,
|
||||
/// Input plates.
|
||||
pub input_bg: Hsla,
|
||||
/// Text caret.
|
||||
pub caret: Hsla,
|
||||
/// Where a dragged block would land.
|
||||
pub drop_line: Hsla,
|
||||
/// Fill of the region a dragged pane would dock into.
|
||||
pub drop_target: Hsla,
|
||||
|
||||
// Bezel tokens: code & diff
|
||||
/// Inline-code text.
|
||||
pub code_text: Hsla,
|
||||
/// Inline-code wash behind [`ThemeColors::code_text`].
|
||||
pub code_wash: Hsla,
|
||||
/// Shared paint-only syntax palette.
|
||||
pub syntax: SyntaxPalette,
|
||||
/// Diff: added lines.
|
||||
pub diff_add: Hsla,
|
||||
/// Diff: deleted lines.
|
||||
pub diff_del: Hsla,
|
||||
/// Diff: hunk-header wash.
|
||||
pub diff_hunk_bg: Hsla,
|
||||
}
|
||||
|
||||
impl Default for ThemeColors {
|
||||
fn default() -> Self {
|
||||
Self::dark()
|
||||
}
|
||||
}
|
||||
|
||||
/// The default colors for the theme.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
impl ThemeColors {
|
||||
/// Returns the default colors for light themes.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
pub(crate) fn light() -> Self {
|
||||
Self {
|
||||
background: neutral().light().step_1(),
|
||||
surface_background: neutral().light().step_2(),
|
||||
elevated_surface_background: neutral().light().step_3(),
|
||||
panel_background: neutral().light().step_1(),
|
||||
overlay: neutral().light_alpha().step_3(),
|
||||
title_bar: neutral().light().step_2(),
|
||||
title_bar_inactive: neutral().light().step_1(),
|
||||
window_border: hsl(240.0, 5.9, 78.0),
|
||||
|
||||
border: neutral().light().step_6(),
|
||||
border_variant: neutral().light().step_4(),
|
||||
border_focused: brand().light().step_7(),
|
||||
border_selected: brand().light().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
border_disabled: neutral().light().step_3(),
|
||||
ring: brand().light().step_8(),
|
||||
|
||||
text: neutral().light().step_12(),
|
||||
text_muted: neutral().light().step_11(),
|
||||
text_placeholder: neutral().light().step_10(),
|
||||
text_accent: brand().light().step_11(),
|
||||
text_danger: danger().light().step_9(),
|
||||
text_warning: warning().light().step_9(),
|
||||
|
||||
icon: neutral().light().step_11(),
|
||||
icon_muted: neutral().light().step_10(),
|
||||
icon_accent: brand().light().step_11(),
|
||||
|
||||
element_foreground: brand().light().step_12(),
|
||||
element_background: brand().light().step_9(),
|
||||
element_hover: brand().light_alpha().step_10(),
|
||||
element_active: brand().light().step_10(),
|
||||
element_selected: brand().light().step_11(),
|
||||
element_disabled: brand().light_alpha().step_3(),
|
||||
|
||||
secondary_foreground: brand().light().step_11(),
|
||||
secondary_background: brand().light().step_3(),
|
||||
secondary_hover: brand().light_alpha().step_4(),
|
||||
secondary_active: brand().light().step_5(),
|
||||
secondary_selected: brand().light().step_5(),
|
||||
secondary_disabled: brand().light_alpha().step_3(),
|
||||
|
||||
danger_foreground: danger().light().step_12(),
|
||||
danger_background: danger().light().step_3(),
|
||||
danger_hover: danger().light_alpha().step_4(),
|
||||
danger_active: danger().light().step_5(),
|
||||
danger_selected: danger().light().step_5(),
|
||||
danger_disabled: danger().light_alpha().step_3(),
|
||||
|
||||
warning_foreground: warning().light().step_12(),
|
||||
warning_background: warning().light().step_3(),
|
||||
warning_hover: warning().light_alpha().step_4(),
|
||||
warning_active: warning().light().step_5(),
|
||||
warning_selected: warning().light().step_5(),
|
||||
warning_disabled: warning().light_alpha().step_3(),
|
||||
|
||||
ghost_element_background: gpui::transparent_black(),
|
||||
ghost_element_background_alt: neutral().light().step_3(),
|
||||
ghost_element_hover: neutral().light_alpha().step_4(),
|
||||
ghost_element_active: neutral().light().step_5(),
|
||||
ghost_element_selected: neutral().light().step_5(),
|
||||
ghost_element_disabled: neutral().light_alpha().step_2(),
|
||||
|
||||
tab_background: neutral().light().step_3(),
|
||||
tab_foreground: neutral().light().step_11(),
|
||||
tab_hover_background: neutral().light_alpha().step_4(),
|
||||
tab_active_background: neutral().light().step_1(),
|
||||
tab_active_foreground: neutral().light().step_12(),
|
||||
|
||||
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
||||
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
|
||||
drop_target_background: brand().light_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
selection: hsl(200., 100., 50.).alpha(0.25),
|
||||
}
|
||||
palette::light()
|
||||
}
|
||||
|
||||
/// Returns the default colors for dark themes.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
pub(crate) fn dark() -> Self {
|
||||
palette::dark()
|
||||
}
|
||||
|
||||
/// Returns the default colors for an appearance.
|
||||
pub fn for_appearance(appearance: ThemeMode) -> Self {
|
||||
palette::for_appearance(appearance)
|
||||
}
|
||||
|
||||
/// A placeholder instance whose legacy fields the palette overwrites.
|
||||
pub(crate) fn legacy_default() -> Self {
|
||||
let transparent = gpui::transparent_black();
|
||||
Self {
|
||||
background: neutral().dark().step_1(),
|
||||
surface_background: neutral().dark().step_2(),
|
||||
elevated_surface_background: neutral().dark().step_3(),
|
||||
panel_background: neutral().dark().step_1(),
|
||||
overlay: neutral().dark_alpha().step_3(),
|
||||
title_bar: neutral().dark().step_2(),
|
||||
title_bar_inactive: neutral().dark().step_1(),
|
||||
window_border: hsl(240.0, 3.7, 28.0),
|
||||
background: transparent,
|
||||
surface_background: transparent,
|
||||
elevated_surface_background: transparent,
|
||||
panel_background: transparent,
|
||||
overlay: transparent,
|
||||
title_bar: transparent,
|
||||
title_bar_inactive: transparent,
|
||||
window_border: transparent,
|
||||
|
||||
border: neutral().dark().step_6(),
|
||||
border_variant: neutral().dark().step_4(),
|
||||
border_focused: brand().dark().step_7(),
|
||||
border_selected: brand().dark().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
border_disabled: neutral().dark().step_3(),
|
||||
ring: brand().dark().step_8(),
|
||||
border: transparent,
|
||||
border_variant: transparent,
|
||||
border_focused: transparent,
|
||||
border_selected: transparent,
|
||||
border_transparent: transparent,
|
||||
border_disabled: transparent,
|
||||
ring: transparent,
|
||||
|
||||
text: neutral().dark().step_12(),
|
||||
text_muted: neutral().dark().step_11(),
|
||||
text_placeholder: neutral().dark().step_10(),
|
||||
text_accent: brand().dark().step_11(),
|
||||
text_danger: danger().dark().step_9(),
|
||||
text_warning: warning().dark().step_9(),
|
||||
text: transparent,
|
||||
text_muted: transparent,
|
||||
text_placeholder: transparent,
|
||||
text_accent: transparent,
|
||||
text_danger: transparent,
|
||||
text_warning: transparent,
|
||||
|
||||
icon: neutral().dark().step_11(),
|
||||
icon_muted: neutral().dark().step_10(),
|
||||
icon_accent: brand().dark().step_11(),
|
||||
icon: transparent,
|
||||
icon_muted: transparent,
|
||||
icon_accent: transparent,
|
||||
|
||||
element_foreground: brand().dark().step_1(),
|
||||
element_background: brand().dark().step_9(),
|
||||
element_hover: brand().dark_alpha().step_10(),
|
||||
element_active: brand().dark().step_10(),
|
||||
element_selected: brand().dark().step_11(),
|
||||
element_disabled: brand().dark_alpha().step_3(),
|
||||
element_foreground: transparent,
|
||||
element_background: transparent,
|
||||
element_hover: transparent,
|
||||
element_active: transparent,
|
||||
element_selected: transparent,
|
||||
element_disabled: transparent,
|
||||
|
||||
secondary_foreground: brand().dark().step_12(),
|
||||
secondary_background: brand().dark().step_3(),
|
||||
secondary_hover: brand().dark_alpha().step_4(),
|
||||
secondary_active: brand().dark().step_5(),
|
||||
secondary_selected: brand().dark().step_5(),
|
||||
secondary_disabled: brand().dark_alpha().step_3(),
|
||||
secondary_foreground: transparent,
|
||||
secondary_background: transparent,
|
||||
secondary_hover: transparent,
|
||||
secondary_active: transparent,
|
||||
secondary_selected: transparent,
|
||||
secondary_disabled: transparent,
|
||||
|
||||
danger_foreground: danger().dark().step_12(),
|
||||
danger_background: danger().dark().step_3(),
|
||||
danger_hover: danger().dark_alpha().step_4(),
|
||||
danger_active: danger().dark().step_5(),
|
||||
danger_selected: danger().dark().step_5(),
|
||||
danger_disabled: danger().dark_alpha().step_3(),
|
||||
danger_foreground: transparent,
|
||||
danger_background: transparent,
|
||||
danger_hover: transparent,
|
||||
danger_active: transparent,
|
||||
danger_selected: transparent,
|
||||
danger_disabled: transparent,
|
||||
|
||||
warning_foreground: warning().dark().step_12(),
|
||||
warning_background: warning().dark().step_3(),
|
||||
warning_hover: warning().dark_alpha().step_4(),
|
||||
warning_active: warning().dark().step_5(),
|
||||
warning_selected: warning().dark().step_5(),
|
||||
warning_disabled: warning().dark_alpha().step_3(),
|
||||
warning_foreground: transparent,
|
||||
warning_background: transparent,
|
||||
warning_hover: transparent,
|
||||
warning_active: transparent,
|
||||
warning_selected: transparent,
|
||||
warning_disabled: transparent,
|
||||
|
||||
ghost_element_background: gpui::transparent_black(),
|
||||
ghost_element_background_alt: neutral().dark().step_3(),
|
||||
ghost_element_hover: neutral().dark_alpha().step_4(),
|
||||
ghost_element_active: neutral().dark().step_5(),
|
||||
ghost_element_selected: neutral().dark().step_5(),
|
||||
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
||||
ghost_element_background: transparent,
|
||||
ghost_element_background_alt: transparent,
|
||||
ghost_element_hover: transparent,
|
||||
ghost_element_active: transparent,
|
||||
ghost_element_selected: transparent,
|
||||
ghost_element_disabled: transparent,
|
||||
|
||||
tab_background: neutral().dark().step_3(),
|
||||
tab_foreground: neutral().dark().step_11(),
|
||||
tab_hover_background: neutral().dark_alpha().step_4(),
|
||||
tab_active_background: neutral().dark().step_1(),
|
||||
tab_active_foreground: neutral().dark().step_12(),
|
||||
tab_background: transparent,
|
||||
tab_foreground: transparent,
|
||||
tab_hover_background: transparent,
|
||||
tab_active_background: transparent,
|
||||
tab_active_foreground: transparent,
|
||||
|
||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
||||
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
scrollbar_thumb_background: transparent,
|
||||
scrollbar_thumb_hover_background: transparent,
|
||||
scrollbar_thumb_border: transparent,
|
||||
scrollbar_track_background: transparent,
|
||||
scrollbar_track_border: transparent,
|
||||
|
||||
drop_target_background: brand().dark_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
selection: hsl(200., 100., 50.).alpha(0.25),
|
||||
drop_target_background: transparent,
|
||||
cursor: transparent,
|
||||
selection: transparent,
|
||||
|
||||
bg: transparent,
|
||||
surface: transparent,
|
||||
surface_raised: transparent,
|
||||
surface_card: transparent,
|
||||
surface_dialog: transparent,
|
||||
surface_overlay: transparent,
|
||||
|
||||
border_faint: transparent,
|
||||
border_strong: transparent,
|
||||
|
||||
text_faint: transparent,
|
||||
text_dim: transparent,
|
||||
|
||||
solid: transparent,
|
||||
on_solid: transparent,
|
||||
|
||||
accent: transparent,
|
||||
accent_strong: transparent,
|
||||
on_accent: transparent,
|
||||
|
||||
danger: transparent,
|
||||
danger_muted: transparent,
|
||||
danger_strong: transparent,
|
||||
warning: transparent,
|
||||
warning_muted: transparent,
|
||||
success: transparent,
|
||||
success_muted: transparent,
|
||||
busy: transparent,
|
||||
|
||||
surface_raised_hover: transparent,
|
||||
band: transparent,
|
||||
input_bg: transparent,
|
||||
caret: transparent,
|
||||
drop_line: transparent,
|
||||
drop_target: transparent,
|
||||
|
||||
code_text: transparent,
|
||||
code_wash: transparent,
|
||||
syntax: crate::syntax::SyntaxPalette::dark(transparent, transparent, transparent),
|
||||
diff_add: transparent,
|
||||
diff_del: transparent,
|
||||
diff_hunk_bg: transparent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn neutral() -> ColorScaleSet {
|
||||
gray()
|
||||
}
|
||||
/// Derive the legacy fields from the tokens.
|
||||
pub(crate) fn sync_legacy(&mut self, appearance: ThemeMode) {
|
||||
// Surfaces
|
||||
self.background = self.bg;
|
||||
self.surface_background = self.surface;
|
||||
self.elevated_surface_background = self.surface_card;
|
||||
self.panel_background = self.surface;
|
||||
self.overlay = ink_for(appearance, 0.10);
|
||||
self.title_bar = gpui::transparent_black();
|
||||
self.title_bar_inactive = self.bg;
|
||||
self.window_border = self.border;
|
||||
|
||||
fn brand() -> ColorScaleSet {
|
||||
yellow()
|
||||
}
|
||||
// Borders
|
||||
self.border_variant = self.border_faint;
|
||||
self.border_focused = self.border_strong;
|
||||
self.border_selected = self.border_strong;
|
||||
self.border_transparent = gpui::transparent_black();
|
||||
self.border_disabled = self.border_faint;
|
||||
|
||||
fn danger() -> ColorScaleSet {
|
||||
tomato()
|
||||
}
|
||||
// Text
|
||||
self.text_placeholder = self.text_faint;
|
||||
self.text_accent = self.accent;
|
||||
self.text_danger = self.danger;
|
||||
self.text_warning = self.warning;
|
||||
|
||||
fn warning() -> ColorScaleSet {
|
||||
orange()
|
||||
}
|
||||
// Icons
|
||||
self.icon = self.text_muted;
|
||||
self.icon_muted = self.text_faint;
|
||||
self.icon_accent = self.accent;
|
||||
|
||||
/// Make a [gpui::Hsla] color.
|
||||
///
|
||||
/// - h: 0..360.0
|
||||
/// - s: 0.0..100.0
|
||||
/// - l: 0.0..100.0
|
||||
pub(crate) fn hsl(h: f32, s: f32, l: f32) -> Hsla {
|
||||
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
|
||||
}
|
||||
// Elements
|
||||
self.element_foreground = self.on_solid;
|
||||
self.element_background = self.solid;
|
||||
self.element_selected = self.element_active;
|
||||
self.element_disabled = wash_for(appearance, 0.06);
|
||||
|
||||
type StaticColorScale = [&'static str; 12];
|
||||
// Secondary
|
||||
self.secondary_foreground = self.text;
|
||||
self.secondary_background = self.surface_raised;
|
||||
self.secondary_hover = self.element_hover;
|
||||
self.secondary_active = self.element_active;
|
||||
self.secondary_selected = self.element_active;
|
||||
self.secondary_disabled = wash_for(appearance, 0.06);
|
||||
|
||||
struct StaticColorScaleSet {
|
||||
light: StaticColorScale,
|
||||
light_alpha: StaticColorScale,
|
||||
dark: StaticColorScale,
|
||||
dark_alpha: StaticColorScale,
|
||||
}
|
||||
// Danger
|
||||
self.danger_foreground = self.on_accent;
|
||||
self.danger_background = self.danger_strong;
|
||||
self.danger_hover = plate_hover_for(appearance, self.danger_strong);
|
||||
self.danger_active = plate_active_for(appearance, self.danger_strong);
|
||||
self.danger_selected = self.danger_muted;
|
||||
self.danger_disabled = wash_for(appearance, 0.06);
|
||||
|
||||
impl TryFrom<StaticColorScaleSet> for ColorScaleSet {
|
||||
type Error = anyhow::Error;
|
||||
// Warning
|
||||
self.warning_foreground = self.text;
|
||||
self.warning_background = self.warning_muted;
|
||||
self.warning_hover = self.element_hover;
|
||||
self.warning_active = self.element_active;
|
||||
self.warning_selected = self.element_active;
|
||||
self.warning_disabled = wash_for(appearance, 0.06);
|
||||
|
||||
fn try_from(value: StaticColorScaleSet) -> Result<Self, Self::Error> {
|
||||
fn to_color_scale(scale: StaticColorScale) -> Result<ColorScale, anyhow::Error> {
|
||||
scale
|
||||
.into_iter()
|
||||
.map(|color| Rgba::try_from(color).map(Hsla::from))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(ColorScale::from_iter)
|
||||
}
|
||||
// Ghost
|
||||
self.ghost_element_background = gpui::transparent_black();
|
||||
self.ghost_element_background_alt = self.surface_raised;
|
||||
self.ghost_element_hover = self.element_hover;
|
||||
self.ghost_element_active = self.element_active;
|
||||
self.ghost_element_selected = self.element_active;
|
||||
self.ghost_element_disabled = wash_for(appearance, 0.06);
|
||||
|
||||
Ok(Self::new(
|
||||
to_color_scale(value.light)?,
|
||||
to_color_scale(value.light_alpha)?,
|
||||
to_color_scale(value.dark)?,
|
||||
to_color_scale(value.dark_alpha)?,
|
||||
))
|
||||
// Tabs
|
||||
self.tab_background = self.surface;
|
||||
self.tab_foreground = self.text_muted;
|
||||
self.tab_hover_background = self.element_hover;
|
||||
self.tab_active_background = self.bg;
|
||||
self.tab_active_foreground = self.text;
|
||||
|
||||
// Scrollbar
|
||||
self.scrollbar_thumb_background = ink_for(appearance, 0.20);
|
||||
self.scrollbar_thumb_hover_background = ink_for(appearance, 0.30);
|
||||
self.scrollbar_thumb_border = gpui::transparent_black();
|
||||
self.scrollbar_track_background = gpui::transparent_black();
|
||||
self.scrollbar_track_border = self.border_faint;
|
||||
|
||||
// Interactive
|
||||
self.drop_target_background = self.drop_target;
|
||||
}
|
||||
|
||||
/// Best-effort re-derivation of the tokens from a legacy palette, for
|
||||
/// old-schema JSON themes loaded through the registry. Fields with no
|
||||
/// legacy source (`success`, `busy`, the syntax palette, the diff colors)
|
||||
/// keep the built-in palette's value for `appearance`.
|
||||
pub fn rederive_tokens(&mut self, appearance: ThemeMode) {
|
||||
let base = Self::for_appearance(appearance);
|
||||
self.success = base.success;
|
||||
self.success_muted = base.success_muted;
|
||||
self.busy = base.busy;
|
||||
self.syntax = base.syntax;
|
||||
self.diff_add = base.diff_add;
|
||||
self.diff_del = base.diff_del;
|
||||
self.diff_hunk_bg = base.diff_hunk_bg;
|
||||
|
||||
self.bg = self.background;
|
||||
self.surface = self.surface_background;
|
||||
self.surface_raised = self.elevated_surface_background;
|
||||
self.surface_card = self.elevated_surface_background;
|
||||
self.surface_dialog = self.elevated_surface_background;
|
||||
self.surface_overlay = self.elevated_surface_background;
|
||||
|
||||
self.border_faint = self.border_variant;
|
||||
self.border_strong = self.border_focused;
|
||||
|
||||
self.text_faint = self.text_placeholder;
|
||||
self.text_dim = self.text_muted;
|
||||
|
||||
self.solid = self.element_background;
|
||||
self.on_solid = self.element_foreground;
|
||||
|
||||
self.accent = self.text_accent;
|
||||
self.accent_strong = self.element_background;
|
||||
self.on_accent = self.element_foreground;
|
||||
|
||||
self.danger = self.text_danger;
|
||||
self.danger_muted = self.text_danger;
|
||||
self.danger_strong = self.danger_background;
|
||||
|
||||
self.warning = self.text_warning;
|
||||
self.warning_muted = self.warning_background;
|
||||
|
||||
self.surface_raised_hover = self.element_hover;
|
||||
self.band = self.ghost_element_background_alt;
|
||||
self.input_bg = self.surface_background;
|
||||
self.caret = self.cursor;
|
||||
self.drop_line = self.border_focused;
|
||||
self.drop_target = self.drop_target_background;
|
||||
|
||||
self.code_text = self.text;
|
||||
self.code_wash = self.ghost_element_hover;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gray() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fcfcfcff",
|
||||
"#f9f9f9ff",
|
||||
"#f0f0f0ff",
|
||||
"#e8e8e8ff",
|
||||
"#e0e0e0ff",
|
||||
"#d9d9d9ff",
|
||||
"#cececeff",
|
||||
"#bbbbbbff",
|
||||
"#8d8d8dff",
|
||||
"#838383ff",
|
||||
"#646464ff",
|
||||
"#202020ff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#00000003",
|
||||
"#00000006",
|
||||
"#0000000f",
|
||||
"#00000017",
|
||||
"#0000001f",
|
||||
"#00000026",
|
||||
"#00000031",
|
||||
"#00000044",
|
||||
"#00000072",
|
||||
"#0000007c",
|
||||
"#0000009b",
|
||||
"#000000df",
|
||||
],
|
||||
dark: [
|
||||
"#111111ff",
|
||||
"#191919ff",
|
||||
"#222222ff",
|
||||
"#2a2a2aff",
|
||||
"#313131ff",
|
||||
"#3a3a3aff",
|
||||
"#484848ff",
|
||||
"#606060ff",
|
||||
"#6e6e6eff",
|
||||
"#7b7b7bff",
|
||||
"#b4b4b4ff",
|
||||
"#eeeeeeff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#00000000",
|
||||
"#ffffff09",
|
||||
"#ffffff12",
|
||||
"#ffffff1b",
|
||||
"#ffffff22",
|
||||
"#ffffff2c",
|
||||
"#ffffff3b",
|
||||
"#ffffff55",
|
||||
"#ffffff64",
|
||||
"#ffffff72",
|
||||
"#ffffffaf",
|
||||
"#ffffffed",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn yellow() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fdfdf9ff",
|
||||
"#fefce9ff",
|
||||
"#fffab8ff",
|
||||
"#fff394ff",
|
||||
"#ffe770ff",
|
||||
"#f3d768ff",
|
||||
"#e4c767ff",
|
||||
"#d5ae39ff",
|
||||
"#ffe629ff",
|
||||
"#ffdc00ff",
|
||||
"#9e6c00ff",
|
||||
"#473b1fff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#aaaa0006",
|
||||
"#f4dd0016",
|
||||
"#ffee0047",
|
||||
"#ffe3016b",
|
||||
"#ffd5008f",
|
||||
"#ebbc0097",
|
||||
"#d2a10098",
|
||||
"#c99700c6",
|
||||
"#ffe100d6",
|
||||
"#ffdc00ff",
|
||||
"#9e6c00ff",
|
||||
"#2e2000e0",
|
||||
],
|
||||
dark: [
|
||||
"#14120bff",
|
||||
"#1b180fff",
|
||||
"#2d2305ff",
|
||||
"#362b00ff",
|
||||
"#433500ff",
|
||||
"#524202ff",
|
||||
"#665417ff",
|
||||
"#836a21ff",
|
||||
"#ffe629ff",
|
||||
"#ffff57ff",
|
||||
"#f5e147ff",
|
||||
"#f6eeb4ff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#d1510004",
|
||||
"#f9b4000b",
|
||||
"#ffaa001e",
|
||||
"#fdb70028",
|
||||
"#febb0036",
|
||||
"#fec40046",
|
||||
"#fdcb225c",
|
||||
"#fdca327b",
|
||||
"#ffe629ff",
|
||||
"#ffff57ff",
|
||||
"#fee949f5",
|
||||
"#fef6baf6",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn orange() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fefcfbff",
|
||||
"#fff7edff",
|
||||
"#ffefd6ff",
|
||||
"#ffdfb5ff",
|
||||
"#ffd19aff",
|
||||
"#ffc182ff",
|
||||
"#f5ae73ff",
|
||||
"#ec9455ff",
|
||||
"#f76b15ff",
|
||||
"#ef5f00ff",
|
||||
"#cc4e00ff",
|
||||
"#582d1dff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#c0400004",
|
||||
"#ff8e0012",
|
||||
"#ff9c0029",
|
||||
"#ff91014a",
|
||||
"#ff8b0065",
|
||||
"#ff81007d",
|
||||
"#ed6c008c",
|
||||
"#e35f00aa",
|
||||
"#f65e00ea",
|
||||
"#ef5f00ff",
|
||||
"#cc4e00ff",
|
||||
"#431200e2",
|
||||
],
|
||||
dark: [
|
||||
"#17120eff",
|
||||
"#1e160fff",
|
||||
"#331e0bff",
|
||||
"#462100ff",
|
||||
"#562800ff",
|
||||
"#66350cff",
|
||||
"#7e451dff",
|
||||
"#a35829ff",
|
||||
"#f76b15ff",
|
||||
"#ff801fff",
|
||||
"#ffa057ff",
|
||||
"#ffe0c2ff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#ec360007",
|
||||
"#fe6d000e",
|
||||
"#fb6a0025",
|
||||
"#ff590039",
|
||||
"#ff61004a",
|
||||
"#fd75045c",
|
||||
"#ff832c75",
|
||||
"#fe84389d",
|
||||
"#fe6d15f7",
|
||||
"#ff801fff",
|
||||
"#ffa057ff",
|
||||
"#ffe0c2ff",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn tomato() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fffcfcff",
|
||||
"#fff8f7ff",
|
||||
"#feebe7ff",
|
||||
"#ffdcd3ff",
|
||||
"#ffcdc2ff",
|
||||
"#fdbdafff",
|
||||
"#f5a898ff",
|
||||
"#ec8e7bff",
|
||||
"#e54d2eff",
|
||||
"#dd4425ff",
|
||||
"#d13415ff",
|
||||
"#5c271fff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#ff000003",
|
||||
"#ff200008",
|
||||
"#f52b0018",
|
||||
"#ff35002c",
|
||||
"#ff2e003d",
|
||||
"#f92d0050",
|
||||
"#e7280067",
|
||||
"#db250084",
|
||||
"#df2600d1",
|
||||
"#d72400da",
|
||||
"#cd2200ea",
|
||||
"#460900e0",
|
||||
],
|
||||
dark: [
|
||||
"#181111ff",
|
||||
"#1f1513ff",
|
||||
"#391714ff",
|
||||
"#4e1511ff",
|
||||
"#5e1c16ff",
|
||||
"#6e2920ff",
|
||||
"#853a2dff",
|
||||
"#ac4d39ff",
|
||||
"#e54d2eff",
|
||||
"#ec6142ff",
|
||||
"#ff977dff",
|
||||
"#fbd3cbff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#f1121208",
|
||||
"#ff55330f",
|
||||
"#ff35232b",
|
||||
"#fd201142",
|
||||
"#fe332153",
|
||||
"#ff4f3864",
|
||||
"#fd644a7d",
|
||||
"#fe6d4ea7",
|
||||
"#fe5431e4",
|
||||
"#ff6847eb",
|
||||
"#ff977dff",
|
||||
"#ffd6cefb",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Layout constants, ported from bezel
|
||||
//! (`crates/theme/src/theme/layout.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors). Numbers drive layout, colors are paint: these live as plain
|
||||
//! numbers and never depend on which color is painted.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// The branded base radius behind every radius accessor, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
|
||||
|
||||
/// Point the radius accessors at a base. Called when a palette is installed.
|
||||
pub(crate) fn set_base_radius(radius: f32) {
|
||||
BASE.store(radius.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
|
||||
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
|
||||
/// all report 8.
|
||||
pub const SPACE: f32 = 8.0;
|
||||
|
||||
/// The margin from content to its container's edge. Same measurement,
|
||||
/// visual format's `|-`.
|
||||
pub const CONTENT_MARGIN: f32 = 20.0;
|
||||
|
||||
/// From a column's edge to the glyph of the control at the end of a row,
|
||||
/// measured to the glyph rather than the button. Mirrored at the trailing end.
|
||||
pub const EDGE: f32 = 12.0;
|
||||
|
||||
/// Button, text field and select-trigger height. Measured:
|
||||
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
|
||||
/// `.regular` — the body line box with 4 above and below.
|
||||
pub const BUTTON_HEIGHT: f32 = 24.0;
|
||||
|
||||
/// The same controls at `.small`.
|
||||
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
|
||||
|
||||
/// Every radius below is a ratio of this, so a brand radius moves the whole
|
||||
/// set together and keeps the concentric relationships intact.
|
||||
pub const BASE_RADIUS: f32 = 8.0;
|
||||
|
||||
/// Message bubble corner radius (2x).
|
||||
pub fn bubble_radius() -> f32 {
|
||||
radius(2.0)
|
||||
}
|
||||
|
||||
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
|
||||
pub fn surface_radius() -> f32 {
|
||||
radius(1.5)
|
||||
}
|
||||
|
||||
/// Panel / card corner radius (1.25x).
|
||||
pub fn panel_radius() -> f32 {
|
||||
radius(1.25)
|
||||
}
|
||||
|
||||
/// Button, text field and select-trigger radius (1x).
|
||||
pub fn button_radius() -> f32 {
|
||||
radius(1.0)
|
||||
}
|
||||
|
||||
/// Small control radius (chips, tags, steppers) — a size down from
|
||||
/// [`button_radius`], for things that sit inside a control rather than being one
|
||||
/// (0.75x).
|
||||
pub fn control_radius() -> f32 {
|
||||
radius(0.75)
|
||||
}
|
||||
|
||||
/// A corner as a multiple of the branded base radius.
|
||||
///
|
||||
/// Read from a process-wide mirror rather than the theme global for the reason
|
||||
/// [`crate::paint::current_appearance`] is: the element builders that round a
|
||||
/// corner are free functions with no `cx` in scope, and a radius is one number
|
||||
/// for the whole app.
|
||||
fn radius(ratio: f32) -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
|
||||
}
|
||||
|
||||
/// The concentric child of a surface: a row inset by `inset` inside a
|
||||
/// container of radius `outer` keeps its corners parallel to the container's,
|
||||
/// rather than looking pasted onto it.
|
||||
///
|
||||
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
|
||||
/// has no container shape to inherit at paint time, so the relationship is
|
||||
/// stated where the child is *defined* instead of resolved at runtime.
|
||||
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
|
||||
if outer > inset { outer - inset } else { 0.0 }
|
||||
}
|
||||
+141
-33
@@ -1,26 +1,53 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::rc::Rc;
|
||||
|
||||
use appearance::AppearanceMode;
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod brand;
|
||||
mod color;
|
||||
mod colors;
|
||||
mod geometry;
|
||||
mod layout;
|
||||
mod notification;
|
||||
mod paint;
|
||||
mod palette;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
mod scrollbar_mode;
|
||||
mod syntax;
|
||||
mod theme;
|
||||
|
||||
pub mod appearance;
|
||||
|
||||
pub use brand::{BASE_COLORS, Brand, Ink, TextInk, Tint};
|
||||
pub use color::{
|
||||
contrast_ratio, flatten, grey, hsl_to_rgb, lightness, mix, neutral, oklch, oklch_to_srgb,
|
||||
relative_luminance, rgb_to_hsl, tint,
|
||||
};
|
||||
pub use colors::*;
|
||||
pub use geometry::*;
|
||||
pub use layout::{
|
||||
BASE_RADIUS, BUTTON_HEIGHT, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SPACE, bubble_radius,
|
||||
button_radius, control_radius, inset_radius, panel_radius, surface_radius,
|
||||
};
|
||||
pub use notification::*;
|
||||
pub use paint::{
|
||||
INK_FILL_SCALE, INK_HAIRLINE_SCALE, SCRIM_ALPHA_DARK, band, current_appearance, hairline, ink,
|
||||
scrim, set_current_appearance, surface_shadows, wash,
|
||||
};
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
pub use scrollbar_mode::*;
|
||||
pub use syntax::{HighlightKind, SyntaxPalette};
|
||||
pub use theme::*;
|
||||
|
||||
/// Which appearance the app is painting. Bezel names this enum `Appearance`;
|
||||
/// coop's `ThemeMode` predates the port and carries the same two variants.
|
||||
pub type Appearance = ThemeMode;
|
||||
|
||||
/// Defines window border radius for platforms that use client side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
|
||||
|
||||
@@ -41,24 +68,64 @@ pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
registry::init(cx);
|
||||
|
||||
Theme::sync_system_appearance(None, cx);
|
||||
appearance::init(AppearanceMode::System, cx);
|
||||
Theme::sync_scrollbar_appearance(cx);
|
||||
}
|
||||
|
||||
/// An alternative palette source. Runs first on every appearance install, so
|
||||
/// an override survives a light/dark switch.
|
||||
pub type PaletteBuilder = fn(ThemeMode) -> ThemeColors;
|
||||
|
||||
struct GlobalPaletteBuilder(Option<PaletteBuilder>);
|
||||
|
||||
impl Global for GlobalPaletteBuilder {}
|
||||
|
||||
/// Install a brand or an alternative palette on the app.
|
||||
pub trait AppExt {
|
||||
/// Install a brand and repaint. Call before [`appearance::init`] to have
|
||||
/// the first frame already branded.
|
||||
fn set_brand(&mut self, brand: Brand);
|
||||
/// Install an alternative palette builder and repaint. The builder runs
|
||||
/// first on every appearance install.
|
||||
fn set_palette(&mut self, build: PaletteBuilder);
|
||||
/// Change the appearance preference (`System`, `Light` or `Dark`).
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn set_brand(&mut self, brand: Brand) {
|
||||
self.set_global(brand);
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_palette(&mut self, build: PaletteBuilder) {
|
||||
self.set_global(GlobalPaletteBuilder(Some(build)));
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode) {
|
||||
appearance::set_mode(mode, self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-install the palette for the current appearance.
|
||||
fn reinstall(cx: &mut App) {
|
||||
let mode = cx
|
||||
.try_global::<Theme>()
|
||||
.map(|theme| theme.mode)
|
||||
.unwrap_or_default();
|
||||
Theme::change(mode, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
|
||||
/// Mirror the active coop theme into the `gpui-base` global theme.
|
||||
///
|
||||
/// Base paints a few things from its own tokens -- the focus ring, the wash
|
||||
/// behind selected text, scrollbars, and overlay backdrops -- so the two
|
||||
/// globals have to agree or those details drift away from the palette.
|
||||
///
|
||||
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
|
||||
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
|
||||
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
|
||||
/// mapping would be invented rather than derived.
|
||||
///
|
||||
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
|
||||
/// authoritative hook that keeps the projection current.
|
||||
/// This is a no-op before the coop theme global exists; [`Theme::change`] is
|
||||
/// the authoritative hook that keeps the projection current.
|
||||
pub fn sync_base(cx: &mut App) {
|
||||
let Some(theme) = cx.try_global::<Theme>() else {
|
||||
return;
|
||||
@@ -83,20 +150,20 @@ pub fn sync_base(cx: &mut App) {
|
||||
base.tokens.typography.sans = font_family;
|
||||
|
||||
let tokens = &mut base.tokens.colors;
|
||||
tokens.background = colors.background;
|
||||
tokens.background = colors.bg;
|
||||
tokens.foreground = colors.text;
|
||||
tokens.surface = colors.surface_background;
|
||||
tokens.surface = colors.surface;
|
||||
tokens.surface_foreground = colors.text;
|
||||
tokens.primary = colors.element_background;
|
||||
tokens.primary_foreground = colors.element_foreground;
|
||||
tokens.secondary = colors.secondary_background;
|
||||
tokens.secondary_foreground = colors.secondary_foreground;
|
||||
tokens.muted = colors.ghost_element_background_alt;
|
||||
tokens.primary = colors.solid;
|
||||
tokens.primary_foreground = colors.on_solid;
|
||||
tokens.secondary = colors.surface_raised;
|
||||
tokens.secondary_foreground = colors.text;
|
||||
tokens.muted = colors.surface_card;
|
||||
tokens.muted_foreground = colors.text_muted;
|
||||
tokens.accent = colors.ghost_element_hover;
|
||||
tokens.accent = colors.element_hover;
|
||||
tokens.accent_foreground = colors.text;
|
||||
tokens.destructive = colors.danger_background;
|
||||
tokens.destructive_foreground = colors.danger_foreground;
|
||||
tokens.destructive = colors.danger_strong;
|
||||
tokens.destructive_foreground = colors.on_accent;
|
||||
tokens.border = colors.border;
|
||||
tokens.input = colors.border;
|
||||
tokens.ring = colors.ring;
|
||||
@@ -128,6 +195,13 @@ pub struct Theme {
|
||||
/// The font family for the application.
|
||||
pub font_family: SharedString,
|
||||
|
||||
/// The family prose is set in — what markdown shapes a document's body
|
||||
/// with. Defaults to `font_family`.
|
||||
pub font_body: SharedString,
|
||||
|
||||
/// Monospace family for code and terminal.
|
||||
pub font_mono: SharedString,
|
||||
|
||||
/// The root font size for the application, default is 15px.
|
||||
pub font_size: Pixels,
|
||||
|
||||
@@ -181,12 +255,7 @@ impl Theme {
|
||||
|
||||
/// Sync the theme with the system appearance
|
||||
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
|
||||
let appearance = window
|
||||
.as_ref()
|
||||
.map(|window| window.appearance())
|
||||
.unwrap_or_else(|| cx.window_appearance());
|
||||
|
||||
Self::change(appearance, window, cx);
|
||||
appearance::record_system_appearance(window.as_deref(), cx);
|
||||
}
|
||||
|
||||
/// Sync the Scrollbar showing behavior with the system
|
||||
@@ -221,23 +290,47 @@ impl Theme {
|
||||
}
|
||||
|
||||
let mode = mode.into();
|
||||
let theme = cx.global_mut::<Theme>();
|
||||
let builder = cx
|
||||
.try_global::<GlobalPaletteBuilder>()
|
||||
.and_then(|builder| builder.0);
|
||||
let mut colors = match builder {
|
||||
Some(build) => build(mode),
|
||||
None => {
|
||||
let family = Theme::global_mut(cx).theme.clone();
|
||||
if mode.is_dark() {
|
||||
*family.dark()
|
||||
} else {
|
||||
*family.light()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// A default brand changes nothing, so a JSON theme's own colors are
|
||||
// left alone; anything else rotates the tokens and re-derives the
|
||||
// legacy fields from them.
|
||||
let installed_brand = brand::installed(cx);
|
||||
if !installed_brand.is_default() {
|
||||
installed_brand.apply(mode, &mut colors);
|
||||
colors.sync_legacy(mode);
|
||||
}
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
|
||||
// Set the theme mode
|
||||
theme.mode = mode;
|
||||
|
||||
// Set the theme colors
|
||||
if mode.is_dark() {
|
||||
theme.colors = *theme.theme.dark();
|
||||
} else {
|
||||
theme.colors = *theme.theme.light();
|
||||
}
|
||||
theme.colors = colors;
|
||||
|
||||
// Refresh the window if available
|
||||
if let Some(window) = window {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Keep the context-free paint helpers and the radius accessors in step
|
||||
paint::set_current_appearance(mode);
|
||||
layout::set_base_radius(installed_brand.radius);
|
||||
|
||||
// Keep the base-layer projection in step with the coop palette
|
||||
sync_base(cx);
|
||||
}
|
||||
@@ -263,8 +356,10 @@ impl From<ThemeFamily> for Theme {
|
||||
Theme {
|
||||
font_size: px(15.),
|
||||
font_family: font_family.into(),
|
||||
radius: px(6.),
|
||||
radius_lg: px(10.),
|
||||
font_body: font_family.into(),
|
||||
font_mono: system_mono().into(),
|
||||
radius: px(layout::button_radius()),
|
||||
radius_lg: px(layout::surface_radius()),
|
||||
shadow: true,
|
||||
scrollbar_mode: ScrollbarMode::default(),
|
||||
notification: NotificationSettings::default(),
|
||||
@@ -274,3 +369,16 @@ impl From<ThemeFamily> for Theme {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mono face has no alias of its own, so each backend is named here.
|
||||
fn system_mono() -> &'static str {
|
||||
if cfg!(target_family = "wasm") {
|
||||
".ZedMono"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
".AppleSystemUIFontMonospaced"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Cascadia Mono"
|
||||
} else {
|
||||
"DejaVu Sans Mono"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The two concrete palettes: dark and light. Ported from bezel
|
||||
//! (`crates/theme/src/theme/palettes.rs`, MIT License, Copyright (c) 2026
|
||||
//! bezel contributors).
|
||||
//!
|
||||
//! Light is designed, not inverted — mirroring lightness reverses surface
|
||||
//! order, elevation and accent contrast. Each light text token lands within
|
||||
//! ~0.5 of its dark counterpart's ratio; `tests/contrast.rs` asserts it.
|
||||
|
||||
use gpui::{hsla, rgb};
|
||||
|
||||
use crate::paint::{band_for, hairline_for};
|
||||
use crate::syntax::SyntaxPalette;
|
||||
use crate::{Ink, TextInk, ThemeColors, ThemeMode, color};
|
||||
|
||||
/// Build the dark palette. The surface tones are sampled from bezel's
|
||||
/// reference screenshots: main panel `#060606`, shell/sidebar `#0d0d0d`.
|
||||
pub(crate) fn dark() -> ThemeColors {
|
||||
let mut colors = ThemeColors {
|
||||
// ---- neutral surfaces ----
|
||||
bg: color::grey(6), // main panel — #060606
|
||||
surface: color::grey(13), // shell / sidebar — #0d0d0d
|
||||
surface_raised: color::neutral(0.235),
|
||||
surface_card: color::grey(0x0e),
|
||||
surface_dialog: color::grey(0x10),
|
||||
surface_overlay: color::grey(0x16),
|
||||
// The reference app's `--color-hover`, pure ink: white at 8%.
|
||||
element_hover: hsla(0.0, 0.0, 1.0, 0.08),
|
||||
// `--color-active`, a rung above the hover: white at 12%.
|
||||
element_active: hsla(0.0, 0.0, 1.0, 0.12),
|
||||
|
||||
// ---- hairlines ----
|
||||
border_faint: hairline_for(ThemeMode::Dark, 0.06),
|
||||
border: hairline_for(ThemeMode::Dark, 0.08),
|
||||
border_strong: hairline_for(ThemeMode::Dark, 0.14),
|
||||
|
||||
// ---- text ----
|
||||
text: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.text),
|
||||
text_muted: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.muted),
|
||||
text_faint: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.faint),
|
||||
text_dim: color::grey(0x98),
|
||||
|
||||
// ---- high-contrast solid ----
|
||||
solid: color::neutral(0.922),
|
||||
on_solid: color::grey(0x0e),
|
||||
|
||||
// ---- accents, neutral by default ----
|
||||
accent: color::neutral(0.673),
|
||||
accent_strong: color::neutral(0.922),
|
||||
on_accent: color::grey(0x0e),
|
||||
|
||||
// ---- semantic ----
|
||||
danger: color::oklch(0.704, 0.191, 22.216), // red-400
|
||||
danger_muted: color::oklch(0.808, 0.114, 19.571), // red-300
|
||||
danger_strong: color::oklch(0.58, 0.16, 25.0),
|
||||
warning: color::oklch(0.828, 0.189, 84.429), // amber-400
|
||||
warning_muted: color::oklch(0.924, 0.12, 95.746), // amber-200
|
||||
success: color::oklch(0.765, 0.177, 163.223), // emerald-400
|
||||
success_muted: color::oklch(0.845, 0.143, 164.978), // emerald-300
|
||||
busy: color::oklch(0.718, 0.202, 349.761), // pink-400
|
||||
|
||||
// ---- components ----
|
||||
surface_raised_hover: color::neutral(0.29),
|
||||
band: band_for(ThemeMode::Dark),
|
||||
input_bg: hsla(0.0, 0.0, 1.0, 0.03),
|
||||
selection: rgb(0x3f638b).into(),
|
||||
cursor: color::neutral(0.94),
|
||||
caret: color::neutral(0.673),
|
||||
ring: hairline_for(ThemeMode::Dark, 0.35),
|
||||
drop_line: hairline_for(ThemeMode::Dark, 0.35),
|
||||
drop_target: hsla(0.0, 0.0, 1.0, 0.10),
|
||||
|
||||
// ---- code & diff ----
|
||||
code_text: color::neutral(0.94),
|
||||
code_wash: hsla(0.0, 0.0, 1.0, 0.08),
|
||||
syntax: SyntaxPalette::dark(
|
||||
color::neutral(0.922),
|
||||
color::neutral(0.60),
|
||||
color::oklch(0.704, 0.191, 22.216),
|
||||
),
|
||||
diff_add: color::oklch(0.765, 0.177, 163.223), // emerald-400
|
||||
diff_del: color::oklch(0.704, 0.191, 22.216), // red-400
|
||||
diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
|
||||
|
||||
..ThemeColors::legacy_default()
|
||||
};
|
||||
colors.sync_legacy(ThemeMode::Dark);
|
||||
colors
|
||||
}
|
||||
|
||||
/// Build the light palette. Neutrals are the same oklch scale read from the
|
||||
/// other end, but the *roles* are reassigned rather than mirrored: content
|
||||
/// plane white, chrome grey, raised surfaces white-plus-shadow. Text tones
|
||||
/// reproduce the dark theme's contrast ratios, and accents drop from the 400
|
||||
/// to the 600 step at identical hue so they clear WCAG AA on white.
|
||||
pub(crate) fn light() -> ThemeColors {
|
||||
let mut colors = ThemeColors {
|
||||
// ---- neutral surfaces ----
|
||||
bg: color::grey(0xff), // main panel — clean white
|
||||
// Deeper than ~neutral-100 looks on paper: the content card is pure
|
||||
// white and sits *inside* this surface, so too small a step leaves the
|
||||
// whole window one flat sheet with a hairline drawn on it.
|
||||
surface: color::neutral(0.968),
|
||||
// A real grey, NOT white: this is the opaque-plate tone, and those sit
|
||||
// directly on the white content plane with no border or shadow to save
|
||||
// them.
|
||||
surface_raised: color::neutral(0.940),
|
||||
surface_card: color::grey(0xff),
|
||||
surface_dialog: color::grey(0xff),
|
||||
surface_overlay: color::grey(0xff),
|
||||
// The same token in light: black at 4%.
|
||||
element_hover: hsla(0.0, 0.0, 0.0, 0.04),
|
||||
// The same rung in light: black at 6%.
|
||||
element_active: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
|
||||
// ---- hairlines ----
|
||||
border_faint: hairline_for(ThemeMode::Light, 0.06),
|
||||
border: hairline_for(ThemeMode::Light, 0.08),
|
||||
border_strong: hairline_for(ThemeMode::Light, 0.14),
|
||||
|
||||
// ---- text ----
|
||||
text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text),
|
||||
text_muted: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.muted),
|
||||
text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint),
|
||||
text_dim: color::neutral(0.50),
|
||||
|
||||
// ---- high-contrast solid ----
|
||||
solid: color::neutral(0.205),
|
||||
on_solid: color::neutral(0.985),
|
||||
|
||||
// ---- accents, neutral by default ----
|
||||
accent: color::neutral(0.511),
|
||||
accent_strong: color::neutral(0.205),
|
||||
on_accent: color::neutral(0.985),
|
||||
|
||||
// ---- semantic ----
|
||||
danger: color::oklch(0.577, 0.245, 27.325), // red-600
|
||||
danger_muted: color::oklch(0.505, 0.213, 27.518), // red-700
|
||||
danger_strong: color::oklch(0.51, 0.20, 25.0),
|
||||
warning: color::oklch(0.555, 0.163, 48.998), // amber-700 — carries 12px text
|
||||
warning_muted: color::oklch(0.473, 0.137, 46.201), // amber-800
|
||||
success: color::oklch(0.596, 0.145, 163.225), // emerald-600
|
||||
success_muted: color::oklch(0.508, 0.118, 165.612), // emerald-700
|
||||
busy: color::oklch(0.592, 0.249, 0.584), // pink-600
|
||||
|
||||
// ---- components ----
|
||||
// Opaque pills darken on hover here rather than brighten — same
|
||||
// "brighten the plate, don't wash it out" rule, read the other way.
|
||||
surface_raised_hover: color::neutral(0.900),
|
||||
band: band_for(ThemeMode::Light),
|
||||
input_bg: color::grey(0xff),
|
||||
selection: rgb(0xb3d7ff).into(),
|
||||
cursor: color::neutral(0.205),
|
||||
caret: color::neutral(0.511),
|
||||
ring: hairline_for(ThemeMode::Light, 0.35),
|
||||
drop_line: hairline_for(ThemeMode::Light, 0.35),
|
||||
drop_target: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
|
||||
// ---- code & diff ----
|
||||
code_text: color::neutral(0.18),
|
||||
code_wash: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
syntax: SyntaxPalette::light(
|
||||
color::neutral(0.25),
|
||||
color::neutral(0.48),
|
||||
color::oklch(0.505, 0.213, 27.518),
|
||||
),
|
||||
diff_add: color::oklch(0.596, 0.145, 163.225), // emerald-600
|
||||
diff_del: color::oklch(0.577, 0.245, 27.325), // red-600
|
||||
diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
|
||||
|
||||
..ThemeColors::legacy_default()
|
||||
};
|
||||
colors.sync_legacy(ThemeMode::Light);
|
||||
colors
|
||||
}
|
||||
|
||||
pub(crate) fn for_appearance(appearance: ThemeMode) -> ThemeColors {
|
||||
match appearance {
|
||||
ThemeMode::Dark => dark(),
|
||||
ThemeMode::Light => light(),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
|
||||
|
||||
use crate::ThemeFamily;
|
||||
use crate::{ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
|
||||
@@ -57,8 +57,11 @@ impl ThemeRegistry {
|
||||
// Load the theme file from the assets
|
||||
let content = asset.load(path)?.context("Theme not found")?;
|
||||
|
||||
// Parse the JSON content into a Theme Family struct
|
||||
let theme: ThemeFamily = serde_json::from_slice(&content)?;
|
||||
// Parse the JSON content into a Theme Family struct, and re-derive the
|
||||
// tokens from the legacy fields the file carries.
|
||||
let mut theme: ThemeFamily = serde_json::from_slice(&content)?;
|
||||
theme.light.rederive_tokens(ThemeMode::Light);
|
||||
theme.dark.rederive_tokens(ThemeMode::Dark);
|
||||
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ impl ColorScale {
|
||||
}
|
||||
|
||||
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale.
|
||||
///
|
||||
/// No longer used to build the built-in palettes; kept public until the
|
||||
/// deprecation window closes (docs/ui-theme-bezel-migration.md, Phase 5).
|
||||
#[allow(dead_code)]
|
||||
pub struct ColorScaleSet {
|
||||
light: ColorScale,
|
||||
dark: ColorScale,
|
||||
@@ -144,6 +148,7 @@ pub struct ColorScaleSet {
|
||||
dark_alpha: ColorScale,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ColorScaleSet {
|
||||
pub(crate) fn new(
|
||||
light: ColorScale,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Paint-only syntax colors, ported from bezel
|
||||
//! (`crates/theme/src/theme/syntax.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors). The hues follow the Git history graph's lane palette
|
||||
//! (indigo, pink, emerald, amber, red, neutral), while light-mode variants are
|
||||
//! darkened enough to remain readable as text on white.
|
||||
|
||||
use gpui::Hsla;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::color;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SyntaxPalette {
|
||||
pub comment: Hsla,
|
||||
pub keyword: Hsla,
|
||||
pub string: Hsla,
|
||||
pub string_special: Hsla,
|
||||
pub escape: Hsla,
|
||||
pub number: Hsla,
|
||||
pub boolean: Hsla,
|
||||
pub type_name: Hsla,
|
||||
pub type_builtin: Hsla,
|
||||
pub constructor: Hsla,
|
||||
pub function: Hsla,
|
||||
pub function_builtin: Hsla,
|
||||
pub macro_name: Hsla,
|
||||
pub property: Hsla,
|
||||
pub constant: Hsla,
|
||||
pub variable: Hsla,
|
||||
pub variable_special: Hsla,
|
||||
pub parameter: Hsla,
|
||||
pub operator: Hsla,
|
||||
pub punctuation: Hsla,
|
||||
pub tag: Hsla,
|
||||
pub attribute: Hsla,
|
||||
pub label: Hsla,
|
||||
pub invalid: Hsla,
|
||||
}
|
||||
|
||||
/// The token category a highlighter paints with, mirroring [`SyntaxPalette`]
|
||||
/// field-for-field so classification can name a kind without knowing its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HighlightKind {
|
||||
Comment,
|
||||
Keyword,
|
||||
String,
|
||||
StringSpecial,
|
||||
Escape,
|
||||
Number,
|
||||
Boolean,
|
||||
TypeName,
|
||||
TypeBuiltin,
|
||||
Constructor,
|
||||
Function,
|
||||
FunctionBuiltin,
|
||||
MacroName,
|
||||
Property,
|
||||
Constant,
|
||||
Variable,
|
||||
VariableSpecial,
|
||||
Parameter,
|
||||
Operator,
|
||||
Punctuation,
|
||||
Tag,
|
||||
Attribute,
|
||||
Label,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl SyntaxPalette {
|
||||
/// The palette color for a token kind.
|
||||
pub fn color(&self, kind: HighlightKind) -> Hsla {
|
||||
match kind {
|
||||
HighlightKind::Comment => self.comment,
|
||||
HighlightKind::Keyword => self.keyword,
|
||||
HighlightKind::String => self.string,
|
||||
HighlightKind::StringSpecial => self.string_special,
|
||||
HighlightKind::Escape => self.escape,
|
||||
HighlightKind::Number => self.number,
|
||||
HighlightKind::Boolean => self.boolean,
|
||||
HighlightKind::TypeName => self.type_name,
|
||||
HighlightKind::TypeBuiltin => self.type_builtin,
|
||||
HighlightKind::Constructor => self.constructor,
|
||||
HighlightKind::Function => self.function,
|
||||
HighlightKind::FunctionBuiltin => self.function_builtin,
|
||||
HighlightKind::MacroName => self.macro_name,
|
||||
HighlightKind::Property => self.property,
|
||||
HighlightKind::Constant => self.constant,
|
||||
HighlightKind::Variable => self.variable,
|
||||
HighlightKind::VariableSpecial => self.variable_special,
|
||||
HighlightKind::Parameter => self.parameter,
|
||||
HighlightKind::Operator => self.operator,
|
||||
HighlightKind::Punctuation => self.punctuation,
|
||||
HighlightKind::Tag => self.tag,
|
||||
HighlightKind::Attribute => self.attribute,
|
||||
HighlightKind::Label => self.label,
|
||||
HighlightKind::Invalid => self.invalid,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dark(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
|
||||
let indigo = git_graph_tone(color::oklch(0.673, 0.182, 276.935));
|
||||
let pink = git_graph_tone(color::oklch(0.718, 0.202, 349.761));
|
||||
let emerald = git_graph_tone(color::oklch(0.765, 0.177, 163.223));
|
||||
let amber = git_graph_tone(color::oklch(0.828, 0.189, 84.429));
|
||||
let red = git_graph_tone(danger);
|
||||
Self {
|
||||
comment,
|
||||
keyword: indigo,
|
||||
string: emerald,
|
||||
string_special: pink,
|
||||
escape: pink,
|
||||
number: amber,
|
||||
boolean: amber,
|
||||
type_name: amber,
|
||||
type_builtin: emerald,
|
||||
constructor: amber,
|
||||
function: indigo,
|
||||
function_builtin: pink,
|
||||
macro_name: pink,
|
||||
property: amber,
|
||||
constant: emerald,
|
||||
variable: text,
|
||||
variable_special: pink,
|
||||
parameter: text,
|
||||
operator: text,
|
||||
punctuation: text,
|
||||
tag: pink,
|
||||
attribute: amber,
|
||||
label: amber,
|
||||
invalid: red,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn light(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
|
||||
let indigo = git_graph_tone(color::oklch(0.47, 0.20, 276.966));
|
||||
let pink = git_graph_tone(color::oklch(0.47, 0.17, 0.584));
|
||||
let emerald = git_graph_tone(color::oklch(0.46, 0.11, 163.225));
|
||||
let amber = git_graph_tone(color::oklch(0.47, 0.12, 48.998));
|
||||
let red = git_graph_tone(danger);
|
||||
Self {
|
||||
comment,
|
||||
keyword: indigo,
|
||||
string: emerald,
|
||||
string_special: pink,
|
||||
escape: pink,
|
||||
number: amber,
|
||||
boolean: amber,
|
||||
type_name: amber,
|
||||
type_builtin: emerald,
|
||||
constructor: amber,
|
||||
function: indigo,
|
||||
function_builtin: pink,
|
||||
macro_name: pink,
|
||||
property: amber,
|
||||
constant: emerald,
|
||||
variable: text,
|
||||
variable_special: pink,
|
||||
parameter: text,
|
||||
operator: text,
|
||||
punctuation: text,
|
||||
tag: pink,
|
||||
attribute: amber,
|
||||
label: amber,
|
||||
invalid: red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Git history intentionally softens lane saturation so the graph remains
|
||||
/// colorful without competing with content. Syntax uses the same treatment.
|
||||
fn git_graph_tone(mut color: Hsla) -> Hsla {
|
||||
color.s *= 0.72;
|
||||
color
|
||||
}
|
||||
@@ -18,6 +18,15 @@ impl ThemeMode {
|
||||
matches!(self, Self::Dark)
|
||||
}
|
||||
|
||||
pub fn is_light(&self) -> bool {
|
||||
matches!(self, Self::Light)
|
||||
}
|
||||
|
||||
/// Map a gpui window appearance onto ours.
|
||||
pub fn from_window(appearance: WindowAppearance) -> Self {
|
||||
Self::from(appearance)
|
||||
}
|
||||
|
||||
/// Return theme name: `light`, `dark`.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -117,9 +126,11 @@ impl ThemeFamily {
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub(crate) fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let json_data = std::fs::read(path)?;
|
||||
let theme_family = serde_json::from_slice(&json_data)?;
|
||||
let mut theme_family: ThemeFamily = serde_json::from_slice(&json_data)?;
|
||||
theme_family.light.rederive_tokens(ThemeMode::Light);
|
||||
theme_family.dark.rederive_tokens(ThemeMode::Dark);
|
||||
|
||||
Ok(theme_family)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Palette contrast tests, mirroring bezel's `tests/theme.rs`: each light text
|
||||
//! token must land within ~0.5 of its dark counterpart's contrast ratio
|
||||
//! against its own background, and the primary text must keep the ratio the
|
||||
//! palette was tuned for.
|
||||
|
||||
use gpui::Hsla;
|
||||
use theme::{Brand, ThemeColors, ThemeFamily, ThemeMode, Tint, contrast_ratio, flatten};
|
||||
|
||||
// The shipped palette's measured floors (AppKit-style ink, per bezel):
|
||||
// text/bg is 14.4:1 dark and 15.1:1 light, muted lands near 6.3 dark and
|
||||
// 4.0 light, and faint sits below 3:1 in both appearances by design.
|
||||
const TEXT_RATIO: f32 = 13.9;
|
||||
const MUTED_RATIO: f32 = 3.9;
|
||||
const FAINT_RATIO: f32 = 1.8;
|
||||
const TEXT_PARITY: f32 = 0.7;
|
||||
|
||||
fn ratio(fg: Hsla, bg: Hsla) -> f32 {
|
||||
contrast_ratio(flatten(fg, bg), bg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_its_contrast() {
|
||||
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
|
||||
let colors = ThemeColors::for_appearance(appearance);
|
||||
assert!(
|
||||
ratio(colors.text, colors.bg) >= TEXT_RATIO,
|
||||
"{appearance:?}: text on bg = {}",
|
||||
ratio(colors.text, colors.bg)
|
||||
);
|
||||
assert!(
|
||||
ratio(colors.text, colors.surface) >= TEXT_RATIO,
|
||||
"{appearance:?}: text on surface"
|
||||
);
|
||||
assert!(
|
||||
ratio(colors.text_muted, colors.bg) >= MUTED_RATIO,
|
||||
"{appearance:?}: muted on bg = {}",
|
||||
ratio(colors.text_muted, colors.bg)
|
||||
);
|
||||
assert!(
|
||||
ratio(colors.text_faint, colors.bg) >= FAINT_RATIO,
|
||||
"{appearance:?}: faint on bg = {}",
|
||||
ratio(colors.text_faint, colors.bg)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_matches_dark_contrast() {
|
||||
let dark = ThemeColors::for_appearance(ThemeMode::Dark);
|
||||
let light = ThemeColors::for_appearance(ThemeMode::Light);
|
||||
|
||||
// Primary text keeps near-parity across appearances. Muted and faint do
|
||||
// not (the light values sit further from the background by design, see
|
||||
// Ink::APPKIT); their floors are asserted in `text_keeps_its_contrast`.
|
||||
let pairs = [
|
||||
("text/bg", dark.text, dark.bg, light.text, light.bg),
|
||||
(
|
||||
"text/surface",
|
||||
dark.text,
|
||||
dark.surface,
|
||||
light.text,
|
||||
light.surface,
|
||||
),
|
||||
];
|
||||
for (name, dark_fg, dark_bg, light_fg, light_bg) in pairs {
|
||||
let dark_ratio = ratio(dark_fg, dark_bg);
|
||||
let light_ratio = ratio(light_fg, light_bg);
|
||||
let delta = (dark_ratio - light_ratio).abs();
|
||||
assert!(
|
||||
delta <= TEXT_PARITY,
|
||||
"{name}: light deviates by {delta} (dark {dark_ratio:.3}, light {light_ratio:.3})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_brand_is_a_no_op() {
|
||||
let brand = Brand::default();
|
||||
assert!(brand.is_default());
|
||||
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
|
||||
let base = ThemeColors::for_appearance(appearance);
|
||||
let branded = brand.branded(appearance);
|
||||
assert_eq!(
|
||||
base, branded,
|
||||
"{appearance:?}: default brand must not move the palette"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brand_tint_rotates_greys_and_keeps_semantics() {
|
||||
let brand = Brand {
|
||||
tint: Tint::new(257.417, 0.046),
|
||||
accent: Tint::new(276.935, 0.182),
|
||||
..Brand::default()
|
||||
};
|
||||
let base = ThemeColors::for_appearance(ThemeMode::Dark);
|
||||
let branded = brand.branded(ThemeMode::Dark);
|
||||
|
||||
assert_ne!(branded.bg, base.bg, "bg takes the tint");
|
||||
assert_ne!(branded.accent, base.accent, "accent takes the accent hue");
|
||||
assert_eq!(
|
||||
branded.danger, base.danger,
|
||||
"danger is semantic and keeps its hue"
|
||||
);
|
||||
assert_eq!(
|
||||
branded.warning, base.warning,
|
||||
"warning is semantic and keeps its hue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_aliases_track_tokens() {
|
||||
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
|
||||
let colors = ThemeColors::for_appearance(appearance);
|
||||
assert_eq!(colors.background, colors.bg);
|
||||
assert_eq!(colors.surface_background, colors.surface);
|
||||
assert_eq!(colors.text_placeholder, colors.text_faint);
|
||||
assert_eq!(colors.element_background, colors.solid);
|
||||
assert_eq!(colors.secondary_background, colors.surface_raised);
|
||||
assert_eq!(colors.tab_active_background, colors.bg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rederive_round_trips_a_legacy_theme() {
|
||||
let json = br##"{
|
||||
"id": "test", "name": "Test", "author": "coop", "url": "",
|
||||
"light": {
|
||||
"background": "#ffffff", "surface_background": "#fafafa",
|
||||
"elevated_surface_background": "#f5f5f5", "panel_background": "#ffffff",
|
||||
"overlay": "#0000001a", "title_bar": "#00000000",
|
||||
"title_bar_inactive": "#ffffff", "window_border": "#c7c7cf",
|
||||
"border": "#dbdbdb", "border_variant": "#d1d1d1",
|
||||
"border_focused": "#3366cc", "border_selected": "#3366cc",
|
||||
"border_transparent": "#00000000", "border_disabled": "#e6e6e6",
|
||||
"ring": "#4d79d6", "text": "#1a1a1a", "text_muted": "#4d4d4d",
|
||||
"text_placeholder": "#808080", "text_accent": "#3366cc",
|
||||
"text_danger": "#cc3333", "text_warning": "#cc9933",
|
||||
"icon": "#4d4d4d", "icon_muted": "#808080", "icon_accent": "#3366cc",
|
||||
"element_foreground": "#ffffff", "element_background": "#3366cc",
|
||||
"element_hover": "#3366cce6", "element_active": "#2e5cb8",
|
||||
"element_selected": "#2952a3", "element_disabled": "#3366cc4d",
|
||||
"secondary_foreground": "#2952a3", "secondary_background": "#e6ecf5",
|
||||
"secondary_hover": "#3366cc1a", "secondary_active": "#d9e2f0",
|
||||
"secondary_selected": "#d9e2f0", "secondary_disabled": "#3366cc4d",
|
||||
"danger_foreground": "#ffffff", "danger_background": "#f5e6e6",
|
||||
"danger_hover": "#cc33331a", "danger_active": "#f0d9d9",
|
||||
"danger_selected": "#f0d9d9", "danger_disabled": "#cc33334d",
|
||||
"warning_foreground": "#1a1a1a", "warning_background": "#f5f0e6",
|
||||
"warning_hover": "#cc99331a", "warning_active": "#f0ead9",
|
||||
"warning_selected": "#f0ead9", "warning_disabled": "#cc99334d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6e6e6",
|
||||
"ghost_element_hover": "#0000001a", "ghost_element_active": "#d9d9d9",
|
||||
"ghost_element_selected": "#d9d9d9", "ghost_element_disabled": "#0000000d",
|
||||
"tab_inactive_background": "#e6e6e6", "tab_hover_background": "#e0e0e0",
|
||||
"tab_active_background": "#d9d9d9",
|
||||
"scrollbar_thumb_background": "#00000033",
|
||||
"scrollbar_thumb_hover_background": "#0000004d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#d9d9d9",
|
||||
"drop_target_background": "#3366cc1a",
|
||||
"cursor": "#3399ff", "selection": "#3399ff40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1a1a1a", "surface_background": "#1f1f1f",
|
||||
"elevated_surface_background": "#242424", "panel_background": "#262626",
|
||||
"overlay": "#ffffff1a", "title_bar": "#00000000",
|
||||
"title_bar_inactive": "#1a1a1a", "window_border": "#404046",
|
||||
"border": "#404040", "border_variant": "#383838",
|
||||
"border_focused": "#4d79d6", "border_selected": "#4d79d6",
|
||||
"border_transparent": "#00000000", "border_disabled": "#2e2e2e",
|
||||
"ring": "#668cdf", "text": "#f2f2f2", "text_muted": "#b3b3b3",
|
||||
"text_placeholder": "#808080", "text_accent": "#668cdf",
|
||||
"text_danger": "#d64d4d", "text_warning": "#d6b34d",
|
||||
"icon": "#b3b3b3", "icon_muted": "#808080", "icon_accent": "#668cdf",
|
||||
"element_foreground": "#ffffff", "element_background": "#4d79d6",
|
||||
"element_hover": "#4d79d6e6", "element_active": "#456dc1",
|
||||
"element_selected": "#3e62ac", "element_disabled": "#4d79d64d",
|
||||
"secondary_foreground": "#3e62ac", "secondary_background": "#2a3652",
|
||||
"secondary_hover": "#4d79d61a", "secondary_active": "#303d5c",
|
||||
"secondary_selected": "#303d5c", "secondary_disabled": "#4d79d64d",
|
||||
"danger_foreground": "#ffffff", "danger_background": "#522a2a",
|
||||
"danger_hover": "#d64d4d1a", "danger_active": "#5c3030",
|
||||
"danger_selected": "#5c3030", "danger_disabled": "#d64d4d4d",
|
||||
"warning_foreground": "#f2f2f2", "warning_background": "#52482a",
|
||||
"warning_hover": "#d6b34d1a", "warning_active": "#5c5430",
|
||||
"warning_selected": "#5c5430", "warning_disabled": "#d6b34d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#2e2e2e",
|
||||
"ghost_element_hover": "#ffffff1a", "ghost_element_active": "#383838",
|
||||
"ghost_element_selected": "#383838", "ghost_element_disabled": "#ffffff0d",
|
||||
"tab_inactive_background": "#2e2e2e", "tab_hover_background": "#333333",
|
||||
"tab_active_background": "#383838",
|
||||
"scrollbar_thumb_background": "#ffffff33",
|
||||
"scrollbar_thumb_hover_background": "#ffffff4d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#383838",
|
||||
"drop_target_background": "#4d79d61a",
|
||||
"cursor": "#4db3ff", "selection": "#4db3ff40"
|
||||
}
|
||||
}"##;
|
||||
|
||||
let family: ThemeFamily = serde_json::from_slice(json).expect("parse");
|
||||
let mut light = family.light;
|
||||
light.rederive_tokens(ThemeMode::Light);
|
||||
assert_eq!(light.bg, light.background, "bg tracks the JSON background");
|
||||
assert_eq!(light.surface, light.surface_background);
|
||||
assert_eq!(light.solid, light.element_background);
|
||||
// Fields with no legacy source keep the built-in palette's value.
|
||||
assert_eq!(
|
||||
light.success,
|
||||
ThemeColors::for_appearance(ThemeMode::Light).success
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user