crates/veilvoice-gui/src/theme.rs

what this file is for · veilvoice-gui · 802 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Colour schemes for the desktop app.
//!
//! # One palette, three front-ends
//!
//! Every theme here is the same set of twelve tokens the website defines in
//! `website/css/themes.css`, with the same names and the same hex values, and
//! Tokyo Night additionally matches the escape codes the CLI emits. The three
//! front-ends are meant to read as one program rather than as three tools that
//! happen to share a name, and the way that is kept true is by copying the
//! numbers rather than by approximating them. Tests assert **every token of
//! every theme** against the stylesheet, in **both directions** -- so a colour
//! changed on either side, a theme removed from either side, or a theme added
//! to the website and forgotten here all fail the build rather than shipping as
//! two products that no longer look alike.
//!
//! # Why the active theme is an index, not a lock
//!
//! Colours are read on every repaint, from the UI thread, dozens of times a
//! frame. A `Mutex` around the palette would be a lock taken hundreds of times
//! a second to read a constant, and a poisoned one would take the window with
//! it. Instead the themes are a `const` array and the selection is a single
//! `AtomicUsize`: reading is one relaxed load, it cannot fail, and it cannot
//! be poisoned.
//!
//! The index is clamped on read as well as on write. A value that somehow got
//! out of range would otherwise panic on a slice index, in a paint loop, which
//! is the worst possible place for it -- so the read saturates to the default
//! instead.
//!
//! # In plain words
//!
//! The colour schemes the application ships with, and the fonts.
//!
//! They are the same schemes the website offers, defined once so the two cannot
//! drift apart. Choosing one applies it straight away and it is remembered for
//! next time.

use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle, Visuals};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;


/// The theme table, once the user's palettes have been folded in.
///
/// Empty until [`load_custom`] runs. See [`themes`] for why this is a
/// `OnceLock` rather than anything that can be written twice.
static TABLE: OnceLock<Vec<Theme>> = OnceLock::new();



/// One complete colour scheme.
///
/// The field names match the CSS custom properties one for one: `bg` is
/// `--bg`, `accent_2` is `--accent-2`, and so on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
    /// Stable identifier, matching the website's `data-theme` value. This is
    /// what gets written to the preferences file, so it must never change for
    /// an existing theme.
    pub id: &'static str,
    /// Human-readable name, as shown in the picker.
    pub name: &'static str,
    /// Whether this is a light scheme. Drives egui's base `Visuals`.
    pub light: bool,

    /// Window and central-panel background.
    pub bg: Color32,
    /// Raised surface, for widgets.
    pub bg_soft: Color32,
    /// Deeper background, for inset panels.
    pub bg_inset: Color32,
    /// Borders and separators.
    pub border: Color32,
    /// Primary text.
    pub fg: Color32,
    /// Secondary text.
    pub muted: Color32,
    /// The project's primary colour.
    pub accent: Color32,
    /// The "veiled" half of the mark.
    pub accent_2: Color32,
    /// Values and figures.
    pub cyan: Color32,
    /// Success.
    pub ok: Color32,
    /// Warning.
    pub warn: Color32,
    /// Error.
    pub err: Color32,
}



const fn rgb(hex: u32) -> Color32 {
    Color32::from_rgb(
        ((hex >> 16) & 0xff) as u8,
        ((hex >> 8) & 0xff) as u8,
        (hex & 0xff) as u8,
    )
}



