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 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 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(authored("action.primary")?),
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),
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)
}
#[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
);
}
}
#[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));
}
}