merman-render 0.8.0-alpha.4

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use std::collections::BTreeMap;

use merman_core::MermaidConfig;

use super::{HostThemePreset, PresentationError, compiler, css_declaration_value, css_font_family};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum HostThemeAppearance {
    #[default]
    Light,
    Dark,
}

impl HostThemeAppearance {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Light => "light",
            Self::Dark => "dark",
        }
    }

    pub const fn is_dark(self) -> bool {
        matches!(self, Self::Dark)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum ThemeRole {
    Canvas,
    Surface,
    SurfaceAlt,
    SurfaceMuted,
    Text,
    SubtleText,
    Border,
    Line,
    EdgeLabelBackground,
    ClusterBackground,
    ClusterBorder,
    NoteBackground,
    NoteBorder,
    NoteText,
    ActorBackground,
    ActorBorder,
    ActorText,
    ActivationBackground,
    ActivationBorder,
    Error,
    Warning,
    Success,
}

impl ThemeRole {
    pub const ALL: [Self; 22] = [
        Self::Canvas,
        Self::Surface,
        Self::SurfaceAlt,
        Self::SurfaceMuted,
        Self::Text,
        Self::SubtleText,
        Self::Border,
        Self::Line,
        Self::EdgeLabelBackground,
        Self::ClusterBackground,
        Self::ClusterBorder,
        Self::NoteBackground,
        Self::NoteBorder,
        Self::NoteText,
        Self::ActorBackground,
        Self::ActorBorder,
        Self::ActorText,
        Self::ActivationBackground,
        Self::ActivationBorder,
        Self::Error,
        Self::Warning,
        Self::Success,
    ];

    pub const fn id(self) -> &'static str {
        match self {
            Self::Canvas => "canvas",
            Self::Surface => "surface",
            Self::SurfaceAlt => "surface-alt",
            Self::SurfaceMuted => "surface-muted",
            Self::Text => "text",
            Self::SubtleText => "subtle-text",
            Self::Border => "border",
            Self::Line => "line",
            Self::EdgeLabelBackground => "edge-label-background",
            Self::ClusterBackground => "cluster-background",
            Self::ClusterBorder => "cluster-border",
            Self::NoteBackground => "note-background",
            Self::NoteBorder => "note-border",
            Self::NoteText => "note-text",
            Self::ActorBackground => "actor-background",
            Self::ActorBorder => "actor-border",
            Self::ActorText => "actor-text",
            Self::ActivationBackground => "activation-background",
            Self::ActivationBorder => "activation-border",
            Self::Error => "error",
            Self::Warning => "warning",
            Self::Success => "success",
        }
    }

    pub fn from_id(id: &str) -> Result<Self, PresentationError> {
        Self::ALL
            .into_iter()
            .find(|role| role.id() == id)
            .ok_or_else(|| PresentationError::UnknownThemeRole(id.to_string()))
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostTheme {
    appearance: Option<HostThemeAppearance>,
    font_family: Option<String>,
    font_size: Option<String>,
    roles: BTreeMap<ThemeRole, String>,
    series_palette: Vec<String>,
}

impl HostTheme {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from_preset(preset: HostThemePreset) -> Self {
        preset.theme()
    }

    pub const fn appearance(&self) -> Option<HostThemeAppearance> {
        self.appearance
    }

    pub fn font_family(&self) -> Option<&str> {
        self.font_family.as_deref()
    }

    pub fn font_size(&self) -> Option<&str> {
        self.font_size.as_deref()
    }

    pub fn role(&self, role: ThemeRole) -> Option<&str> {
        self.roles.get(&role).map(String::as_str)
    }

    pub fn roles(&self) -> impl ExactSizeIterator<Item = (ThemeRole, &str)> {
        self.roles
            .iter()
            .map(|(role, value)| (*role, value.as_str()))
    }

    pub fn series_palette(&self) -> &[String] {
        &self.series_palette
    }

    pub fn with_appearance(mut self, appearance: HostThemeAppearance) -> Self {
        self.appearance = Some(appearance);
        self
    }

    pub fn try_with_font_family(
        mut self,
        font_family: impl Into<String>,
    ) -> Result<Self, PresentationError> {
        self.font_family = Some(css_font_family(font_family, "theme.font_family")?);
        Ok(self)
    }

    pub fn try_with_font_size(
        mut self,
        font_size: impl Into<String>,
    ) -> Result<Self, PresentationError> {
        self.font_size = Some(css_declaration_value(font_size, "theme.font_size")?);
        Ok(self)
    }

    pub fn try_with_role(
        mut self,
        role: ThemeRole,
        value: impl Into<String>,
    ) -> Result<Self, PresentationError> {
        self.roles.insert(
            role,
            css_declaration_value(value, &format!("theme.roles.{}", role.id()))?,
        );
        Ok(self)
    }

    pub fn try_with_series_palette(
        mut self,
        palette: impl IntoIterator<Item = impl Into<String>>,
    ) -> Result<Self, PresentationError> {
        self.series_palette = palette
            .into_iter()
            .enumerate()
            .map(|(index, value)| {
                css_declaration_value(value, &format!("theme.series_palette[{index}]"))
            })
            .collect::<Result<Vec<_>, _>>()?;
        Ok(self)
    }

    pub(crate) fn mermaid_config_patch(&self) -> MermaidConfig {
        compiler::compile(self)
    }

    pub(super) fn bundled(
        appearance: HostThemeAppearance,
        roles: &[(ThemeRole, &str)],
        series_palette: &[&str],
    ) -> Self {
        Self {
            appearance: Some(appearance),
            font_family: Some(
                r#"Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif"#
                    .to_string(),
            ),
            font_size: Some("14px".to_string()),
            roles: roles
                .iter()
                .map(|(role, value)| (*role, (*value).to_string()))
                .collect(),
            series_palette: series_palette
                .iter()
                .map(|value| (*value).to_string())
                .collect(),
        }
    }

    pub(super) fn has_values(&self) -> bool {
        self.appearance.is_some()
            || self.font_family.is_some()
            || self.font_size.is_some()
            || !self.roles.is_empty()
            || !self.series_palette.is_empty()
    }
}