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