//! Choosing a theme. //! //! The file half of this crate was always shared; the *selection* half was not, //! and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in //! localStorage, Balanced Breakfast treats an absent value as follow-the-system //! and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id //! in a synced SQLite table, and the Alloy console parses COLORFGBG. They also //! disagreed about what a variant string means: this crate defaults a missing //! one to "dark" while alloy_tui parsed an unrecognized one as light. //! //! What cannot be shared is the store — localStorage, a synced config table and //! a TOML file are genuinely different places. What can be shared, and is here, //! is the *meaning*: one vocabulary for variants, one encoding for "what did the //! user choose", and one rule for turning that into an id that exists. use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast}; use serde::Serialize; use std::path::PathBuf; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::parse_meta; /// A theme's kind, as declared by `meta.variant`. /// /// Three, not two: one shipped theme is `high-contrast`, and an app that /// matched on light-or-dark alone would quietly file it under the wrong one. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Variant { Light, Dark, HighContrast, } impl Variant { /// The spelling used in a theme file and in [`ThemeMeta::variant`]. #[must_use] pub const fn as_str(self) -> &'static str { match self { Variant::Light => "light", Variant::Dark => "dark", Variant::HighContrast => "high-contrast", } } /// Read a variant string, or `None` if it names none of them. #[must_use] pub fn parse(raw: &str) -> Option { match raw { "light" => Some(Variant::Light), "dark" => Some(Variant::Dark), "high-contrast" => Some(Variant::HighContrast), _ => None, } } } impl std::fmt::Display for Variant { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// Anything unrecognized reads as dark, which is what [`parse_meta`] already /// does with a missing one. Consumers that guessed light for an unknown string /// were disagreeing with the crate that produced it. impl From<&str> for Variant { fn from(raw: &str) -> Self { Variant::parse(raw).unwrap_or(Variant::Dark) } } impl ThemeMeta { /// This theme's variant as a value rather than a string. #[must_use] pub fn kind(&self) -> Variant { Variant::from(self.variant.as_str()) } } /// The spelling of "follow whatever the system is doing", in every store. pub const FOLLOW: &str = "system"; /// What the user chose, as opposed to what is being rendered. /// /// The distinction is the whole point: `Follow` is a standing instruction that /// resolves differently as the ambient mode changes, and a `Fixed` id is an /// answer that does not. An app that stored only the rendered id could not tell /// the two apart the next time the system flipped to dark. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ThemeSelection { /// Track the ambient light/dark mode. #[default] Follow, /// Always this theme. Fixed(String), } impl ThemeSelection { /// Read a stored selection. An empty or absent value is [`Follow`], which /// is what an app with nothing saved yet should do. /// /// [`Follow`]: ThemeSelection::Follow #[must_use] pub fn parse(raw: Option<&str>) -> Self { match raw.map(str::trim) { None | Some("" | FOLLOW) => ThemeSelection::Follow, Some(id) => ThemeSelection::Fixed(id.to_string()), } } /// The string to persist, whatever the store is. #[must_use] pub fn as_str(&self) -> &str { match self { ThemeSelection::Follow => FOLLOW, ThemeSelection::Fixed(id) => id, } } /// Turn a selection into a theme id that exists. /// /// `ambient` is the light/dark mode the app learned however it can: a /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG` /// from a terminal. `available` is what [`list_themes_from_dirs`] found. /// /// A `Fixed` id that is no longer on disk falls through to the same path as /// `Follow` rather than being returned anyway. Themes are deletable in /// three of the four apps, and handing back an id that will fail to load /// only moves the error somewhere less helpful. /// /// The fallback chain is: the app's own default for the ambient mode if it /// is installed, then any installed theme of that variant, then the app's /// default regardless. The last step means this always returns something, /// and an app with no theme directory at all gets the id it ships with and /// the load error it would have had anyway. #[must_use] pub fn resolve( &self, ambient: Variant, defaults: &ThemeDefaults, available: &[ThemeMeta], ) -> String { let installed = |id: &str| available.iter().any(|meta| meta.id == id); if let ThemeSelection::Fixed(id) = self && installed(id) { return id.clone(); } let preferred = defaults.for_variant(ambient); if installed(preferred) { return preferred.to_string(); } available .iter() .find(|meta| meta.kind() == ambient) .map_or_else(|| preferred.to_string(), |meta| meta.id.clone()) } } impl std::fmt::Display for ThemeSelection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// The themes an app falls back to, one per ambient mode. /// /// App-specific on purpose: which theme is "the app's own" is the app's /// identity, not this crate's business. What is shared is everything around it. #[derive(Debug, Clone)] pub struct ThemeDefaults { light: String, dark: String, high_contrast: Option, } impl ThemeDefaults { pub fn new(light: impl Into, dark: impl Into) -> Self { Self { light: light.into(), dark: dark.into(), high_contrast: None, } } /// Name a theme for a high-contrast ambient mode. Without one, that mode /// falls back to the dark default, which is the safer of the two to read. #[must_use] pub fn high_contrast(mut self, id: impl Into) -> Self { self.high_contrast = Some(id.into()); self } /// Whether a high-contrast default was named. /// /// [`for_variant`] answers for every mode by falling back to the dark /// theme, which is right for resolving a selection and wrong for emitting /// a `prefers-contrast: more` block: that block would then answer the /// preference with a theme that does not honour it. A caller that renders /// per ambient mode asks this first. /// /// [`for_variant`]: ThemeDefaults::for_variant #[must_use] pub const fn names_high_contrast(&self) -> bool { self.high_contrast.is_some() } #[must_use] pub fn for_variant(&self, variant: Variant) -> &str { match variant { Variant::Light => &self.light, Variant::Dark => &self.dark, Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark), } } } /// How legible a theme's muted text is, measured rather than declared. /// /// The worst WCAG contrast ratio of `content.muted` against the two panel /// grounds a reader actually meets it on, `surface.page` and `surface.sunken`, /// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average, /// because a theme that is legible on one panel and not the other is a theme /// with an illegible panel. /// /// It is measured here rather than authored in the theme file for the reason /// the whole crate exists: a curated palette keeps its identity and the reader /// still gets told what it costs them. An author cannot mis-declare it, and a /// theme edited on disk re-measures on the next scan. /// /// Ordered worst-first, so `sort` puts the most legible theme last and /// [`theme_options`] reverses it into what a picker wants at the top. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(rename_all = "kebab-case")] pub enum ContrastTier { /// Muted text below the 3:1 floor WCAG sets for large text and UI parts. Low, /// Muted text meets 3:1 but not the 4.5:1 bar for normal text. Standard, /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better. High, } impl ContrastTier { /// The machine spelling, for a data attribute or a stored value. #[must_use] pub const fn as_str(self) -> &'static str { match self { ContrastTier::Low => "low", ContrastTier::Standard => "standard", ContrastTier::High => "high", } } /// Measure a loaded theme. /// /// A theme missing either ground or the muted content colour reads as /// [`Standard`](Self::Standard): the measurement did not happen, and /// claiming `Low` would badge a theme for the scan's failure rather than /// its own. #[must_use] pub fn of(theme: &ThemeColors) -> Self { let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v)); let (Some(muted), Some(page), Some(sunken)) = ( colour("content.muted"), colour("surface.page"), colour("surface.sunken"), ) else { return ContrastTier::Standard; }; let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken)); if worst >= 4.5 { ContrastTier::High } else if worst >= 3.0 { ContrastTier::Standard } else { ContrastTier::Low } } } /// One theme, as a picker offers it. /// /// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies: /// the variant as a value rather than a string, and the measured contrast tier. /// Owned, because it outlives the directory scan that produced it and is held /// by an app across the frames or requests that draw the control. /// /// It carries no `is_custom`. A picker that sorted the user's own themes apart /// from the shipped ones would be answering a different question, and /// [`ThemeMeta`] is still there for a screen that wants it. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeOption { /// The id stored, and the value the picker submits. pub id: String, /// What the picker reads. pub name: String, /// Which group it belongs to. pub variant: Variant, /// How legible its muted text measured. pub contrast: ContrastTier, } /// Every installed theme, in the order a picker should offer them. /// /// This is the half of a theme picker that is not the control: which themes /// exist, which group each is in, how legible each one is, and what order that /// puts them in. Three apps derived it three ways and two of them lost it /// entirely when their pickers were described, which is what makes it the /// crate's job rather than each app's. /// /// # The order /// /// By variant in [`Variant`]'s own order — light, dark, high contrast — then /// by measured contrast **best first**, then by name. The middle key is the one /// no app can supply without redoing the work this crate has already done: the /// tier comes off the resolved colours, and an app sorting a `Vec` /// has only the names. /// /// Grouping is left implicit in the order rather than returned as groups. A /// renderer that draws headings walks the run of one variant; one that cannot /// draw headings still gets the useful order. Handing back /// `Vec<(Variant, Vec)>` would force the second renderer to /// flatten what the first wanted, and neither shape is more true. /// /// # What it costs /// /// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its /// metadata, once here for the colours the tier is measured from. Measured /// rather than assumed to be cheap: a picker is drawn on a settings screen, the /// shipped set is around twenty files, and the alternative is caching a /// derived value that a theme edited on disk would then be wrong about. /// A theme whose colours will not load keeps its metadata and reads as /// [`ContrastTier::Standard`], on the same footing as one missing a ground. /// /// A host whose themes are not all on disk builds its own [`ThemeOption`]s and /// calls [`order_theme_options`], which is this function's second half. #[must_use] pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec { let mut options: Vec = list_themes_from_dirs(dirs) .into_iter() .map(|meta| { let contrast = load_theme(dirs, &meta.id) .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme)); ThemeOption { variant: meta.kind(), contrast, id: meta.id, name: meta.name, } }) .collect(); order_theme_options(&mut options); options } /// Put an already-collected set into the order a picker offers them in. /// /// [`theme_options`]' second half, reachable on its own because not every host /// resolves its themes by scanning a directory. audiofiles embeds its shipped /// set at compile time and reads only its custom themes off disk, so a /// directory scan cannot see most of what it offers, and the alternative to /// this being public was that app re-deriving the sort — which is exactly the /// three-apps-three-orders state the picker was described to end. /// /// The order is by variant in [`Variant`]'s own order, then by measured /// contrast **best first**, then by name. pub fn order_theme_options(options: &mut [ThemeOption]) { options.sort_by(|a, b| { variant_order(a.variant) .cmp(&variant_order(b.variant)) .then(b.contrast.cmp(&a.contrast)) .then_with(|| a.name.cmp(&b.name)) }); } /// Where a variant sits in a picker, light first. /// /// Not `Variant as usize`: the declaration order of an enum is not a promise /// about how it reads, and a member inserted for a fourth variant would /// silently reorder every picker in the tree. const fn variant_order(variant: Variant) -> u8 { match variant { Variant::Light => 0, Variant::Dark => 1, Variant::HighContrast => 2, } } #[cfg(test)] mod tests { use super::*; use crate::bundled_themes_dir; use std::collections::HashMap; fn meta(id: &str, variant: &str) -> ThemeMeta { ThemeMeta { id: id.to_string(), name: id.to_string(), variant: variant.to_string(), is_custom: false, } } fn defaults() -> ThemeDefaults { ThemeDefaults::new("flatwhite", "nord") } // The three the shipped themes actually declare. #[test] fn every_shipped_variant_parses() { assert_eq!(Variant::parse("light"), Some(Variant::Light)); assert_eq!(Variant::parse("dark"), Some(Variant::Dark)); assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast)); assert_eq!(Variant::parse("sepia"), None); } // parse_meta already defaults a *missing* variant to dark, so an // unrecognized one reading as light would have the crate disagreeing with // itself. alloy_tui did exactly that before this existed. #[test] fn an_unrecognized_variant_reads_the_way_a_missing_one_does() { assert_eq!(Variant::from("sepia"), Variant::Dark); assert_eq!(Variant::from(""), Variant::Dark); let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap(); assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark); } #[test] fn a_selection_round_trips_through_any_store() { for (stored, expect) in [ (Some("system"), ThemeSelection::Follow), (None, ThemeSelection::Follow), (Some(""), ThemeSelection::Follow), (Some(" "), ThemeSelection::Follow), (Some("nord"), ThemeSelection::Fixed("nord".into())), ] { let parsed = ThemeSelection::parse(stored); assert_eq!(parsed, expect, "{stored:?}"); assert_eq!( ThemeSelection::parse(Some(parsed.as_str())), expect, "what is written reads back as what was meant", ); } } // Nothing saved is follow-the-system, which is what Balanced Breakfast // expressed as an absent value and GoingsOn as a sentinel. Both are now the // same thing. #[test] fn nothing_chosen_yet_is_follow() { assert_eq!(ThemeSelection::default(), ThemeSelection::Follow); } #[test] fn a_fixed_selection_wins_when_its_theme_is_installed() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("nord".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "nord", "a chosen theme is not overridden by the ambient mode", ); } // Themes are deletable in three of the four apps. Handing back an id that // will fail to load only moves the error somewhere less helpful. #[test] fn a_fixed_selection_whose_theme_is_gone_falls_back() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("deleted".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } #[test] fn follow_picks_the_apps_default_for_the_ambient_mode() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let follow = ThemeSelection::Follow; assert_eq!( follow.resolve(Variant::Dark, &defaults(), &available), "nord", ); assert_eq!( follow.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } // The behaviour Balanced Breakfast could not have: following the system // into a theme the user installed, when the app's own default is absent. #[test] fn follow_uses_any_installed_theme_of_the_right_variant() { let available = [meta("solarized-light", "light"), meta("mine", "dark")]; assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), "mine", "the app's `nord` is not installed, but a dark theme is", ); } // Always returns something: an app with no theme directory gets the id it // ships with, and the load error it would have had anyway. #[test] fn an_empty_catalog_still_names_the_apps_default() { assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]), "nord", ); } #[test] fn high_contrast_falls_back_to_dark_unless_named() { let plain = defaults(); assert_eq!(plain.for_variant(Variant::HighContrast), "nord"); let named = defaults().high_contrast("sharp"); assert_eq!(named.for_variant(Variant::HighContrast), "sharp"); } #[test] fn theme_options_groups_by_variant_light_first() { let dirs = vec![(bundled_themes_dir().unwrap(), false)]; let options = theme_options(&dirs); assert!(!options.is_empty(), "the shipped set is not empty"); let order: Vec = options.iter().map(|o| variant_order(o.variant)).collect(); let mut sorted = order.clone(); sorted.sort_unstable(); assert_eq!( order, sorted, "every variant should occupy one run, light first" ); } #[test] fn theme_options_puts_the_most_legible_theme_first_in_its_group() { let dirs = vec![(bundled_themes_dir().unwrap(), false)]; let options = theme_options(&dirs); for pair in options.windows(2) { let (a, b) = (&pair[0], &pair[1]); if a.variant != b.variant { continue; } assert!( a.contrast >= b.contrast, "within {}, {} ({:?}) should not follow {} ({:?})", a.variant, b.id, b.contrast, a.id, a.contrast ); if a.contrast == b.contrast { assert!( a.name <= b.name, "ties break by name: {} then {}", a.name, b.name ); } } } #[test] fn theme_options_carries_every_theme_the_scan_found() { let dirs = vec![(bundled_themes_dir().unwrap(), false)]; let mut scanned: Vec = list_themes_from_dirs(&dirs) .into_iter() .map(|meta| meta.id) .collect(); let mut offered: Vec = theme_options(&dirs).into_iter().map(|o| o.id).collect(); scanned.sort(); offered.sort(); assert_eq!(scanned, offered, "ordering must not drop a theme"); } #[test] fn a_theme_that_cannot_be_measured_reads_as_standard() { // Not Low: a missing ground is the scan failing, and badging the theme // for that would tell the reader something untrue about the theme. let theme = ThemeColors { meta: ThemeMeta { id: "unmeasurable".to_string(), name: "Unmeasurable".to_string(), variant: "dark".to_string(), is_custom: false, }, colors: HashMap::new(), }; assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard); } #[test] fn the_house_themes_measure_high() { // The two we author. A change that drops either below AA is a // regression in a theme we control. // // `high-contrast` is deliberately not in this list. It measures // 4.89/3.53 and therefore reads as Standard: its muted text misses AA // on its own sunken panel. That is a finding about the theme file, not // about the measurement, and it is filed rather than asserted away. let dirs = vec![(bundled_themes_dir().unwrap(), false)]; for id in ["goingson", "audiofiles"] { let theme = load_theme(&dirs, id).expect("shipped"); assert_eq!( ContrastTier::of(&theme), ContrastTier::High, "{id} is one of ours and should meet AA on both grounds" ); } } #[test] fn contrast_tiers_order_worst_first() { assert!(ContrastTier::Low < ContrastTier::Standard); assert!(ContrastTier::Standard < ContrastTier::High); } }