Vectrum
Sign In
934 lines · 32.3 KB · Rust
//! Themes: MetalUI's Bone and Graphite, plus popular color schemes (Tokyo
//! Night, Catppuccin, GitHub and more) rebuilt as MetalUI palettes, so caps
//! still rise over sunk wells in every one of them, grouped into the families
//! the Appearance page shows as cards.

use std::sync::LazyLock;

use gpui_kit::component::{h_flex, v_flex};
use gpui_kit::*;

use crate::settings::{self, ThemeMode};
use crate::ui::metal::{self, BONE, GRAPHITE, Palette, hex, shadows, vertical};

/// A published color scheme, by the few colors a palette is built from.
pub struct Scheme {
    pub id: &'static str,
    pub name: &'static str,
    pub dark: bool,
    /// The editor background, which the sheet takes.
    pub bg: &'static str,
    /// The panels around the editor, which the page takes.
    pub panel: &'static str,
    pub fg: &'static str,
    /// Secondary text: comments, descriptions.
    pub muted: &'static str,
    /// Primary buttons.
    pub accent: &'static str,
    pub green: &'static str,
    pub red: &'static str,
    pub yellow: &'static str,
    pub blue: &'static str,
    /// Orange, pink, teal, magenta, blue and violet, standing in for MetalUI's
    /// ember, blush, tide, spark, dusk and iris.
    pub tints: [&'static str; 6],
}

/// The schemes, from their published palettes. Dark ones first, then light,
/// in the order the Themes page shows them.
pub const SCHEMES: &[Scheme] = &[
    Scheme {
        id: "tokyo-night",
        name: "Tokyo Night",
        dark: true,
        bg: "#1a1b26",
        panel: "#16161e",
        fg: "#c0caf5",
        muted: "#737aa2",
        accent: "#7aa2f7",
        green: "#9ece6a",
        red: "#f7768e",
        yellow: "#e0af68",
        blue: "#7aa2f7",
        tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"],
    },
    Scheme {
        id: "tokyo-night-storm",
        name: "Tokyo Night Storm",
        dark: true,
        bg: "#24283b",
        panel: "#1f2335",
        fg: "#c0caf5",
        muted: "#737aa2",
        accent: "#7aa2f7",
        green: "#9ece6a",
        red: "#f7768e",
        yellow: "#e0af68",
        blue: "#7aa2f7",
        tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"],
    },
    Scheme {
        id: "catppuccin-mocha",
        name: "Catppuccin Mocha",
        dark: true,
        bg: "#1e1e2e",
        panel: "#181825",
        fg: "#cdd6f4",
        muted: "#7f849c",
        accent: "#cba6f7",
        green: "#a6e3a1",
        red: "#f38ba8",
        yellow: "#f9e2af",
        blue: "#89b4fa",
        tints: ["#fab387", "#f5c2e7", "#94e2d5", "#cba6f7", "#89b4fa", "#b4befe"],
    },
    Scheme {
        id: "catppuccin-macchiato",
        name: "Catppuccin Macchiato",
        dark: true,
        bg: "#24273a",
        panel: "#1e2030",
        fg: "#cad3f5",
        muted: "#8087a2",
        accent: "#c6a0f6",
        green: "#a6da95",
        red: "#ed8796",
        yellow: "#eed49f",
        blue: "#8aadf4",
        tints: ["#f5a97f", "#f5bde6", "#8bd5ca", "#c6a0f6", "#8aadf4", "#b7bdf8"],
    },
    Scheme {
        id: "catppuccin-frappe",
        name: "Catppuccin Frappé",
        dark: true,
        bg: "#303446",
        panel: "#292c3c",
        fg: "#c6d0f5",
        muted: "#838ba7",
        accent: "#ca9ee6",
        green: "#a6d189",
        red: "#e78284",
        yellow: "#e5c890",
        blue: "#8caaee",
        tints: ["#ef9f76", "#f4b8e4", "#81c8be", "#ca9ee6", "#8caaee", "#babbf1"],
    },
    Scheme {
        id: "github-dark",
        name: "GitHub Dark",
        dark: true,
        bg: "#0d1117",
        panel: "#010409",
        fg: "#e6edf3",
        muted: "#7d8590",
        accent: "#238636",
        green: "#3fb950",
        red: "#f85149",
        yellow: "#d29922",
        blue: "#4493f8",
        tints: ["#ffa657", "#db61a2", "#a5d6ff", "#d2a8ff", "#79c0ff", "#a371f7"],
    },
    Scheme {
        id: "github-dark-dimmed",
        name: "GitHub Dark Dimmed",
        dark: true,
        bg: "#22272e",
        panel: "#1c2128",
        fg: "#adbac7",
        muted: "#768390",
        accent: "#347d39",
        green: "#57ab5a",
        red: "#e5534b",
        yellow: "#c69026",
        blue: "#539bf5",
        tints: ["#f69d50", "#c96198", "#96d0ff", "#dcbdfb", "#6cb6ff", "#986ee2"],
    },
    Scheme {
        id: "one-dark",
        name: "One Dark",
        dark: true,
        bg: "#282c34",
        panel: "#21252b",
        fg: "#abb2bf",
        muted: "#7f848e",
        accent: "#61afef",
        green: "#98c379",
        red: "#e06c75",
        yellow: "#e5c07b",
        blue: "#61afef",
        tints: ["#d19a66", "#e06c75", "#56b6c2", "#c678dd", "#61afef", "#e5c07b"],
    },
    Scheme {
        id: "nord",
        name: "Nord",
        dark: true,
        bg: "#3b4252",
        panel: "#2e3440",
        fg: "#eceff4",
        muted: "#616e88",
        accent: "#88c0d0",
        green: "#a3be8c",
        red: "#bf616a",
        yellow: "#ebcb8b",
        blue: "#81a1c1",
        tints: ["#d08770", "#bf616a", "#8fbcbb", "#b48ead", "#81a1c1", "#5e81ac"],
    },
    Scheme {
        id: "gruvbox-dark",
        name: "Gruvbox Dark",
        dark: true,
        bg: "#282828",
        panel: "#1d2021",
        fg: "#ebdbb2",
        muted: "#a89984",
        accent: "#fe8019",
        green: "#b8bb26",
        red: "#fb4934",
        yellow: "#fabd2f",
        blue: "#83a598",
        tints: ["#fe8019", "#fb4934", "#8ec07c", "#d3869b", "#83a598", "#fabd2f"],
    },
    Scheme {
        id: "rose-pine",
        name: "Rosé Pine",
        dark: true,
        bg: "#1f1d2e",
        panel: "#191724",
        fg: "#e0def4",
        muted: "#908caa",
        accent: "#ebbcba",
        green: "#9ccfd8",
        red: "#eb6f92",
        yellow: "#f6c177",
        blue: "#c4a7e7",
        tints: ["#ebbcba", "#eb6f92", "#9ccfd8", "#c4a7e7", "#31748f", "#f6c177"],
    },
    Scheme {
        id: "rose-pine-moon",
        name: "Rosé Pine Moon",
        dark: true,
        bg: "#2a273f",
        panel: "#232136",
        fg: "#e0def4",
        muted: "#908caa",
        accent: "#ea9a97",
        green: "#9ccfd8",
        red: "#eb6f92",
        yellow: "#f6c177",
        blue: "#c4a7e7",
        tints: ["#ea9a97", "#eb6f92", "#9ccfd8", "#c4a7e7", "#3e8fb0", "#f6c177"],
    },
    Scheme {
        id: "solarized-dark",
        name: "Solarized Dark",
        dark: true,
        bg: "#002b36",
        panel: "#00212b",
        fg: "#93a1a1",
        muted: "#586e75",
        accent: "#268bd2",
        green: "#859900",
        red: "#dc322f",
        yellow: "#b58900",
        blue: "#268bd2",
        tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"],
    },
    Scheme {
        id: "dracula",
        name: "Dracula",
        dark: true,
        bg: "#282a36",
        panel: "#21222c",
        fg: "#f8f8f2",
        muted: "#6272a4",
        accent: "#bd93f9",
        green: "#50fa7b",
        red: "#ff5555",
        yellow: "#f1fa8c",
        blue: "#8be9fd",
        tints: ["#ffb86c", "#ff79c6", "#8be9fd", "#bd93f9", "#50fa7b", "#f1fa8c"],
    },
    Scheme {
        id: "tokyo-night-day",
        name: "Tokyo Night Day",
        dark: false,
        bg: "#e1e2e7",
        panel: "#d0d5e3",
        fg: "#3760bf",
        muted: "#6172b0",
        accent: "#2e7de9",
        green: "#587539",
        red: "#f52a65",
        yellow: "#8c6c3e",
        blue: "#2e7de9",
        tints: ["#b15c00", "#f52a65", "#118c74", "#9854f1", "#2e7de9", "#7847bd"],
    },
    Scheme {
        id: "catppuccin-latte",
        name: "Catppuccin Latte",
        dark: false,
        bg: "#eff1f5",
        panel: "#e6e9ef",
        fg: "#4c4f69",
        muted: "#8c8fa1",
        accent: "#8839ef",
        green: "#40a02b",
        red: "#d20f39",
        yellow: "#df8e1d",
        blue: "#1e66f5",
        tints: ["#fe640b", "#ea76cb", "#179299", "#8839ef", "#1e66f5", "#7287fd"],
    },
    Scheme {
        id: "github-light",
        name: "GitHub Light",
        dark: false,
        bg: "#ffffff",
        panel: "#f6f8fa",
        fg: "#1f2328",
        muted: "#656d76",
        accent: "#1f883d",
        green: "#1a7f37",
        red: "#d1242f",
        yellow: "#9a6700",
        blue: "#0969da",
        tints: ["#bc4c00", "#bf3989", "#1b7c83", "#8250df", "#0550ae", "#6639ba"],
    },
    Scheme {
        id: "one-light",
        name: "One Light",
        dark: false,
        bg: "#fafafa",
        panel: "#eaeaeb",
        fg: "#383a42",
        muted: "#a0a1a7",
        accent: "#4078f2",
        green: "#50a14f",
        red: "#e45649",
        yellow: "#c18401",
        blue: "#4078f2",
        tints: ["#986801", "#e45649", "#0184bc", "#a626a4", "#4078f2", "#c18401"],
    },
    Scheme {
        id: "gruvbox-light",
        name: "Gruvbox Light",
        dark: false,
        bg: "#fbf1c7",
        panel: "#f2e5bc",
        fg: "#3c3836",
        muted: "#7c6f64",
        accent: "#af3a03",
        green: "#79740e",
        red: "#9d0006",
        yellow: "#b57614",
        blue: "#076678",
        tints: ["#af3a03", "#9d0006", "#427b58", "#8f3f71", "#076678", "#b57614"],
    },
    Scheme {
        id: "rose-pine-dawn",
        name: "Rosé Pine Dawn",
        dark: false,
        bg: "#fffaf3",
        panel: "#faf4ed",
        fg: "#575279",
        muted: "#797593",
        accent: "#d7827e",
        green: "#56949f",
        red: "#b4637a",
        yellow: "#ea9d34",
        blue: "#907aa9",
        tints: ["#d7827e", "#b4637a", "#56949f", "#907aa9", "#286983", "#ea9d34"],
    },
    Scheme {
        id: "solarized-light",
        name: "Solarized Light",
        dark: false,
        bg: "#fdf6e3",
        panel: "#eee8d5",
        fg: "#586e75",
        muted: "#93a1a1",
        accent: "#268bd2",
        green: "#859900",
        red: "#dc322f",
        yellow: "#b58900",
        blue: "#268bd2",
        tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"],
    },
];

impl Palette {
    /// Builds a full MetalUI palette from a scheme. Every surface steps off
    /// the scheme's background the way Graphite's (dark) or Bone's (light)
    /// step off theirs, inks are nudged until they read on those surfaces, and
    /// the shadow recipes are Graphite's, or Bone's tinted to the scheme's hue.
    pub fn from_scheme(scheme: &Scheme) -> Palette {
        let dark = scheme.dark;
        let bg = hex(scheme.bg);
        let page = hex(scheme.panel);
        let (s_hi, s_lo, well, cap, pressed, row_on, kbd) = if dark {
            (
                lighten(bg, 0.029),
                dim(bg, 0.87),
                (dim(bg, 0.54), dim(bg, 0.66)),
                (lighten(bg, 0.049), lighten(bg, 0.014)),
                (dim(bg, 0.66), dim(bg, 0.77)),
                (lighten(bg, 0.059), lighten(bg, 0.021)),
                (lighten(bg, 0.07), lighten(bg, 0.021)),
            )
        } else {
            (
                lighten(bg, 0.023),
                lighten(bg, -0.026),
                (lighten(bg, -0.067), lighten(bg, -0.039)),
                (lighten(bg, 0.06), lighten(bg, -0.008)),
                (lighten(bg, -0.057), lighten(bg, -0.029)),
                (lighten(bg, 0.06), bg),
                (lighten(bg, 0.06), lighten(bg, -0.015)),
            )
        };

        // Inks get lighter on dark themes and darker on light ones until they
        // read on everything text sits on.
        let behind = [page, s_hi, bg, row_on.0, well.0];
        let ink = legible(hex(scheme.fg), &behind, 4.5, dark);
        let ink3 = legible(hex(scheme.muted), &behind, 3.5, dark);
        let ink2 = legible(mix(ink, ink3, 0.5), &behind, 4.5, dark);
        let text = |color: &str, ratio: f32| legible(hex(color), &[s_hi, bg], ratio, dark);

        let accent = hex(scheme.accent);
        let primary_ink = [white(), if dark { page } else { ink }]
            .into_iter()
            .max_by(|a, b| contrast(*a, accent).total_cmp(&contrast(*b, accent)))
            .unwrap_or(white());
        // Danger caps carry white text.
        let danger = legible(hex(scheme.red), &[white()], 3.5, false);
        let (primary_sh, primary_pressed_sh) = colored_cap(accent, dark);
        let (danger_sh, danger_pressed_sh) = colored_cap(danger, dark);

        // Bone's shadows are warm browns; these take the scheme's hue instead.
        // Graphite's are black and white, so tinting leaves them be.
        let tone = hsla(bg.h, bg.s.min(0.25), 0.14, 1.);
        let metal: &Palette = if dark { &GRAPHITE } else { &BONE };

        Palette {
            dark,
            page,
            s_hi,
            s: bg,
            s_lo,
            well_top: well.0,
            well_bot: well.1,
            ink,
            ink2,
            ink3,
            icon: mix(ink, ink2, 0.4),
            engrave: ink.alpha(if dark { 0.5 } else { 0.65 }),
            tag_ring: ink.alpha(if dark { 0.16 } else { 0.2 }),
            rule: black().alpha(if dark { 0.45 } else { 0.075 }),
            // A near-black sheet swallows a dark rule, so its lip carries it.
            rule_lip: white().alpha(if dark { 0.035 + (0.14 - bg.l).max(0.) * 0.5 } else { 0.8 }),
            row_hover: white().alpha(if dark { 0.06 } else { 0.7 }),
            menu_bg: if dark { mix(bg, s_lo, 0.6) } else { lighten(bg, 0.015) }.alpha(0.98),
            menu_row_hover: if dark { white().alpha(0.06) } else { black().alpha(0.05) },
            field_ink: mix(ink3, bg, 0.2),
            cap,
            pressed,
            row_on,
            kbd,
            primary: (lighten(accent, 0.04), lighten(accent, -0.04)),
            primary_pressed: (lighten(accent, -0.1), lighten(accent, -0.06)),
            primary_ink,
            danger: (lighten(danger, 0.04), lighten(danger, -0.04)),
            danger_pressed: (lighten(danger, -0.1), lighten(danger, -0.045)),
            region: if dark {
                (white().alpha(0.02), white().alpha(0.012))
            } else {
                (tone.alpha(0.028), tone.alpha(0.012))
            },
            green_deep: hex(scheme.green),
            green_ink: text(scheme.green, 4.5),
            red: text(scheme.red, 4.),
            amber: hex(scheme.yellow),
            amber_ink: text(scheme.yellow, 4.5),
            blue_ink: text(scheme.blue, 4.5),
            led_off: if dark {
                (mix(ink, bg, 0.45), mix(ink, bg, 0.8))
            } else {
                (lighten(bg, -0.13), lighten(bg, -0.18))
            },
            tints: scheme.tints.map(|tint| text(tint, 3.)),
            raise: retint(&metal.raise, tone),
            raise_lite: retint(&metal.raise_lite, tone),
            raise_sm: retint(&metal.raise_sm, tone),
            btn: retint(&metal.btn, tone),
            pressed_sh: retint(&metal.pressed_sh, tone),
            well: retint(&metal.well, tone),
            region_sh: retint(&metal.region_sh, tone),
            primary_sh,
            primary_pressed_sh,
            danger_sh,
            danger_pressed_sh,
            led_off_sh: metal.led_off_sh.clone(),
        }
    }
}

/// The raised and pressed shadows of a cap in a color of its own. On light
/// themes they're MetalUI's danger recipe, shaded in the cap's hue; on dark
/// ones the page is darker than any shade of it, so they fall black.
fn colored_cap(color: Hsla, dark: bool) -> (Vec<BoxShadow>, Vec<BoxShadow>) {
    let shade = |l: f32, a: f32| css(hsla(color.h, color.s, l, a));
    let (edge, near, far, deep, ring) = if dark {
        let black = |a: f32| css(black().alpha(a));
        (black(0.6), black(0.35), black(0.45), black(0.35), black(0.6))
    } else {
        (shade(0.25, 0.3), shade(0.22, 0.14), shade(0.37, 0.24), shade(0.16, 0.45), shade(0.22, 0.55))
    };
    (
        shadows(&format!(
            "inset 0 0 6px 1px #ffffff24, inset 1px 2px 2px -1px #ffffff52, 0 0 0 .5px {edge}, 0 1px 2px {near}, \
             0 5px 12px -4px {far}"
        )),
        shadows(&format!("inset 0 1px 3px {deep}, 0 0 0 .5px {ring}")),
    )
}

/// Turns a recipe's tinted shadows to `tone`'s hue and saturation, keeping
/// their lightness and alpha. Black and white layers stay as they are.
fn retint(layers: &[BoxShadow], tone: Hsla) -> Vec<BoxShadow> {
    layers
        .iter()
        .map(|layer| {
            let mut layer = layer.clone();
            if layer.color.s > 0. {
                layer.color = hsla(tone.h, tone.s, layer.color.l, layer.color.a);
            }
            layer
        })
        .collect()
}

// ---- color math ----------------------------------------------------------------

/// Shifts lightness by `by`, darker when negative.
fn lighten(color: Hsla, by: f32) -> Hsla {
    Hsla { l: (color.l + by).clamp(0., 1.), ..color }
}

/// Scales lightness, for darkening an already dark color in proportion.
fn dim(color: Hsla, factor: f32) -> Hsla {
    Hsla { l: color.l * factor, ..color }
}

/// Blends `a` toward `b` in RGB.
fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
    let (a, b) = (a.to_rgb(), b.to_rgb());
    let lerp = |x: f32, y: f32| x + (y - x) * t;
    Rgba { r: lerp(a.r, b.r), g: lerp(a.g, b.g), b: lerp(a.b, b.b), a: lerp(a.a, b.a) }.into()
}

