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 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)
}
#[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 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));
}
}