bonbon 0.4.2

A sweet and simple Rust library for generating static diabetes data visualizations.
Documentation
use image::Rgba;
use serde::{Deserialize, Deserializer};
use std::fs::File;
use std::io::Read;
use std::path::Path;

#[derive(Clone, Debug, Deserialize)]
pub struct Theme {
    #[serde(deserialize_with = "deserialize_color")]
    pub background: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub grid_major: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub grid_minor: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub axis_lines: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub text_primary: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub text_secondary: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub text_dim: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub glucose_high: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub glucose_low: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub glucose_in_range: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub insulin: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub carbs: Rgba<u8>,

    #[serde(deserialize_with = "deserialize_color")]
    pub glucose_reading_fill: Rgba<u8>,
    #[serde(deserialize_with = "deserialize_color")]
    pub glucose_reading_outline: Rgba<u8>,
}

macro_rules! builtin_theme {
    ($(#[$doc:meta])* $fn_name:ident, $file:literal) => {
        $(#[$doc])*
        pub fn $fn_name() -> Self {
            let json = include_str!(concat!("./themes/", $file, ".json"));
            serde_json::from_str(json)
                .unwrap_or_else(|e| panic!("Built-in theme `{}` is invalid JSON: {e}", $file))
        }
    };
}

impl Theme {
    pub fn dark() -> Self {
        let json = include_str!("./themes/beetroot_dark.json");
        serde_json::from_str(json).expect("Built-in dark theme is invalid JSON")
    }

    pub fn light() -> Self {
        let json = include_str!("./themes/beetroot_light.json");
        serde_json::from_str(json).expect("Built-in light theme is invalid JSON")
    }

    builtin_theme!(licorice_dark, "licorice_dark");
    builtin_theme!(watermelon_dark, "watermelon_dark");

    // --- Light themes -------------------------------------------------------

    builtin_theme!(paper_light, "paper_light");
    builtin_theme!(ube_light, "ube_light");

    pub fn builtins() -> Vec<(&'static str, Theme)> {
        vec![
            ("beetroot_dark", Self::dark()),
            ("licorice_dark", Self::licorice_dark()),
            ("watermelon_dark", Self::watermelon_dark()),
            ("beetroot_light", Self::light()),
            ("paper_light", Self::paper_light()),
            ("ube_light", Self::ube_light()),
        ]
    }

    /// Load a custom theme from a file path
    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
        let mut file = File::open(path)?;
        let mut contents = String::new();
        file.read_to_string(&mut contents)?;

        let theme: Theme = serde_json::from_str(&contents)?;
        Ok(theme)
    }
}

fn deserialize_color<'de, D>(deserializer: D) -> Result<Rgba<u8>, D::Error>
where
    D: Deserializer<'de>,
{
    let s: String = Deserialize::deserialize(deserializer)?;
    parse_hex_color(&s).map_err(serde::de::Error::custom)
}

fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, String> {
    let hex = hex.trim_start_matches('#');

    match hex.len() {
        6 => {
            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid Red")?;
            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid Green")?;
            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid Blue")?;
            Ok(Rgba([r, g, b, 255]))
        }
        8 => {
            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid Red")?;
            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid Green")?;
            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid Blue")?;
            let a = u8::from_str_radix(&hex[6..8], 16).map_err(|_| "Invalid Alpha")?;
            Ok(Rgba([r, g, b, a]))
        }
        _ => Err(format!(
            "Invalid hex length: {}. Expected 6 (RGB) or 8 (RGBA)",
            hex.len()
        )),
    }
}