//! 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. 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::*; 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#""#; 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. 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, pub raise_lite: Vec, pub raise_sm: Vec, pub btn: Vec, pub pressed_sh: Vec, pub well: Vec, pub region_sh: Vec, pub primary_sh: Vec, pub primary_pressed_sh: Vec, pub danger_sh: Vec, pub danger_pressed_sh: Vec, pub led_off_sh: Vec, } pub static BONE: LazyLock = 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 = 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 colorway. pub fn palette(cx: &App) -> &'static Palette { if cx.theme().is_dark() { &GRAPHITE } else { &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 { 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::().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.)) } /// Which colorway the user picked. `Auto` follows the window's appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Colorway { Bone, #[default] Auto, Graphite, } impl Colorway { pub const ALL: [Colorway; 3] = [Colorway::Bone, Colorway::Auto, Colorway::Graphite]; pub fn label(self) -> &'static str { match self { Colorway::Bone => "Bone", Colorway::Auto => "Auto", Colorway::Graphite => "Graphite", } } } #[derive(Default)] struct ColorwayState { choice: Colorway, /// Whether the theme currently applied is Graphite, once one is. applied_dark: Option, } impl Global for ColorwayState {} pub fn colorway(cx: &App) -> Colorway { cx.try_global::().map(|state| state.choice).unwrap_or_default() } /// Switches colorway, repaints every themed component to match, and /// remembers the choice. pub fn set_colorway(colorway: Colorway, window: &Window, cx: &mut App) { cx.default_global::().choice = colorway; sync_appearance(window, cx); crate::settings::update(|settings| settings.colorway = Some(colorway.label().to_lowercase())); } /// Picks up the colorway saved last time. Call before the window opens. pub fn restore_colorway(cx: &mut App) { let saved = crate::settings::load().colorway; let choice = Colorway::ALL .into_iter() .find(|colorway| saved.as_deref() == Some(colorway.label().to_lowercase().as_str())) .unwrap_or_default(); cx.default_global::().choice = choice; } /// Re-reads the saved colorway and applies it, after the settings were reset. pub fn reload_colorway(window: &Window, cx: &mut App) { restore_colorway(cx); sync_appearance(window, cx); } /// Applies the chosen colorway, resolving `Auto` against the window's current /// appearance. Does nothing when that colorway is already applied. pub fn sync_appearance(window: &Window, cx: &mut App) { let system_dark = matches!( window.appearance(), WindowAppearance::Dark | WindowAppearance::VibrantDark ); let state = cx.default_global::(); let dark = match state.choice { Colorway::Bone => false, Colorway::Graphite => true, Colorway::Auto => system_dark, }; if state.applied_dark.replace(dark) != Some(dark) { apply_theme(dark, cx); } } /// Maps the colorway onto gpui-component's theme so the stock widgets (menus, /// tooltips, the composer's text area, markdown) match the custom chrome. fn apply_theme(dark: bool, cx: &mut App) { Theme::change(if dark { ThemeMode::Dark } else { ThemeMode::Light }, None, cx); let p: &Palette = if dark { &GRAPHITE } else { &BONE }; 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 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, 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, 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, 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()) } /// 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, 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, on: bool, p: &Palette, on_toggle: impl Fn(bool, &mut Window, &mut App) + 'static, ) -> Stateful
{ 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( 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, 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; /// A MetalUI button: a pill-shaped cap that sinks into a well when pressed. #[derive(IntoElement)] pub struct MetalButton { base: Stateful
, kind: CapKind, size: CapSize, icon: Option, icon_color: Option, label: Option, caret: bool, stretch: bool, selected: bool, disabled: bool, tooltip: Option, on_click: Option, } impl MetalButton { pub fn new(id: impl Into) -> 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) -> 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) -> 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) -> 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")); } }