/// `#rrggbbaa`, as `metal::shadows` reads it.
fn css(color: Hsla) -> String {
    let c = color.to_rgb();
    let byte = |v: f32| (v.clamp(0., 1.) * 255.).round() as u8;
    format!("#{:02x}{:02x}{:02x}{:02x}", byte(c.r), byte(c.g), byte(c.b), byte(c.a))
}

/// WCAG relative luminance.
fn luminance(color: Hsla) -> f32 {
    let c = color.to_rgb();
    let linear = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
    0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b)
}

/// WCAG contrast ratio, 1 to 21.
pub fn contrast(a: Hsla, b: Hsla) -> f32 {
    let (a, b) = (luminance(a), luminance(b));
    (a.max(b) + 0.05) / (a.min(b) + 0.05)
}

/// Moves `color` lighter (or darker) until it reads at `ratio` on every one
/// of `surfaces`.
fn legible(mut color: Hsla, surfaces: &[Hsla], ratio: f32, lighter: bool) -> Hsla {
    for _ in 0..100 {
        if surfaces.iter().all(|surface| contrast(color, *surface) >= ratio) {
            break;
        }
        color = lighten(color, if lighter { 0.01 } else { -0.01 });
    }
    color
}

// ---- the catalog -----------------------------------------------------------------