/// Every theme, in the order the picker shows them.
///
/// Index 0 is the default and is the one a clamped-out-of-range read lands on.
pub const THEMES: &[Theme] = &[
    Theme {
        id: "tokyo-night",
        name: "Tokyo Night",
        light: false,
        bg: rgb(0x1a1b26),
        bg_soft: rgb(0x1f2335),
        bg_inset: rgb(0x16161e),
        border: rgb(0x2f3549),
        fg: rgb(0xc0caf5),
        muted: rgb(0x737aa2),
        accent: rgb(0x7aa2f7),
        accent_2: rgb(0xbb9af7),
        cyan: rgb(0x7dcfff),
        ok: rgb(0x9ece6a),
        warn: rgb(0xe0af68),
        err: rgb(0xf7768e),
    },

    Theme {
        id: "gruvbox",
        name: "Gruvbox",
        light: false,
        bg: rgb(0x282828),
        bg_soft: rgb(0x32302f),
        bg_inset: rgb(0x1d2021),
        border: rgb(0x504945),
        fg: rgb(0xebdbb2),
        muted: rgb(0x928374),
        accent: rgb(0x83a598),
        accent_2: rgb(0xd3869b),
        cyan: rgb(0x8ec07c),
        ok: rgb(0xb8bb26),
        warn: rgb(0xfabd2f),
        err: rgb(0xfb4934),
    },
    Theme {
        id: "dracula",
        name: "Dracula",
        light: false,
        bg: rgb(0x282a36),
        bg_soft: rgb(0x343746),
        bg_inset: rgb(0x21222c),
        border: rgb(0x44475a),
        fg: rgb(0xf8f8f2),
        muted: rgb(0x6272a4),
        accent: rgb(0xbd93f9),
        accent_2: rgb(0xff79c6),
        cyan: rgb(0x8be9fd),
        ok: rgb(0x50fa7b),
        warn: rgb(0xf1fa8c),
        err: rgb(0xff5555),
    },
    Theme {
        id: "nord",
        name: "Nord",
        light: false,
        bg: rgb(0x2e3440),
        bg_soft: rgb(0x3b4252),
        bg_inset: rgb(0x272c36),
        border: rgb(0x4c566a),
        fg: rgb(0xeceff4),
        muted: rgb(0x7b88a1),
        accent: rgb(0x88c0d0),
        accent_2: rgb(0xb48ead),
        cyan: rgb(0x8fbcbb),
        ok: rgb(0xa3be8c),
        warn: rgb(0xebcb8b),
        err: rgb(0xbf616a),
    },
    Theme {
        id: "catppuccin",
        name: "Catppuccin Mocha",
        light: false,
        bg: rgb(0x1e1e2e),
        bg_soft: rgb(0x313244),
        bg_inset: rgb(0x181825),
        border: rgb(0x45475a),
        fg: rgb(0xcdd6f4),
        muted: rgb(0x7f849c),
        accent: rgb(0x89b4fa),
        accent_2: rgb(0xcba6f7),
        cyan: rgb(0x94e2d5),
        ok: rgb(0xa6e3a1),
        warn: rgb(0xf9e2af),
        err: rgb(0xf38ba8),
    },
    Theme {
        id: "everforest",
        name: "Everforest",
        light: false,
        bg: rgb(0x2d353b),
        bg_soft: rgb(0x343f44),
        bg_inset: rgb(0x272e33),
        border: rgb(0x475258),
        fg: rgb(0xd3c6aa),
        muted: rgb(0x859289),
        accent: rgb(0xa7c080),
        accent_2: rgb(0xd699b6),
        cyan: rgb(0x83c092),
        ok: rgb(0xa7c080),
        warn: rgb(0xdbbc7f),
        err: rgb(0xe67e80),
    },
    Theme {
        id: "solarized",
        name: "Solarized Dark",
        light: false,
        bg: rgb(0x002b36),
        bg_soft: rgb(0x073642),
        bg_inset: rgb(0x00212b),
        border: rgb(0x0f4b5c),
        fg: rgb(0x93a1a1),
        muted: rgb(0x657b83),
        accent: rgb(0x268bd2),
        accent_2: rgb(0xd33682),
        cyan: rgb(0x2aa198),
        ok: rgb(0x859900),
        warn: rgb(0xb58900),
        err: rgb(0xdc322f),
    },
    Theme {
        id: "rose-pine",
        name: "Rose Pine",
        light: false,
        bg: rgb(0x191724),
        bg_soft: rgb(0x1f1d2e),
        bg_inset: rgb(0x14121f),
        border: rgb(0x33304a),
        fg: rgb(0xe0def4),
        muted: rgb(0x6e6a86),
        accent: rgb(0x9ccfd8),
        accent_2: rgb(0xc4a7e7),
        cyan: rgb(0x31748f),
        ok: rgb(0xa6da95),
        warn: rgb(0xf6c177),
        err: rgb(0xeb6f92),
    },
    Theme {
        id: "paper",
        name: "Paper (light)",
        light: true,
        bg: rgb(0xfaf4ed),
        bg_soft: rgb(0xf2e9e1),
        bg_inset: rgb(0xfffaf3),
        border: rgb(0xdfd8d0),
        fg: rgb(0x575279),
        muted: rgb(0x797593),
        accent: rgb(0x286983),
        accent_2: rgb(0x907aa9),
        cyan: rgb(0x56949f),
        ok: rgb(0x618774),
        warn: rgb(0xea9d34),
        err: rgb(0xb4637a),
    },
];


