//! MetalUI, the "soft hardware" design system vectrum.cc is built on: warm
//! Bone and Graphite colorways, raised caps over inset wells, Geist type and
//! engraved mono labels.
//!
//! The colors and shadows below are copied from MetalUI's CSS custom
//! properties (`--mu-*`), so they can be checked against the source verbatim.
//! The other themes are built from popular color schemes in `themes`.
use std::{borrow::Cow, rc::Rc, sync::LazyLock, time::Duration};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Selectable, Sizable as _, StyledExt as _, Theme, ThemeMode,
h_flex, menu::DropdownMenu, tooltip::Tooltip,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::ui::themes;
pub const SANS: &str = "Geist";
pub const MONO: &str = "Geist Mono";
/// A dot-matrix face for numeric readouts, MetalUI's "pixel" voice.
pub const PIXEL: &str = "Geist Pixel";
/// Geist, Geist Mono and Geist Pixel ship inside the binary (SIL OFL 1.1, see
/// `assets/fonts/OFL.txt`), so the app looks the same on every machine.
const FONTS: [&[u8]; 7] = [
include_bytes!("../../assets/fonts/Geist-Regular.ttf"),
include_bytes!("../../assets/fonts/Geist-Medium.ttf"),
include_bytes!("../../assets/fonts/Geist-SemiBold.ttf"),
include_bytes!("../../assets/fonts/Geist-Bold.ttf"),
include_bytes!("../../assets/fonts/GeistMono-Regular.ttf"),
include_bytes!("../../assets/fonts/GeistMono-Medium.ttf"),
include_bytes!("../../assets/fonts/GeistPixel-Circle.ttf"),
];
/// The Vectrum mark, from vectrum.cc.
pub const LOGO_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="currentColor" fill-rule="evenodd" d="M2 13a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0-17 0ZM11.54 7.69Q10 4 14 4H26Q30 4 28.46 7.69L21.54 24.31Q20 28 18.46 24.31Z"/></svg>"#;
pub fn load_fonts(cx: &App) {
let fonts = FONTS.iter().map(|font| Cow::Borrowed(*font)).collect();
if let Err(err) = cx.text_system().add_fonts(fonts) {
eprintln!("vectrum-code: failed to load the bundled fonts: {err:#}");
}
}
/// One MetalUI colorway: surfaces, inks, accents and the shadow recipes that
/// make a surface read as raised, pressed or sunk. Themes other than Bone and
/// Graphite derive theirs with `Palette::from_scheme`.
pub struct Palette {
pub dark: bool,
/// The table everything sits on.
pub page: Hsla,
pub s_hi: Hsla,
pub s: Hsla,
pub s_lo: Hsla,
pub well_top: Hsla,
pub well_bot: Hsla,
pub ink: Hsla,
pub ink2: Hsla,
pub ink3: Hsla,
pub icon: Hsla,
/// Engraved mono labels.
pub engrave: Hsla,
/// The hairline ring around an engraved tag.
pub tag_ring: Hsla,
pub rule: Hsla,
/// The highlight a hairline rule casts below itself.
pub rule_lip: Hsla,
pub row_hover: Hsla,
pub menu_bg: Hsla,
pub menu_row_hover: Hsla,
pub field_ink: Hsla,
pub cap: (Hsla, Hsla),
pub pressed: (Hsla, Hsla),
pub row_on: (Hsla, Hsla),
pub kbd: (Hsla, Hsla),
pub primary: (Hsla, Hsla),
pub primary_pressed: (Hsla, Hsla),
pub primary_ink: Hsla,
pub danger: (Hsla, Hsla),
pub danger_pressed: (Hsla, Hsla),
pub region: (Hsla, Hsla),
pub green_deep: Hsla,
/// Green that is legible as text on this colorway's surfaces.
pub green_ink: Hsla,
pub red: Hsla,
pub amber: Hsla,
pub amber_ink: Hsla,
pub blue_ink: Hsla,
pub led_off: (Hsla, Hsla),
/// MetalUI's accent tints: ember, blush, tide, spark, dusk, iris.
pub tints: [Hsla; 6],
pub raise: Vec<BoxShadow>,
pub raise_lite: Vec<BoxShadow>,
pub raise_sm: Vec<BoxShadow>,
pub btn: Vec<BoxShadow>,
pub pressed_sh: Vec<BoxShadow>,
pub well: Vec<BoxShadow>,
pub region_sh: Vec<BoxShadow>,
pub primary_sh: Vec<BoxShadow>,
pub primary_pressed_sh: Vec<BoxShadow>,
pub danger_sh: Vec<BoxShadow>,
pub danger_pressed_sh: Vec<BoxShadow>,
pub led_off_sh: Vec<BoxShadow>,
}
pub static BONE: LazyLock<Palette> = LazyLock::new(|| Palette {
dark: false,
page: hex("#f3f3f1"),
s_hi: hex("#fcfbf9"),
s: hex("#f6f5f2"),
s_lo: hex("#f0efeb"),
well_top: hex("#e6e5e1"),
well_bot: hex("#edece8"),
ink: hex("#1b1b1d"),
ink2: hex("#5c5c60"),
ink3: hex("#6c6c6f"),
icon: hex("#3a3a3d"),
engrave: hex("#282620a6"),
tag_ring: hex("#28262033"),
rule: hex("#00000013"),
rule_lip: hex("#ffffffcc"),
row_hover: hex("#ffffffb3"),
menu_bg: hex("#fbfaf8fa"),
menu_row_hover: hex("#0000000d"),
field_ink: hex("#8e8e93"),
cap: (hex("#ffffff"), hex("#f4f3f0")),
pressed: (hex("#e9e8e4"), hex("#efeeea")),
row_on: (hex("#ffffff"), hex("#f6f5f2")),
kbd: (hex("#ffffff"), hex("#f2f1ee")),
primary: (hex("#38383c"), hex("#252528")),
primary_pressed: (hex("#1c1c1e"), hex("#242427")),
primary_ink: hex("#ffffff"),
danger: (hex("#e6594e"), hex("#d0433a")),
danger_pressed: (hex("#be352c"), hex("#cf4238")),
region: (hex("#28262007"), hex("#28262003")),
green_deep: hex("#3fb97a"),
green_ink: hex("#2b7a52"),
red: hex("#d8453b"),
amber: hex("#f5bf55"),
amber_ink: hex("#9a6a12"),
blue_ink: hex("#3558c9"),
led_off: (hex("#d6d5d1"), hex("#c9c8c4")),
tints: [hex("#d0560e"), hex("#d54487"), hex("#108a8b"), hex("#b653c5"), hex("#0f7ddc"), hex("#736aec")],
raise: shadows(
"inset 0 0 6px 2px #ffffffbf, inset 2px 3px 3px -1px #fffffff2, inset -1px -3px 5px -2px #6e645012, \
0 0 0 .5px #1816100b, 0 1px 2px #1816100d, 0 8px 18px -6px #18161014, 0 26px 50px -16px #18161021, \
0 52px 96px -36px #18161021",
),
raise_lite: shadows(
"inset 0 0 6px 2px #ffffffbf, inset 2px 3px 3px -1px #fffffff2, inset -1px -3px 5px -2px #6e64500f, \
0 0 0 .5px #1816100a, 0 1px 2px #1816100a, 0 8px 18px -8px #18161014, 0 22px 40px -18px #1816101a",
),
raise_sm: shadows(
"inset 0 0 4px 1px #ffffffd9, inset 1px 2px 2px -1px #fff, 0 0 0 .5px #1816100d, 0 1px 2px #18161012, \
0 5px 12px -4px #18161017",
),
btn: shadows(
"inset 0 0 4px 1px #ffffffe6, inset 1px 2px 2px -1px #fff, 0 0 0 .5px #0000000f, 0 1px 2px #0000000f, \
0 4px 9px -3px #00000014",
),
pressed_sh: shadows(
"inset 0 2px 5px -1px #3c372824, inset 0 0 0 .5px #00000009, inset 0 -2px 3px -2px #ffffffe6",
),
well: shadows("inset 1px 2px 5px -1px #3c37281c, inset 0 0 0 .5px #1816101a, 0 .5px 0 #ffffffe6"),
region_sh: shadows(
"inset 0 2px 6px -1px #3c372812, inset 0 0 0 .5px #00000009, inset 0 -2px 4px -2px #ffffffe6",
),
primary_sh: shadows(
"inset 0 0 5px 1px #ffffff12, inset 1px 2px 2px -1px #ffffff2e, 0 0 0 .5px #00000080, \
0 1px 2px #00000026, 0 5px 12px -4px #00000038",
),
primary_pressed_sh: shadows("inset 0 1px 3px #0009, 0 0 0 .5px #000000b3"),
danger_sh: shadows(
"inset 0 0 6px 1px #ffffff24, inset 1px 2px 2px -1px #ffffff52, 0 0 0 .5px #78140a4d, \
0 1px 2px #6e0a0024, 0 5px 12px -4px #aa1e143d",
),
danger_pressed_sh: shadows("inset 0 1px 3px #50000073, 0 0 0 .5px #6e0a008c"),
led_off_sh: shadows("inset 0 1px 1px #0003"),
});
pub static GRAPHITE: LazyLock<Palette> = LazyLock::new(|| Palette {
dark: true,
page: hex("#19191b"),
s_hi: hex("#2c2c2f"),
s: hex("#252527"),
s_lo: hex("#202022"),
well_top: hex("#141415"),
well_bot: hex("#18181a"),
ink: hex("#f2f2f0"),
ink2: hex("#a6a6a9"),
ink3: hex("#939397"),
icon: hex("#d6d6d8"),
engrave: hex("#ffffff7d"),
tag_ring: hex("#ffffff29"),
rule: hex("#00000073"),
rule_lip: hex("#ffffff09"),
row_hover: hex("#ffffff0f"),
menu_bg: hex("#222225fa"),
menu_row_hover: hex("#ffffff0f"),
field_ink: hex("#8e8e93"),
cap: (hex("#313134"), hex("#28282b")),
pressed: (hex("#18181a"), hex("#1c1c1e")),
row_on: (hex("#333337"), hex("#2a2a2d")),
kbd: (hex("#35353a"), hex("#2a2a2d")),
primary: (hex("#f4f3f0"), hex("#e4e3df")),
primary_pressed: (hex("#dad9d5"), hex("#e3e2de")),
primary_ink: hex("#1b1b1d"),
danger: (hex("#e6594e"), hex("#d0433a")),
danger_pressed: (hex("#be352c"), hex("#cf4238")),
region: (hex("#ffffff05"), hex("#ffffff03")),
green_deep: hex("#3fb97a"),
green_ink: hex("#5fcb93"),
red: hex("#ff7a66"),
amber: hex("#f5bf55"),
amber_ink: hex("#e7c98a"),
blue_ink: hex("#8fb0ff"),
led_off: (hex("#8b8b8e"), hex("#4a4a4d")),
tints: [hex("#fb794a"), hex("#ed6ba2"), hex("#25c2c3"), hex("#ce76dc"), hex("#3e9bfa"), hex("#8986fa")],
raise: shadows(
"inset 0 0 6px 2px #ffffff0e, inset 1.5px 2.5px 3px -1px #ffffff1a, inset -1px -3px 5px -2px #00000059, \
0 0 0 .5px #00000073, 0 1px 2px #00000038, 0 8px 18px -6px #0003, 0 26px 50px -16px #00000057, \
0 52px 96px -36px #0000004d",
),
raise_lite: shadows(
"inset 0 0 6px 2px #ffffff0d, inset 1.5px 2.5px 3px -1px #ffffff17, inset -1px -3px 5px -2px #0000004d, \
0 0 0 .5px #0006, 0 1px 2px #0003, 0 8px 18px -8px #0003, 0 22px 40px -18px #00000047",
),
raise_sm: shadows(
"inset 0 0 4px 1px #ffffff0f, inset 1px 2px 2px -1px #ffffff1f, 0 0 0 .5px #0000008c, \
0 1px 2px #0000004d, 0 5px 12px -4px #0000004d",
),
btn: shadows(
"inset 0 0 4px 1px #ffffff0d, inset 1px 2px 2px -1px #ffffff1a, 0 0 0 .5px #0009, 0 1px 2px #0000004d, \
0 4px 9px -3px #0000004d",
),
pressed_sh: shadows(
"inset 0 2px 5px -1px #000000a6, inset 0 0 0 .5px #00000080, inset 0 -2px 3px -2px #ffffff0d",
),
well: shadows("inset 1px 2px 5px -1px #0009, inset 0 0 0 .5px #0000008c, 0 .5px 0 #ffffff12"),
region_sh: shadows(
"inset 0 2px 6px -1px #00000080, inset 0 0 0 .5px #00000059, inset 0 -2px 4px -2px #ffffff0d",
),
primary_sh: shadows(
"inset 0 0 4px 1px #ffffffb3, inset 1px 2px 2px -1px #fff, 0 0 0 .5px #0009, 0 1px 2px #00000059, \
0 5px 12px -4px #00000073",
),
primary_pressed_sh: shadows("inset 0 1px 3px #00000047, 0 0 0 .5px #0009"),
danger_sh: shadows(
"inset 0 0 6px 1px #ffffff24, inset 1px 2px 2px -1px #ffffff52, 0 0 0 .5px #78140a4d, \
0 1px 2px #6e0a0024, 0 5px 12px -4px #aa1e143d",
),
danger_pressed_sh: shadows("inset 0 1px 3px #50000073, 0 0 0 .5px #6e0a008c"),
led_off_sh: shadows("0 0 0 .5px #00000040"),
});
/// The palette of the active theme.
pub fn palette(cx: &App) -> &'static Palette {
match cx.try_global::<AppliedTheme>() {
Some(applied) => applied.0,
None if cx.theme().is_dark() => &GRAPHITE,
None => &BONE,
}
}
/// Parses a CSS hex color: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
pub fn hex(color: &str) -> Hsla {
let digits = color.trim_start_matches('#');
let expanded: String = match digits.len() {
3 | 4 => digits.chars().flat_map(|c| [c, c]).collect(),
6 | 8 => digits.to_string(),
_ => panic!("invalid hex color {color:?}"),
};
let value = u32::from_str_radix(&expanded, 16).unwrap_or_else(|_| panic!("invalid hex color {color:?}"));
let value = if expanded.len() == 6 { (value << 8) | 0xff } else { value };
rgba(value).into()
}
/// Parses a CSS `box-shadow` list whose colors are hex, as MetalUI writes them.
pub fn shadows(css: &str) -> Vec<BoxShadow> {
css.split(',')
.map(|layer| {
let mut parts = layer.split_whitespace().peekable();
let inset = parts.next_if_eq(&"inset").is_some();
let mut lengths = Vec::new();
let mut color = None;
for part in parts {
if part.starts_with('#') {
color = Some(hex(part));
} else {
let number = part.trim_end_matches("px");
let number = number.parse::<f32>().unwrap_or_else(|_| panic!("bad shadow length {part:?}"));
lengths.push(number);
}
}
let length = |ix: usize| px(lengths.get(ix).copied().unwrap_or(0.));
BoxShadow {
color: color.unwrap_or_else(|| panic!("shadow without a color: {layer:?}")),
offset: point(length(0), length(1)),
blur_radius: length(2),
spread_radius: length(3),
inset,
}
})
.collect()
}
/// A top-to-bottom gradient, CSS's `linear-gradient(top, bottom)`.
pub fn vertical(colors: (Hsla, Hsla)) -> Background {
linear_gradient(180., linear_color_stop(colors.0, 0.), linear_color_stop(colors.1, 1.))
}
/// The palette on screen, once a theme is applied.
struct AppliedTheme(&'static Palette);
impl Global for AppliedTheme {}
/// Applies the theme the settings pick, resolving the System mode against the
/// window's current appearance. Does nothing when that theme is already
/// applied, so it's cheap to call after any change.
pub fn sync_appearance(window: &Window, cx: &mut App) {
let system_dark = matches!(
window.appearance(),
WindowAppearance::Dark | WindowAppearance::VibrantDark
);
let p = themes::chosen(system_dark);
if !cx.try_global::<AppliedTheme>().is_some_and(|applied| std::ptr::eq(applied.0, p)) {
cx.set_global(AppliedTheme(p));
apply_theme(p, cx);
}
}
/// Maps the palette onto gpui-component's theme so the stock widgets (menus,
/// tooltips, the composer's text area, markdown) match the custom chrome.
fn apply_theme(p: &Palette, cx: &mut App) {
Theme::change(if p.dark { ThemeMode::Dark } else { ThemeMode::Light }, None, cx);
Theme::update(cx, |theme| {
theme.font_family = SANS.into();
theme.mono_font_family = MONO.into();
theme.mono_font_size = px(12.5);
// Menus and tooltips round to `radius`: between MetalUI's 16px menus
// and 11px tips.
theme.radius = px(12.);
theme.radius_lg = px(16.);
theme.shadow = true;
let c = &mut theme.colors;
c.background = p.s_hi;
c.foreground = p.ink;
c.muted = p.well_bot;
c.muted_foreground = p.ink3;
c.border = p.rule;
c.input = p.rule;
c.ring = p.green_deep;
c.caret = p.ink;
c.selection = p.green_deep.opacity(0.28);
c.accent = p.menu_row_hover;
c.accent_foreground = p.ink;
c.popover = p.menu_bg;
c.popover_foreground = p.ink;
c.primary = p.ink;
c.primary_hover = p.ink2;
c.primary_active = p.ink;
c.primary_foreground = p.page;
c.secondary = p.s_lo;
c.secondary_hover = p.row_hover;
c.secondary_active = p.pressed.0;
c.secondary_foreground = p.ink;
c.link = p.blue_ink;
c.link_hover = p.blue_ink.opacity(0.8);
c.link_active = p.blue_ink;
c.success = p.green_deep;
c.danger = p.red;
c.warning = p.amber;
c.info = p.blue_ink;
c.list_hover = p.menu_row_hover;
c.list_active = p.menu_row_hover;
c.table_head = p.s_lo;
c.table_head_foreground = p.ink2;
c.table_row_border = p.rule;
c.scrollbar = p.page.opacity(0.);
c.scrollbar_thumb = p.ink.opacity(0.16);
c.scrollbar_thumb_hover = p.ink.opacity(0.3);
c.sidebar = p.page;
c.sidebar_foreground = p.ink;
c.sidebar_border = p.rule;
c.sidebar_accent = p.row_on.0;
c.sidebar_accent_foreground = p.ink;
c.title_bar = p.page;
c.title_bar_border = p.rule;
c.window_border = p.rule;
c.skeleton = p.well_bot;
});
}
/// Surface recipes, as extension methods on anything styleable.
pub trait MetalStyled: Styled + Sized {
/// A large raised sheet, like vectrum.cc's page plate.
fn plate(self, p: &Palette) -> Self {
self.bg(vertical((p.s_hi, p.s))).shadow(p.raise_lite.clone())
}
/// A small raised object sitting on a surface: cards, bubbles, badges.
fn raised(self, p: &Palette) -> Self {
self.bg(vertical(p.row_on)).shadow(p.raise_sm.clone())
}
/// A pressed-in field.
fn well(self, p: &Palette) -> Self {
self.bg(vertical((p.well_top, p.well_bot))).shadow(p.well.clone())
}
/// A gently recessed area of a sheet.
fn region(self, p: &Palette) -> Self {
self.bg(vertical(p.region)).shadow(p.region_sh.clone())
}
/// Mono, uppercase, engraved: MetalUI's small label voice.
fn engraved(self, p: &Palette) -> Self {
self.font_family(MONO).text_size(px(9.5)).text_color(p.engrave).line_height(px(12.))
}
}
impl<E: Styled> MetalStyled for E {}
/// A hairline rule with the light lip MetalUI casts below it.
pub fn rule(p: &Palette) -> Div {
div()
.h(px(1.))
.flex_none()
.bg(p.rule)
.shadow(vec![BoxShadow::new(px(0.), px(1.), p.rule_lip)])
}
/// An uppercase engraved label.
pub fn engraved(text: impl Into<SharedString>, p: &Palette) -> Div {
div().engraved(p).child(text.into().to_uppercase())
}
/// An engraved tag with a hairline ring, like vectrum.cc's `PUBLIC`.
pub fn tag(text: impl Into<SharedString>, p: &Palette) -> Div {
div()
.flex()
.flex_none()
.items_center()
.h(px(16.))
.px(px(6.))
.rounded(px(5.))
.shadow(vec![
BoxShadow::new(px(0.), px(0.), p.tag_ring)
.spread_radius(px(0.5))
.inset(),
])
.engraved(p)
.child(text.into().to_uppercase())
}
/// A small tinted badge with a project's initials, like T3 Code's.
pub fn badge(initials: impl Into<SharedString>, tint: usize, p: &Palette) -> Div {
let color = p.tints[tint % p.tints.len()];
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.h(px(16.))
.min_w(px(20.))
.px(px(3.))
.rounded(px(5.))
.bg(color.opacity(if p.dark { 0.2 } else { 0.13 }))
.font_family(MONO)
.text_size(px(9.))
.font_weight(FontWeight::BOLD)
.text_color(color)
.child(initials.into())
}
/// The colors diffs and change counts use, from the palette, as the Diff
/// colors setting picks them.
pub struct DiffInk {
/// Text for additions: `+12`.
pub added: Hsla,
pub removed: Hsla,
/// The tint behind an added line.
pub added_bg: Hsla,
pub removed_bg: Hsla,
}
pub fn diff_ink(p: &Palette) -> DiffInk {
match crate::settings::get(|settings| settings.diff_colors) {
crate::settings::DiffColors::RedGreen => DiffInk {
added: p.green_ink,
removed: p.red,
added_bg: p.green_deep.opacity(0.14),
removed_bg: p.red.opacity(0.12),
},
crate::settings::DiffColors::BlueOrange => DiffInk {
added: p.blue_ink,
removed: p.tints[0],
added_bg: p.blue_ink.opacity(0.14),
removed_bg: p.tints[0].opacity(0.14),
},
}
}
/// A vertical hairline, for separating toolbar groups.
pub fn vrule(p: &Palette) -> Div {
div().flex_none().w(px(1.)).h(px(16.)).bg(p.rule)
}
/// A keycap: `Ctrl`, `N`, `Esc`.
pub fn keycap(text: impl Into<SharedString>, p: &Palette) -> Div {
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.h(px(18.))
.min_w(px(19.))
.px(px(5.))
.rounded(px(7.))
.bg(vertical(p.kbd))
.shadow(p.raise_sm.clone())
.font_family(MONO)
.text_size(px(10.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink2)
.child(text.into())
}
/// A row of keycaps for one shortcut.
pub fn shortcut(keys: &[&'static str], p: &Palette) -> Div {
h_flex().gap(px(3.)).children(keys.iter().map(|key| keycap(*key, p)))
}
/// A switch: a sunk track with a raised knob, lit green when on.
pub fn toggle(
id: impl Into<ElementId>,
on: bool,
p: &Palette,
on_toggle: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Stateful<Div> {
div()
.id(id)
.flex()
.flex_none()
.items_center()
.when(on, |this| this.justify_end())
.w(px(38.))
.h(px(22.))
.p(px(3.))
.rounded_full()
.cursor_pointer()
.map(|this| {
if on {
this.bg(vertical((p.green_ink, p.green_deep))).shadow(p.pressed_sh.clone())
} else {
this.well(p)
}
})
.on_click(move |_, window, cx| on_toggle(!on, window, cx))
.child(div().size(px(16.)).rounded_full().bg(vertical(p.cap)).shadow(p.raise_sm.clone()))
}
/// A segmented switch: options as caps in one well, the current one raised.
pub fn segmented<T: Copy + PartialEq + 'static>(
id: &'static str,
options: &[(T, &'static str)],
current: T,
p: &Palette,
on_pick: impl Fn(T, &mut Window, &mut App) + 'static,
) -> Div {
let on_pick = Rc::new(on_pick);
h_flex()
.flex_none()
.p(px(3.))
.gap(px(2.))
.rounded_full()
.well(p)
.children(options.iter().enumerate().map(|(ix, &(value, label))| {
let selected = value == current;
let on_pick = on_pick.clone();
div()
.id((id, ix))
.flex()
.items_center()
.h(px(24.))
.px(px(12.))
.rounded_full()
.text_size(px(12.5))
.font_weight(FontWeight::MEDIUM)
.map(|this| {
if selected {
this.bg(vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink)
} else {
this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink))
}
})
.on_click(move |_, window, cx| on_pick(value, window, cx))
.child(label)
}))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Led {
Live,
Waiting,
Failed,
Off,
}
/// A small domed status light. `pulse` breathes it, for work in progress.
pub fn led(id: impl Into<ElementId>, kind: Led, pulse: bool, p: &Palette) -> AnyElement {
let (top, bottom, glow) = match kind {
Led::Live => (rgb(0xd9ffe9).into(), rgb(0x2fb673).into(), Some(rgba(0x7beaae8c).into())),
Led::Waiting => (rgb(0xfff1cf).into(), rgb(0xc98a18).into(), Some(rgba(0xf5bf558c).into())),
Led::Failed => (rgb(0xffd9d2).into(), rgb(0xd5392a).into(), Some(rgba(0xff7a668c).into())),
Led::Off => (p.led_off.0, p.led_off.1, None),
};
let light = div()
.flex_none()
.size(px(6.))
.rounded_full()
.bg(linear_gradient(
150.,
linear_color_stop(top, 0.),
linear_color_stop(bottom, 0.75),
))
.shadow(match glow {
Some(glow) => vec![
BoxShadow::new(px(0.), px(0.), rgba(0x00000040).into()).spread_radius(px(0.5)),
BoxShadow::new(px(0.), px(0.), glow).blur_radius(px(3.)),
],
None => p.led_off_sh.clone(),
});
if pulse {
light
.with_animation(
id,
Animation::new(Duration::from_millis(1600))
.repeat()
.with_easing(pulsating_between(0.35, 1.)),
|light, delta| light.opacity(delta),
)
.into_any_element()
} else {
light.into_any_element()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapKind {
/// The everyday raised button.
Standard,
/// The one action that matters: an inverted cap.
Primary,
Danger,
/// No cap until the pointer is over it.
Ghost,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapSize {
/// 32px, MetalUI's default button.
Regular,
/// 26px, for toolbars.
Compact,
/// 22px, for icon buttons tucked into rows.
Mini,
}
impl CapSize {
fn height(self) -> Pixels {
match self {
CapSize::Regular => px(32.),
CapSize::Compact => px(26.),
CapSize::Mini => px(22.),
}
}
fn pad(self) -> Pixels {
match self {
CapSize::Regular => px(15.),
CapSize::Compact => px(11.),
CapSize::Mini => px(8.),
}
}
fn font(self) -> Pixels {
match self {
CapSize::Regular => px(13.),
CapSize::Compact | CapSize::Mini => px(12.),
}
}
fn glyph(self) -> Pixels {
match self {
CapSize::Regular => px(16.),
CapSize::Compact => px(14.),
CapSize::Mini => px(13.),
}
}
}
type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
/// A MetalUI button: a pill-shaped cap that sinks into a well when pressed.
#[derive(IntoElement)]
pub struct MetalButton {
base: Stateful<Div>,
kind: CapKind,
size: CapSize,
icon: Option<Icon>,
icon_color: Option<Hsla>,
label: Option<SharedString>,
caret: bool,
stretch: bool,
selected: bool,
disabled: bool,
tooltip: Option<SharedString>,
on_click: Option<ClickHandler>,
}
impl MetalButton {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().id(id),
kind: CapKind::Standard,
size: CapSize::Regular,
icon: None,
icon_color: None,
label: None,
caret: false,
stretch: false,
selected: false,
disabled: false,
tooltip: None,
on_click: None,
}
}
pub fn kind(mut self, kind: CapKind) -> Self {
self.kind = kind;
self
}
pub fn primary(self) -> Self {
self.kind(CapKind::Primary)
}
pub fn danger(self) -> Self {
self.kind(CapKind::Danger)
}
pub fn ghost(self) -> Self {
self.kind(CapKind::Ghost)
}
pub fn compact(mut self) -> Self {
self.size = CapSize::Compact;
self
}
pub fn mini(mut self) -> Self {
self.size = CapSize::Mini;
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn icon_color(mut self, color: Hsla) -> Self {
self.icon_color = Some(color);
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
/// Lets the label take the spare width, pushing the caret to the far edge.
pub fn stretch(mut self) -> Self {
self.stretch = true;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Styled for MetalButton {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl InteractiveElement for MetalButton {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for MetalButton {}
impl DropdownMenu for MetalButton {}
impl Selectable for MetalButton {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl RenderOnce for MetalButton {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let p = palette(cx);
let mut base = self.base;
let instance_style = base.style().clone();
let icon_only = self.label.is_none();
let height = self.size.height();
let (bg, pressed_bg, shadow, pressed_shadow, ink) = match self.kind {
CapKind::Standard | CapKind::Ghost => {
(vertical(p.cap), vertical(p.pressed), p.btn.clone(), p.pressed_sh.clone(), p.ink)
}
CapKind::Primary => (
vertical(p.primary),
vertical(p.primary_pressed),
p.primary_sh.clone(),
p.primary_pressed_sh.clone(),
p.primary_ink,
),
CapKind::Danger => (
vertical(p.danger),
vertical(p.danger_pressed),
p.danger_sh.clone(),
p.danger_pressed_sh.clone(),
white(),
),
};
let ghost = self.kind == CapKind::Ghost;
let interactive = !self.disabled;
base.flex()
.flex_none()
.items_center()
.justify_center()
.gap(px(6.))
.h(height)
.map(|this| if icon_only { this.w(height) } else { this.px(self.size.pad()) })
.rounded_full()
.text_size(self.size.font())
.font_weight(FontWeight::MEDIUM)
.line_height(px(16.))
.text_color(if ghost { p.ink2 } else { ink })
.when(!ghost, |this| this.bg(bg).shadow(shadow))
.when(interactive, |this| {
this.cursor_pointer()
.when(ghost, |this| this.hover(|style| style.bg(p.row_hover).text_color(p.ink)))
.active(|style| style.bg(pressed_bg).shadow(pressed_shadow.clone()))
})
.when(self.selected, |this| {
this.bg(vertical(p.pressed)).shadow(p.pressed_sh.clone()).text_color(p.ink)
})
.when(self.disabled, |this| this.opacity(0.4))
.refine_style(&instance_style)
.when_some(self.icon, |this, icon| {
let icon = icon.size(self.size.glyph());
this.child(match self.icon_color {
Some(color) => icon.text_color(color),
None => icon,
})
})
.when_some(self.label, |this, label| {
this.child(
div()
.whitespace_nowrap()
.when(self.stretch, |this| this.flex_1().min_w_0().truncate())
.child(label),
)
})
.when(self.caret, |this| {
this.child(
Icon::new(IconName::ChevronDown)
.xsmall()
.text_color(if ghost { p.ink3 } else { ink.opacity(0.6) }),
)
})
.when_some(self.tooltip.filter(|_| interactive), |this, tooltip| {
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
})
.when_some(self.on_click.filter(|_| interactive), |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
#[cfg(test)]
mod tests {
// Not a glob: GPUI exports its own `test` attribute.
use super::{BONE, GRAPHITE, hex, shadows};
use gpui_kit::px;
#[test]
fn palettes_parse() {
assert!(!BONE.raise.is_empty());
assert!(!GRAPHITE.raise.is_empty());
}
#[test]
fn shadow_layers() {
let layers = shadows("inset 0 0 0 .5px #0009, 0 1px 2px #00000038");
assert_eq!(layers.len(), 2);
assert!(layers[0].inset);
assert_eq!(layers[0].spread_radius, px(0.5));
assert!(!layers[1].inset);
assert_eq!(layers[1].blur_radius, px(2.));
}
#[test]
fn short_hex() {
assert_eq!(hex("#fff"), hex("#ffffffff"));
assert_eq!(hex("#0009"), hex("#00000099"));
}
}