use gpui::{Hsla, Rgba};
fn rgb(r: u8, g: u8, b: u8) -> Hsla {
Rgba {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: 1.0,
}
.into()
}
fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
Rgba {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a,
}
.into()
}
fn lighten(base: Hsla, factor: f32) -> Hsla {
base.blend(gpui::white().opacity(factor))
}
#[allow(dead_code)]
fn darken(base: Hsla, factor: f32) -> Hsla {
base.blend(gpui::black().opacity(factor))
}
#[derive(Clone)]
pub struct ColorFamily {
pub base: Hsla,
pub hover: Hsla,
pub active: Hsla,
pub suppl: Hsla,
pub light_9: Hsla,
pub light_8: Hsla,
pub light_7: Hsla,
}
impl ColorFamily {
fn new(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
Self {
base,
hover,
active,
suppl,
light_9: lighten(base, 0.9),
light_8: lighten(base, 0.8),
light_7: lighten(base, 0.7),
}
}
fn new_dark(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
Self {
base,
hover,
active,
suppl,
light_9: base.opacity(0.16),
light_8: base.opacity(0.22),
light_7: base.opacity(0.30),
}
}
}
#[derive(Clone)]
pub struct NeutralTokens {
pub body: Hsla,
pub card: Hsla,
pub modal: Hsla,
pub popover: Hsla,
pub inverted: Hsla,
pub text_1: Hsla,
pub text_2: Hsla,
pub text_3: Hsla,
pub text_disabled: Hsla,
pub placeholder: Hsla,
pub icon: Hsla,
pub border: Hsla,
pub divider: Hsla,
pub hover: Hsla,
pub pressed: Hsla,
pub rail: Hsla,
pub overlay: Hsla,
pub mask: Hsla,
}
#[derive(Clone)]
pub struct Spacing {
pub xs: f32,
pub sm: f32,
pub md: f32,
pub lg: f32,
pub xl: f32,
}
#[derive(Clone)]
pub struct Radius {
pub sm: f32,
pub md: f32,
pub lg: f32,
pub full: f32,
}
#[derive(Clone)]
pub struct FontSize {
pub xs: f32,
pub sm: f32,
pub md: f32,
pub lg: f32,
pub xl: f32,
}
#[derive(Clone)]
pub struct SecondaryColors {
pub bg: Hsla,
pub hover: Hsla,
pub pressed: Hsla,
}
#[derive(Clone)]
pub struct Theme {
pub name: String,
pub spacing: Spacing,
pub radius: Radius,
pub font_size: FontSize,
pub primary: ColorFamily,
pub info: ColorFamily,
pub success: ColorFamily,
pub warning: ColorFamily,
pub danger: ColorFamily,
pub neutral: NeutralTokens,
pub secondary: SecondaryColors,
pub shadow_1: &'static str,
pub shadow_2: &'static str,
pub shadow_3: &'static str,
}
impl Default for Theme {
fn default() -> Self {
Self::light()
}
}
impl Theme {
pub fn light() -> Self {
Self {
name: "light".into(),
spacing: Spacing {
xs: 4.0,
sm: 8.0,
md: 12.0,
lg: 20.0,
xl: 32.0,
},
radius: Radius {
sm: 2.0,
md: 4.0,
lg: 8.0,
full: 9999.0,
},
font_size: FontSize {
xs: 10.0,
sm: 12.0,
md: 14.0,
lg: 16.0,
xl: 20.0,
},
primary: ColorFamily::new(
rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
info: ColorFamily::new(
rgb(32, 128, 240), rgb(64, 152, 252), rgb(16, 96, 201), rgb(64, 152, 252), ),
success: ColorFamily::new(
rgb(24, 160, 88), rgb(54, 173, 106), rgb(12, 122, 67), rgb(54, 173, 106), ),
warning: ColorFamily::new(
rgb(240, 160, 32), rgb(252, 176, 64), rgb(201, 124, 16), rgb(252, 176, 64), ),
danger: ColorFamily::new(
rgb(208, 48, 80), rgb(222, 87, 109), rgb(171, 31, 63), rgb(222, 87, 109), ),
neutral: NeutralTokens {
body: rgb(255, 255, 255),
card: rgb(255, 255, 255),
modal: rgb(255, 255, 255),
popover: rgb(255, 255, 255),
inverted: rgb(0, 20, 40),
text_1: rgb(31, 34, 37),
text_2: rgb(51, 54, 57),
text_3: rgb(118, 124, 130),
text_disabled: rgba(194, 194, 194, 1.0),
placeholder: rgba(194, 194, 194, 1.0),
icon: rgba(31, 34, 37, 1.0),
border: rgb(224, 224, 230),
divider: rgb(239, 239, 245),
hover: rgb(243, 243, 245),
pressed: rgb(237, 237, 239),
rail: rgb(219, 219, 223),
overlay: rgba(0, 0, 0, 0.50),
mask: rgba(255, 255, 255, 0.90),
},
secondary: SecondaryColors {
bg: rgba(46, 51, 56, 0.05),
hover: rgba(46, 51, 56, 0.09),
pressed: rgba(46, 51, 56, 0.13),
},
shadow_1: "0 1px 2px -2px rgba(0,0,0,.08), 0 3px 6px 0 rgba(0,0,0,.06), 0 5px 12px 4px rgba(0,0,0,.04)",
shadow_2: "0 3px 6px -4px rgba(0,0,0,.12), 0 6px 16px 0 rgba(0,0,0,.08), 0 9px 28px 8px rgba(0,0,0,.05)",
shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
}
}
pub fn dark() -> Self {
Self {
name: "dark".into(),
spacing: Spacing {
xs: 4.0,
sm: 8.0,
md: 12.0,
lg: 20.0,
xl: 32.0,
},
radius: Radius {
sm: 2.0,
md: 4.0,
lg: 8.0,
full: 9999.0,
},
font_size: FontSize {
xs: 12.0,
sm: 14.0,
md: 14.0,
lg: 15.0,
xl: 16.0,
},
primary: ColorFamily::new_dark(
rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
info: ColorFamily::new_dark(
rgb(112, 192, 232), rgb(138, 203, 236), rgb(102, 175, 211), rgb(56, 137, 197), ),
success: ColorFamily::new_dark(
rgb(99, 226, 183), rgb(127, 231, 196), rgb(90, 206, 167), rgb(42, 148, 125), ),
warning: ColorFamily::new_dark(
rgb(242, 201, 125), rgb(245, 213, 153), rgb(230, 194, 96), rgb(240, 138, 0), ),
danger: ColorFamily::new_dark(
rgb(232, 128, 128), rgb(233, 139, 139), rgb(229, 114, 114), rgb(208, 58, 82), ),
neutral: NeutralTokens {
body: rgb(16, 16, 20), card: rgb(24, 24, 28), modal: rgb(44, 44, 50), popover: rgb(72, 72, 78), inverted: rgb(255, 255, 255),
text_1: rgba(255, 255, 255, 0.90),
text_2: rgba(255, 255, 255, 0.82),
text_3: rgba(255, 255, 255, 0.52),
text_disabled: rgba(255, 255, 255, 0.38),
placeholder: rgba(255, 255, 255, 0.38),
icon: rgba(255, 255, 255, 0.38),
border: rgba(255, 255, 255, 0.24),
divider: rgba(255, 255, 255, 0.09),
hover: rgba(255, 255, 255, 0.09),
pressed: rgba(255, 255, 255, 0.05),
rail: rgba(255, 255, 255, 0.20),
overlay: rgba(0, 0, 0, 0.60),
mask: rgba(0, 0, 0, 0.70),
},
shadow_1: "0 1px 2px -2px rgba(0,0,0,.24), 0 3px 6px 0 rgba(0,0,0,.18), 0 5px 12px 4px rgba(0,0,0,.12)",
shadow_2: "0 3px 6px -4px rgba(0,0,0,.24), 0 6px 12px 0 rgba(0,0,0,.16), 0 9px 18px 8px rgba(0,0,0,.10)",
shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
secondary: SecondaryColors {
bg: rgba(255, 255, 255, 0.08),
hover: rgba(255, 255, 255, 0.12),
pressed: rgba(255, 255, 255, 0.16),
},
}
}
pub fn color_by_variant(
&self,
variant: ButtonVariant,
secondary: bool,
background: bool,
border: bool,
) -> ButtonVariantColors {
if secondary {
return self.secondary_colors(variant, background, border);
}
match variant {
ButtonVariant::Default => ButtonVariantColors {
bg: rgba(0, 0, 0, 0.0),
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: self.neutral.border,
text_hover: self.primary.base,
border_hover: self.primary.base,
},
ButtonVariant::Tertiary => ButtonVariantColors {
bg: self.secondary.bg,
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: rgba(0, 0, 0, 0.0),
text_hover: self.neutral.text_1,
border_hover: rgba(0, 0, 0, 0.0),
},
ButtonVariant::Text => ButtonVariantColors {
bg: rgba(0, 0, 0, 0.0),
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: rgba(0, 0, 0, 0.0),
text_hover: self.primary.base,
border_hover: rgba(0, 0, 0, 0.0),
},
ButtonVariant::Primary => self.filled_colors(&self.primary),
ButtonVariant::Info => self.filled_colors(&self.info),
ButtonVariant::Success => self.filled_colors(&self.success),
ButtonVariant::Warning => self.filled_colors(&self.warning),
ButtonVariant::Danger => self.filled_colors(&self.danger),
}
}
fn secondary_colors(
&self,
variant: ButtonVariant,
show_bg: bool,
show_border: bool,
) -> ButtonVariantColors {
match variant {
ButtonVariant::Default => ButtonVariantColors {
bg: if show_bg {
self.secondary.bg
} else {
rgba(0, 0, 0, 0.0)
},
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: if show_border {
self.neutral.border
} else {
rgba(0, 0, 0, 0.0)
},
text_hover: self.primary.base,
border_hover: self.primary.base,
},
ButtonVariant::Tertiary => ButtonVariantColors {
bg: if show_bg {
self.secondary.bg
} else {
rgba(0, 0, 0, 0.0)
},
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: if show_border {
self.neutral.border
} else {
rgba(0, 0, 0, 0.0)
},
text_hover: self.neutral.text_1,
border_hover: rgba(0, 0, 0, 0.0),
},
ButtonVariant::Text => ButtonVariantColors {
bg: rgba(0, 0, 0, 0.0),
hover_bg: self.secondary.hover,
active_bg: self.secondary.pressed,
text: self.neutral.text_2,
border: rgba(0, 0, 0, 0.0),
text_hover: self.primary.base,
border_hover: rgba(0, 0, 0, 0.0),
},
ButtonVariant::Primary => self.secondary_family(&self.primary, show_bg, show_border),
ButtonVariant::Info => self.secondary_family(&self.info, show_bg, show_border),
ButtonVariant::Success => self.secondary_family(&self.success, show_bg, show_border),
ButtonVariant::Warning => self.secondary_family(&self.warning, show_bg, show_border),
ButtonVariant::Danger => self.secondary_family(&self.danger, show_bg, show_border),
}
}
fn secondary_family(
&self,
family: &ColorFamily,
show_bg: bool,
show_border: bool,
) -> ButtonVariantColors {
ButtonVariantColors {
bg: if show_bg {
family.light_9
} else {
rgba(0, 0, 0, 0.0)
},
hover_bg: family.light_8,
active_bg: family.light_7,
text: family.base,
border: if show_border {
family.base
} else {
rgba(0, 0, 0, 0.0)
},
text_hover: family.hover,
border_hover: family.hover,
}
}
fn filled_colors(&self, family: &ColorFamily) -> ButtonVariantColors {
let hover = family.base.blend(gpui::black().opacity(0.10));
let active = family.base.blend(gpui::black().opacity(0.25));
ButtonVariantColors {
bg: family.base,
hover_bg: hover,
active_bg: active,
text: rgb(255, 255, 255),
border: family.base,
text_hover: rgb(255, 255, 255),
border_hover: hover,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonVariant {
Default,
Tertiary,
Text,
Primary,
Info,
Success,
Warning,
Danger,
}
pub struct ButtonVariantColors {
pub bg: Hsla,
pub hover_bg: Hsla,
pub active_bg: Hsla,
pub text: Hsla,
pub border: Hsla,
pub text_hover: Hsla,
pub border_hover: Hsla,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonSize {
Small,
Default,
Large,
}
impl ButtonSize {
pub fn height(&self) -> f32 {
match self {
ButtonSize::Small => 28.0, ButtonSize::Default => 34.0, ButtonSize::Large => 40.0, }
}
pub fn padding_x(&self) -> f32 {
match self {
ButtonSize::Small => 12.0, ButtonSize::Default => 14.0, ButtonSize::Large => 18.0, }
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::Rgba;
fn rgba_color(color: Hsla) -> Rgba {
color.into()
}
#[test]
fn filled_button_hover_and_active_backgrounds_get_progressively_darker() {
let theme = Theme::light();
let colors = theme.color_by_variant(ButtonVariant::Primary, false, true, true);
let bg = rgba_color(colors.bg);
let hover = rgba_color(colors.hover_bg);
let active = rgba_color(colors.active_bg);
assert!(hover.r < bg.r, "hover red channel should be darker");
assert!(hover.g < bg.g, "hover green channel should be darker");
assert!(hover.b < bg.b, "hover blue channel should be darker");
assert!(
active.r < hover.r,
"active red channel should be darker than hover"
);
assert!(
active.g < hover.g,
"active green channel should be darker than hover"
);
assert!(
active.b < hover.b,
"active blue channel should be darker than hover"
);
}
#[test]
fn dark_semantic_subtle_backgrounds_remain_translucent() {
let theme = Theme::dark();
assert!(theme.primary.light_9.a < 0.2);
assert!(theme.primary.light_8.a > theme.primary.light_9.a);
assert!(theme.primary.light_7.a > theme.primary.light_8.a);
assert_eq!(theme.primary.light_9.h, theme.primary.base.h);
}
#[test]
fn light_semantic_subtle_backgrounds_remain_opaque_tints() {
let theme = Theme::light();
assert_eq!(theme.primary.light_9.a, 1.0);
assert!(theme.primary.light_9.l > theme.primary.base.l);
}
#[test]
fn default_button_hover_and_active_backgrounds_are_visible_overlays() {
let theme = Theme::light();
let colors = theme.color_by_variant(ButtonVariant::Default, false, true, true);
let bg = rgba_color(colors.bg);
let hover = rgba_color(colors.hover_bg);
let active = rgba_color(colors.active_bg);
assert_eq!(
bg.a, 0.0,
"default button base background should stay transparent"
);
assert!(hover.a > bg.a, "hover background should be visible");
assert!(
active.a > hover.a,
"active background should be stronger than hover"
);
}
}