port bezel theme system into the theme crate

This commit is contained in:
2026-10-09 08:42:54 +07:00
parent 22f57def7d
commit 90377c8c76
14 changed files with 2121 additions and 566 deletions
+150
View File
@@ -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();
}
}
+294
View File
@@ -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()
}
+215
View File
@@ -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
View File
@@ -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()
}
+87
View File
@@ -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
View File
@@ -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"
}
}
+181
View File
@@ -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)
}
+181
View File
@@ -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(),
}
}
+6 -3
View File
@@ -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)
}
+5
View File
@@ -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,
+176
View File
@@ -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
}
+13 -2
View File
@@ -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)
}
+218
View File
@@ -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
);
}