use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use gpui::{App, Global, Hsla, SharedString, hsla};
pub mod appearance;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Appearance {
#[default]
Dark,
Light,
}
impl Appearance {
pub fn is_dark(self) -> bool {
matches!(self, Self::Dark)
}
pub fn is_light(self) -> bool {
matches!(self, Self::Light)
}
pub fn from_window(appearance: gpui::WindowAppearance) -> Self {
use gpui::WindowAppearance::*;
match appearance {
Light | VibrantLight => Self::Light,
Dark | VibrantDark => Self::Dark,
}
}
}
static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
static THEME_GENERATION: AtomicU32 = AtomicU32::new(0);
pub fn current_appearance() -> Appearance {
match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
1 => Appearance::Light,
_ => Appearance::Dark,
}
}
pub fn theme_generation() -> u32 {
THEME_GENERATION.load(Ordering::Relaxed)
}
#[doc(hidden)]
pub fn lock_appearance() -> std::sync::MutexGuard<'static, ()> {
static APPEARANCE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
APPEARANCE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn set_current_appearance(appearance: Appearance) {
let encoded = match appearance {
Appearance::Dark => 0,
Appearance::Light => 1,
};
if CURRENT_APPEARANCE.swap(encoded, Ordering::Relaxed) != encoded {
THEME_GENERATION.fetch_add(1, Ordering::Relaxed);
}
}
pub const INK_FILL_SCALE: f32 = 1.0;
pub const INK_HAIRLINE_SCALE: f32 = 1.35;
#[derive(Debug, Clone)]
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,
}
impl SyntaxPalette {
fn dark(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
let indigo = git_graph_tone(oklch(0.673, 0.182, 276.935));
let pink = git_graph_tone(oklch(0.718, 0.202, 349.761));
let emerald = git_graph_tone(oklch(0.765, 0.177, 163.223));
let amber = git_graph_tone(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,
}
}
fn light(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
let indigo = git_graph_tone(oklch(0.47, 0.20, 276.966));
let pink = git_graph_tone(oklch(0.47, 0.17, 0.584));
let emerald = git_graph_tone(oklch(0.46, 0.11, 163.225));
let amber = git_graph_tone(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,
}
}
}
fn git_graph_tone(mut color: Hsla) -> Hsla {
color.s *= 0.72;
color
}
#[derive(Debug, Clone)]
pub struct Theme {
pub appearance: Appearance,
pub bg: Hsla,
pub surface: Hsla,
pub surface_raised: Hsla,
pub surface_card: Hsla,
pub surface_dialog: Hsla,
pub surface_overlay: Hsla,
pub element_hover: Hsla,
pub element_active: Hsla,
pub border: Hsla,
pub border_strong: Hsla,
pub text: Hsla,
pub text_muted: Hsla,
pub text_faint: Hsla,
pub text_dim: Hsla,
pub solid: Hsla,
pub on_solid: Hsla,
pub accent: Hsla,
pub accent_strong: Hsla,
pub on_accent: Hsla,
pub danger: Hsla,
pub danger_muted: Hsla,
pub warning: Hsla,
pub warning_muted: Hsla,
pub success: Hsla,
pub busy: Hsla,
pub success_muted: Hsla,
pub surface_raised_hover: Hsla,
pub band: Hsla,
pub input_bg: Hsla,
pub selection: Hsla,
pub cursor: Hsla,
pub caret: Hsla,
pub danger_strong: Hsla,
pub code_text: Hsla,
pub code_wash: Hsla,
pub syntax: SyntaxPalette,
pub diff_add: Hsla,
pub diff_del: Hsla,
pub diff_hunk_bg: Hsla,
pub font_sans: SharedString,
pub font_mono: SharedString,
pub font_sans_fallback: SharedString,
pub font_mono_fallback: SharedString,
}
impl Theme {
pub const GLASS_ALPHA: f32 = if cfg!(target_os = "macos") { 0.80 } else { 1.0 };
pub const GLASS_ALPHA_LIGHT: f32 = if cfg!(target_os = "macos") { 0.80 } else { 1.0 };
pub const HEADER_HEIGHT: f32 = 44.0;
pub const TITLEBAR_HEIGHT: f32 = 38.0;
pub const TITLEBAR_TOP_PAD: f32 = 2.0;
pub const STATUS_STRIP_HEIGHT: f32 = 24.0;
pub const TRANSCRIPT_FADE_BAND: f32 = 24.0;
pub const BUBBLE_RADIUS: f32 = 16.0;
pub const PANEL_RADIUS: f32 = 10.0;
pub const CONTROL_RADIUS: f32 = 6.0;
pub const SPACE_XS: f32 = 4.0;
pub const SPACE_SM: f32 = 8.0;
pub const SPACE_MD: f32 = 12.0;
pub const SPACE_LG: f32 = 16.0;
pub fn glass(&self) -> Hsla {
match self.appearance {
Appearance::Dark => {
if Self::GLASS_ALPHA < 1.0 {
grey(8).opacity(Self::GLASS_ALPHA)
} else {
self.surface
}
}
Appearance::Light => {
if Self::GLASS_ALPHA_LIGHT < 1.0 {
grey(0xfa).opacity(Self::GLASS_ALPHA_LIGHT)
} else {
self.surface
}
}
}
}
pub fn is_glass(&self) -> bool {
self.glass().a < 1.0
}
pub fn glass_hover(&self) -> Hsla {
match self.appearance {
Appearance::Dark => wash_for(Appearance::Dark, 0.11),
Appearance::Light => wash_for(Appearance::Light, 0.06),
}
}
pub fn glass_overlay(&self) -> Hsla {
match self.appearance {
Appearance::Dark => oklch(0.33, 0.0, 0.0).opacity(0.34),
Appearance::Light => self.surface_overlay.opacity(0.85),
}
}
pub fn input_glass_bg(&self) -> Hsla {
if self.is_glass() && matches!(self.appearance, Appearance::Light) {
self.input_bg.opacity(0.30)
} else {
self.input_bg
}
}
pub fn card_glass_bg(&self) -> Hsla {
if self.is_glass() {
self.surface.opacity(0.40)
} else {
self.surface
}
}
pub fn scrim(&self) -> Hsla {
scrim_for(self.appearance, SCRIM_ALPHA_DARK)
}
pub fn window_background_appearance(&self) -> gpui::WindowBackgroundAppearance {
if self.is_glass() {
gpui::WindowBackgroundAppearance::Blurred
} else {
gpui::WindowBackgroundAppearance::Opaque
}
}
pub fn dark() -> Self {
Self {
appearance: Appearance::Dark,
bg: grey(6), surface: grey(13), surface_raised: neutral(0.235),
surface_card: grey(0x0e),
surface_dialog: grey(0x10),
surface_overlay: grey(0x16),
element_hover: hsla(0.0, 0.0, 0.92, 0.11),
element_active: hsla(0.0, 0.0, 0.92, 0.16),
border: hsla(0.0, 0.0, 1.0, 0.08),
border_strong: hsla(0.0, 0.0, 1.0, 0.14),
text: neutral(0.922), text_muted: neutral(0.708), text_faint: neutral(0.556), text_dim: grey(0x98),
solid: neutral(0.922), on_solid: grey(0x0e), accent: oklch(0.673, 0.182, 276.935), accent_strong: oklch(0.585, 0.233, 277.117), on_accent: neutral(0.985),
danger: oklch(0.704, 0.191, 22.216), danger_muted: oklch(0.808, 0.114, 19.571), warning: oklch(0.828, 0.189, 84.429), warning_muted: oklch(0.924, 0.12, 95.746), success: oklch(0.765, 0.177, 163.223), busy: oklch(0.718, 0.202, 349.761), success_muted: oklch(0.845, 0.143, 164.978), surface_raised_hover: neutral(0.29),
band: band_for(Appearance::Dark),
input_bg: hsla(0.0, 0.0, 1.0, 0.03),
selection: hsla(0.66, 0.6, 0.55, 0.35),
cursor: hsla(0.0, 0.0, 1.0, 0.35),
caret: hsla(0.66, 0.7, 0.7, 1.0),
danger_strong: oklch(0.58, 0.16, 25.0),
code_text: oklch(0.811, 0.111, 293.571), code_wash: oklch(0.702, 0.183, 293.541).opacity(0.12), syntax: SyntaxPalette::dark(neutral(0.922), neutral(0.60), oklch(0.704, 0.191, 22.216)),
diff_add: oklch(0.765, 0.177, 163.223), diff_del: oklch(0.704, 0.191, 22.216), diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
font_sans: "Geist".into(),
font_mono: "Geist Mono".into(),
font_sans_fallback: system_sans().into(),
font_mono_fallback: system_mono().into(),
}
}
pub fn light() -> Self {
Self {
appearance: Appearance::Light,
bg: grey(0xff), surface: neutral(0.968),
surface_raised: neutral(0.940),
surface_card: grey(0xff),
surface_dialog: grey(0xff),
surface_overlay: grey(0xff),
element_hover: hsla(0.0, 0.0, 0.10, 0.06),
element_active: hsla(0.0, 0.0, 0.10, 0.10),
border: hsla(0.0, 0.0, 0.0, 0.10),
border_strong: hsla(0.0, 0.0, 0.0, 0.17),
text: neutral(0.25),
text_muted: neutral(0.439), text_faint: neutral(0.535),
text_dim: neutral(0.50),
solid: neutral(0.205), on_solid: neutral(0.985), accent: oklch(0.511, 0.262, 276.966), accent_strong: oklch(0.511, 0.262, 276.966), on_accent: neutral(0.985),
danger: oklch(0.577, 0.245, 27.325), danger_muted: oklch(0.505, 0.213, 27.518), warning: oklch(0.555, 0.163, 48.998), warning_muted: oklch(0.473, 0.137, 46.201), success: oklch(0.596, 0.145, 163.225), busy: oklch(0.592, 0.249, 0.584), success_muted: oklch(0.508, 0.118, 165.612), surface_raised_hover: neutral(0.900),
band: band_for(Appearance::Light),
input_bg: grey(0xff),
selection: hsla(0.66, 0.75, 0.62, 0.28),
cursor: hsla(0.0, 0.0, 0.0, 0.55),
caret: hsla(0.66, 0.78, 0.42, 1.0),
danger_strong: oklch(0.51, 0.20, 25.0),
code_text: oklch(0.491, 0.27, 292.581), code_wash: oklch(0.541, 0.281, 293.009).opacity(0.10), syntax: SyntaxPalette::light(neutral(0.25), neutral(0.48), oklch(0.505, 0.213, 27.518)),
diff_add: oklch(0.596, 0.145, 163.225), diff_del: oklch(0.577, 0.245, 27.325), diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
font_sans: "Geist".into(),
font_mono: "Geist Mono".into(),
font_sans_fallback: system_sans().into(),
font_mono_fallback: system_mono().into(),
}
}
pub fn for_appearance(appearance: Appearance) -> Self {
match appearance {
Appearance::Dark => Self::dark(),
Appearance::Light => Self::light(),
}
}
pub fn install(appearance: Appearance, cx: &mut App) {
set_current_appearance(appearance);
cx.set_global(Self::for_appearance(appearance));
}
pub fn of(cx: &App) -> &Theme {
cx.global::<Theme>()
}
pub fn ink(&self, alpha: f32) -> Hsla {
ink_for(self.appearance, alpha)
}
pub fn hairline(&self, alpha: f32) -> Hsla {
hairline_for(self.appearance, alpha)
}
pub fn wash(&self, alpha: f32) -> Hsla {
wash_for(self.appearance, alpha)
}
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
impl Global for Theme {}
fn system_sans() -> &'static str {
if cfg!(target_os = "macos") {
"Helvetica"
} else if cfg!(target_os = "windows") {
"Segoe UI"
} else {
"DejaVu Sans"
}
}
fn system_mono() -> &'static str {
if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"DejaVu Sans Mono"
}
}
pub fn neutral(lightness: f32) -> Hsla {
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
hsla(0.0, 0.0, v, 1.0)
}
pub fn ink(alpha: f32) -> Hsla {
ink_for(current_appearance(), alpha)
}
fn ink_for(appearance: Appearance, alpha: f32) -> Hsla {
match appearance {
Appearance::Dark => hsla(0.0, 0.0, 1.0, alpha),
Appearance::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
}
}
pub fn hairline(alpha: f32) -> Hsla {
hairline_for(current_appearance(), alpha)
}
fn hairline_for(appearance: Appearance, alpha: f32) -> Hsla {
match appearance {
Appearance::Dark => hsla(0.0, 0.0, 1.0, alpha),
Appearance::Light => hsla(0.0, 0.0, 0.0, (alpha * INK_HAIRLINE_SCALE).min(0.5)),
}
}
pub fn wash(alpha: f32) -> Hsla {
wash_for(current_appearance(), alpha)
}
fn wash_for(appearance: Appearance, alpha: f32) -> Hsla {
match appearance {
Appearance::Dark => hsla(0.0, 0.0, 0.92, alpha),
Appearance::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
}
}
pub const SCRIM_ALPHA_DARK: f32 = 0.60;
pub fn scrim(alpha_dark: f32) -> Hsla {
scrim_for(current_appearance(), alpha_dark)
}
fn scrim_for(appearance: Appearance, alpha_dark: f32) -> Hsla {
match appearance {
Appearance::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
Appearance::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
}
}
pub fn band() -> Hsla {
band_for(current_appearance())
}
fn band_for(appearance: Appearance) -> Hsla {
match appearance {
Appearance::Dark => hsla(0.0, 0.0, 0.0, 0.16),
Appearance::Light => hsla(0.0, 0.0, 0.0, 0.045),
}
}
pub fn glass_selected_bg() -> Hsla {
match current_appearance() {
Appearance::Dark => wash(0.11),
Appearance::Light => wash(0.06),
}
}
pub fn user_bubble_bg() -> Hsla {
match current_appearance() {
Appearance::Dark => wash(0.08),
Appearance::Light => wash(0.04),
}
}
pub fn card_selected_bg() -> Hsla {
match current_appearance() {
Appearance::Dark => wash(0.11),
Appearance::Light => wash(0.06),
}
}
pub fn glass_selected_shadows() -> Vec<gpui::BoxShadow> {
card_selected_shadows()
}
pub fn card_selected_shadows() -> Vec<gpui::BoxShadow> {
let color = match current_appearance() {
Appearance::Dark => hairline(0.09),
Appearance::Light => hsla(0.0, 0.0, 0.0, 0.07),
};
vec![gpui::BoxShadow {
color,
offset: gpui::point(gpui::px(0.0), gpui::px(0.0)),
blur_radius: gpui::px(0.0),
spread_radius: gpui::px(1.0),
inset: true,
}]
}
pub fn grey(value: u8) -> Hsla {
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
}
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)
}
pub(crate) fn oklch_to_srgb(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();
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_);
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;
[gamma_encode(r), gamma_encode(g), gamma_encode(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
}
}
pub(crate) 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)
}
pub(crate) 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)]
}
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)
}
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)
}
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)
}
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),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn srgb_u8(c: [f32; 3]) -> [u8; 3] {
[
(c[0] * 255.0).round() as u8,
(c[1] * 255.0).round() as u8,
(c[2] * 255.0).round() as u8,
]
}
#[test]
fn neutral_950_is_0a0a0a() {
let rgb = srgb_u8(oklch_to_srgb(0.145, 0.0, 0.0));
assert_eq!(rgb, [10, 10, 10]);
}
#[test]
fn oklch_accents_match_reference() {
assert_eq!(
srgb_u8(oklch_to_srgb(0.673, 0.182, 276.935)),
[124, 134, 255]
); assert_eq!(
srgb_u8(oklch_to_srgb(0.704, 0.191, 22.216)),
[255, 100, 103]
); assert_eq!(srgb_u8(oklch_to_srgb(0.828, 0.189, 84.429)), [255, 185, 0]); }
#[test]
fn hsl_roundtrips_through_rgb() {
for c in [
Theme::dark().accent,
Theme::dark().warning,
Theme::light().accent,
Theme::light().danger,
neutral(0.556),
] {
let [r, g, b] = hsl_to_rgb(c.h, c.s, c.l);
let (h, s, l) = rgb_to_hsl(r, g, b);
assert!((l - c.l).abs() < 1e-3, "lightness drift for {c:?}");
assert!((s - c.s).abs() < 1e-3, "saturation drift for {c:?}");
if c.s > 1e-3 {
assert!((h - c.h).abs() < 1e-3, "hue drift for {c:?}");
}
}
}
#[test]
fn contrast_ratio_hits_known_anchors() {
let white = grey(0xff);
let black = grey(0x00);
assert!((contrast_ratio(white, black) - 21.0).abs() < 0.01);
assert!((contrast_ratio(white, white) - 1.0).abs() < 0.01);
assert!((contrast_ratio(black, white) - contrast_ratio(white, black)).abs() < 1e-4);
}
#[test]
fn text_contrast_is_paired_across_appearances() {
let (d, l) = (Theme::dark(), Theme::light());
for (name, dark_fg, light_fg) in [
("text", d.text, l.text),
("text_muted", d.text_muted, l.text_muted),
("text_faint", d.text_faint, l.text_faint),
] {
let dr = contrast_ratio(dark_fg, d.bg);
let lr = contrast_ratio(light_fg, l.bg);
assert!(
(dr - lr).abs() < 1.0,
"{name}: dark {dr:.2}:1 vs light {lr:.2}:1 — not a matched pair"
);
}
}
#[test]
fn text_tones_clear_wcag_aa() {
for t in [Theme::dark(), Theme::light()] {
for (name, fg, floor) in [
("text", t.text, 4.5),
("text_muted", t.text_muted, 4.5),
("text_dim", t.text_dim, 4.5),
("text_faint", t.text_faint, 4.1),
] {
let on_bg = contrast_ratio(fg, t.bg);
let on_surface = contrast_ratio(fg, t.surface);
assert!(
on_bg >= floor,
"{:?} {name} on bg is {on_bg:.2}:1, below {floor}",
t.appearance
);
assert!(
on_surface >= floor,
"{:?} {name} on surface is {on_surface:.2}:1, below {floor}",
t.appearance
);
}
}
}
#[test]
fn accents_clear_contrast_on_their_background() {
let l = Theme::light();
assert!(
contrast_ratio(l.accent, l.bg) >= 4.5,
"light accent {:.2}:1",
contrast_ratio(l.accent, l.bg)
);
assert!(
contrast_ratio(l.danger, l.bg) >= 4.0,
"light danger {:.2}:1",
contrast_ratio(l.danger, l.bg)
);
for c in [l.warning, l.success, l.busy] {
assert!(
contrast_ratio(c, l.bg) >= 3.0,
"light status color {:.2}:1 — below the 3:1 non-text floor",
contrast_ratio(c, l.bg)
);
}
let d = Theme::dark();
assert!(
contrast_ratio(d.warning, l.bg) < 3.0,
"dark amber-400 unexpectedly passes on white; the invert-is-wrong \
premise needs rechecking"
);
}
#[test]
fn code_and_syntax_tones_are_readable() {
for t in [Theme::dark(), Theme::light()] {
for (name, fg) in [
("code_text", t.code_text),
("syntax_keyword", t.syntax.keyword),
("syntax_string", t.syntax.string),
("syntax_number", t.syntax.number),
] {
let r = contrast_ratio(fg, t.bg);
assert!(r >= 4.5, "{:?} {name} is {r:.2}:1 on bg", t.appearance);
}
for (name, fg) in [("diff_add", t.diff_add), ("diff_del", t.diff_del)] {
let r = contrast_ratio(fg, t.bg);
assert!(r >= 3.0, "{:?} {name} is {r:.2}:1 on bg", t.appearance);
}
}
}
#[test]
fn syntax_palette_is_readable_on_code_and_diff_backgrounds() {
for theme in [Theme::dark(), Theme::light()] {
let add_bg = flatten(theme.diff_add.opacity(0.055), theme.bg);
let del_bg = flatten(theme.diff_del.opacity(0.055), theme.bg);
let s = &theme.syntax;
let tokens = [
("comment", s.comment, 3.0),
("keyword", s.keyword, 4.5),
("string", s.string, 4.5),
("string_special", s.string_special, 4.5),
("escape", s.escape, 4.5),
("number", s.number, 4.5),
("boolean", s.boolean, 4.5),
("type_name", s.type_name, 4.5),
("type_builtin", s.type_builtin, 4.5),
("constructor", s.constructor, 4.5),
("function", s.function, 4.5),
("function_builtin", s.function_builtin, 4.5),
("macro_name", s.macro_name, 4.5),
("property", s.property, 4.5),
("constant", s.constant, 4.5),
("variable", s.variable, 4.5),
("variable_special", s.variable_special, 4.5),
("parameter", s.parameter, 4.5),
("operator", s.operator, 3.0),
("punctuation", s.punctuation, 3.0),
("tag", s.tag, 4.5),
("attribute", s.attribute, 4.5),
("label", s.label, 4.5),
("invalid", s.invalid, 4.5),
];
for (token, color, floor) in tokens {
for (name, background) in [("code", theme.bg), ("add", add_bg), ("del", del_bg)] {
let ratio = contrast_ratio(color, background);
assert!(
ratio >= floor,
"{:?} {token} is {ratio:.2}:1 on {name}",
theme.appearance
);
}
}
}
}
#[test]
fn caret_is_findable_on_its_background() {
for t in [Theme::dark(), Theme::light()] {
let r = contrast_ratio(t.caret, t.bg);
assert!(r >= 3.0, "{:?} caret is {r:.2}:1 on bg", t.appearance);
}
}
#[test]
fn solid_button_is_legible_in_both_appearances() {
for t in [Theme::dark(), Theme::light()] {
let r = contrast_ratio(t.on_solid, t.solid);
assert!(r >= 7.0, "{:?} solid button {r:.2}:1", t.appearance);
let a = contrast_ratio(t.on_accent, t.accent_strong);
assert!(a >= 4.0, "{:?} accent button {a:.2}:1", t.appearance);
}
}
#[test]
fn surfaces_are_separated_in_both_appearances() {
let d = Theme::dark();
assert!(d.bg.l < d.surface.l, "dark: chrome sits above content");
assert!(d.surface.l < d.surface_raised.l, "dark: raised is lighter");
let l = Theme::light();
assert!(
l.surface.l < l.bg.l,
"light: chrome recedes *below* the content plane"
);
assert!(
(l.bg.l - l.surface.l) > 0.015,
"light: sidebar must be visibly separated from the panel"
);
assert!(contrast_ratio(flatten(l.border, l.bg), l.bg) > 1.15);
}
#[test]
fn dark_elevation_ladder_is_strictly_ordered() {
let d = Theme::dark();
let ladder = [
("bg", d.bg),
("surface_card", d.surface_card),
("surface_dialog", d.surface_dialog),
("surface_overlay", d.surface_overlay),
("surface_raised", d.surface_raised),
];
for pair in ladder.windows(2) {
let ((lower, lo), (upper, hi)) = (pair[0], pair[1]);
assert!(
lo.l < hi.l,
"dark: {upper} ({:.4}) must sit above {lower} ({:.4})",
hi.l,
lo.l
);
}
}
#[test]
fn light_elevation_is_flat_white_and_leans_on_borders() {
let l = Theme::light();
for (name, c) in [
("surface_card", l.surface_card),
("surface_dialog", l.surface_dialog),
("surface_overlay", l.surface_overlay),
] {
assert_eq!(c.l, 1.0, "light {name} should be white");
}
assert!(contrast_ratio(flatten(l.border, l.bg), l.bg) > 1.15);
}
#[test]
fn bare_plates_are_visible_against_their_panel() {
for t in [Theme::dark(), Theme::light()] {
let delta = (t.surface_raised.l - t.bg.l).abs();
assert!(
delta > 0.03,
"{:?} surface_raised ({:.3}) is only {delta:.3} from bg ({:.3}) — \
a plate with no border needs lightness to read",
t.appearance,
t.surface_raised.l,
t.bg.l
);
let hover_delta = (t.surface_raised_hover.l - t.surface_raised.l).abs();
assert!(
hover_delta > 0.02,
"{:?} raised-plate hover moves only {hover_delta:.3}",
t.appearance
);
}
}
#[test]
fn neutrals_are_achromatic() {
for t in [Theme::dark(), Theme::light()] {
for c in [
t.bg,
t.surface,
t.surface_raised,
t.text,
t.text_muted,
t.text_faint,
t.solid,
t.on_solid,
] {
assert_eq!(c.s, 0.0, "{:?} neutral has chroma", t.appearance);
assert_eq!(c.a, 1.0, "{:?} neutral is translucent", t.appearance);
}
}
}
#[test]
fn hairlines_and_washes_flip_tone_with_appearance() {
let _guard = lock_appearance();
set_current_appearance(Appearance::Dark);
assert_eq!(hairline(0.1).l, 1.0, "dark hairlines are white");
assert_eq!(ink(0.1).l, 1.0, "dark fills are white");
assert_eq!(ink(0.1).a, 0.1, "dark alphas pass through untouched");
assert_eq!(wash(0.14).l, 0.92, "dark washes are soft-white");
set_current_appearance(Appearance::Light);
assert_eq!(hairline(0.1).l, 0.0, "light hairlines are black");
assert_eq!(ink(0.1).l, 0.0, "light fills are black");
assert_eq!(wash(0.14).l, 0.10, "light washes are soft-black");
assert_eq!(ink(0.10).a, 0.10, "light fills keep their alpha");
assert!(hairline(0.10).a > 0.10, "light hairlines strengthen");
assert!(hairline(0.60).a <= 0.5, "hairline alpha is capped");
set_current_appearance(Appearance::Dark);
}
#[test]
fn hover_wash_is_visible_on_its_surface() {
let _guard = lock_appearance();
for (appearance, theme) in [
(Appearance::Dark, Theme::dark()),
(Appearance::Light, Theme::light()),
] {
set_current_appearance(appearance);
let hovered = flatten(wash(0.14), theme.surface);
let delta = (hovered.l - theme.surface.l).abs();
assert!(
delta > 0.02,
"{appearance:?} hover wash shifts lightness by only {delta:.4}"
);
}
set_current_appearance(Appearance::Dark);
}
#[test]
fn faintest_fills_survive_in_both_appearances() {
let _guard = lock_appearance();
for (appearance, theme) in [
(Appearance::Dark, Theme::dark()),
(Appearance::Light, Theme::light()),
] {
set_current_appearance(appearance);
for alpha in [0.03, 0.05] {
let plate = flatten(ink(alpha), theme.bg);
let delta = (plate.l - theme.bg.l).abs();
assert!(
delta >= 0.02,
"{appearance:?} ink({alpha}) shifts its background by only \
{delta:.4} — the fill is invisible"
);
}
}
set_current_appearance(Appearance::Dark);
}
#[test]
fn both_appearances_stay_frosted_and_light_runs_heavier() {
if Theme::GLASS_ALPHA < 1.0 {
let (dark, light) = (Theme::dark(), Theme::light());
assert!(dark.glass().a < 1.0, "dark keeps its translucent frost");
assert!(light.glass().a < 1.0, "light is glass-forward like dark");
assert!(
light.glass().a > dark.glass().a - f32::EPSILON,
"a light tint dominates the blur less, so it must not run looser than dark"
);
assert!(
light.glass_overlay().a > dark.glass_overlay().a,
"light floating cards need more coverage over blur for legible rows"
);
} else {
assert_eq!(Theme::light().glass().a, 1.0);
assert_eq!(Theme::dark().glass().a, 1.0);
}
}
#[test]
fn input_plate_never_reads_as_recessed() {
for t in [Theme::dark(), Theme::light()] {
let plate = flatten(t.input_bg, t.bg);
assert!(
plate.l >= t.bg.l,
"{:?} input plate ({:.3}) is darker than its panel ({:.3}) — \
that reads as recessed, not raised",
t.appearance,
plate.l,
t.bg.l
);
}
}
#[test]
fn card_selection_paints_nothing_behind_its_row() {
let _guard = lock_appearance();
for appearance in [Appearance::Dark, Appearance::Light] {
set_current_appearance(appearance);
for shadow in card_selected_shadows() {
assert!(
shadow.inset,
"{appearance:?}: card selection may only paint inset edges"
);
}
}
set_current_appearance(Appearance::Dark);
}
#[test]
fn glass_selection_is_edge_only_and_subtle() {
let _guard = lock_appearance();
for appearance in [Appearance::Dark, Appearance::Light] {
set_current_appearance(appearance);
let shadows = glass_selected_shadows();
assert!(
shadows.iter().all(|s| s.inset),
"{appearance:?}: glass selection may only paint inset edges"
);
let ring = shadows.iter().find(|s| s.inset).expect("selection ring");
assert!(
ring.color.a <= 0.09,
"{appearance:?}: ring at {:.2} alpha frames the chip instead of defining it",
ring.color.a
);
}
set_current_appearance(Appearance::Dark);
}
#[test]
fn appearance_mirror_tracks_installed_theme() {
let _guard = lock_appearance();
set_current_appearance(Appearance::Light);
assert_eq!(current_appearance(), Appearance::Light);
set_current_appearance(Appearance::Dark);
assert_eq!(current_appearance(), Appearance::Dark);
}
#[test]
fn window_appearance_maps_onto_ours() {
use gpui::WindowAppearance as W;
assert_eq!(Appearance::from_window(W::Light), Appearance::Light);
assert_eq!(Appearance::from_window(W::VibrantLight), Appearance::Light);
assert_eq!(Appearance::from_window(W::Dark), Appearance::Dark);
assert_eq!(Appearance::from_window(W::VibrantDark), Appearance::Dark);
}
#[test]
fn scrim_is_black_but_lighter_in_light_mode() {
let (d, l) = (Theme::dark(), Theme::light());
assert_eq!(d.scrim().l, 0.0);
assert_eq!(l.scrim().l, 0.0);
assert!(l.scrim().a < d.scrim().a);
}
#[test]
fn mix_endpoints_and_midpoint() {
let a = hsla(0.0, 0.0, 0.0, 1.0);
let b = hsla(0.5, 1.0, 1.0, 0.0);
assert_eq!(mix(a, b, 0.0), a);
assert_eq!(mix(a, b, 1.0), b);
let mid = mix(a, b, 0.5);
assert!((mid.l - 0.5).abs() < 1e-6 && (mid.a - 0.5).abs() < 1e-6);
assert_eq!(mix(a, b, 2.0), b);
}
#[test]
fn layout_numbers_match_the_reference() {
assert_eq!(Theme::HEADER_HEIGHT, 44.0); assert_eq!(Theme::STATUS_STRIP_HEIGHT, 24.0); assert_eq!(Theme::BUBBLE_RADIUS, 16.0);
}
}