Vectrum
Sign In
970 lines · 32.0 KB · Rust
//! 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#"<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.
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 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<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.))
}

/// 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<bool>,
}

impl Global for ColorwayState {}

pub fn colorway(cx: &App) -> Colorway {
    cx.try_global::<ColorwayState>().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::<ColorwayState>().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::<ColorwayState>().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::<ColorwayState>();
    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<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())
}

/// 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"));
    }
}