use anyhow::{Context, Result, bail, ensure};
use clap::ValueEnum;
use serde::Deserialize;
use crate::{Category, category::ALL, util::Map};
pub const SOURCE: &str = include_str!("../assets/palette.json");
const DARK_INK: &str = "#0b0b0b";
const LIGHT_INK: &str = "#ffffff";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum Theme {
#[default]
Light,
Dark,
}
impl Theme {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Family {
Logic,
Alloc,
Unsure,
}
impl Family {
pub const ALL: [Self; 3] = [Self::Logic, Self::Alloc, Self::Unsure];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Logic => "logic",
Self::Alloc => "alloc",
Self::Unsure => "unsure",
}
}
}
#[derive(Debug, Deserialize)]
struct File {
families: Families,
light: Colours,
dark: Colours,
}
#[derive(Debug, Deserialize)]
struct Families {
logic: Vec<String>,
alloc: Vec<String>,
unsure: Vec<String>,
}
impl Families {
fn resolve(&self) -> Result<[Family; ALL.len()]> {
let mut found = [None; ALL.len()];
let listed = [
(Family::Logic, &self.logic),
(Family::Alloc, &self.alloc),
(Family::Unsure, &self.unsure),
];
for (family, names) in listed {
for name in names {
let Ok(category) = name.parse::<Category>() else {
bail!("the palette names an unknown category `{name}`");
};
let slot = &mut found[category as usize];
ensure!(
slot.is_none(),
"the palette puts `{name}` in two families"
);
*slot = Some(family);
}
}
let mut families = [Family::Unsure; ALL.len()];
for (slot, category) in families.iter_mut().zip(ALL) {
*slot = found[category as usize].with_context(|| {
format!("the palette puts `{}` in no family", category.name())
})?;
}
Ok(families)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Colours {
pub page: String,
pub ink: String,
pub label: String,
pub muted: String,
pub gate: String,
#[serde(rename = "match")]
pub matched: String,
pub brand: String,
pub family: FamilyColours,
pub call: CallColours,
pub panic: Map<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FamilyColours {
pub logic: String,
pub alloc: String,
pub unsure: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CallColours {
pub none: String,
pub logic: String,
pub alloc: String,
pub unsure: String,
}
impl Colours {
fn check(&self, theme: Theme) -> Result<()> {
let fixed = [
&self.page,
&self.ink,
&self.label,
&self.muted,
&self.gate,
&self.matched,
&self.brand,
&self.family.logic,
&self.family.alloc,
&self.family.unsure,
&self.call.none,
&self.call.logic,
&self.call.alloc,
&self.call.unsure,
];
for colour in fixed.into_iter().chain(self.panic.values()) {
ensure!(
is_colour(colour),
"the palette's {} theme writes `{colour}`, which is not a \
colour of the form #rrggbb",
theme.name()
);
}
for category in ALL {
ensure!(
self.panic.contains_key(category.name()),
"the palette's {} theme gives `{}` no colour",
theme.name(),
category.name()
);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Palette {
colours: Colours,
families: [Family; ALL.len()],
}
impl Palette {
pub fn load(theme: Theme) -> Result<Self> {
let file: File = serde_json::from_str(SOURCE)
.context("the palette file does not parse")?;
let families = file.families.resolve()?;
let colours = match theme {
Theme::Light => file.light,
Theme::Dark => file.dark,
};
colours.check(theme)?;
Ok(Self { colours, families })
}
#[must_use]
pub const fn colours(&self) -> &Colours {
&self.colours
}
#[must_use]
pub const fn family(&self, category: Category) -> Family {
self.families[category as usize]
}
#[must_use]
pub fn panic(&self, category: Category) -> &str {
self.colours
.panic
.get(category.name())
.map_or(&self.colours.call.none, String::as_str)
}
#[must_use]
pub fn call(&self, family: Option<Family>) -> &str {
let call = &self.colours.call;
match family {
None => &call.none,
Some(Family::Logic) => &call.logic,
Some(Family::Alloc) => &call.alloc,
Some(Family::Unsure) => &call.unsure,
}
}
#[must_use]
pub fn family_colour(&self, family: Family) -> &str {
let set = &self.colours.family;
match family {
Family::Logic => &set.logic,
Family::Alloc => &set.alloc,
Family::Unsure => &set.unsure,
}
}
}
fn is_colour(text: &str) -> bool {
text.strip_prefix('#').is_some_and(|hex| {
hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit())
})
}
#[must_use]
pub fn ink_on(fill: &str) -> &'static str {
let dark = contrast(fill, DARK_INK);
let light = contrast(fill, LIGHT_INK);
match (dark, light) {
(Some(dark), Some(light)) if light > dark => LIGHT_INK,
_ => DARK_INK,
}
}
#[must_use]
pub fn contrast(a: &str, b: &str) -> Option<f64> {
let a = luminance(a)?;
let b = luminance(b)?;
Some((a.max(b) + 0.05) / (a.min(b) + 0.05))
}
fn luminance(hex: &str) -> Option<f64> {
let hex = hex.strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let channel = |at: usize| -> Option<f64> {
let byte = u8::from_str_radix(hex.get(at..at + 2)?, 16).ok()?;
let c = f64::from(byte) / 255.0;
Some(if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
})
};
let red = 0.2126 * channel(0)?;
let green = 0.7152f64.mul_add(channel(2)?, red);
Some(0.0722f64.mul_add(channel(4)?, green))
}