/// A theme on the Themes page.
pub struct Theme {
    pub id: &'static str,
    pub name: &'static str,
    pub palette: &'static Palette,
}

static THEMES: LazyLock<Vec<Theme>> = LazyLock::new(|| {
    let metal = [("graphite", "Graphite", &*GRAPHITE), ("bone", "Bone", &*BONE)];
    let schemes = SCHEMES.iter().map(|scheme| {
        let palette: &'static Palette = Box::leak(Box::new(Palette::from_scheme(scheme)));
        (scheme.id, scheme.name, palette)
    });
    metal.into_iter().chain(schemes).map(|(id, name, palette)| Theme { id, name, palette }).collect()
});

/// Every theme, Bone and Graphite first.
pub fn all() -> &'static [Theme] {
    &THEMES
}

/// The palette of the theme with this id, if it's of the right kind, or
/// Graphite or Bone.
pub fn find(id: &str, dark: bool) -> &'static Palette {
    all()
        .iter()
        .find(|theme| theme.id == id && theme.palette.dark == dark)
        .map_or(if dark { &*GRAPHITE } else { &*BONE }, |theme| theme.palette)
}

/// The palette the settings pick: the dark or light theme, by mode.
pub fn chosen(system_dark: bool) -> &'static Palette {
    let (mode, light, dark) =
        settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone()));
    match mode {
        ThemeMode::Light => find(&light, false),
        ThemeMode::Dark => find(&dark, true),
        ThemeMode::System if system_dark => find(&dark, true),
        ThemeMode::System => find(&light, false),
    }
}

