simplify theme crate to builtin-only registry

This commit is contained in:
2026-10-08 09:25:24 +07:00
parent af20a1b150
commit 4dfc9fd668
10 changed files with 82 additions and 299 deletions
-1
View File
@@ -7,7 +7,6 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-base.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
+16 -17
View File
@@ -1,4 +1,3 @@
use std::collections::BTreeMap;
use std::sync::OnceLock;
@@ -14,6 +13,17 @@ pub fn builtin_registry() -> &'static ThemeRegistry {
REGISTRY.get_or_init(|| ThemeRegistry {
families: vec![
family("zeron", "Zeron", vec![zeron_light(), zeron_dark()]),
family("dracula", "Dracula", vec![dracula()]),
family("github", "GitHub", vec![github_light(), github_dark()]),
family("ayu", "Ayu", vec![ayu_light(), ayu_dark(), ayu_mirage()]),
family("gruvbox", "Gruvbox", vec![gruvbox_light(), gruvbox_dark()]),
family("nord", "Nord", vec![nord()]),
family("one-dark-pro", "One Dark Pro", vec![one_dark_pro()]),
family("atom-one-dark", "Atom One Dark", vec![atom_one_dark()]),
family("palenight", "Palenight", vec![palenight()]),
family("synthwave-84", "SynthWave '84", vec![synthwave_84()]),
family("cobalt2", "Cobalt2", vec![cobalt2()]),
family("andromeda", "Andromeda", vec![andromeda()]),
family(
"vscode-default",
"VS Code Default",
@@ -29,37 +39,26 @@ pub fn builtin_registry() -> &'static ThemeRegistry {
"Tokyo Night",
vec![tokyo_light(), tokyo_dark()],
),
family("dracula", "Dracula", vec![dracula()]),
family("github", "GitHub", vec![github_light(), github_dark()]),
family("ayu", "Ayu", vec![ayu_light(), ayu_dark(), ayu_mirage()]),
family("gruvbox", "Gruvbox", vec![gruvbox_light(), gruvbox_dark()]),
family(
"rose-pine",
"Rosé Pine",
vec![rose_pine_dawn(), rose_pine_moon()],
),
family("nord", "Nord", vec![nord()]),
family("one-dark-pro", "One Dark Pro", vec![one_dark_pro()]),
family("atom-one-dark", "Atom One Dark", vec![atom_one_dark()]),
family(
"night-owl",
"Night Owl",
vec![night_owl_light(), night_owl()],
),
family(
"winter-is-coming",
"Winter is Coming",
vec![winter_light(), winter_dark_blue()],
),
family("palenight", "Palenight", vec![palenight()]),
family("synthwave-84", "SynthWave '84", vec![synthwave_84()]),
family(
"shades-of-purple",
"Shades of Purple",
vec![shades_of_purple()],
),
family("cobalt2", "Cobalt2", vec![cobalt2()]),
family("andromeda", "Andromeda", vec![andromeda()]),
family(
"winter-is-coming",
"Winter is Coming",
vec![winter_light(), winter_dark_blue()],
),
],
})
}
+1 -11
View File
@@ -3,7 +3,6 @@ use std::fmt::Debug;
use gpui::Axis;
use serde::{Deserialize, Serialize};
/// A enum for defining the side of the element.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Side {
#[serde(rename = "left")]
@@ -13,20 +12,17 @@ pub enum Side {
}
impl Side {
/// Returns true if the side is left.
#[inline]
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
/// Returns true if the side is right.
#[inline]
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// A trait to extend the [`Axis`] enum with utility methods.
pub trait AxisExt {
#[allow(clippy::wrong_self_convention)]
fn is_horizontal(self) -> bool;
@@ -46,18 +42,12 @@ impl AxisExt for Axis {
}
}
/// A struct for defining the edges of an element.
///
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
/// [`gpui::Edges`] with serde support.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
#[repr(C)]
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
/// The size of the top edge.
pub top: T,
/// The size of the right edge.
pub right: T,
/// The size of the bottom edge.
pub bottom: T,
/// The size of the left edge.
pub left: T,
}
+4 -27
View File
@@ -1,8 +1,8 @@
use gpui::{App, Pixels, px};
pub mod builtins;
mod builtins;
mod geometry;
pub mod model;
mod model;
mod notification;
mod platform_kind;
mod resolved;
@@ -17,26 +17,15 @@ pub use resolved::{ResolvedAccentRoles, ResolvedColors};
pub use runtime::Theme;
pub use scrollbar_mode::*;
/// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
/// Defines window shadow size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
/// Defines window border size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height
pub(crate) const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(36.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
pub fn init(cx: &mut App) {
for issue in ThemeRegistry::active().validate() {
for issue in ThemeRegistry::builtin().validate() {
log::warn!("theme {}: {}", issue.variant_id, issue.message);
}
@@ -44,19 +33,7 @@ pub fn init(cx: &mut App) {
Theme::sync_scrollbar_appearance(cx);
}
/// Mirror the active coop theme into the `gpui-base` global theme.
///
/// Base paints a few things from its own tokens -- the focus ring, the wash
/// behind selected text, scrollbars, and overlay backdrops -- so the two
/// globals have to agree or those details drift away from the palette.
///
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
/// mapping would be invented rather than derived.
///
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
/// authoritative hook that keeps the projection current.
/// Project the active theme onto the `gpui-base` global (focus ring, selection wash, scrollbars, backdrops).
pub fn sync_base(cx: &mut App) {
let Some(theme) = cx.try_global::<Theme>() else {
return;
+14 -96
View File
@@ -1,23 +1,10 @@
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::str::FromStr;
use std::sync::{OnceLock, RwLock};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub use crate::builtins::builtin_registry;
fn custom_families() -> &'static RwLock<Vec<ThemeFamily>> {
static CUSTOM: OnceLock<RwLock<Vec<ThemeFamily>>> = OnceLock::new();
CUSTOM.get_or_init(|| RwLock::new(Vec::new()))
}
/// Replace the process-wide custom portion of the runtime registry.
pub fn replace_custom_families(families: Vec<ThemeFamily>) {
*custom_families()
.write()
.expect("custom theme registry lock was poisoned") = families;
}
use crate::builtins::builtin_registry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -28,8 +15,6 @@ pub enum Appearance {
}
impl Appearance {
pub const ALL: [Self; 2] = [Self::Light, Self::Dark];
pub fn is_dark(self) -> bool {
matches!(self, Self::Dark)
}
@@ -53,8 +38,6 @@ pub enum SurfacePreference {
}
impl SurfacePreference {
pub const ALL: [Self; 3] = [Self::ThemeDefault, Self::Frosted, Self::Opaque];
pub fn resolve(self, recommended: SurfaceTreatment) -> SurfaceTreatment {
match self {
Self::ThemeDefault => recommended,
@@ -455,7 +438,6 @@ pub struct ThemeVariant {
pub family_id: String,
pub name: String,
pub appearance: Appearance,
/// The author's recommended treatment.
#[serde(alias = "surfaceTreatment")]
pub recommended_surface_treatment: SurfaceTreatment,
pub colors: ThemeColors,
@@ -486,7 +468,7 @@ pub struct ThemeFamily {
pub variants: Vec<ThemeVariant>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeRegistry {
pub families: Vec<ThemeFamily>,
}
@@ -496,19 +478,6 @@ impl ThemeRegistry {
builtin_registry()
}
/// Built-ins plus the currently installed custom families.
pub fn active() -> Self {
let mut families = Self::builtin().families.clone();
families.extend(
custom_families()
.read()
.expect("custom theme registry lock was poisoned")
.iter()
.cloned(),
);
Self { families }
}
pub fn variant(&self, id: &str) -> Option<&ThemeVariant> {
self.families
.iter()
@@ -516,13 +485,6 @@ impl ThemeRegistry {
.find(|variant| variant.id == id)
}
pub fn variants_for(&self, appearance: Appearance) -> impl Iterator<Item = &ThemeVariant> {
self.families
.iter()
.flat_map(|family| &family.variants)
.filter(move |variant| variant.appearance == appearance)
}
pub fn resolve(&self, selection: &ThemeSelection, appearance: Appearance) -> &ThemeVariant {
self.variant(selection.variant_id(appearance))
.or_else(|| {
@@ -631,25 +593,21 @@ fn validate_contrast(
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Warning,
Error,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationCategory {
Structural,
#[default]
Contrast,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub variant_id: String,
#[serde(default)]
pub category: ValidationCategory,
pub severity: ValidationSeverity,
pub message: String,
@@ -682,42 +640,6 @@ impl ValidationIssue {
message: message.into(),
}
}
pub fn is_blocking(&self) -> bool {
self.category == ValidationCategory::Structural
&& self.severity == ValidationSeverity::Error
}
}
/// Stable visual QA scenes every bundled variant must be reviewed against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum VisualFixture {
Sidebar,
TranscriptMarkdown,
TranscriptCode,
Composer,
PickerPopover,
AppearanceSettings,
Diff,
Terminal,
EmptyState,
Dialog,
}
impl VisualFixture {
pub const ALL: [Self; 10] = [
Self::Sidebar,
Self::TranscriptMarkdown,
Self::TranscriptCode,
Self::Composer,
Self::PickerPopover,
Self::AppearanceSettings,
Self::Diff,
Self::Terminal,
Self::EmptyState,
Self::Dialog,
];
}
#[cfg(test)]
@@ -735,7 +657,7 @@ mod tests {
#[test]
fn accent_overlay_meets_interaction_contrast() {
for appearance in Appearance::ALL {
for appearance in [Appearance::Light, Appearance::Dark] {
let background = if appearance.is_dark() {
Color::rgb(6, 6, 6)
} else {
@@ -781,6 +703,14 @@ mod tests {
fn builtins_have_complete_provenance_and_no_validation_errors() {
let registry = ThemeRegistry::builtin();
assert_eq!(registry.families.len(), 19);
assert_eq!(
registry
.families
.iter()
.map(|family| family.variants.len())
.sum::<usize>(),
30
);
assert!(registry.variant("zeron-light").is_some());
assert!(registry.variant("zeron-dark").is_some());
let errors: Vec<_> = registry
@@ -790,16 +720,4 @@ mod tests {
.collect();
assert!(errors.is_empty(), "{errors:#?}");
}
#[test]
fn visual_fixture_matrix_covers_every_variant_and_scene() {
let registry = ThemeRegistry::builtin();
let variants = registry
.families
.iter()
.map(|family| family.variants.len())
.sum::<usize>();
assert_eq!(variants, 30);
assert_eq!(variants * VisualFixture::ALL.len(), 300);
}
}
-4
View File
@@ -2,14 +2,10 @@ use gpui::{Anchor, Pixels, px};
use crate::{Edges, TITLEBAR_HEIGHT};
/// The settings for notifications.
#[derive(Debug, Clone)]
pub struct NotificationSettings {
/// The placement of the notification, default: [`Anchor::TopRight`]
pub placement: Anchor,
/// The margins of the notification with respect to the window edges.
pub margins: Edges<Pixels>,
/// The maximum number of notifications to show at once, default: 10
pub max_items: usize,
}
+32 -76
View File
@@ -1,8 +1,8 @@
use gpui::Hsla;
use crate::model::{AccentRoles, ThemeColors, ThemeVariant};
use crate::model::{AccentRoles, Color, ThemeColors, ThemeVariant};
fn hsla(color: crate::model::Color) -> Hsla {
fn hsla(color: Color) -> Hsla {
let packed = ((color.r as u32) << 24)
| ((color.g as u32) << 16)
| ((color.b as u32) << 8)
@@ -10,23 +10,17 @@ fn hsla(color: crate::model::Color) -> Hsla {
Hsla::from(gpui::rgba(packed))
}
/// The accent roles a component can consume, pre-resolved to `Hsla` together
/// with the derived states (hover/active, contrast-safe text) that several
/// components need; computing them here keeps call sites free of color math.
/// Accent roles resolved to [`Hsla`], with derived interaction states precomputed.
#[derive(Debug, Clone, Copy)]
pub struct ResolvedAccentRoles {
pub primary: Hsla,
pub strong: Hsla,
pub wash: Hsla,
pub on: Hsla,
pub selection: Hsla,
pub caret: Hsla,
pub activity: Hsla,
pub glyph: [Hsla; 3],
pub hover: Hsla,
pub active: Hsla,
pub selected: Hsla,
/// Accent text guaranteed readable on `wash`.
/// Text guaranteed 4.5:1 readable on `wash`.
pub text_on_wash: Hsla,
/// Text guaranteed readable on the flat `danger` color.
pub on_danger: Hsla,
@@ -38,17 +32,9 @@ impl ResolvedAccentRoles {
let wash_background = accent.wash.blend_over(background);
Self {
primary: hsla(accent.primary),
strong: hsla(accent.strong),
wash: hsla(accent.wash),
on: hsla(accent.on),
selection: hsla(accent.selection),
caret: hsla(accent.caret),
activity: hsla(accent.activity),
glyph: [
hsla(accent.glyph[0]),
hsla(accent.glyph[1]),
hsla(accent.glyph[2]),
],
hover: hsla(accent.primary.mix(background, 0.12)),
active: hsla(accent.primary.mix(background, 0.2)),
selected: hsla(accent.primary.mix(background, 0.28)),
@@ -58,8 +44,7 @@ impl ResolvedAccentRoles {
}
}
/// The `Hsla` snapshot components render from, rebuilt whenever the active
/// variant, accent, or appearance changes.
/// The `Hsla` snapshot components render from, rebuilt on every theme change.
#[derive(Debug, Clone, Copy)]
pub struct ResolvedColors {
pub background: Hsla,
@@ -92,63 +77,34 @@ pub struct ResolvedColors {
impl ResolvedColors {
pub fn resolve(variant: &ThemeVariant) -> Self {
let colors = variant.colors.clone();
let ThemeColors {
background,
shell,
raised,
card,
dialog,
overlay,
hover,
active,
border,
border_strong,
text,
text_muted,
text_faint,
solid,
on_solid,
danger,
danger_muted,
warning,
warning_muted,
success,
success_muted,
input,
cursor,
diff_add,
diff_delete,
diff_hunk,
} = colors;
let colors = &variant.colors;
Self {
background: hsla(background),
shell: hsla(shell),
raised: hsla(raised),
card: hsla(card),
dialog: hsla(dialog),
overlay: hsla(overlay),
hover: hsla(hover),
active: hsla(active),
border: hsla(border),
border_strong: hsla(border_strong),
text: hsla(text),
text_muted: hsla(text_muted),
text_faint: hsla(text_faint),
solid: hsla(solid),
on_solid: hsla(on_solid),
danger: hsla(danger),
danger_muted: hsla(danger_muted),
warning: hsla(warning),
warning_muted: hsla(warning_muted),
success: hsla(success),
success_muted: hsla(success_muted),
input: hsla(input),
cursor: hsla(cursor),
diff_add: hsla(diff_add),
diff_delete: hsla(diff_delete),
diff_hunk: hsla(diff_hunk),
background: hsla(colors.background),
shell: hsla(colors.shell),
raised: hsla(colors.raised),
card: hsla(colors.card),
dialog: hsla(colors.dialog),
overlay: hsla(colors.overlay),
hover: hsla(colors.hover),
active: hsla(colors.active),
border: hsla(colors.border),
border_strong: hsla(colors.border_strong),
text: hsla(colors.text),
text_muted: hsla(colors.text_muted),
text_faint: hsla(colors.text_faint),
solid: hsla(colors.solid),
on_solid: hsla(colors.on_solid),
danger: hsla(colors.danger),
danger_muted: hsla(colors.danger_muted),
warning: hsla(colors.warning),
warning_muted: hsla(colors.warning_muted),
success: hsla(colors.success),
success_muted: hsla(colors.success_muted),
input: hsla(colors.input),
cursor: hsla(colors.cursor),
diff_add: hsla(colors.diff_add),
diff_delete: hsla(colors.diff_delete),
diff_hunk: hsla(colors.diff_hunk),
}
}
}
+14 -65
View File
@@ -1,4 +1,4 @@
use std::ops::{Deref, DerefMut};
use std::ops::Deref;
use std::sync::Arc;
use gpui::{App, Global, Pixels, SharedString, Window, px};
@@ -21,49 +21,21 @@ impl From<gpui::WindowAppearance> for Appearance {
#[derive(Debug, Clone)]
pub struct Theme {
/// The active theme variant.
pub variant: Arc<ThemeVariant>,
/// The active appearance (light or dark).
pub appearance: Appearance,
/// The per-appearance variant selection.
/// Named `theme_selection` to avoid shadowing the `selection` color role via `Deref`.
pub theme_selection: ThemeSelection,
/// The accent override applied on top of the variant.
pub accent_selection: AccentSelection,
/// The user's surface treatment preference.
pub surface_preference: SurfacePreference,
/// The surface treatment in effect for this session.
pub surface: SurfaceTreatment,
/// The resolved accent roles.
pub accent: ResolvedAccentRoles,
/// The resolved color snapshot.
pub colors: ResolvedColors,
/// The font family for the application.
pub font_family: SharedString,
/// The root font size for the application, default is 15px.
pub font_size: Pixels,
/// Radius for the general elements.
pub radius: Pixels,
/// Radius for the large elements, e.g.: dialog, notification.
pub radius_lg: Pixels,
/// Enable shadow for the general elements. default is true
pub shadow: bool,
/// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
}
@@ -75,43 +47,34 @@ impl Deref for Theme {
}
}
impl DerefMut for Theme {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.colors
}
}
impl Global for Theme {}
impl Theme {
/// Returns the global theme reference
pub fn global(cx: &App) -> &Theme {
cx.global::<Theme>()
}
/// Returns the global theme mutable reference
pub(crate) fn global_mut(cx: &mut App) -> &mut Theme {
cx.global_mut::<Theme>()
}
/// Returns true if the theme is dark.
pub fn is_dark(&self) -> bool {
self.appearance.is_dark()
}
fn new() -> Self {
let platform = PlatformKind::platform();
let appearance = Appearance::default();
let selection = ThemeSelection::default();
// Define the font family based on the platform.
let font_family = match platform {
let font_family = match PlatformKind::platform() {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
};
let selection = ThemeSelection::default();
let registry = ThemeRegistry::active();
let variant = registry.resolve(&selection, appearance).clone();
let variant = ThemeRegistry::builtin()
.resolve(&selection, appearance)
.clone();
let accent = variant.accent_for(AccentSelection::default());
let surface = SurfacePreference::default().resolve(variant.recommended_surface_treatment);
let resolved_accent = ResolvedAccentRoles::resolve(&accent, &variant.colors);
@@ -136,7 +99,6 @@ impl Theme {
}
}
/// Sync the theme with the system appearance
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
let appearance = window
.as_ref()
@@ -146,7 +108,6 @@ impl Theme {
Self::change(appearance, window, cx);
}
/// Sync the Scrollbar showing behavior with the system
pub fn sync_scrollbar_appearance(cx: &mut App) {
Theme::global_mut(cx).scrollbar_mode = if cx.should_auto_hide_scrollbars() {
ScrollbarMode::Scrolling
@@ -155,32 +116,19 @@ impl Theme {
};
}
/// Apply a theme variant by id for the current appearance.
pub fn apply_variant(id: &str, window: Option<&mut Window>, cx: &mut App) {
let appearance = Theme::global(cx).appearance;
Theme::global_mut(cx)
.theme_selection
.set_variant(appearance, id);
Self::change(appearance, window, cx);
}
/// Replace the per-appearance variant selection.
pub fn set_selection(selection: ThemeSelection, cx: &mut App) {
Theme::global_mut(cx).theme_selection = selection;
}
/// Replace the accent override and re-resolve the theme.
pub fn set_accent_selection(selection: AccentSelection, cx: &mut App) {
Theme::global_mut(cx).accent_selection = selection;
}
/// Replace the surface treatment preference.
pub fn set_surface_preference(preference: SurfacePreference, cx: &mut App) {
Theme::global_mut(cx).surface_preference = preference;
}
/// Change the app's appearance, resolving the selected variant (with its
/// accent override) into a fresh color snapshot.
/// Switch appearance and re-resolve the selected variant, accent, and surface.
pub fn change<M>(appearance: M, window: Option<&mut Window>, cx: &mut App)
where
M: Into<Appearance>,
@@ -190,6 +138,7 @@ impl Theme {
}
let appearance = appearance.into();
let (selection, accent_selection, surface_preference) = {
let theme = cx.global_mut::<Theme>();
theme.appearance = appearance;
@@ -200,8 +149,10 @@ impl Theme {
)
};
let registry = ThemeRegistry::active();
let variant = registry.resolve(&selection, appearance).clone();
let variant = ThemeRegistry::builtin()
.resolve(&selection, appearance)
.clone();
let accent = variant.accent_for(accent_selection);
let surface = surface_preference.resolve(variant.recommended_surface_treatment);
let colors = ResolvedColors::resolve(&variant);
@@ -214,12 +165,10 @@ impl Theme {
theme.colors = colors;
}
// Refresh the window if available
if let Some(window) = window {
window.refresh();
}
// Keep the base-layer projection in step with the coop palette
sync_base(cx);
}
}