makeover-tui 0.6.0

The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side.
Documentation
//! A loaded makeover theme, resolved into the colours ratatui draws with.
//!
//! Behind the `theme` feature, because it is the one thing here that needs
//! `makeover` itself. The rest of this crate takes [`Color`]s it is handed and
//! never asks where they came from, which keeps a consumer that only wants
//! [`frame`](crate::frame) off the theme loader and its embedded theme files.
//!
//! # Why this lives here rather than in each consumer
//!
//! Reading makeover's intents into ratatui `Color`s is the same work every
//! terminal consumer does, and doing it twice is how two of them end up
//! disagreeing about which intent a surface reads from. The mapping is
//! mechanical, the failure mode is silent, and there is exactly one right
//! answer, so it belongs with the renderer.
//!
//! # What is deliberately absent
//!
//! Tokens a consumer derives for itself. `alloy_tui` mixes a `border-subtle`
//! and its own focus-ring `border-strong` out of the authored border, holding
//! the latter to WCAG AA-UI against the page because Alloy spends it as the
//! entire focus cue. makeover emits a `border-strong` too, and it is a flat 5%
//! darkening: a firmer divider, not a focus ring. Those are different tokens
//! wearing one name, and on Akari Dawn they land at 1.63:1 and 3.27:1. This
//! struct carries makeover's, and a consumer that needs its own keeps deriving
//! it. Adopting one for the other would take a focus ring to half its floor.

use makeover::{Rgb, ThemeColors};
use ratatui::style::Color;

/// A theme's polarity, as its author declared it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Light,
    Dark,
    HighContrast,
}

/// A makeover theme's intents, resolved to ratatui colours.
///
/// `#[non_exhaustive]`: this gains a field whenever makeover gains an intent,
/// and without the attribute every one of those would be a major here. Nothing
/// should be building one field-by-field anyway, since [`Theme::from_theme`] is
/// the only way to get one and a partial theme is an error rather than a
/// default.
#[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,

    /// makeover's inset content surface: the surface inside a raised container,
    /// so a list reads as content in a container rather than as bands on a
    /// panel.
    ///
    /// Not [`surface_sunken`](Theme::surface_sunken). A theme is free to author
    /// sunken *darker* than raised while a well always inverts away from the
    /// text, so substituting one for the other lands a well on the wrong side of
    /// its face on exactly the themes where it matters.
    ///
    /// `None` where makeover derived nothing, which is a theme authoring no
    /// raised surface or no content colour. Left missing rather than guessed,
    /// the same way [`Palette::fill`](crate::Palette::fill) answers a missing
    /// well with structure instead of a substitute colour.
    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,

    /// The authored border colour.
    pub line_border: Color,
    /// makeover's derived firmer divider: the authored border, 5% darker.
    ///
    /// A divider, not a focus ring. See the module header before spending it as
    /// one.
    pub border_strong: Color,

    /// The lit and shadowed edges of a raised surface.
    ///
    /// A control is lit from the top left, so its top and left edges take
    /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
    /// two recesses it, which is what a pressed state and a text well are. The
    /// light source does not flip with polarity, or the rule stops transferring
    /// between widgets, which is the whole reason to have one.
    pub bevel_light: Color,
    pub bevel_dark: Color,

    pub category: [Color; 6],
}

/// Why a theme could not be resolved.
///
/// Both variants name the key, because "the theme is bad" is not something a
/// user can act on and "the theme is missing `content.muted`" is.
#[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 {
    /// Resolve a loaded [`ThemeColors`] into the colours ratatui draws with.
    ///
    /// Every intent this struct names is required, apart from
    /// [`surface_well`](Theme::surface_well), which makeover derives only when
    /// the theme gave it enough to derive from. A malformed or partial theme is
    /// rejected rather than papered over with defaults: rendering in colours
    /// that appear nowhere in the theme file is worse than refusing to render.
    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(),
            })
        };

        // The bevel pair, the well and the firm border are makeover's derived
        // intents, so a console, a webview and an egui app light a raised
        // surface the same way. Read through `resolve` rather than recomputed
        // here, which is the point of them living in that crate.
        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")?),
            ],
        })
    }

    /// The depth-painting palette this theme implies, at `fidelity`.
    ///
    /// The bridge between the two halves of this crate: [`Theme`] is what a
    /// theme file says, [`Palette`](crate::Palette) is the subset
    /// [`frame`](crate::frame) and [`paint_bevel`](crate::paint_bevel) need. A
    /// consumer holding a `Theme` should not be assembling that by hand and
    /// picking the wrong surface for the well.
    #[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() {
        // The point of rejecting a partial theme is that it never happens to a
        // theme we ship. If one of these stops resolving, that is a real gap in
        // the theme file, not a reason to soften the error.
        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() {
        // The substitution this crate deleted from the description, asserted
        // absent here too: a theme authoring sunken darker than raised would
        // land the well on the wrong side of its face.
        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));
    }
}