/// The index of the theme currently in force.
static ACTIVE: AtomicUsize = AtomicUsize::new(0);



/// The theme currently in force.
///
/// Clamped on read: an index out of range would otherwise panic on a slice
/// index inside the paint loop, which is the worst place for it.
pub fn active() -> &'static Theme {
    let index = ACTIVE.load(Ordering::Relaxed);
    let table = themes();
    &table[index.min(table.len() - 1)]
}



/// Every theme the picker offers: the built-in ones, then any the user added.
///
/// Returns the `const` array until [`load_custom`] has run, which is what every
/// test and the first frames of startup see. After it has run, the same array
/// with the user's palettes appended -- appended rather than merged, so a
/// built-in theme keeps its index and a preferences file written before a
/// palette was added still selects the same scheme.
///
/// Reading this is a `OnceLock::get`, which is one atomic load. No mutex: this
/// is on the path of every repaint, and the reason `ACTIVE` is an
/// `AtomicUsize` in the first place applies just as much here.
pub fn themes() -> &'static [Theme] {
    match TABLE.get() {
        Some(table) => table.as_slice(),
        None => THEMES,
    }
}



/// Read the user's palettes and add them to the table. Returns any complaints.
///
/// Called once, during startup, before the first frame. Calling it again is a
/// no-op that returns no problems -- `OnceLock` accepts one value, and silently
/// keeping the first is right here: the alternative is a theme table that
/// changes shape while indices into it are live.
pub fn load_custom(dir: &std::path::Path) -> Vec<String> {
    let (custom, problems) = crate::palettes::load(dir);
    if custom.is_empty() {
        return problems;
    }
    let mut table: Vec<Theme> = THEMES.to_vec();
    table.extend(custom);
    // Ignore the error: a second call means the table is already built, and the
    // themes it holds are still perfectly good ones.
    let _ = TABLE.set(table);
    problems
}



/// Look a theme up by its stable identifier.
pub fn by_id(id: &str) -> Option<(usize, &'static Theme)> {
    themes().iter().enumerate().find(|(_, t)| t.id == id)
}



/// Switch to `id`, and apply it to `ctx`. Unknown identifiers are ignored, so
/// a preferences file naming a theme this build does not have keeps the
/// default rather than failing to start.
pub fn set_by_id(ctx: &egui::Context, id: &str) -> bool {
    match by_id(id) {
        Some((index, _)) => {
            ACTIVE.store(index, Ordering::Relaxed);
            install(ctx);
            true
        }
        None => false,
    }
}



/// Shorthand accessors, so call sites read as `p::fg()` rather than
/// `theme::active().fg`.
///
/// These were `const` values when there was one theme. They are functions now
/// because the palette is chosen at runtime; the call sites are otherwise
/// unchanged.
pub mod palette {
    use super::active;
    use egui::Color32;

