feat: add new theme (#61)

Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
2026-10-10 00:30:42 +00:00
parent 4ef764984b
commit 602a91b4f0
94 changed files with 3337 additions and 4456 deletions
-1
View File
@@ -1 +0,0 @@
../../LICENSE
-1
View File
@@ -1 +0,0 @@
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
+151
View File
@@ -0,0 +1,151 @@
//! 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 — matches every other native app on the machine,
/// including when the OS is set to switch at sunset.
System,
Light,
/// Paint dark regardless of the OS.
#[default]
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 `Dark` 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();
}
}
+298
View File
@@ -0,0 +1,298 @@
//! 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);
// apply() re-asserts this brand's ink, so the default brand — whose
// ink is the shipped one — must skip it to stay a strict no-op.
if !self.is_default() {
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),
)
}
+99 -564
View File
@@ -1,583 +1,118 @@
use gpui::{Hsla, Rgba, hsla};
use gpui::Hsla;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::scale::{ColorScale, ColorScaleSet};
use crate::syntax::SyntaxPalette;
use crate::{ThemeMode, palette};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(default)]
pub struct ThemeColors {
// Surface colors
pub background: Hsla,
pub surface_background: Hsla,
pub elevated_surface_background: Hsla,
pub panel_background: Hsla,
pub overlay: Hsla,
pub title_bar: Hsla,
pub title_bar_inactive: Hsla,
pub window_border: Hsla,
/// The main content plane.
pub bg: Hsla,
/// The shell / sidebar plane.
pub surface: Hsla,
/// Opaque pills and chips raised above the panel.
pub surface_raised: Hsla,
/// An inline card resting on the main plane.
pub surface_card: Hsla,
/// A modal dialog, over the scrim.
pub surface_dialog: Hsla,
/// Popovers and menus — the highest plane.
pub surface_overlay: Hsla,
/// Row hover — translucent ink over whatever is beneath.
pub element_hover: Hsla,
/// Pressed or selected, one rung past [`ThemeColors::element_hover`].
pub element_active: Hsla,
// Border colors
/// The standard hairline border.
pub border: Hsla,
pub border_variant: Hsla,
pub border_focused: Hsla,
pub border_selected: Hsla,
pub border_transparent: Hsla,
pub border_disabled: Hsla,
/// Dividers inside cards, nested frames.
pub border_faint: Hsla,
/// Focused or raised edges.
pub border_strong: Hsla,
/// The focus ring.
pub ring: Hsla,
// Text colors
/// Primary text.
pub text: Hsla,
/// Secondary text.
pub text_muted: Hsla,
pub text_placeholder: Hsla,
pub text_accent: Hsla,
pub text_danger: Hsla,
pub text_warning: Hsla,
/// Placeholders and disabled text.
pub text_faint: Hsla,
/// Diff file paths — one notch below [`ThemeColors::text_muted`].
pub text_dim: Hsla,
// Icon colors
pub icon: Hsla,
pub icon_muted: Hsla,
pub icon_accent: Hsla,
// Element colors
pub element_foreground: Hsla,
pub element_background: Hsla,
pub element_hover: Hsla,
pub element_active: Hsla,
pub element_selected: Hsla,
pub element_disabled: Hsla,
// Secondary element colors
pub secondary_foreground: Hsla,
pub secondary_background: Hsla,
pub secondary_hover: Hsla,
pub secondary_active: Hsla,
pub secondary_selected: Hsla,
pub secondary_disabled: Hsla,
// Danger element colors
pub danger_foreground: Hsla,
pub danger_background: Hsla,
pub danger_hover: Hsla,
pub danger_active: Hsla,
pub danger_selected: Hsla,
pub danger_disabled: Hsla,
// Warning element colors
pub warning_foreground: Hsla,
pub warning_background: Hsla,
pub warning_hover: Hsla,
pub warning_active: Hsla,
pub warning_selected: Hsla,
pub warning_disabled: Hsla,
// Ghost element colors
pub ghost_element_background: Hsla,
pub ghost_element_background_alt: Hsla,
pub ghost_element_hover: Hsla,
pub ghost_element_active: Hsla,
pub ghost_element_selected: Hsla,
pub ghost_element_disabled: Hsla,
// Tab colors
pub tab_background: Hsla,
pub tab_foreground: Hsla,
pub tab_hover_background: Hsla,
pub tab_active_background: Hsla,
pub tab_active_foreground: Hsla,
// Scrollbar colors
pub scrollbar_thumb_background: Hsla,
pub scrollbar_thumb_hover_background: Hsla,
pub scrollbar_thumb_border: Hsla,
pub scrollbar_track_background: Hsla,
pub scrollbar_track_border: Hsla,
// Interactive colors
pub drop_target_background: Hsla,
/// The text cursor.
pub cursor: Hsla,
/// The selected-text wash.
pub selection: Hsla,
/// The primary button plate — the maximum-contrast fill.
pub solid: Hsla,
/// Label ink on [`ThemeColors::solid`].
pub on_solid: Hsla,
/// Emphasis for text and icons; neutral by default.
pub accent: Hsla,
/// Accent fills carrying [`ThemeColors::on_accent`].
pub accent_strong: Hsla,
/// Label ink on [`ThemeColors::accent_strong`].
pub on_accent: Hsla,
/// Errors and destructive actions.
pub danger: Hsla,
/// Inline error copy.
pub danger_muted: Hsla,
/// The destructive plate — white ink in both appearances.
pub danger_strong: Hsla,
/// Warnings.
pub warning: Hsla,
/// The translucent warning wash behind chips and banners.
pub warning_muted: Hsla,
/// Success and online presence.
pub success: Hsla,
/// Text on success-tinted chips.
pub success_muted: Hsla,
/// Working or streaming states.
pub busy: Hsla,
/// Hover over [`ThemeColors::surface_raised`].
pub surface_raised_hover: Hsla,
/// A recessed header / footer strip.
pub band: Hsla,
/// Input backgrounds.
pub input_bg: Hsla,
/// The text caret.
pub caret: Hsla,
/// The line marking where a drag will land.
pub drop_line: Hsla,
/// The fill of the region a dragged pane would dock into.
pub drop_target: Hsla,
/// Inline-code text.
pub code_text: Hsla,
/// The wash behind [`ThemeColors::code_text`].
pub code_wash: Hsla,
/// The shared syntax palette, read at paint time.
pub syntax: SyntaxPalette,
/// Diff additions.
pub diff_add: Hsla,
/// Diff deletions.
pub diff_del: Hsla,
/// The diff hunk-header wash.
pub diff_hunk_bg: Hsla,
}
impl Default for ThemeColors {
fn default() -> Self {
palette::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),
}
}
/// 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 {
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),
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(),
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(),
icon: neutral().dark().step_11(),
icon_muted: neutral().dark().step_10(),
icon_accent: brand().dark().step_11(),
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(),
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(),
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(),
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(),
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(),
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(),
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(),
drop_target_background: brand().dark_alpha().step_2(),
cursor: hsl(200., 100., 50.),
selection: hsl(200., 100., 50.).alpha(0.25),
}
/// Returns the default colors for an appearance.
pub fn for_appearance(appearance: ThemeMode) -> Self {
palette::for_appearance(appearance)
}
}
fn neutral() -> ColorScaleSet {
gray()
}
fn brand() -> ColorScaleSet {
yellow()
}
fn danger() -> ColorScaleSet {
tomato()
}
fn warning() -> ColorScaleSet {
orange()
}
/// 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)
}
type StaticColorScale = [&'static str; 12];
struct StaticColorScaleSet {
light: StaticColorScale,
light_alpha: StaticColorScale,
dark: StaticColorScale,
dark_alpha: StaticColorScale,
}
impl TryFrom<StaticColorScaleSet> for ColorScaleSet {
type Error = anyhow::Error;
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)
}
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)?,
))
}
}
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 }
}
+135 -99
View File
@@ -1,25 +1,51 @@
use std::ops::{Deref, DerefMut};
use std::rc::Rc;
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;
mod typography;
pub mod appearance;
pub use appearance::AppearanceMode;
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,
plate_active, plate_hover, 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::*;
pub use typography::{
DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size, set_base_text_size,
};
/// 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);
@@ -40,25 +66,57 @@ pub const TABBAR_HEIGHT: Pixels = px(36.0);
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::default(), cx);
Theme::sync_scrollbar_appearance(cx);
}
/// An alternative palette source.
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 {
fn set_brand(&mut self, brand: Brand);
fn set_palette(&mut self, build: PaletteBuilder);
fn set_appearance_mode(&mut self, mode: AppearanceMode);
fn set_base_text_size(&mut self, points: f32);
}
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);
}
fn set_base_text_size(&mut self, points: f32) {
typography::set_base_text_size(points);
self.refresh_windows();
}
}
/// 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.
pub fn sync_base(cx: &mut App) {
let Some(theme) = cx.try_global::<Theme>() else {
return;
@@ -75,28 +133,28 @@ pub fn sync_base(cx: &mut App) {
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
};
let colors = theme.colors;
let font_family = theme.font_family.clone();
let font_sans = theme.font_sans.clone();
let base = gpui_base::Theme::global_mut(cx);
base.appearance = appearance;
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
base.tokens.typography.sans = font_family;
base.tokens.typography.sans = font_sans;
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 = gpui::white();
tokens.border = colors.border;
tokens.input = colors.border;
tokens.ring = colors.ring;
@@ -116,34 +174,12 @@ impl ActiveTheme for App {
#[derive(Debug, Clone)]
pub struct Theme {
/// Theme colors
pub colors: ThemeColors,
/// Theme family
pub theme: Rc<ThemeFamily>,
/// The appearance of the theme (light or dark).
pub mode: ThemeMode,
/// The font family for the application.
pub font_family: SharedString,
/// The root font size for the application, default is 15px.
pub font_size: Pixels,
/// Radius for the general elements.
pub radius: Pixels,
/// Radius for the large elements, e.g.: dialog, notification.
pub radius_lg: Pixels,
/// Enable shadow for the general elements. default is true
pub font_sans: SharedString,
pub font_mono: SharedString,
pub shadow: bool,
/// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
}
@@ -181,12 +217,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
@@ -198,79 +229,84 @@ impl Theme {
};
}
/// Apply a new theme to the application.
pub fn apply_theme(new_theme: Rc<ThemeFamily>, window: Option<&mut Window>, cx: &mut App) {
let theme = cx.global_mut::<Theme>();
let mode = theme.mode;
// Update the theme
theme.theme = new_theme;
// Emit a theme change event
Self::change(mode, window, cx);
}
/// Change the app's appearance
pub fn change<M>(mode: M, window: Option<&mut Window>, cx: &mut App)
where
M: Into<ThemeMode>,
{
if !cx.has_global::<Theme>() {
let default_theme = ThemeFamily::default();
let theme = Theme::from(default_theme);
cx.set_global(theme);
cx.set_global(Theme::default());
}
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 => ThemeColors::for_appearance(mode),
};
// A default brand changes nothing; anything else rotates the tokens.
let installed_brand = brand::installed(cx);
if !installed_brand.is_default() {
installed_brand.apply(mode, &mut colors);
}
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);
}
}
impl From<ThemeFamily> for Theme {
fn from(family: ThemeFamily) -> Self {
impl Default for Theme {
fn default() -> Self {
let platform = PlatformKind::platform();
let mode = ThemeMode::default();
// Define the font family based on the platform.
let font_family = match platform {
// The system UI font, by platform.
let font_sans = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
};
// Define the theme colors based on the appearance
let colors = match mode {
ThemeMode::Light => family.light(),
ThemeMode::Dark => family.dark(),
};
Theme {
font_size: px(15.),
font_family: font_family.into(),
radius: px(6.),
radius_lg: px(10.),
Self {
font_sans: font_sans.into(),
font_mono: system_mono().into(),
shadow: true,
scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(),
mode,
colors: *colors,
theme: Rc::new(family),
colors: ThemeColors::for_appearance(mode),
}
}
}
/// 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"
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Context-free paint helpers, ported from bezel
//! (`crates/theme/src/paint.rs`, MIT License, Copyright (c) 2026 bezel
//! contributors): the process-wide appearance mirror and the free functions
//! ([`ink`], [`hairline`], [`wash`], …) that element builders call without a
//! `cx` in scope.
use std::sync::atomic::{AtomicU8, Ordering};
use gpui::{BoxShadow, Hsla, hsla, point, px};
use crate::ThemeMode;
/// Process-wide mirror of the installed theme's appearance.
///
/// The paint helpers are free functions called from deep inside element
/// builders that have no `cx` in scope, so they read the appearance from here
/// instead of the gpui global. Appearance is genuinely process-wide — one
/// setting for every window — so a single mirror is sound; [`Theme::change`]
/// is the only writer outside tests.
static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
/// The appearance the context-free paint helpers are painting for.
pub fn current_appearance() -> ThemeMode {
match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
1 => ThemeMode::Light,
_ => ThemeMode::Dark,
}
}
/// Point the context-free paint helpers at an appearance. Called by
/// [`Theme::change`]; exposed for tests that build a palette without an `App`.
pub fn set_current_appearance(appearance: ThemeMode) {
let encoded = match appearance {
ThemeMode::Dark => 0,
ThemeMode::Light => 1,
};
CURRENT_APPEARANCE.store(encoded, Ordering::Relaxed);
}
/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill
/// backgrounds).
///
/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%,
/// selected ≈ 14% black — which is where the dark palette's white alphas already
/// sit. So the honest multiplier is 1: the same number in both appearances, with
/// only the *tone* flipping. Any per-state correction belongs in that state's
/// token, not in a blanket multiplier.
pub const INK_FILL_SCALE: f32 = 1.0;
/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings).
/// Opposite of fills: a 1px edge has to hold its own against a bright surround,
/// and the dark palette's white hairlines are deliberately faint. Scaling up
/// keeps separators legible instead of dissolving into the panel.
pub const INK_HAIRLINE_SCALE: f32 = 1.35;
/// Lightness of [`hairline`] ink, dark then light.
const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25);
/// Translucent **fill** ink for interactive states and chip plates: soft-white on
/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
///
/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
/// the tuned one — and the light value is derived. Callers keep one number and
/// both appearances stay in the relationship the dark tuning established.
pub fn ink(alpha: f32) -> Hsla {
ink_for(current_appearance(), alpha)
}
pub(crate) fn ink_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 1.0, alpha),
ThemeMode::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
}
}
/// Translucent **hairline** ink for borders, dividers and rings: a light grey on
/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never
/// pure white or black.
///
/// Separate from [`ink`] because edges and fills scale in opposite directions
/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
/// less.
pub fn hairline(alpha: f32) -> Hsla {
hairline_for(current_appearance(), alpha)
}
pub(crate) fn hairline_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha),
ThemeMode::Light => hsla(
0.0,
0.0,
HAIRLINE_TONE.1,
(alpha * INK_HAIRLINE_SCALE).min(0.5),
),
}
}
/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
/// white so hover plates read as tinted glass rather than paint.
pub fn wash(alpha: f32) -> Hsla {
wash_for(current_appearance(), alpha)
}
pub(crate) fn wash_for(appearance: ThemeMode, alpha: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.92, alpha),
ThemeMode::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
}
}
/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
pub const SCRIM_ALPHA_DARK: f32 = 0.60;
/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
///
/// Black in both appearances — a scrim's job is to darken what is behind it, and
/// a "light scrim" of white would wash the modal out rather than seat it. What
/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
/// blackout, so light mode scales to roughly half.
pub fn scrim(alpha_dark: f32) -> Hsla {
scrim_for(current_appearance(), alpha_dark)
}
pub(crate) fn scrim_for(appearance: ThemeMode, alpha_dark: f32) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
}
}
/// Recessed band behind a picker header or footer strip.
///
/// A free function as well as a token so the chrome that paints it can be built
/// from context-free helpers; both resolve to the same value.
pub fn band() -> Hsla {
band_for(current_appearance())
}
pub(crate) fn band_for(appearance: ThemeMode) -> Hsla {
match appearance {
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, 0.16),
// A recessed strip on white needs far less ink than on near-black; the
// dark 16% would read as a bruise.
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.045),
}
}
/// The elevation shadow a floating surface casts. Painted cut to outside the
/// shape, because the same drop shadow under a translucent fill reads as a grey
/// plate.
pub fn surface_shadows() -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.1),
offset: point(px(0.0), px(10.0)),
blur_radius: px(15.0),
spread_radius: px(-3.0),
inset: false,
},
BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.1),
offset: point(px(0.0), px(4.0)),
blur_radius: px(6.0),
spread_radius: px(-4.0),
inset: false,
},
]
}
/// Hover over an opaque plate: brighten the plate in dark mode, darken it in
/// light — never swap an opaque plate for a translucent wash.
pub(crate) fn plate_hover_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
crate::color::flatten(ink_for(appearance, 0.10), plate)
}
/// Pressed over an opaque plate, one rung above [`plate_hover_for`].
pub(crate) fn plate_active_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
crate::color::flatten(ink_for(appearance, 0.16), plate)
}
/// Hover over an opaque plate, for call sites outside this crate.
pub fn plate_hover(plate: gpui::Hsla) -> gpui::Hsla {
plate_hover_for(current_appearance(), plate)
}
/// Pressed over an opaque plate, for call sites outside this crate.
pub fn plate_active(plate: gpui::Hsla) -> gpui::Hsla {
plate_active_for(current_appearance(), plate)
}
+137
View File
@@ -0,0 +1,137 @@
use gpui::{hsla, rgb};
use crate::paint::{band_for, hairline_for};
use crate::syntax::SyntaxPalette;
use crate::{Ink, TextInk, ThemeColors, ThemeMode, color};
pub(crate) fn dark() -> ThemeColors {
let warning = color::oklch(0.828, 0.189, 84.429);
ThemeColors {
bg: color::grey(6),
surface: color::grey(13),
surface_raised: color::neutral(0.235),
surface_card: color::grey(0x0e),
surface_dialog: color::grey(0x10),
surface_overlay: color::grey(0x16),
element_hover: hsla(0.0, 0.0, 1.0, 0.08),
element_active: hsla(0.0, 0.0, 1.0, 0.12),
border_faint: hairline_for(ThemeMode::Dark, 0.06),
border: hairline_for(ThemeMode::Dark, 0.08),
border_strong: hairline_for(ThemeMode::Dark, 0.14),
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),
// The restored Radix yellow brand: #ffe629 plate, #14120b label.
solid: color::oklch(0.918, 0.184, 100.9),
on_solid: color::grey(0x0e),
// The restored Radix yellow brand: #f5e147 ink, #ffe629 plate.
accent: color::oklch(0.900, 0.166, 101.7),
accent_strong: color::oklch(0.918, 0.184, 100.9),
on_accent: color::grey(0x0e),
danger: color::oklch(0.704, 0.191, 22.216),
danger_muted: color::oklch(0.808, 0.114, 19.571),
danger_strong: color::oklch(0.58, 0.16, 25.0),
success: color::oklch(0.765, 0.177, 163.223),
success_muted: color::oklch(0.845, 0.143, 164.978),
busy: color::oklch(0.718, 0.202, 349.761),
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_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),
diff_del: color::oklch(0.704, 0.191, 22.216),
diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
warning,
warning_muted: warning.alpha(0.16),
}
}
pub(crate) fn light() -> ThemeColors {
let warning = color::oklch(0.555, 0.163, 48.998);
ThemeColors {
bg: color::grey(0xff),
surface: color::neutral(0.968),
surface_raised: color::neutral(0.940),
surface_card: color::grey(0xff),
surface_dialog: color::grey(0xff),
surface_overlay: color::grey(0xff),
element_hover: hsla(0.0, 0.0, 0.0, 0.04),
element_active: hsla(0.0, 0.0, 0.0, 0.06),
border_faint: hairline_for(ThemeMode::Light, 0.06),
border: hairline_for(ThemeMode::Light, 0.08),
border_strong: hairline_for(ThemeMode::Light, 0.14),
text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text),
text_muted: color::neutral(0.45),
text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint),
text_dim: color::neutral(0.50),
// The restored Radix yellow brand: #ffe629 plate, #473b1f label.
solid: color::oklch(0.918, 0.184, 100.9),
on_solid: color::oklch(0.358, 0.046, 86.9),
// The restored Radix yellow brand: #9e6c00 ink, #ffe629 plate.
accent: color::oklch(0.569, 0.119, 76.8),
accent_strong: color::oklch(0.918, 0.184, 100.9),
on_accent: color::neutral(0.205),
danger: color::oklch(0.577, 0.245, 27.325),
danger_muted: color::oklch(0.505, 0.213, 27.518),
danger_strong: color::oklch(0.51, 0.20, 25.0),
warning,
warning_muted: warning.alpha(0.10),
success: color::oklch(0.596, 0.145, 163.225),
success_muted: color::oklch(0.508, 0.118, 165.612),
busy: color::oklch(0.592, 0.249, 0.584),
surface_raised_hover: color::neutral(0.900),
band: band_for(ThemeMode::Light),
input_bg: color::neutral(0.950),
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_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),
diff_del: color::oklch(0.577, 0.245, 27.325),
diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
}
}
pub(crate) fn for_appearance(appearance: ThemeMode) -> ThemeColors {
match appearance {
ThemeMode::Dark => dark(),
ThemeMode::Light => light(),
}
}
-70
View File
@@ -1,70 +0,0 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error};
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
use crate::ThemeFamily;
pub fn init(cx: &mut App) {
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
}
struct GlobalThemeRegistry(Entity<ThemeRegistry>);
impl Global for GlobalThemeRegistry {}
pub struct ThemeRegistry {
/// Map of theme names to theme families
themes: HashMap<SharedString, Rc<ThemeFamily>>,
}
impl ThemeRegistry {
/// Retrieve the global theme registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalThemeRegistry>().0.clone()
}
/// Set the global theme registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalThemeRegistry(state));
}
/// Create a new theme registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut themes = HashMap::new();
let asset = cx.asset_source();
if let Ok(paths) = asset.list("themes") {
for path in paths.into_iter() {
match Self::load(&path, asset) {
Ok(theme) => {
themes.insert(path, Rc::new(theme));
}
Err(e) => {
log::error!("Failed to load theme: {path}. Error: {e}");
}
}
}
}
Self { themes }
}
/// Load a theme from the asset source.
fn load(path: &str, asset: &Arc<dyn AssetSource>) -> Result<ThemeFamily, Error> {
// 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)?;
Ok(theme)
}
/// Returns a reference to the map of themes.
pub fn themes(&self) -> &HashMap<SharedString, Rc<ThemeFamily>> {
&self.themes
}
}
-177
View File
@@ -1,177 +0,0 @@
use gpui::Hsla;
/// A collection of colors that are used to style the UI.
///
/// Each step has a semantic meaning, and is used to style different parts of the UI.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub struct ColorScaleStep(usize);
impl ColorScaleStep {
pub const EIGHT: Self = Self(8);
pub const ELEVEN: Self = Self(11);
pub const FIVE: Self = Self(5);
pub const FOUR: Self = Self(4);
pub const NINE: Self = Self(9);
pub const ONE: Self = Self(1);
pub const SEVEN: Self = Self(7);
pub const SIX: Self = Self(6);
pub const TEN: Self = Self(10);
pub const THREE: Self = Self(3);
pub const TWELVE: Self = Self(12);
pub const TWO: Self = Self(2);
}
/// A scale of colors for a given [`ColorScaleSet`].
///
/// Each [`ColorScale`] contains exactly 12 colors. Refer to
/// [`ColorScaleStep`] for a reference of what each step is used for.
pub struct ColorScale(Vec<Hsla>);
impl FromIterator<Hsla> for ColorScale {
fn from_iter<T: IntoIterator<Item = Hsla>>(iter: T) -> Self {
Self(Vec::from_iter(iter))
}
}
impl ColorScale {
/// Returns the specified step in the [`ColorScale`].
#[inline]
pub fn step(&self, step: ColorScaleStep) -> Hsla {
// Steps are one-based, so we need convert to the zero-based vec index.
self.0[step.0 - 1]
}
/// `Step 1` - Used for main application backgrounds.
///
/// This step provides a neutral base for any overlaying components, ideal for applications' main backdrop or empty spaces such as canvas areas.
///
#[inline]
pub fn step_1(&self) -> Hsla {
self.step(ColorScaleStep::ONE)
}
/// `Step 2` - Used for both main application backgrounds and subtle component backgrounds.
///
/// Like `Step 1`, this step allows variations in background styles, from striped tables, sidebar backgrounds, to card backgrounds.
#[inline]
pub fn step_2(&self) -> Hsla {
self.step(ColorScaleStep::TWO)
}
/// `Step 3` - Used for UI component backgrounds in their normal states.
///
/// This step maintains accessibility by guaranteeing a contrast ratio of 4.5:1 with steps 11 and 12 for text. It could also suit hover states for transparent components.
#[inline]
pub fn step_3(&self) -> Hsla {
self.step(ColorScaleStep::THREE)
}
/// `Step 4` - Used for UI component backgrounds in their hover states.
///
/// Also suited for pressed or selected states of components with a transparent background.
#[inline]
pub fn step_4(&self) -> Hsla {
self.step(ColorScaleStep::FOUR)
}
/// `Step 5` - Used for UI component backgrounds in their pressed or selected states.
#[inline]
pub fn step_5(&self) -> Hsla {
self.step(ColorScaleStep::FIVE)
}
/// `Step 6` - Used for subtle borders on non-interactive components.
///
/// Its usage spans from sidebars' borders, headers' dividers, cards' outlines, to alerts' edges and separators.
#[inline]
pub fn step_6(&self) -> Hsla {
self.step(ColorScaleStep::SIX)
}
/// `Step 7` - Used for subtle borders on interactive components.
///
/// This step subtly delineates the boundary of elements users interact with.
#[inline]
pub fn step_7(&self) -> Hsla {
self.step(ColorScaleStep::SEVEN)
}
/// `Step 8` - Used for stronger borders on interactive components and focus rings.
///
/// It strengthens the visibility and accessibility of active elements and their focus states.
#[inline]
pub fn step_8(&self) -> Hsla {
self.step(ColorScaleStep::EIGHT)
}
/// `Step 9` - Used for solid backgrounds.
///
/// `Step 9` is the most saturated step, having the least mix of white or black.
///
/// Due to its high chroma, `Step 9` is versatile and particularly useful for semantic colors such as
/// error, warning, and success indicators.
#[inline]
pub fn step_9(&self) -> Hsla {
self.step(ColorScaleStep::NINE)
}
/// `Step 10` - Used for hovered or active solid backgrounds, particularly when `Step 9` is their normal state.
///
/// May also be used for extremely low contrast text. This should be used sparingly, as it may be difficult to read.
#[inline]
pub fn step_10(&self) -> Hsla {
self.step(ColorScaleStep::TEN)
}
/// `Step 11` - Used for text and icons requiring low contrast or less emphasis.
#[inline]
pub fn step_11(&self) -> Hsla {
self.step(ColorScaleStep::ELEVEN)
}
/// `Step 12` - Used for text and icons requiring high contrast or prominence.
#[inline]
pub fn step_12(&self) -> Hsla {
self.step(ColorScaleStep::TWELVE)
}
}
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale.
pub struct ColorScaleSet {
light: ColorScale,
dark: ColorScale,
light_alpha: ColorScale,
dark_alpha: ColorScale,
}
impl ColorScaleSet {
pub(crate) fn new(
light: ColorScale,
light_alpha: ColorScale,
dark: ColorScale,
dark_alpha: ColorScale,
) -> Self {
Self {
light,
light_alpha,
dark,
dark_alpha,
}
}
pub(crate) fn light(&self) -> &ColorScale {
&self.light
}
pub(crate) fn light_alpha(&self) -> &ColorScale {
&self.light_alpha
}
pub(crate) fn dark(&self) -> &ColorScale {
&self.dark
}
pub(crate) fn dark_alpha(&self) -> &ColorScale {
&self.dark_alpha
}
}
+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
}
+12 -329
View File
@@ -1,16 +1,12 @@
use std::path::Path;
use gpui::{SharedString, WindowAppearance};
use schemars::JsonSchema;
use gpui::WindowAppearance;
use serde::{Deserialize, Serialize};
use crate::ThemeColors;
/// Which appearance the palette is painted for.
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)]
pub enum ThemeMode {
#[default]
Light,
Dark,
Light,
}
impl ThemeMode {
@@ -18,6 +14,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 {
@@ -35,325 +40,3 @@ impl From<WindowAppearance> for ThemeMode {
}
}
}
/// Theme family
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
pub struct ThemeFamily {
/// The unique identifier for the theme.
pub id: String,
/// The name of the theme.
pub name: SharedString,
/// The author of the theme.
pub author: SharedString,
/// The URL of the theme.
pub url: String,
/// The light colors for the theme.
pub light: ThemeColors,
/// The dark colors for the theme.
pub dark: ThemeColors,
}
impl Default for ThemeFamily {
fn default() -> Self {
ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
}
}
impl ThemeFamily {
/// Returns the light colors for the theme.
#[inline(always)]
pub fn light(&self) -> &ThemeColors {
&self.light
}
/// Returns the dark colors for the theme.
#[inline(always)]
pub fn dark(&self) -> &ThemeColors {
&self.dark
}
/// Load a theme family from a JSON file.
///
/// # Arguments
///
/// * `path` - Path to the JSON file containing the theme family. This can be
/// an absolute path or a path relative to the current working directory.
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Load from a relative path
/// let theme = ThemeFamily::from_file("assets/themes/my-theme.json")?;
///
/// // Load from an absolute path
/// let theme = ThemeFamily::from_file("/path/to/themes/my-theme.json")?;
/// # Ok(())
/// # }
/// ```
pub(crate) 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)?;
Ok(theme_family)
}
/// Load a theme family from a JSON file in the assets/themes directory.
///
/// This function looks for the file at `assets/themes/{name}.json` relative
/// to the current working directory. This is useful for loading themes
/// from the standard theme directory in the project structure.
///
/// # Arguments
///
/// * `name` - Name of the theme file (without the .json extension)
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Assuming the file exists at `assets/themes/my-theme.json`
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?;
///
/// println!("Loaded theme: {}", theme.name);
/// # Ok(())
/// # }
/// ```
pub fn from_assets(target: &str) -> anyhow::Result<Self> {
let path = format!("assets/{target}");
Self::from_file(path)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn test_from_file() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("test-theme.json");
// Create a minimal valid theme JSON with hex colors
// Using simple hex colors that Hsla can parse
// Note: We need to escape the # characters in the raw string
let json_data = r##"{
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/reyakov/coop",
"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",
"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",
"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"
}
}"##;
// Write the JSON to the file
fs::write(&file_path, json_data).unwrap();
// Test loading the theme from file
let theme = ThemeFamily::from_file(&file_path).unwrap();
// Verify the loaded theme
assert_eq!(theme.id, "test-theme");
assert_eq!(theme.name, "Test Theme");
// Clean up
dir.close().unwrap();
}
#[test]
fn test_from_file_nonexistent() {
// Test that loading a non-existent file returns an error
let result = ThemeFamily::from_file("non-existent-file.json");
assert!(result.is_err());
}
#[test]
fn test_from_file_invalid_json() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("invalid-theme.json");
// Write invalid JSON
fs::write(&file_path, "invalid json").unwrap();
// Test that loading invalid JSON returns an error
let result = ThemeFamily::from_file(&file_path);
assert!(result.is_err());
// Clean up
dir.close().unwrap();
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::sync::atomic::{AtomicU32, Ordering};
use gpui::{FontWeight, Styled, px};
/// The base the ladder starts at; bezel's measured body is 13, coop keeps 15.
pub const DEFAULT_BASE_TEXT_SIZE: f32 = 15.0;
/// The body size every painted role is scaled against, as raw `f32` bits.
static BASE: AtomicU32 = AtomicU32::new(DEFAULT_BASE_TEXT_SIZE.to_bits());
/// Point the ladder at a body size in points; the caller refreshes windows.
pub fn set_base_text_size(points: f32) {
BASE.store(points.max(1.0).to_bits(), Ordering::Relaxed);
}
/// The body size in points every painted role is scaled against.
pub fn base_text_size() -> f32 {
f32::from_bits(BASE.load(Ordering::Relaxed))
}
/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TextStyle {
LargeTitle,
Title,
Title2,
Title3,
Headline,
Subheadline,
Body,
Callout,
Footnote,
Caption,
Caption2,
}
impl TextStyle {
/// The role's measured size in points.
pub const fn size(self) -> f32 {
match self {
Self::LargeTitle => 26.0,
Self::Title => 22.0,
Self::Title2 => 17.0,
Self::Title3 => 15.0,
Self::Headline | Self::Body => 13.0,
Self::Callout => 12.0,
Self::Subheadline => 11.0,
Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
}
}
pub fn painted(self) -> f32 {
self.size() * base_text_size() / Self::Body.size()
}
pub const fn line_height(self) -> f32 {
match self {
Self::LargeTitle => 32.0,
Self::Title => 26.0,
Self::Title2 => 22.0,
Self::Title3 => 20.0,
Self::Headline | Self::Body => 16.0,
Self::Callout => 15.0,
Self::Subheadline => 14.0,
Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
}
}
pub fn painted_line_height(self) -> f32 {
self.line_height() * base_text_size() / Self::Body.size()
}
pub const fn weight(self) -> FontWeight {
match self {
Self::Headline => FontWeight::BOLD,
Self::Caption2 => FontWeight::MEDIUM,
_ => FontWeight::NORMAL,
}
}
}
/// One role as it is set: a rung on the ladder, its leading, weight and scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metrics {
pub role: TextStyle,
pub leading: f32,
pub weight: FontWeight,
pub scale: f32,
}
impl Metrics {
pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
Self {
role,
leading,
weight,
scale: 1.0,
}
}
pub const fn scaled(self, scale: f32) -> Self {
Self { scale, ..self }
}
pub fn size(self) -> f32 {
self.role.painted() * self.scale
}
pub fn line_height(self) -> f32 {
self.size() * self.leading
}
}
impl From<TextStyle> for Metrics {
/// The ladder's own setting for a role: its measured leading and weight.
fn from(role: TextStyle) -> Self {
Self::new(role, role.line_height() / role.size(), role.weight())
}
}
/// The ladder, on anything styled.
pub trait Typeset: Styled + Sized {
/// Size, line height and weight together, from [`TextStyle`].
fn text_style(self, style: TextStyle) -> Self {
self.text_size(px(style.painted()))
.line_height(px(style.painted_line_height()))
.font_weight(style.weight())
}
}
impl<E: Styled> Typeset for E {}