use makeover::{Rgb, ThemeColors};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Light,
Dark,
HighContrast,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct Theme {
pub mode: Mode,
pub surface_page: Color,
pub surface_raised: Color,
pub surface_sunken: Color,
pub surface_overlay: Color,
pub surface_well: Option<Color>,
pub content_primary: Color,
pub content_secondary: Color,
pub content_muted: Color,
pub action_primary: Color,
pub status_danger: Color,
pub status_success: Color,
pub status_warning: Color,
pub status_info: Color,
pub line_border: Color,
pub border_strong: Color,
pub selection_on: Color,
pub focus_ring: Color,
pub bevel_light: Color,
pub bevel_dark: Color,
pub category: [Color; 6],
}
#[derive(Debug, Clone)]
pub enum ThemeError {
MissingKey(&'static str),
InvalidHex { key: &'static str, value: String },
}
impl std::fmt::Display for ThemeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
Self::InvalidHex { key, value } => {
write!(f, "theme key `{key}` has invalid hex value `{value}`")
}
}
}
}
impl std::error::Error for ThemeError {}
impl Theme {
pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
let authored = |key: &'static str| -> Result<Rgb, ThemeError> {
let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
key,
value: hex.clone(),
})
};
let resolved = makeover::resolve(theme);
let derived = |key: &'static str| -> Result<Rgb, ThemeError> {
let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
key,
value: hex.to_string(),
})
};
let accent = authored("action.primary")?;
let page = authored("surface.page")?;
let selection_on = on_accent(accent, page, authored("content.primary")?);
let mode = match theme.meta.variant.as_str() {
"dark" => Mode::Dark,
"high-contrast" => Mode::HighContrast,
_ => Mode::Light,
};
Ok(Self {
mode,
surface_page: rgb(authored("surface.page")?),
surface_raised: rgb(authored("surface.raised")?),
surface_sunken: rgb(authored("surface.sunken")?),
surface_overlay: rgb(authored("surface.overlay")?),
surface_well: resolved
.hex("surface-well")
.and_then(Rgb::from_hex)
.map(rgb),
content_primary: rgb(authored("content.primary")?),
content_secondary: rgb(authored("content.secondary")?),
content_muted: rgb(authored("content.muted")?),
action_primary: rgb(accent),
selection_on: rgb(selection_on),
focus_ring: rgb(focus_ring(accent, page)),
status_danger: rgb(authored("status.danger")?),
status_success: rgb(authored("status.success")?),
status_warning: rgb(authored("status.warning")?),
status_info: rgb(authored("status.info")?),
line_border: rgb(authored("line.border")?),
border_strong: rgb(derived("border-strong")?),
bevel_light: rgb(derived("bevel-light")?),
bevel_dark: rgb(derived("bevel-dark")?),
category: [
rgb(authored("category.one")?),
rgb(authored("category.two")?),
rgb(authored("category.three")?),
rgb(authored("category.four")?),
rgb(authored("category.five")?),
rgb(authored("category.six")?),
],
})
}
#[must_use]
pub fn for_terminal(self, fidelity: crate::Fidelity) -> Self {
let Some(q) = Quantize::for_fidelity(fidelity) else {
return self;
};
let plain = |c: Color| q.plain(c);
let on_page = |c: Color| q.against(c, self.surface_page);
Self {
mode: self.mode,
surface_page: plain(self.surface_page),
surface_raised: plain(self.surface_raised),
surface_sunken: plain(self.surface_sunken),
surface_overlay: plain(self.surface_overlay),
surface_well: self.surface_well.map(plain),
content_primary: on_page(self.content_primary),
content_secondary: on_page(self.content_secondary),
content_muted: on_page(self.content_muted),
action_primary: on_page(self.action_primary),
selection_on: q.against(self.selection_on, self.action_primary),
focus_ring: on_page(self.focus_ring),
status_danger: on_page(self.status_danger),
status_success: on_page(self.status_success),
status_warning: on_page(self.status_warning),
status_info: on_page(self.status_info),
line_border: on_page(self.line_border),
border_strong: on_page(self.border_strong),
bevel_light: plain(self.bevel_light),
bevel_dark: plain(self.bevel_dark),
category: self.category.map(on_page),
}
}
#[must_use]
pub const fn palette(&self, fidelity: crate::Fidelity) -> crate::Palette {
crate::Palette {
page: self.surface_page,
raised: self.surface_raised,
overlay: self.surface_overlay,
well: self.surface_well,
bevel_light: self.bevel_light,
bevel_dark: self.bevel_dark,
fidelity,
}
}
}
fn rgb(c: Rgb) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
const TEXT_FLOOR: f32 = 4.5;
const UI_FLOOR: f32 = 3.0;
fn on_accent(accent: Rgb, page: Rgb, content: Rgb) -> Rgb {
let best = if makeover::wcag_contrast(page, accent) >= makeover::wcag_contrast(content, accent)
{
page
} else {
content
};
if makeover::wcag_contrast(best, accent) >= TEXT_FLOOR {
best
} else {
makeover::readable_on(accent)
}
}
fn focus_ring(accent: Rgb, page: Rgb) -> Rgb {
if makeover::wcag_contrast(accent, page) >= UI_FLOOR {
return accent;
}
let page_is_dark = makeover::wcag_contrast(
Rgb {
r: 255,
g: 255,
b: 255,
},
page,
) > makeover::wcag_contrast(Rgb { r: 0, g: 0, b: 0 }, page);
let mut candidate = accent;
for _ in 0..20 {
candidate = if page_is_dark {
makeover::lighten(candidate, 0.05)
} else {
makeover::darken(candidate, 0.05)
};
if makeover::wcag_contrast(candidate, page) >= UI_FLOOR {
return candidate;
}
}
candidate
}
#[derive(Debug, Clone, Copy)]
pub struct Quantize {
palette: &'static [Rgb],
offset: usize,
}
impl Quantize {
#[must_use]
pub const fn for_fidelity(fidelity: crate::Fidelity) -> Option<Self> {
match fidelity {
crate::Fidelity::TrueColor => None,
crate::Fidelity::Ansi256 => Some(Self {
palette: makeover::ANSI_240,
offset: makeover::ANSI_240_OFFSET,
}),
crate::Fidelity::Ansi16 => Some(Self {
palette: &makeover::ANSI_16,
offset: 0,
}),
}
}
#[must_use]
pub fn plain(&self, c: Color) -> Color {
match c {
Color::Rgb(r, g, b) => Color::Indexed(
(makeover::quantize(Rgb { r, g, b }, self.palette) + self.offset) as u8,
),
other => other,
}
}
#[must_use]
pub fn against(&self, c: Color, on: Color) -> Color {
match (c, on) {
(Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
(makeover::quantize_against(
Rgb { r, g, b },
Rgb {
r: br,
g: bg,
b: bb,
},
self.palette,
) + self.offset) as u8,
),
_ => self.plain(c),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bundled(id: &str) -> ThemeColors {
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
makeover::load_theme(&[(dir, false)], id).expect("bundled theme loads")
}
#[test]
fn every_bundled_theme_resolves() {
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
let metas = makeover::list_themes_from_dirs(&[(dir, false)]);
assert!(
!metas.is_empty(),
"makeover shipped no themes to test against"
);
for meta in &metas {
let colors = bundled(&meta.id);
assert!(
Theme::from_theme(&colors).is_ok(),
"bundled theme `{}` failed to resolve",
meta.id
);
}
}
fn to_rgb(c: Color) -> Rgb {
match c {
Color::Rgb(r, g, b) => Rgb { r, g, b },
other => panic!("expected a resolved colour, got {other:?}"),
}
}
#[test]
fn the_two_derived_cues_are_legible_in_every_bundled_theme() {
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
for meta in makeover::list_themes_from_dirs(&[(dir, false)]) {
let theme = Theme::from_theme(&bundled(&meta.id)).expect("resolves");
let selection =
makeover::wcag_contrast(to_rgb(theme.selection_on), to_rgb(theme.action_primary));
assert!(
selection >= TEXT_FLOOR,
"{}: selection_on is {selection:.2} on the accent",
meta.id
);
let ring =
makeover::wcag_contrast(to_rgb(theme.focus_ring), to_rgb(theme.surface_page));
assert!(
ring >= UI_FLOOR,
"{}: focus_ring is {ring:.2} on the page",
meta.id
);
assert_ne!(theme.focus_ring, theme.border_strong, "{}", meta.id);
}
}
#[test]
fn the_derived_cues_are_quantised_for_the_surface_each_sits_on() {
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
let ansi16 = theme.for_terminal(crate::Fidelity::Ansi16);
let q = Quantize::for_fidelity(crate::Fidelity::Ansi16).expect("Ansi16 quantises");
assert_eq!(
ansi16.selection_on,
q.against(theme.selection_on, theme.action_primary)
);
assert_eq!(
ansi16.focus_ring,
q.against(theme.focus_ring, theme.surface_page)
);
}
#[test]
fn a_missing_intent_names_the_key_it_wanted() {
let mut colors = bundled("goingson");
colors.colors.remove("content.muted");
match Theme::from_theme(&colors) {
Err(ThemeError::MissingKey(k)) => assert_eq!(k, "content.muted"),
other => panic!("expected MissingKey(content.muted), got {other:?}"),
}
}
#[test]
fn an_unparseable_hex_names_the_key_and_the_value() {
let mut colors = bundled("goingson");
colors
.colors
.insert("content.muted".into(), "not-a-colour".into());
match Theme::from_theme(&colors) {
Err(ThemeError::InvalidHex { key, value }) => {
assert_eq!(key, "content.muted");
assert_eq!(value, "not-a-colour");
}
other => panic!("expected InvalidHex, got {other:?}"),
}
}
#[test]
fn a_capable_terminal_gets_the_theme_as_authored() {
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
let same = theme.for_terminal(crate::Fidelity::TrueColor);
assert_eq!(same.surface_page, theme.surface_page);
assert_eq!(same.content_primary, theme.content_primary);
assert!(matches!(same.surface_page, Color::Rgb(..)));
}
#[test]
fn a_limited_terminal_gets_indices_rather_than_rgb() {
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
for fidelity in [crate::Fidelity::Ansi16, crate::Fidelity::Ansi256] {
let q = theme.for_terminal(fidelity);
assert!(
matches!(q.surface_page, Color::Indexed(_)),
"{fidelity:?} left a surface as rgb"
);
assert!(
matches!(q.content_primary, Color::Indexed(_)),
"{fidelity:?} left content as rgb"
);
}
}
#[test]
fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
let q = theme.for_terminal(crate::Fidelity::Ansi256);
for (name, c) in [
("surface_page", q.surface_page),
("content_primary", q.content_primary),
("bevel_light", q.bevel_light),
("bevel_dark", q.bevel_dark),
] {
match c {
Color::Indexed(i) => assert!(i >= 16, "{name} landed on repaintable index {i}"),
other => panic!("{name} was not quantised: {other:?}"),
}
}
}
#[test]
fn the_bevel_pair_stays_two_tones_at_256() {
let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
let q = theme.for_terminal(crate::Fidelity::Ansi256);
assert_ne!(q.bevel_light, q.bevel_dark);
}
#[test]
fn the_palette_takes_the_well_and_not_the_sunken_surface() {
let colors = bundled("goingson");
let theme = Theme::from_theme(&colors).expect("resolves");
let palette = theme.palette(crate::Fidelity::TrueColor);
assert_eq!(palette.well, theme.surface_well);
assert_ne!(palette.well, Some(theme.surface_sunken));
}
}