    /// Window and central-panel background.
    pub fn bg() -> Color32 {
        active().bg
    }
    /// Deeper background, for inset panels.
    pub fn bg_dark() -> Color32 {
        active().bg_inset
    }
    /// Raised surface, for widgets.
    pub fn surface() -> Color32 {
        active().bg_soft
    }
    /// Hovered surface: the raised surface, lifted towards the text colour.
    ///
    /// Derived rather than tabulated, so a new theme cannot be added with a
    /// hover state that does not belong to it. `lerp` towards `fg` works for
    /// light schemes as well as dark ones, where simply lightening would not.
    pub fn surface_hi() -> Color32 {
        let t = active();
        blend(t.bg_soft, t.fg, 0.12)
    }
    /// Borders and separators.
    pub fn border() -> Color32 {
        active().border
    }
    /// Primary text.
    pub fn fg() -> Color32 {
        active().fg
    }
    /// Secondary text.
    pub fn muted() -> Color32 {
        active().muted
    }
    /// The project's primary colour.
    pub fn blue() -> Color32 {
        active().accent
    }
    /// Values and figures.
    pub fn cyan() -> Color32 {
        active().cyan
    }
    /// The "veiled" half of the mark.
    pub fn purple() -> Color32 {
        active().accent_2
    }
    /// Success.
    pub fn green() -> Color32 {
        active().ok
    }
    /// Warning.
    pub fn yellow() -> Color32 {
        active().warn
    }
    /// Error.
    pub fn red() -> Color32 {
        active().err
    }

    /// Mix `a` towards `b` by `t` in 0..=1.
    pub fn blend(a: Color32, b: Color32, t: f32) -> Color32 {
        let t = t.clamp(0.0, 1.0);
        let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t) as u8;
        Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
    }
}



/// Places JetBrains Mono is normally installed.
///
/// The font is not vendored: it is Apache-2.0 and redistributable, but shipping
/// a binary blob would undercut the claim that everything here is auditable
/// from source. If it is installed the UI uses it; otherwise egui's own
/// monospace face stands in, which looks close enough that nothing breaks.
const JETBRAINS_MONO_PATHS: &[&str] = &[
    // Windows
    r"C:\Windows\Fonts\JetBrainsMono-Regular.ttf",
    // macOS
    "/Library/Fonts/JetBrainsMono-Regular.ttf",
    "/System/Library/Fonts/JetBrainsMono-Regular.ttf",
    // Linux
    "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf",
    "/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf",
    "/usr/share/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf",
];



fn user_font_paths() -> Vec<std::path::PathBuf> {
    let mut paths: Vec<std::path::PathBuf> = JETBRAINS_MONO_PATHS
        .iter()
        .map(std::path::PathBuf::from)
        .collect();
    if let Some(home) = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) {
        let home = std::path::PathBuf::from(home);
        paths.push(home.join(".local/share/fonts/JetBrainsMono-Regular.ttf"));
        paths.push(home.join("AppData/Local/Microsoft/Windows/Fonts/JetBrainsMono-Regular.ttf"));
        paths.push(home.join("Library/Fonts/JetBrainsMono-Regular.ttf"));
    }
    paths
}



/// Where JetBrains Mono is on this machine, if it is anywhere.
///
/// Separated from [`install_fonts`] so the question can be asked **without
/// opening a window**. `veilvoice-gui --typeface` answers from this, which is
/// what `tools/shots/gui.sh` checks before it photographs anything: a capture
/// taken with the built-in face instead looks subtly different from every other
/// one and nothing about the run says so, which is the failure that made this
/// worth having a flag for.
pub fn jetbrains_mono_path() -> Option<std::path::PathBuf> {
    user_font_paths().into_iter().find(|path| path.is_file())
}



/// Load JetBrains Mono if the system has it. Returns whether it was found.
pub fn install_fonts(ctx: &egui::Context) -> bool {
    for path in user_font_paths() {
        let Ok(bytes) = std::fs::read(&path) else {
            continue;
        };
        let mut fonts = egui::FontDefinitions::default();
        fonts.font_data.insert(
            "jetbrains".to_owned(),
            std::sync::Arc::new(egui::FontData::from_owned(bytes)),
        );
        for family in [FontFamily::Monospace, FontFamily::Proportional] {
            fonts
                .families
                .entry(family)
                .or_default()
                .insert(0, "jetbrains".to_owned());
        }
        ctx.set_fonts(fonts);
        return true;
    }
    false
}



