use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast};
use serde::Serialize;
use std::path::PathBuf;
#[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)
}
}
impl ThemeMeta {
#[must_use]
pub fn kind(&self) -> Variant {
Variant::from(self.variant.as_str())
}
}
pub const FOLLOW: &str = "system";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ThemeSelection {
#[default]
Follow,
Fixed(String),
}
impl ThemeSelection {
#[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()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
ThemeSelection::Follow => FOLLOW,
ThemeSelection::Fixed(id) => id,
}
}
#[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())
}
}
#[derive(Debug, Clone)]
pub struct ThemeDefaults {
light: String,
dark: String,
high_contrast: Option<String>,
}
impl ThemeDefaults {
pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
Self {
light: light.into(),
dark: dark.into(),
high_contrast: None,
}
}
#[must_use]
pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
self.high_contrast = Some(id.into());
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) -> &str {
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 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
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOption {
pub id: String,
pub name: String,
pub variant: Variant,
pub contrast: ContrastTier,
}
#[must_use]
pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
let mut options: Vec<ThemeOption> = 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
}
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;
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")
}
#[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("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",
);
}
}
#[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",
);
}
#[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",
);
}
#[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("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<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 = 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<String> = list_themes_from_dirs(&dirs)
.into_iter()
.map(|meta| meta.id)
.collect();
let mut offered: Vec<String> = 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: "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() {
let dirs = vec![(bundled_themes_dir().unwrap(), false)];
for id in ["goingson", "audiofiles", "makenotwork"] {
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);
}
}