/// Switches between light, dark and the desktop's choice, and applies it.
pub fn set_mode(mode: ThemeMode, window: &Window, cx: &mut App) {
    settings::update(|settings| settings.theme_mode = mode);
    metal::sync_appearance(window, cx);
}

/// Makes `theme` the light or dark theme, switching to its mode unless the
/// mode follows the desktop, and applies it.
pub fn pick(theme: &Theme, window: &Window, cx: &mut App) {
    let (id, dark) = (theme.id.to_string(), theme.palette.dark);
    settings::update(|settings| {
        if dark {
            settings.dark_theme = id;
        } else {
            settings.light_theme = id;
        }
        if settings.theme_mode != ThemeMode::System {
            settings.theme_mode = if dark { ThemeMode::Dark } else { ThemeMode::Light };
        }
    });
    metal::sync_appearance(window, cx);
}

/// Themes from one color scheme, shown as one card with a swatch per
/// variant, light ones first.
pub struct Family {
    pub name: &'static str,
    /// Theme ids.
    pub themes: &'static [&'static str],
}

pub const FAMILIES: &[Family] = &[
    Family { name: "Vectrum", themes: &["bone", "graphite"] },
    Family { name: "Tokyo Night", themes: &["tokyo-night-day", "tokyo-night", "tokyo-night-storm"] },
    Family { name: "Catppuccin", themes: &["catppuccin-latte", "catppuccin-mocha", "catppuccin-macchiato", "catppuccin-frappe"] },
    Family { name: "GitHub", themes: &["github-light", "github-dark", "github-dark-dimmed"] },
    Family { name: "One", themes: &["one-light", "one-dark"] },
    Family { name: "Nord", themes: &["nord"] },
    Family { name: "Gruvbox", themes: &["gruvbox-light", "gruvbox-dark"] },
    Family { name: "Rosé Pine", themes: &["rose-pine-dawn", "rose-pine", "rose-pine-moon"] },
    Family { name: "Solarized", themes: &["solarized-light", "solarized-dark"] },
    Family { name: "Dracula", themes: &["dracula"] },
];