/// Apply the active theme's visuals and a monospace-everywhere type scale.
pub fn install(ctx: &egui::Context) {
    use palette as p;
    let theme = active();

    let mut visuals = if theme.light {
        Visuals::light()
    } else {
        Visuals::dark()
    };

    visuals.override_text_color = Some(p::fg());
    visuals.panel_fill = p::bg();
    visuals.window_fill = p::bg();
    visuals.extreme_bg_color = p::bg_dark();
    visuals.faint_bg_color = p::bg_dark();
    visuals.window_stroke = Stroke::new(1.0, p::border());
    visuals.selection.bg_fill = p::blue().gamma_multiply(0.35);
    visuals.selection.stroke = Stroke::new(1.0, p::blue());
    visuals.hyperlink_color = p::cyan();

    let rounding = CornerRadius::same(4);
    for (widget, fill) in [
        (&mut visuals.widgets.noninteractive, p::bg_dark()),
        (&mut visuals.widgets.inactive, p::surface()),
        (&mut visuals.widgets.hovered, p::surface_hi()),
        (&mut visuals.widgets.active, p::surface_hi()),
        (&mut visuals.widgets.open, p::surface()),
    ] {
        widget.bg_fill = fill;
        widget.weak_bg_fill = fill;
        widget.corner_radius = rounding;
        widget.bg_stroke = Stroke::new(1.0, p::border());
        widget.fg_stroke = Stroke::new(1.0, p::fg());
    }
    visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p::blue());
    visuals.widgets.active.bg_stroke = Stroke::new(1.0, p::blue());
    visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p::muted());

    // Both of egui's modes get the same look: the palette is this
    // application's own, light or dark by its own choice, and egui's
    // dark/light split underneath it must not disagree with it.
    ctx.all_styles_mut(|style| style.visuals = visuals.clone());

    // Monospace throughout: this is a tool for people who care what the numbers
    // say, and a fixed advance keeps live readouts from jittering as they
    // update.
    let mut style = (*ctx.global_style()).clone();
    style.text_styles = [
        (TextStyle::Heading, FontId::new(20.0, FontFamily::Monospace)),
        (TextStyle::Body, FontId::new(13.0, FontFamily::Monospace)),
        (
            TextStyle::Monospace,
            FontId::new(13.0, FontFamily::Monospace),
        ),
        (TextStyle::Button, FontId::new(13.0, FontFamily::Monospace)),
        (TextStyle::Small, FontId::new(11.0, FontFamily::Monospace)),
    ]
    .into();
    style.spacing.item_spacing = egui::vec2(8.0, 8.0);
    style.spacing.button_padding = egui::vec2(10.0, 6.0);
    ctx.all_styles_mut(|s| *s = style.clone());
}


