use std::ops::Range;
use gpui_kit::{
FontWeight, Hsla, Pixels, SharedString,
base::{
ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens,
TextStyleToken, ThemeAppearance, TypographyTokens,
},
px,
};
use super::{ThemeConfig, color::mix};
const BASE_FONT_SIZE: f32 = 16.;
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct ThemeTokens {
pub appearance: ThemeAppearance,
pub touch: bool,
pub base: SemanticThemeTokens,
pub selected: Hsla,
pub link: Hsla,
pub tooltip: Hsla,
pub tooltip_foreground: Hsla,
pub scrim: Hsla,
pub sidebar: Hsla,
pub sidebar_border: Hsla,
pub sidebar_accent: Hsla,
pub sidebar_selected: Hsla,
pub sidebar_muted_foreground: Hsla,
pub text_control: TextStyleToken,
pub text_heading: TextStyleToken,
pub text_title: TextStyleToken,
pub metrics: MetricTokens,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct MetricTokens {
pub control_xs: Pixels,
pub control_sm: Pixels,
pub control_md: Pixels,
pub control_lg: Pixels,
pub control_padding_xs: Pixels,
pub control_padding_sm: Pixels,
pub control_padding_md: Pixels,
pub control_padding_lg: Pixels,
pub row_sm: Pixels,
pub row: Pixels,
pub row_lg: Pixels,
pub title_bar: Pixels,
pub window_controls_position: Pixels,
pub window_controls_inset: Pixels,
pub window_control_width: Pixels,
pub sidebar_width: Pixels,
pub sidebar_width_range: Range<Pixels>,
pub icon_sidebar_width: Pixels,
pub sidebar_sheet_breakpoint: Pixels,
pub sidebar_sheet_width: Pixels,
pub resize_handle: Pixels,
pub focus_ring: Pixels,
}
impl MetricTokens {
fn derive(ui_font_size: Pixels, touch: bool) -> Self {
let factor = f32::from(ui_font_size) / BASE_FONT_SIZE;
let scaled = |value: f32| px(value * factor);
let (control_xs, control_sm, control_md, control_lg) = if touch {
(px(36.), px(40.), px(44.), px(48.))
} else {
(scaled(20.), scaled(24.), scaled(28.), scaled(32.))
};
let (row_sm, row, row_lg) = if touch {
(px(40.), px(44.), px(56.))
} else {
(scaled(28.), scaled(30.), scaled(48.))
};
Self {
control_xs,
control_sm,
control_md,
control_lg,
control_padding_xs: scaled(6.),
control_padding_sm: scaled(8.),
control_padding_md: scaled(if touch { 14. } else { 10. }),
control_padding_lg: scaled(18.),
row_sm,
row,
row_lg,
title_bar: px(if touch { 44. } else { 46. }),
window_controls_position: px(16.),
window_controls_inset: px(88.),
window_control_width: px(46.),
sidebar_width: px(300.),
sidebar_width_range: px(220.)..px(480.),
icon_sidebar_width: px(48.),
sidebar_sheet_breakpoint: px(768.),
sidebar_sheet_width: px(288.),
resize_handle: px(8.),
focus_ring: px(3.),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Metrics {
pub radius: Pixels,
pub ui_font_size: Pixels,
pub code_font_size: Pixels,
pub touch: bool,
}
impl ThemeTokens {
pub(super) fn derive(
config: &ThemeConfig,
appearance: ThemeAppearance,
metrics: Metrics,
) -> Self {
let dark = appearance == ThemeAppearance::Dark;
let c = f32::from(config.contrast) / 50.;
let surface = config.surface;
let ink = config.ink;
let toward_ink = |amount: f32| mix(surface, ink, amount);
let soft = toward_ink(0.044 * c);
let selected = toward_ink(if dark { 0.0867 } else { 0.10 } * c);
let border = toward_ink(0.07);
let input = toward_ink(0.10);
let foreground = if dark { mix(ink, surface, 0.12) } else { ink };
let muted_foreground = mix(ink, surface, if dark { 0.412 } else { 0.38 });
let card = if dark {
toward_ink(0.0475 * c)
} else {
surface
};
let link = if dark {
mix(config.accent, ink, 0.35)
} else {
config.accent
};
let (tooltip, tooltip_foreground) = if dark {
(toward_ink(0.016), ink)
} else {
(ink, surface)
};
let primary = mix(ink, surface, 0.12);
let primary_foreground = mix(surface, ink, 0.11);
let sidebar = if dark { soft } else { toward_ink(0.008) };
let sidebar_step = |amount: f32| mix(sidebar, ink, amount);
let sidebar_accent = sidebar_step(if dark { 0.055 } else { 0.025 });
let sidebar_selected = sidebar_step(if dark { 0.095 } else { 0.05 });
let sidebar_border = sidebar_step(if dark { 0.10 } else { 0.07 });
let sidebar_muted_foreground = mix(ink, sidebar, if dark { 0.59 } else { 0.665 });
let destructive_foreground = readable_on(config.semantic.destructive, surface, ink);
let colors = ColorTokens {
background: surface,
foreground,
surface: card,
surface_foreground: foreground,
primary,
primary_foreground,
secondary: soft,
secondary_foreground: foreground,
muted: soft,
muted_foreground,
accent: soft,
accent_foreground: foreground,
destructive: config.semantic.destructive,
destructive_foreground,
border,
input,
ring: config.accent,
selection: config.accent.alpha(0.3),
};
let typography = typography(config, metrics);
let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
let text_control = TextStyleToken {
size: px(if metrics.touch { 17. } else { 13. } * factor),
line_height: px(if metrics.touch { 22. } else { 16. } * factor),
weight: FontWeight::NORMAL,
};
let text_heading = TextStyleToken {
size: px(if metrics.touch { 17. } else { 15. } * factor),
line_height: px(22. * factor),
weight: if metrics.touch {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
},
};
let text_title = TextStyleToken {
size: px(if metrics.touch { 28. } else { 24. } * factor),
line_height: px(if metrics.touch { 34. } else { 32. } * factor),
weight: FontWeight::SEMIBOLD,
};
let shadow = ShadowTokens::elevations(if dark {
gpui_kit::black().alpha(0.4)
} else {
ink.alpha(0.1)
});
Self {
appearance,
touch: metrics.touch,
base: SemanticThemeTokens {
colors,
radius: radius_scale(metrics.radius),
spacing: SpacingTokens::default(),
typography,
shadow,
},
selected,
link,
tooltip,
tooltip_foreground,
scrim: gpui_kit::hsla(0., 0., 0., 0.5),
sidebar,
sidebar_border,
sidebar_accent,
sidebar_selected,
sidebar_muted_foreground,
text_control,
text_heading,
text_title,
metrics: MetricTokens::derive(metrics.ui_font_size, metrics.touch),
}
}
pub fn is_dark(&self) -> bool {
self.appearance == ThemeAppearance::Dark
}
pub fn colors(&self) -> &ColorTokens {
&self.base.colors
}
pub fn background(&self) -> Hsla {
self.base.colors.background
}
pub fn foreground(&self) -> Hsla {
self.base.colors.foreground
}
pub fn primary(&self) -> Hsla {
self.base.colors.primary
}
pub fn primary_foreground(&self) -> Hsla {
self.base.colors.primary_foreground
}
pub fn secondary(&self) -> Hsla {
self.base.colors.secondary
}
pub fn secondary_foreground(&self) -> Hsla {
self.base.colors.secondary_foreground
}
pub fn muted(&self) -> Hsla {
self.base.colors.muted
}
pub fn muted_foreground(&self) -> Hsla {
self.base.colors.muted_foreground
}
pub fn accent(&self) -> Hsla {
self.base.colors.accent
}
pub fn accent_foreground(&self) -> Hsla {
self.base.colors.accent_foreground
}
pub fn destructive(&self) -> Hsla {
self.base.colors.destructive
}
pub fn destructive_foreground(&self) -> Hsla {
self.base.colors.destructive_foreground
}
pub fn border(&self) -> Hsla {
self.base.colors.border
}
pub fn input(&self) -> Hsla {
self.base.colors.input
}
pub fn ring(&self) -> Hsla {
self.base.colors.ring
}
pub fn selection(&self) -> Hsla {
self.base.colors.selection
}
pub fn font_family(&self) -> &SharedString {
&self.base.typography.sans
}
pub fn mono_font_family(&self) -> &SharedString {
&self.base.typography.mono
}
pub fn radius_sm(&self) -> Pixels {
self.base.radius.sm
}
pub fn radius_md(&self) -> Pixels {
self.base.radius.md
}
pub fn radius_lg(&self) -> Pixels {
self.base.radius.lg
}
pub fn radius_xl(&self) -> Pixels {
self.base.radius.xl
}
pub fn radius_full(&self) -> Pixels {
self.base.radius.full
}
}
fn radius_scale(radius: Pixels) -> RadiusTokens {
RadiusTokens {
none: px(0.),
sm: radius * 0.6,
md: radius * 0.8,
lg: radius,
xl: radius * 1.4,
full: px(9999.),
}
}
fn typography(config: &ThemeConfig, metrics: Metrics) -> TypographyTokens {
let defaults = TypographyTokens::default();
let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
let scale = |token: TextStyleToken| TextStyleToken {
size: token.size * factor,
line_height: token.line_height * factor,
weight: token.weight,
};
let code_factor = f32::from(metrics.code_font_size) / f32::from(defaults.mono_md.size);
TypographyTokens {
sans: config.fonts.ui.clone().unwrap_or(defaults.sans),
mono: config.fonts.code.clone().unwrap_or(defaults.mono),
xs: scale(defaults.xs),
sm: scale(defaults.sm),
md: scale(defaults.md),
lg: scale(defaults.lg),
xl: scale(defaults.xl),
mono_md: TextStyleToken {
size: metrics.code_font_size,
line_height: defaults.mono_md.line_height * code_factor,
weight: defaults.mono_md.weight,
},
}
}
fn readable_on(background: Hsla, surface: Hsla, ink: Hsla) -> Hsla {
use super::color::lightness;
const LIGHT_BACKGROUND: f32 = 0.72;
let (lighter, darker) = if lightness(surface) >= lightness(ink) {
(surface, ink)
} else {
(ink, surface)
};
if lightness(background) > LIGHT_BACKGROUND {
darker
} else {
lighter
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::color::{hex, lightness, to_hex};
fn metrics() -> Metrics {
Metrics {
radius: px(10.),
ui_font_size: px(16.),
code_font_size: px(13.),
touch: false,
}
}
fn light() -> ThemeTokens {
ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics())
}
#[test]
fn touch_grows_controls_to_the_hig_hit_target_and_text_to_body_size() {
let touch = ThemeTokens::derive(
&ThemeConfig::light(),
ThemeAppearance::Light,
Metrics {
touch: true,
..metrics()
},
);
assert!(touch.touch);
assert_eq!(touch.metrics.control_md, px(44.), "the HIG minimum");
assert_eq!(touch.metrics.row, px(44.));
assert_eq!(touch.metrics.control_xs, px(36.));
assert_eq!(touch.metrics.title_bar, px(44.), "an iOS navigation bar");
assert_eq!(touch.text_control.size, px(17.), "HIG body");
assert_eq!(touch.text_title.size, px(28.), "HIG title 1");
assert_eq!(touch.metrics.sidebar_sheet_width, px(288.));
let mouse = light();
assert!(!mouse.touch);
assert_eq!(mouse.metrics.control_md, px(28.));
assert_eq!(mouse.metrics.row, px(30.));
assert_eq!(mouse.text_control.size, px(13.));
}
fn dark() -> ThemeTokens {
ThemeTokens::derive(&ThemeConfig::dark(), ThemeAppearance::Dark, metrics())
}
#[test]
fn surfaces_step_away_from_the_background_in_both_appearances() {
let light = light();
let bg = lightness(light.background());
assert!(lightness(light.secondary()) < bg);
assert!(lightness(light.border()) < lightness(light.secondary()));
assert!(lightness(light.input()) < lightness(light.border()));
assert!(lightness(light.selected) < lightness(light.secondary()));
assert_eq!(
light.base.colors.surface,
light.background(),
"light cards sit flat"
);
assert!(lightness(light.muted_foreground()) > lightness(light.foreground()));
let dark = dark();
let bg = lightness(dark.background());
assert!(lightness(dark.secondary()) > bg);
assert!(lightness(dark.border()) > bg);
assert!(lightness(dark.input()) > lightness(dark.border()));
assert!(
lightness(dark.base.colors.surface) > bg,
"dark cards are lifted"
);
assert!(lightness(dark.foreground()) < lightness(dark.primary()) + 0.001);
assert!(lightness(dark.muted_foreground()) < lightness(dark.foreground()));
}
#[test]
fn tokens_reproduce_the_reference_values() {
let light = light();
assert_eq!(to_hex(light.secondary()), "#f5f5f5");
assert_eq!(to_hex(light.selected), "#e8e8e8");
assert_eq!(to_hex(light.border()), "#ededed");
assert_eq!(to_hex(light.input()), "#e5e5e6");
assert_eq!(to_hex(light.muted_foreground()), "#67696b");
let dark = dark();
assert_eq!(to_hex(dark.secondary()), "#222222");
assert_eq!(to_hex(dark.base.colors.surface), "#232323");
assert_eq!(to_hex(dark.selected), "#2c2c2c");
assert_eq!(to_hex(dark.border()), "#252525");
assert_eq!(to_hex(dark.foreground()), "#dfdfdf");
assert_eq!(to_hex(dark.muted_foreground()), "#969696");
assert_eq!(to_hex(dark.primary()), "#dfdfdf");
assert_eq!(to_hex(dark.primary_foreground()), "#2d2d2d");
assert_eq!(to_hex(dark.tooltip), "#1b1b1b");
assert_eq!(to_hex(dark.tooltip_foreground), "#ffffff");
assert_eq!(to_hex(light.tooltip), "#1a1c1f");
assert_eq!(to_hex(light.foreground()), "#1a1c1f");
}
#[test]
fn sidebar_tokens_reproduce_the_reference_values() {
let dark = dark();
assert_eq!(to_hex(dark.sidebar), "#222222");
assert_eq!(to_hex(dark.sidebar_selected), "#333333");
assert_eq!(to_hex(dark.sidebar_accent), "#2c2c2c");
assert!(lightness(dark.sidebar_accent) > lightness(dark.sidebar));
assert!(lightness(dark.sidebar_accent) < lightness(dark.sidebar_selected));
assert_eq!(to_hex(dark.sidebar_border), "#343434");
assert_eq!(to_hex(dark.sidebar_muted_foreground), "#747474");
let light = light();
assert_eq!(to_hex(light.sidebar), "#fdfdfd");
assert_eq!(to_hex(light.sidebar_selected), "#f0f0f0");
assert_eq!(to_hex(light.sidebar_accent), "#f6f6f7");
assert!(lightness(light.sidebar_accent) < lightness(light.sidebar));
assert!(lightness(light.sidebar_accent) > lightness(light.sidebar_selected));
assert_eq!(to_hex(light.sidebar_border), "#ebebeb");
assert_eq!(to_hex(light.sidebar_muted_foreground), "#a9aaab");
}
#[test]
fn contrast_scales_the_distance() {
let mut low = ThemeConfig::light();
low.contrast = 20;
let mut high = ThemeConfig::light();
high.contrast = 80;
let low = ThemeTokens::derive(&low, ThemeAppearance::Light, metrics());
let high = ThemeTokens::derive(&high, ThemeAppearance::Light, metrics());
assert!(lightness(low.secondary()) > lightness(high.secondary()));
assert!(lightness(low.selected) > lightness(high.selected));
assert_eq!(
low.border(),
high.border(),
"separators do not move with contrast"
);
}
#[test]
fn accent_and_semantic_colors_pass_through() {
let light = light();
assert_eq!(light.ring(), hex("#339cff"));
assert_eq!(light.destructive(), hex("#ba2623"));
assert_eq!(light.destructive_foreground(), hex("#ffffff"));
assert_eq!(light.selection().a, 0.3);
let dark = dark();
assert_eq!(dark.ring(), hex("#539af8"));
assert_eq!(
to_hex(dark.link),
"#91bffd",
"links are paler than the ring on dark"
);
assert_eq!(light.link, light.ring(), "and the accent itself on light");
assert_eq!(dark.destructive_foreground(), hex("#ffffff"));
let mut pale = ThemeConfig::light();
pale.semantic.destructive = hex("#ffe066");
let pale = ThemeTokens::derive(&pale, ThemeAppearance::Light, metrics());
assert_eq!(
pale.destructive_foreground(),
hex("#1a1c1f"),
"ink reads on pale yellow"
);
}
#[test]
fn radius_scale_derives_from_one_value() {
let tokens = light();
assert_eq!(tokens.radius_sm(), px(6.));
assert_eq!(tokens.radius_md(), px(8.));
assert_eq!(tokens.radius_lg(), px(10.));
assert_eq!(tokens.radius_xl(), px(14.));
assert_eq!(tokens.radius_full(), px(9999.));
}
#[test]
fn control_text_and_metrics_scale_with_the_font_size_but_chrome_does_not() {
let default = light();
assert_eq!(default.text_control.size, px(13.));
assert_eq!(default.text_control.line_height, px(16.));
assert_eq!(default.text_heading.size, px(15.));
assert_eq!(default.text_title.size, px(24.));
assert_eq!(default.text_title.weight, FontWeight::SEMIBOLD);
assert_eq!(default.metrics.control_md, px(28.));
assert_eq!(default.metrics.row, px(30.));
assert_eq!(default.metrics.title_bar, px(46.));
let large = ThemeTokens::derive(
&ThemeConfig::light(),
ThemeAppearance::Light,
Metrics {
radius: px(10.),
ui_font_size: px(20.),
code_font_size: px(15.),
touch: false,
},
);
assert_eq!(large.text_control.size, px(16.25));
assert_eq!(large.metrics.control_md, px(35.));
assert_eq!(large.metrics.row, px(37.5));
assert_eq!(large.metrics.title_bar, px(46.), "window chrome stays");
assert_eq!(large.metrics.sidebar_width, px(300.), "layout stays");
}
#[test]
fn typography_follows_the_font_sizes_and_families() {
let mut config = ThemeConfig::light();
config.fonts.ui = Some("Inter".into());
config.fonts.code = Some("JetBrains Mono".into());
let tokens = ThemeTokens::derive(
&config,
ThemeAppearance::Light,
Metrics {
radius: px(10.),
ui_font_size: px(20.),
code_font_size: px(15.),
touch: false,
},
);
assert_eq!(tokens.font_family(), &SharedString::from("Inter"));
assert_eq!(
tokens.mono_font_family(),
&SharedString::from("JetBrains Mono")
);
assert_eq!(tokens.base.typography.md.size, px(20.));
assert_eq!(tokens.base.typography.sm.size, px(17.5));
assert_eq!(tokens.base.typography.md.line_height, px(30.));
assert_eq!(tokens.base.typography.mono_md.size, px(15.));
let defaults =
ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics());
assert_eq!(defaults.font_family(), &TypographyTokens::default().sans);
assert_eq!(defaults.base.typography.md.size, px(16.));
assert_eq!(defaults.base.typography.mono_md.size, px(13.));
}
}