impl Family {
    /// The family's themes, light ones first.
    pub fn members(&self) -> impl Iterator<Item = &'static Theme> + '_ {
        self.themes.iter().filter_map(|id| all().iter().find(|theme| theme.id == *id))
    }
}

/// Makes `theme` the light or dark theme without touching the mode.
pub fn assign(theme: &Theme, window: &Window, cx: &mut App) {
    let id = theme.id.to_string();
    settings::update(|settings| {
        if theme.palette.dark {
            settings.dark_theme = id;
        } else {
            settings.light_theme = id;
        }
    });
    metal::sync_appearance(window, cx);
}

/// A miniature of the app in a theme: the sheet on the table, a thread list
/// with one thread picked, a code well and a composer.
pub fn preview(c: &Palette) -> Div {
    let line = |width: f32, color: Hsla| div().flex_none().h(px(4.)).w(px(width)).rounded_full().bg(color);
    h_flex()
        .size_full()
        .overflow_hidden()
        .rounded(px(14.))
        .bg(c.page)
        .p(px(8.))
        .gap(px(8.))
        .child(
            v_flex()
                .w(px(30.))
                .gap(px(6.))
                .child(
                    div()
                        .p(px(3.))
                        .rounded(px(4.))
                        .bg(vertical(c.row_on))
                        .shadow(c.raise_sm.clone())
                        .child(line(20., c.ink2)),
                )
                .child(div().px(px(3.)).child(line(16., c.ink3.opacity(0.7))))
                .child(div().px(px(3.)).child(line(20., c.ink3.opacity(0.7)))),
        )
        .child(
            v_flex()
                .flex_1()
                .h_full()
                .p(px(8.))
                .gap(px(5.))
                .rounded(px(10.))
                .bg(vertical((c.s_hi, c.s)))
                .shadow(c.raise_sm.clone())
                .child(line(40., c.ink.opacity(0.8)))
                .child(line(56., c.ink3.opacity(0.7)))
                .child(
                    v_flex()
                        .gap(px(3.))
                        .p(px(4.))
                        .rounded(px(5.))
                        .bg(vertical((c.well_top, c.well_bot)))
                        .shadow(c.well.clone())
                        .child(h_flex().gap(px(3.)).child(line(10., c.blue_ink)).child(line(18., c.green_ink)))
                        .child(h_flex().gap(px(3.)).child(line(14., c.tints[3])).child(line(10., c.amber_ink))),
                )
                .child(div().flex_1())
                .child(
                    h_flex()
                        .h(px(16.))
                        .px(px(4.))
                        .rounded(px(6.))
                        .bg(vertical(c.row_on))
                        .shadow(c.raise_sm.clone())
                        .child(div().flex_1())
                        .child(div().size(px(8.)).rounded_full().bg(vertical(c.primary))),
                ),
        )
}