#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    /// **F-78.** Four tests here read and write the same process-global
    /// `ACTIVE`, and `cargo test` runs them on parallel threads in one
    /// process. Nothing kept them apart, so any two could interleave: one
    /// switched the theme to `paper` and asked whether the palette had
    /// changed, while another put the index back to zero in between and made
    /// the answer no.
    ///
    /// Measured: one failure in forty runs of this module alone, and it fired
    /// for real during a full-workspace run, where there are more threads
    /// competing. A test that fails one run in forty is worse than one that
    /// fails every time, because the answer people learn is "run it again".
    ///
    /// So the tests that touch the global take this in turn. Poisoning is
    /// stepped over rather than propagated: a panic in one test has already
    /// failed that test, and turning it into a failure in every other one
    /// hides which was the real fault.
    static ACTIVE_THEME: Mutex<()> = Mutex::new(());

    fn alone() -> std::sync::MutexGuard<'static, ()> {
        ACTIVE_THEME
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    fn reset() {
        ACTIVE.store(0, Ordering::Relaxed);
    }

    #[test]
    fn palette_matches_the_cli_escape_codes() {
        let _alone = alone();
        reset();
        // Both front-ends must be the same program to look at. These values are
        // duplicated in veilvoice-cli's `theme::colour`.
        assert_eq!(palette::blue(), Color32::from_rgb(122, 162, 247));
        assert_eq!(palette::green(), Color32::from_rgb(158, 206, 106));
        assert_eq!(palette::red(), Color32::from_rgb(247, 118, 142));
        assert_eq!(palette::muted(), Color32::from_rgb(115, 122, 162));
    }

    /// The website must not gain a theme the app has never heard of.
    ///
    /// The test below walks `THEMES` and checks each against the stylesheet,
    /// which catches a theme changed or removed on either side -- and misses a
    /// theme **added to the website**, because nothing was walking the
    /// stylesheet looking for entries this crate does not know.
    ///
    /// That is the shape of the defect recorded against `html.test.js`: a check
    /// enumerating from a hardcoded list is only ever as wide as the list, and
    /// a page added later went unchecked for precisely that reason. So this
    /// enumerates from the *stylesheet* instead, and the pair of tests closes
    /// the loop in both directions.
    ///
    /// A reader who picks a theme on the website and then opens the app should
    /// find it there. Silently falling back to the default is a small thing
    /// that reads as the application being broken.
    #[test]
    fn the_website_has_no_theme_the_app_is_missing() {
        let css = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/css/themes.css"),
        )
        .expect("themes.css should be readable from the crate directory");

        let mut found = Vec::new();
        let mut rest = css.as_str();
        while let Some(at) = rest.find("[data-theme=\"") {
            let after = &rest[at + "[data-theme=\"".len()..];
            let end = after.find('"').expect("unterminated data-theme selector");
            found.push(after[..end].to_string());
            rest = &after[end..];
        }

        assert!(
            !found.is_empty(),
            "no [data-theme] selectors were found -- the parser, not the CSS, \
             is what changed"
        );

        for id in &found {
            assert!(
                THEMES.iter().any(|t| t.id == id),
                "the website defines the '{id}' theme and the app does not. \
                 Add it to THEMES, or remove it from website/css/themes.css."
            );
        }
    }

    /// The app's themes and the website's are meant to be the same themes, not
    /// two sets that happen to share names. Parsed straight out of the
    /// stylesheet so a change to either side without the other fails here.
    #[test]
    fn every_theme_matches_the_website_stylesheet() {
        let css = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/css/themes.css"),
        )
        .expect("themes.css should be readable from the crate directory");

        for theme in THEMES {
            let marker = format!("[data-theme=\"{}\"]", theme.id);
            let start = css
                .find(&marker)
                .unwrap_or_else(|| panic!("{} is not in themes.css", theme.id));
            let block_end = css[start..].find('}').expect("unterminated block") + start;
            let block = &css[start..block_end];

            for (token, expected) in [
                ("--bg", theme.bg),
                ("--bg-soft", theme.bg_soft),
                ("--bg-inset", theme.bg_inset),
                ("--border", theme.border),
                ("--fg", theme.fg),
                ("--muted", theme.muted),
                ("--accent-2", theme.accent_2),
                ("--cyan", theme.cyan),
                ("--ok", theme.ok),
                ("--warn", theme.warn),
                ("--err", theme.err),
            ] {
                let needle = format!("{token}:");
                let at = block
                    .find(&needle)
                    .unwrap_or_else(|| panic!("{} has no {token}", theme.id));
                let rest = &block[at + needle.len()..];
                let hex: String = rest
                    .trim_start()
                    .trim_start_matches('#')
                    .chars()
                    .take(6)
                    .collect();
                let value = u32::from_str_radix(&hex, 16)
                    .unwrap_or_else(|_| panic!("{} {token} is not a hex colour", theme.id));
                assert_eq!(
                    rgb(value),
                    expected,
                    "{} {token} differs between the app and the website",
                    theme.id
                );
            }

            // `--accent` needs its own lookup: `--accent-2` contains it as a
            // prefix, so a naive `find` would match the wrong line.
            let at = block
                .match_indices("--accent:")
                .next()
                .unwrap_or_else(|| panic!("{} has no --accent", theme.id))
                .0;
            let hex: String = block[at + "--accent:".len()..]
                .trim_start()
                .trim_start_matches('#')
                .chars()
                .take(6)
                .collect();
            assert_eq!(
                rgb(u32::from_str_radix(&hex, 16).unwrap()),
                theme.accent,
                "{} --accent differs between the app and the website",
                theme.id
            );
        }
    }

    /// The website offers exactly these themes, in this order. A theme added to
    /// one and not the other is the kind of drift nobody notices.
    #[test]
    fn the_app_offers_the_same_themes_as_the_website() {
        let js = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/js/theme.js"),
        )
        .expect("theme.js should be readable");
        for theme in THEMES {
            assert!(
                js.contains(&format!("\"{}\"", theme.id)),
                "the website does not offer {}",
                theme.id
            );
        }
    }

    #[test]
    fn switching_theme_changes_the_palette_and_the_visuals() {
        let _alone = alone();
        reset();
        let ctx = egui::Context::default();
        install(&ctx);
        let tokyo = palette::bg();

        assert!(set_by_id(&ctx, "paper"));
        assert_ne!(palette::bg(), tokyo, "the palette did not change");
        assert_eq!(ctx.global_style().visuals.panel_fill, palette::bg());
        assert!(active().light, "paper is a light scheme");

        // An unknown identifier must be ignored rather than fatal: a
        // preferences file naming a theme this build does not have has to
        // still start.
        assert!(!set_by_id(&ctx, "no-such-theme"));
        assert_eq!(active().id, "paper", "an unknown id changed the theme");
        reset();
    }

    /// An out-of-range index must saturate rather than panic. This is read
    /// inside the paint loop, which is the worst place for an index panic.
    #[test]
    fn an_impossible_index_saturates_instead_of_panicking() {
        let _alone = alone();
        ACTIVE.store(usize::MAX, Ordering::Relaxed);
        let _ = active();
        assert_eq!(active().id, THEMES[THEMES.len() - 1].id);
        reset();
    }

    #[test]
    fn theme_identifiers_are_unique_and_stable() {
        let mut ids: Vec<&str> = THEMES.iter().map(|t| t.id).collect();
        ids.sort_unstable();
        let count = ids.len();
        ids.dedup();
        assert_eq!(ids.len(), count, "duplicate theme id");
        // Written into the preferences file, so they must stay ASCII and plain.
        for theme in THEMES {
            assert!(
                theme.id.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
                "{} is not a stable identifier",
                theme.id
            );
            assert!(!theme.name.is_empty());
        }
    }

    #[test]
    fn font_search_covers_every_supported_platform() {
        let paths = user_font_paths();
        assert!(paths
            .iter()
            .any(|p| p.to_string_lossy().contains("Windows")));
        assert!(paths
            .iter()
            .any(|p| p.to_string_lossy().contains("usr/share/fonts")));
        assert!(paths
            .iter()
            .any(|p| p.to_string_lossy().contains("Library/Fonts")));
    }

    #[test]
    fn styling_applies_without_a_window() {
        let _alone = alone();
        reset();
        let ctx = egui::Context::default();
        install(&ctx);
        assert_eq!(ctx.global_style().visuals.panel_fill, palette::bg());
        assert!(ctx
            .global_style()
            .text_styles
            .contains_key(&TextStyle::Monospace));
    }

    /// Missing JetBrains Mono must degrade to the built-in face, never panic.
    #[test]
    fn missing_font_is_not_fatal() {
        let _alone = alone();
        let ctx = egui::Context::default();
        let _found = install_fonts(&ctx);
        install(&ctx);
    }
}