use crate::load::scan;
use crate::{ThemeColors, ThemeDirs, ThemeId, ThemeMeta, Token, resolve, wcag_contrast};
use serde::Serialize;
#[allow(unused_imports)]
use crate::list_themes_from_dirs;
#[allow(unused_imports)]
use crate::parse_meta;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
Light,
Dark,
HighContrast,
}
impl Variant {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Variant::Light => "light",
Variant::Dark => "dark",
Variant::HighContrast => "high-contrast",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
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())
}
}
impl From<&str> for Variant {
fn from(raw: &str) -> Self {
Variant::parse(raw).unwrap_or(Variant::Dark)
}
}
pub const FOLLOW: &str = "system";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ThemeSelection {
#[default]
Follow,
Fixed(ThemeId),
}
impl ThemeSelection {
#[must_use]
pub fn parse(raw: Option<&str>) -> Self {
match raw.map(str::trim) {
None | Some("" | FOLLOW) => ThemeSelection::Follow,
Some(id) => ThemeId::new(id).map_or(ThemeSelection::Follow, ThemeSelection::Fixed),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
ThemeSelection::Follow => FOLLOW,
ThemeSelection::Fixed(id) => id.as_str(),
}
}
#[must_use]
pub fn resolve(
&self,
ambient: Variant,
defaults: &ThemeDefaults,
available: &[ThemeMeta],
) -> ThemeId {
let installed = |id: &ThemeId| 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.clone();
}
available
.iter()
.find(|meta| meta.variant == ambient)
.map_or_else(|| preferred.clone(), |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())
}
}
#[derive(Debug, Clone)]
pub struct ThemeDefaults {
light: ThemeId,
dark: ThemeId,
high_contrast: Option<ThemeId>,
}
impl ThemeDefaults {
#[must_use]
pub fn new(light: ThemeId, dark: ThemeId) -> Self {
Self {
light,
dark,
high_contrast: None,
}
}
#[must_use]
pub fn high_contrast(mut self, id: ThemeId) -> Self {
self.high_contrast = Some(id);
self
}
#[must_use]
pub const fn names_high_contrast(&self) -> bool {
self.high_contrast.is_some()
}
#[must_use]
pub fn for_variant(&self, variant: Variant) -> &ThemeId {
match variant {
Variant::Light => &self.light,
Variant::Dark => &self.dark,
Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContrastTier {
Low,
Standard,
High,
}
impl ContrastTier {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
ContrastTier::Low => "low",
ContrastTier::Standard => "standard",
ContrastTier::High => "high",
}
}
#[must_use]
pub fn of(theme: &ThemeColors) -> Self {
let tokens = resolve(theme);
let (Some(muted), Some(page), Some(well)) = (
tokens.color(Token::ContentMuted),
tokens.color(Token::SurfacePage),
tokens.color(Token::SurfaceWell),
) else {
return ContrastTier::Standard;
};
let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, well));
if worst >= 4.5 {
ContrastTier::High
} else if worst >= 3.0 {
ContrastTier::Standard
} else {
ContrastTier::Low
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOption {
pub id: ThemeId,
pub name: String,
pub variant: Variant,
pub contrast: ContrastTier,
}
#[must_use]
pub fn theme_options(dirs: &ThemeDirs) -> Vec<ThemeOption> {
let (seen, _) = scan(dirs);
let mut options: Vec<ThemeOption> = seen
.into_values()
.map(|scanned| {
let contrast = ContrastTier::of(&scanned.colors());
ThemeOption {
variant: scanned.meta.variant,
contrast,
id: scanned.meta.id,
name: scanned.meta.name,
}
})
.collect();
order_theme_options(&mut options);
options
}
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))
});
}
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;
#[allow(unused_imports)]
use crate::{ThemeDirs, ThemeId, Token};
use std::collections::HashMap;
fn meta(id: &'static str, variant: &str) -> ThemeMeta {
ThemeMeta {
id: ThemeId::from_static(id),
name: id.to_string(),
variant: Variant::from(variant),
is_custom: false,
}
}
fn defaults() -> ThemeDefaults {
ThemeDefaults::new(
ThemeId::from_static("flatwhite"),
ThemeId::from_static("nord"),
)
}
#[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);
}
#[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(&ThemeId::from_static("x"), &missing, false).variant,
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(ThemeId::from_static("nord")),
),
] {
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",
);
}
}
#[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(ThemeId::from_static("nord"));
assert_eq!(
fixed.resolve(Variant::Light, &defaults(), &available),
"nord",
"a chosen theme is not overridden by the ambient mode",
);
}
#[test]
fn a_fixed_selection_whose_theme_is_gone_falls_back() {
let available = [meta("nord", "dark"), meta("flatwhite", "light")];
let fixed = ThemeSelection::Fixed(ThemeId::from_static("deleted"));
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",
);
}
#[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",
);
}
#[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(ThemeId::from_static("sharp"));
assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
}
#[test]
fn theme_options_groups_by_variant_light_first() {
let dirs = ThemeDirs::new().bundled(bundled_themes_dir());
let options = theme_options(&dirs);
assert!(!options.is_empty(), "the shipped set is not empty");
let order: Vec<u8> = 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 = ThemeDirs::new().bundled(bundled_themes_dir());
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 = ThemeDirs::new().bundled(bundled_themes_dir());
let mut scanned: Vec<ThemeId> = crate::list_themes_from_dirs(&dirs)
.themes
.into_iter()
.map(|meta| meta.id)
.collect();
let mut offered: Vec<ThemeId> = 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() {
let theme = ThemeColors {
meta: ThemeMeta {
id: ThemeId::from_static("unmeasurable"),
name: "Unmeasurable".to_string(),
variant: Variant::Dark,
is_custom: false,
},
colors: HashMap::new(),
};
assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
}
#[test]
fn the_house_themes_measure_high() {
let dirs = ThemeDirs::new().bundled(bundled_themes_dir());
for id in ["goingson", "audiofiles", "makenotwork", "high-contrast"] {
let theme = crate::load_theme(&dirs, &ThemeId::from_static(id)).expect("shipped");
assert_eq!(
ContrastTier::of(&theme),
ContrastTier::High,
"{id} is one of ours and should meet AA on both grounds"
);
}
}
#[test]
fn a_theme_whose_muted_is_lifted_measures_on_the_lifted_colour() {
let dirs = ThemeDirs::new().bundled(bundled_themes_dir());
let theme = crate::load_theme(&dirs, &ThemeId::from_static("dawnfox")).expect("shipped");
assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
}
#[test]
fn contrast_tiers_order_worst_first() {
assert!(ContrastTier::Low < ContrastTier::Standard);
assert!(ContrastTier::Standard < ContrastTier::High);
}
}