#[cfg(test)]
mod tests {
    use super::{FAMILIES, SCHEMES, all, contrast, css, find, luminance};
    use crate::ui::metal::{BONE, GRAPHITE, Palette};
    use gpui_kit::{BoxShadow, Hsla, white};

    #[test]
    fn every_theme_reads() {
        for theme in all() {
            let (id, p) = (theme.id, theme.palette);
            for surface in [p.page, p.s_hi, p.s] {
                assert!(contrast(p.ink, surface) >= 4.5, "{id}: ink on {surface:?}");
                assert!(contrast(p.ink2, surface) >= 3., "{id}: ink2 on {surface:?}");
                assert!(contrast(p.ink3, surface) >= 3., "{id}: ink3 on {surface:?}");
                assert!(contrast(p.blue_ink, surface) >= 3., "{id}: blue ink on {surface:?}");
                assert!(contrast(p.green_ink, surface) >= 3., "{id}: green ink on {surface:?}");
            }
            assert!(contrast(p.primary_ink, p.primary.1) >= 3., "{id}: primary cap");
            assert!(contrast(white(), p.danger.1) >= 3., "{id}: danger cap");
            assert!(luminance(p.page) < luminance(p.s_hi), "{id}: the sheet should stand off the page");
            assert_eq!(p.dark, luminance(p.s) < 0.18, "{id}: dark or light");
        }
    }

