use serde::Deserialize;
use tatara_lisp::{DeriveKeywordSexp, DeriveTataraDomain, TataraDomain};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct Srgb8 {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Srgb8 {
#[must_use]
pub fn to_bare_hex(self) -> String {
format!("{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
}
impl TryFrom<String> for Srgb8 {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
let h = s.strip_prefix('#').unwrap_or(&s);
if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"expected six hex digits (with or without a leading '#'), got {s:?}"
));
}
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).map_err(|e| e.to_string());
Ok(Self {
r: p(0)?,
g: p(2)?,
b: p(4)?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, DeriveKeywordSexp)]
pub enum Token {
Base00,
Base01,
Base02,
Base03,
Base04,
Base05,
Base06,
Base07,
Base08,
Base09,
Base0a,
Base0b,
Base0c,
Base0d,
Base0e,
Base0f,
Selection,
Glow,
}
#[derive(Debug, Clone, PartialEq, Eq, DeriveTataraDomain)]
#[tatara(keyword = "deframp")]
pub struct Ramp {
pub base00: Srgb8,
pub base01: Srgb8,
pub base02: Srgb8,
pub base03: Srgb8,
pub base04: Srgb8,
pub base05: Srgb8,
pub base06: Srgb8,
pub base07: Srgb8,
pub base08: Srgb8,
pub base09: Srgb8,
pub base0a: Srgb8,
pub base0b: Srgb8,
pub base0c: Srgb8,
pub base0d: Srgb8,
pub base0e: Srgb8,
pub base0f: Srgb8,
}
impl Ramp {
#[must_use]
pub fn get(&self, t: Token) -> Option<Srgb8> {
Some(match t {
Token::Base00 => self.base00,
Token::Base01 => self.base01,
Token::Base02 => self.base02,
Token::Base03 => self.base03,
Token::Base04 => self.base04,
Token::Base05 => self.base05,
Token::Base06 => self.base06,
Token::Base07 => self.base07,
Token::Base08 => self.base08,
Token::Base09 => self.base09,
Token::Base0a => self.base0a,
Token::Base0b => self.base0b,
Token::Base0c => self.base0c,
Token::Base0d => self.base0d,
Token::Base0e => self.base0e,
Token::Base0f => self.base0f,
Token::Selection | Token::Glow => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, DeriveTataraDomain)]
#[tatara(keyword = "defblend")]
pub struct Blend {
#[tatara(keyword_enum)]
pub over: Token,
#[tatara(keyword_enum)]
pub tint: Token,
pub alpha: f64,
}
#[derive(Debug, Clone, PartialEq, DeriveTataraDomain)]
#[tatara(keyword = "defderived")]
pub struct Derived {
#[tatara(domain)]
pub selection: Blend,
#[tatara(domain)]
pub glow: Blend,
}
#[derive(Debug, Clone, PartialEq, Eq, DeriveTataraDomain)]
#[tatara(keyword = "defansi")]
pub struct Ansi16 {
#[tatara(keyword_enum)]
pub black: Token,
#[tatara(keyword_enum)]
pub red: Token,
#[tatara(keyword_enum)]
pub green: Token,
#[tatara(keyword_enum)]
pub yellow: Token,
#[tatara(keyword_enum)]
pub blue: Token,
#[tatara(keyword_enum)]
pub magenta: Token,
#[tatara(keyword_enum)]
pub cyan: Token,
#[tatara(keyword_enum)]
pub white: Token,
#[tatara(keyword_enum)]
pub bright_black: Token,
#[tatara(keyword_enum)]
pub bright_red: Token,
#[tatara(keyword_enum)]
pub bright_green: Token,
#[tatara(keyword_enum)]
pub bright_yellow: Token,
#[tatara(keyword_enum)]
pub bright_blue: Token,
#[tatara(keyword_enum)]
pub bright_magenta: Token,
#[tatara(keyword_enum)]
pub bright_cyan: Token,
#[tatara(keyword_enum)]
pub bright_white: Token,
}
impl Ansi16 {
#[must_use]
pub fn in_order(&self) -> [Token; 16] {
[
self.black,
self.red,
self.green,
self.yellow,
self.blue,
self.magenta,
self.cyan,
self.white,
self.bright_black,
self.bright_red,
self.bright_green,
self.bright_yellow,
self.bright_blue,
self.bright_magenta,
self.bright_cyan,
self.bright_white,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, DeriveTataraDomain)]
#[tatara(keyword = "defroles")]
pub struct Roles {
#[tatara(keyword_enum)]
pub background: Token,
#[tatara(keyword_enum)]
pub surface: Token,
#[tatara(keyword_enum)]
pub surface_elevated: Token,
#[tatara(keyword_enum)]
pub text: Token,
#[tatara(keyword_enum)]
pub text_muted: Token,
#[tatara(keyword_enum)]
pub text_dim: Token,
#[tatara(keyword_enum)]
pub primary: Token,
#[tatara(keyword_enum)]
pub accent: Token,
#[tatara(keyword_enum)]
pub border: Token,
#[tatara(keyword_enum)]
pub error: Token,
#[tatara(keyword_enum)]
pub warning: Token,
#[tatara(keyword_enum)]
pub success: Token,
#[tatara(keyword_enum)]
pub info: Token,
#[tatara(keyword_enum)]
pub selection: Token,
#[tatara(keyword_enum)]
pub cursor: Token,
#[tatara(keyword_enum)]
pub agent: Token,
}
#[derive(Debug, Clone, PartialEq, Eq, DeriveTataraDomain)]
#[tatara(keyword = "defbrand")]
pub struct Brand {
pub ink: Srgb8,
pub void: Srgb8,
pub shadow_tone: Srgb8,
pub paper: Srgb8,
pub steel: Srgb8,
pub silver: Srgb8,
pub platinum: Srgb8,
}
#[derive(Debug, Clone, PartialEq, DeriveTataraDomain)]
#[tatara(keyword = "deftheme")]
pub struct Theme {
pub name: String,
pub title: String,
pub author: String,
pub contrast_floor: f64,
#[tatara(domain)]
pub ramp: Ramp,
#[tatara(domain)]
pub derived: Derived,
#[tatara(domain)]
pub ansi: Ansi16,
#[tatara(domain)]
pub roles: Roles,
#[tatara(domain)]
pub brand: Brand,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SpecError {
#[error("alpha for `{field}` is {value:?}; expected 0.0..=1.0")]
AlphaOutOfRange { field: &'static str, value: String },
#[error("contrast floor is {value:?}; expected a positive ratio (WCAG AA is 4.5)")]
ContrastFloorInvalid { value: String },
#[error("`{field}` blends over `{over:?}`, which is itself derived; a blend may only stack on a ramp slot")]
BlendOnDerived { field: &'static str, over: Token },
}
impl Theme {
#[must_use]
pub fn resolve(&self, t: Token) -> Srgb8 {
match t {
Token::Selection => self.blend(self.derived.selection),
Token::Glow => self.blend(self.derived.glow),
other => self
.ramp
.get(other)
.expect("Ramp::get is total over non-derived tokens"),
}
}
fn blend(&self, b: Blend) -> Srgb8 {
fn to_linear(c: u8) -> f64 {
let c = f64::from(c) / 255.0;
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn to_channel(l: f64) -> u8 {
let v = if l <= 0.003_130_8 {
12.92 * l
} else {
1.055 * l.powf(1.0 / 2.4) - 0.055
};
let scaled = (v * 255.0).round();
if scaled.is_nan() {
return 0;
}
scaled.clamp(0.0, 255.0) as u8
}
let over = self.ramp.get(b.over).unwrap_or(self.ramp.base00);
let tint = self.ramp.get(b.tint).unwrap_or(self.ramp.base00);
let a = b.alpha.clamp(0.0, 1.0);
let mix = |o: u8, t: u8| to_channel(to_linear(o).mul_add(1.0 - a, to_linear(t) * a));
Srgb8 {
r: mix(over.r, tint.r),
g: mix(over.g, tint.g),
b: mix(over.b, tint.b),
}
}
pub fn validate(&self) -> Result<(), SpecError> {
if !(self.contrast_floor > 0.0) {
return Err(SpecError::ContrastFloorInvalid {
value: self.contrast_floor.to_string(),
});
}
for (field, b) in [
("selection", self.derived.selection),
("glow", self.derived.glow),
] {
if !(0.0..=1.0).contains(&b.alpha) {
return Err(SpecError::AlphaOutOfRange {
field,
value: b.alpha.to_string(),
});
}
for t in [b.over, b.tint] {
if self.ramp.get(t).is_none() {
return Err(SpecError::BlendOnDerived { field, over: t });
}
}
}
Ok(())
}
pub fn from_source(src: &str) -> tatara_lisp::Result<Self> {
let forms = tatara_lisp::read(src)?;
let first = forms
.first()
.ok_or_else(|| tatara_lisp::LispError::Compile {
form: Self::KEYWORD.to_string(),
message: "source contains no forms".into(),
})?;
Self::compile_from_sexp(first)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, DeriveKeywordSexp)]
pub enum FleetTheme {
Nord,
Borealis,
Vellum,
}
impl Default for FleetTheme {
fn default() -> Self {
Self::Nord
}
}
impl FleetTheme {
pub const ALL: [Self; 3] = [Self::Nord, Self::Borealis, Self::Vellum];
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Nord => "nord",
Self::Borealis => "borealis",
Self::Vellum => "vellum",
}
}
#[must_use]
pub const fn source(self) -> &'static str {
match self {
Self::Nord => include_str!("../../themes/nord.theme.lisp"),
Self::Borealis => include_str!("../../themes/borealis.theme.lisp"),
Self::Vellum => include_str!("../../themes/vellum.theme.lisp"),
}
}
pub fn load(self) -> tatara_lisp::Result<Theme> {
Theme::from_source(self.source())
}
}
#[cfg(test)]
mod tests {
use super::*;
const NORD: &str = FleetTheme::Nord.source();
fn nord() -> Theme {
Theme::from_source(NORD).expect("the authored Nord theme must compile")
}
#[test]
fn every_keyword_is_what_was_written() {
assert_eq!(Theme::KEYWORD, "deftheme");
assert_eq!(Ramp::KEYWORD, "deframp");
assert_eq!(Blend::KEYWORD, "defblend");
assert_eq!(Derived::KEYWORD, "defderived");
assert_eq!(Ansi16::KEYWORD, "defansi");
assert_eq!(Roles::KEYWORD, "defroles");
assert_eq!(Brand::KEYWORD, "defbrand");
}
#[test]
fn the_authored_nord_theme_compiles_and_validates() {
let t = nord();
assert_eq!(t.name, "nord");
t.validate().expect("authored Nord must validate");
}
#[test]
fn the_ramp_matches_the_fleet_nord_bytes() {
let r = nord().ramp;
for (got, want) in [
(r.base00, "2e3440"),
(r.base01, "3b4252"),
(r.base02, "434c5e"),
(r.base03, "4c566a"),
(r.base04, "d8dee9"),
(r.base05, "e5e9f0"),
(r.base06, "eceff4"),
(r.base07, "8fbcbb"),
(r.base08, "bf616a"),
(r.base09, "d08770"),
(r.base0a, "ebcb8b"),
(r.base0b, "a3be8c"),
(r.base0c, "88c0d0"),
(r.base0d, "81a1c1"),
(r.base0e, "b48ead"),
(r.base0f, "5e81ac"),
] {
assert_eq!(got.to_bare_hex(), want);
}
}
#[test]
fn the_contested_ansi_slots_resolve_to_the_verdict() {
let t = nord();
let a = t.ansi.in_order();
assert_eq!(t.resolve(a[0]).to_bare_hex(), "3b4252", "ANSI black");
assert_eq!(t.resolve(a[6]).to_bare_hex(), "88c0d0", "ANSI cyan");
assert_eq!(t.resolve(a[7]).to_bare_hex(), "e5e9f0", "ANSI white");
assert_eq!(t.resolve(a[14]).to_bare_hex(), "8fbcbb", "ANSI bright-cyan");
assert_ne!(
t.resolve(a[0]),
t.ramp.base00,
"ANSI black must never be the background"
);
}
#[test]
fn every_role_resolves_including_the_derived_ones() {
let t = nord();
assert_eq!(t.roles.selection, Token::Selection);
assert_eq!(t.roles.agent, Token::Glow);
for tok in [t.roles.background, t.roles.selection, t.roles.agent] {
let _ = t.resolve(tok);
}
let sel = t.resolve(Token::Selection);
assert_ne!(sel, t.ramp.base00);
assert_ne!(sel, t.ramp.base0c);
}
#[test]
fn a_hex_literal_in_a_role_position_is_rejected() {
let bad = NORD.replace(":background :base00", r#":background "2E3440""#);
assert_ne!(bad, NORD, "the fixture must actually have been edited");
assert!(
Theme::from_source(&bad).is_err(),
"a hex in a role position must not compile"
);
}
#[test]
fn a_typod_kwarg_is_rejected_with_a_suggestion() {
let bad = NORD.replace(":base0c \"88C0D0\"", ":base0z \"88C0D0\"");
assert_ne!(bad, NORD);
let err = Theme::from_source(&bad)
.expect_err("a typo'd ramp slot must not compile")
.to_string();
assert!(err.contains("base0z"), "must name the bad key: {err}");
}
#[test]
fn an_out_of_range_alpha_is_caught_by_validate_not_by_parse() {
let bad = NORD.replace(":alpha 0.30", ":alpha 1.80");
assert_ne!(bad, NORD);
let t = Theme::from_source(&bad).expect("an out-of-range alpha still parses");
assert!(matches!(
t.validate(),
Err(SpecError::AlphaOutOfRange { .. })
));
}
#[test]
fn nord_is_the_default() {
assert_eq!(FleetTheme::default(), FleetTheme::Nord);
assert_eq!(FleetTheme::default().load().unwrap().name, "nord");
}
#[test]
fn every_fleet_theme_compiles_and_validates() {
for t in FleetTheme::ALL {
let theme = t
.load()
.unwrap_or_else(|e| panic!("{t:?} failed to compile: {e}"));
theme
.validate()
.unwrap_or_else(|e| panic!("{t:?} failed to validate: {e}"));
}
}
#[test]
fn the_three_themes_are_distinctly_named() {
let names: Vec<String> = FleetTheme::ALL
.iter()
.map(|t| t.load().unwrap().name)
.collect();
assert_eq!(names, vec!["nord", "borealis", "vellum"]);
}
#[test]
fn no_two_themes_share_a_ramp() {
let ramps: Vec<(FleetTheme, Ramp)> = FleetTheme::ALL
.iter()
.map(|t| (*t, t.load().unwrap().ramp))
.collect();
for (i, (ta, ra)) in ramps.iter().enumerate() {
for (tb, rb) in ramps.iter().skip(i + 1) {
assert_ne!(ra, rb, "{ta:?} and {tb:?} are the same palette");
}
}
let bg = |t: FleetTheme| t.load().unwrap().ramp.base00;
assert!(
bg(FleetTheme::Vellum).r > bg(FleetTheme::Vellum).b,
"Vellum is warm"
);
assert!(
bg(FleetTheme::Borealis).b > bg(FleetTheme::Borealis).r,
"Borealis is cool"
);
}
#[test]
fn every_theme_resolves_every_role_and_ansi_slot() {
for t in FleetTheme::ALL {
let th = t.load().unwrap();
let r = &th.roles;
for tok in [
r.background,
r.surface,
r.surface_elevated,
r.text,
r.text_muted,
r.text_dim,
r.primary,
r.accent,
r.border,
r.error,
r.warning,
r.success,
r.info,
r.selection,
r.cursor,
r.agent,
] {
let _ = th.resolve(tok);
}
for (i, tok) in th.ansi.in_order().into_iter().enumerate() {
let c = th.resolve(tok);
assert_ne!(
(i, c),
(0, th.ramp.base00),
"{t:?}: ANSI black must never be the background"
);
}
}
}
#[test]
fn the_blend_is_linear_space_not_srgb() {
let night0 = Srgb8 {
r: 0x1F,
g: 0x22,
b: 0x2F,
};
let violet = Srgb8 {
r: 0xB6,
g: 0x9A,
b: 0xE9,
};
let mut t = FleetTheme::Borealis.load().unwrap();
t.ramp.base00 = night0;
t.ramp.base0c = violet;
t.derived.selection = Blend {
over: Token::Base00,
tint: Token::Base0c,
alpha: 0.08,
};
assert_eq!(
t.resolve(Token::Selection).to_bare_hex(),
"3f3955",
"must reproduce ishou's shipped blend_linear output"
);
}
#[test]
fn srgb8_normalises_a_leading_hash_and_rejects_junk() {
assert_eq!(
Srgb8::try_from("#2E3440".to_string())
.unwrap()
.to_bare_hex(),
"2e3440"
);
assert!(Srgb8::try_from("2E344".to_string()).is_err());
assert!(Srgb8::try_from("zzzzzz".to_string()).is_err());
}
}