feat: add new theme (#61)
Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../../LICENSE
|
||||
@@ -1 +0,0 @@
|
||||
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user