    #[test]
    fn theme_ids_are_unique() {
        let themes = all();
        assert_eq!(themes.len(), SCHEMES.len() + 2);
        for (ix, theme) in themes.iter().enumerate() {
            assert!(themes[ix + 1..].iter().all(|other| other.id != theme.id), "{} twice", theme.id);
        }
    }

    #[test]
    fn every_theme_has_one_family() {
        for theme in all() {
            let families = super::FAMILIES.iter().filter(|family| family.themes.contains(&theme.id)).count();
            assert_eq!(families, 1, "{} is in {families} families", theme.id);
        }
        for family in super::FAMILIES {
            assert_eq!(family.members().count(), family.themes.len(), "{} names a missing theme", family.name);
        }
    }

    #[test]
    fn unknown_themes_fall_back() {
        assert!(std::ptr::eq(find("graphite", true), &*GRAPHITE));
        assert!(std::ptr::eq(find("no-such-theme", true), &*GRAPHITE));
        assert!(std::ptr::eq(find("tokyo-night", false), &*BONE));
        let latte = find("catppuccin-latte", false);
        assert!(!latte.dark && !std::ptr::eq(latte, &*BONE));
    }

    /// Writes every theme's palette for the Android app, which draws the same
    /// colorways: `cargo test export_android_themes -- --ignored`. Colors are
    /// `#rrggbbaa`, fields are named as in [`Palette`].
    #[test]
    #[ignore]
    fn export_android_themes() {
        use serde_json::{Map, Value, json};

        let color = |color: Hsla| Value::from(css(color));
        let pair = |(top, bottom): (Hsla, Hsla)| json!([css(top), css(bottom)]);
        let layers = |shadows: &[BoxShadow]| -> Value {
            shadows
                .iter()
                .map(|shadow| {
                    json!({
                        "x": f32::from(shadow.offset.x),
                        "y": f32::from(shadow.offset.y),
                        "blur": f32::from(shadow.blur_radius),
                        "spread": f32::from(shadow.spread_radius),
                        "inset": shadow.inset,
                        "color": css(shadow.color),
                    })
                })
                .collect()
        };
        let themes: Vec<Value> = all()
            .iter()
            .map(|theme| {
                // Taken apart field by field, so a new one can't be left out.
                let Palette {
                    dark,
                    page,
                    s_hi,
                    s,
                    s_lo,
                    well_top,
                    well_bot,
                    ink,
                    ink2,
                    ink3,
                    icon,
                    engrave,
                    tag_ring,
                    rule,
                    rule_lip,
                    row_hover,
                    menu_bg,
                    menu_row_hover,
                    field_ink,
                    cap,
                    pressed,
                    row_on,
                    kbd,
                    primary,
                    primary_pressed,
                    primary_ink,
                    danger,
                    danger_pressed,
                    region,
                    green_deep,
                    green_ink,
                    red,
                    amber,
                    amber_ink,
                    blue_ink,
                    led_off,
                    tints,
                    raise,
                    raise_lite,
                    raise_sm,
                    btn,
                    pressed_sh,
                    well,
                    region_sh,
                    primary_sh,
                    primary_pressed_sh,
                    danger_sh,
                    danger_pressed_sh,
                    led_off_sh,
                } = theme.palette;
                let mut palette = Map::new();
                palette.insert("dark".into(), Value::from(*dark));
                for (name, value) in [
                    ("page", page),
                    ("s_hi", s_hi),
                    ("s", s),
                    ("s_lo", s_lo),
                    ("well_top", well_top),
                    ("well_bot", well_bot),
                    ("ink", ink),
                    ("ink2", ink2),
                    ("ink3", ink3),
                    ("icon", icon),
                    ("engrave", engrave),
                    ("tag_ring", tag_ring),
                    ("rule", rule),
                    ("rule_lip", rule_lip),
                    ("row_hover", row_hover),
                    ("menu_bg", menu_bg),
                    ("menu_row_hover", menu_row_hover),
                    ("field_ink", field_ink),
                    ("primary_ink", primary_ink),
                    ("green_deep", green_deep),
                    ("green_ink", green_ink),
                    ("red", red),
                    ("amber", amber),
                    ("amber_ink", amber_ink),
                    ("blue_ink", blue_ink),
                ] {
                    palette.insert(name.into(), color(*value));
                }
                for (name, value) in [
                    ("cap", cap),
                    ("pressed", pressed),
                    ("row_on", row_on),
                    ("kbd", kbd),
                    ("primary", primary),
                    ("primary_pressed", primary_pressed),
                    ("danger", danger),
                    ("danger_pressed", danger_pressed),
                    ("region", region),
                    ("led_off", led_off),
                ] {
                    palette.insert(name.into(), pair(*value));
                }
                palette.insert("tints".into(), tints.iter().map(|tint| color(*tint)).collect());
                for (name, value) in [
                    ("raise", raise),
                    ("raise_lite", raise_lite),
                    ("raise_sm", raise_sm),
                    ("btn", btn),
                    ("pressed_sh", pressed_sh),
                    ("well", well),
                    ("region_sh", region_sh),
                    ("primary_sh", primary_sh),
                    ("primary_pressed_sh", primary_pressed_sh),
                    ("danger_sh", danger_sh),
                    ("danger_pressed_sh", danger_pressed_sh),
                    ("led_off_sh", led_off_sh),
                ] {
                    palette.insert(name.into(), layers(value));
                }
                json!({"id": theme.id, "name": theme.name, "palette": palette})
            })
            .collect();
        let families: Vec<Value> =
            FAMILIES.iter().map(|family| json!({"name": family.name, "themes": family.themes})).collect();
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("android/app/src/main/assets/themes.json");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        let text = serde_json::to_string(&json!({"themes": themes, "families": families})).unwrap();
        std::fs::write(&path, text + "\n").unwrap();
    }
}