kitmd 0.2.0

A terminal-based markdown and mermaid renderer/viewer using the Kitty graphics protocol
use serde::{Deserialize, Serialize};

const MERMAID_GIT_COLORS: [&str; 8] = [
    "hsl(240, 100%, 46.2745098039%)",
    "hsl(60, 100%, 43.5294117647%)",
    "hsl(80, 100%, 46.2745098039%)",
    "hsl(210, 100%, 46.2745098039%)",
    "hsl(180, 100%, 46.2745098039%)",
    "hsl(150, 100%, 46.2745098039%)",
    "hsl(300, 100%, 46.2745098039%)",
    "hsl(0, 100%, 46.2745098039%)",
];

const MERMAID_GIT_INV_COLORS: [&str; 8] = [
    "hsl(60, 100%, 3.7254901961%)",
    "rgb(0, 0, 160.5)",
    "rgb(48.8333333334, 0, 146.5000000001)",
    "rgb(146.5000000001, 73.2500000001, 0)",
    "rgb(146.5000000001, 0, 0)",
    "rgb(146.5000000001, 0, 73.2500000001)",
    "rgb(0, 146.5000000001, 0)",
    "rgb(0, 146.5000000001, 146.5000000001)",
];

const MERMAID_GIT_BRANCH_LABEL_COLORS: [&str; 8] = [
    "#ffffff", "black", "black", "#ffffff", "black", "black", "black", "black",
];

const MERMAID_GIT_COMMIT_LABEL_COLOR: &str = "#000021";
const MERMAID_GIT_COMMIT_LABEL_BG: &str = "#ffffde";
const MERMAID_GIT_TAG_LABEL_COLOR: &str = "#131300";
const MERMAID_GIT_TAG_LABEL_BG: &str = "#ECECFF";
const MERMAID_GIT_TAG_LABEL_BORDER: &str = "hsl(240, 60%, 86.2745098039%)";
const MERMAID_TEXT_COLOR: &str = "#333";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Theme {
    pub font_family: String,
    pub font_size: f32,
    pub primary_color: String,
    pub primary_text_color: String,
    pub primary_border_color: String,
    pub line_color: String,
    pub secondary_color: String,
    pub tertiary_color: String,
    pub edge_label_background: String,
    pub cluster_background: String,
    pub cluster_border: String,
    pub background: String,
    pub sequence_actor_fill: String,
    pub sequence_actor_border: String,
    pub sequence_actor_line: String,
    pub sequence_note_fill: String,
    pub sequence_note_border: String,
    pub sequence_activation_fill: String,
    pub sequence_activation_border: String,
    pub text_color: String,
    pub git_colors: [String; 8],
    pub git_inv_colors: [String; 8],
    pub git_branch_label_colors: [String; 8],
    pub git_commit_label_color: String,
    pub git_commit_label_background: String,
    pub git_tag_label_color: String,
    pub git_tag_label_background: String,
    pub git_tag_label_border: String,
    pub pie_colors: [String; 12],
    pub pie_title_text_size: f32,
    pub pie_title_text_color: String,
    pub pie_section_text_size: f32,
    pub pie_section_text_color: String,
    pub pie_legend_text_size: f32,
    pub pie_legend_text_color: String,
    pub pie_stroke_color: String,
    pub pie_stroke_width: f32,
    pub pie_outer_stroke_width: f32,
    pub pie_outer_stroke_color: String,
    pub pie_opacity: f32,
}

impl Theme {
    pub fn mermaid_default() -> Self {
        let primary_color = "#ECECFF".to_string();
        let secondary_color = "#FFFFDE".to_string();
        let tertiary_color = "#ECECFF".to_string();
        let pie_colors = default_pie_colors(&primary_color, &secondary_color, &tertiary_color);
        Self {
            font_family: "'trebuchet ms', verdana, arial, \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\"".to_string(),
            font_size: 16.0,
            primary_color,
            primary_text_color: "#333333".to_string(),
            primary_border_color: "#7B88A8".to_string(),
            line_color: "#2F3B4D".to_string(),
            secondary_color,
            tertiary_color,
            edge_label_background: "rgba(248,250,252, 0.92)".to_string(),
            cluster_background: "#FFFFDE".to_string(),
            cluster_border: "#AAAA33".to_string(),
            background: "#FFFFFF".to_string(),
            sequence_actor_fill: "#EAEAEA".to_string(),
            sequence_actor_border: "#666666".to_string(),
            sequence_actor_line: "#999999".to_string(),
            sequence_note_fill: "#FFF5AD".to_string(),
            sequence_note_border: "#AAAA33".to_string(),
            sequence_activation_fill: "#F4F4F4".to_string(),
            sequence_activation_border: "#666666".to_string(),
            text_color: MERMAID_TEXT_COLOR.to_string(),
            git_colors: MERMAID_GIT_COLORS.map(|value| value.to_string()),
            git_inv_colors: MERMAID_GIT_INV_COLORS.map(|value| value.to_string()),
            git_branch_label_colors: MERMAID_GIT_BRANCH_LABEL_COLORS.map(|value| value.to_string()),
            git_commit_label_color: MERMAID_GIT_COMMIT_LABEL_COLOR.to_string(),
            git_commit_label_background: MERMAID_GIT_COMMIT_LABEL_BG.to_string(),
            git_tag_label_color: MERMAID_GIT_TAG_LABEL_COLOR.to_string(),
            git_tag_label_background: MERMAID_GIT_TAG_LABEL_BG.to_string(),
            git_tag_label_border: MERMAID_GIT_TAG_LABEL_BORDER.to_string(),
            pie_colors,
            pie_title_text_size: 25.0,
            pie_title_text_color: MERMAID_TEXT_COLOR.to_string(),
            pie_section_text_size: 17.0,
            pie_section_text_color: MERMAID_TEXT_COLOR.to_string(),
            pie_legend_text_size: 17.0,
            pie_legend_text_color: MERMAID_TEXT_COLOR.to_string(),
            pie_stroke_color: "#000000".to_string(),
            pie_stroke_width: 2.0,
            pie_outer_stroke_width: 2.0,
            pie_outer_stroke_color: "#000000".to_string(),
            pie_opacity: 0.7,
        }
    }

    pub fn modern() -> Self {
        let primary_color = "#F8FAFC".to_string();
        let secondary_color = "#E2E8F0".to_string();
        let tertiary_color = "#FFFFFF".to_string();
        let pie_colors = default_pie_colors(&primary_color, &secondary_color, &tertiary_color);
        Self {
            font_family: "Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\""
                .to_string(),
            font_size: 14.0,
            primary_color,
            primary_text_color: "#0F172A".to_string(),
            primary_border_color: "#94A3B8".to_string(),
            line_color: "#64748B".to_string(),
            secondary_color,
            tertiary_color,
            edge_label_background: "#FFFFFF".to_string(),
            cluster_background: "#F1F5F9".to_string(),
            cluster_border: "#CBD5E1".to_string(),
            background: "#FFFFFF".to_string(),
            sequence_actor_fill: "#F8FAFC".to_string(),
            sequence_actor_border: "#94A3B8".to_string(),
            sequence_actor_line: "#64748B".to_string(),
            sequence_note_fill: "#FFF7ED".to_string(),
            sequence_note_border: "#FDBA74".to_string(),
            sequence_activation_fill: "#E2E8F0".to_string(),
            sequence_activation_border: "#94A3B8".to_string(),
            text_color: "#0F172A".to_string(),
            git_colors: MERMAID_GIT_COLORS.map(|value| value.to_string()),
            git_inv_colors: MERMAID_GIT_INV_COLORS.map(|value| value.to_string()),
            git_branch_label_colors: MERMAID_GIT_BRANCH_LABEL_COLORS.map(|value| value.to_string()),
            git_commit_label_color: MERMAID_GIT_COMMIT_LABEL_COLOR.to_string(),
            git_commit_label_background: MERMAID_GIT_COMMIT_LABEL_BG.to_string(),
            git_tag_label_color: MERMAID_GIT_TAG_LABEL_COLOR.to_string(),
            git_tag_label_background: MERMAID_GIT_TAG_LABEL_BG.to_string(),
            git_tag_label_border: MERMAID_GIT_TAG_LABEL_BORDER.to_string(),
            pie_colors,
            pie_title_text_size: 25.0,
            pie_title_text_color: "#0F172A".to_string(),
            pie_section_text_size: 17.0,
            pie_section_text_color: "#0F172A".to_string(),
            pie_legend_text_size: 17.0,
            pie_legend_text_color: "#0F172A".to_string(),
            pie_stroke_color: "#334155".to_string(),
            pie_stroke_width: 1.6,
            pie_outer_stroke_width: 1.6,
            pie_outer_stroke_color: "#CBD5E1".to_string(),
            pie_opacity: 0.85,
        }
    }
}

fn default_pie_colors(primary: &str, secondary: &str, tertiary: &str) -> [String; 12] {
    [
        primary.to_string(),
        secondary.to_string(),
        tertiary.to_string(),
        adjust_color(primary, 0.0, 0.0, -10.0),
        adjust_color(secondary, 0.0, 0.0, -10.0),
        adjust_color(tertiary, 0.0, 0.0, -10.0),
        adjust_color(primary, 60.0, 0.0, -10.0),
        adjust_color(primary, -60.0, 0.0, -10.0),
        adjust_color(primary, 120.0, 0.0, 0.0),
        adjust_color(primary, 60.0, 0.0, -20.0),
        adjust_color(primary, -60.0, 0.0, -20.0),
        adjust_color(primary, 120.0, 0.0, -10.0),
    ]
}

pub(crate) fn adjust_color(color: &str, delta_h: f32, delta_s: f32, delta_l: f32) -> String {
    let Some((h, s, l)) = parse_color_to_hsl(color) else {
        return color.to_string();
    };
    let mut h = h + delta_h;
    if h < 0.0 {
        h = (h % 360.0) + 360.0;
    } else if h >= 360.0 {
        h %= 360.0;
    }
    let s = (s + delta_s).clamp(0.0, 100.0);
    let l = (l + delta_l).clamp(0.0, 100.0);
    format!("hsl({:.10}, {:.10}%, {:.10}%)", h, s, l)
}

pub(crate) fn parse_color_to_hsl(color: &str) -> Option<(f32, f32, f32)> {
    let color = color.trim();
    if let Some(hsl) = parse_hsl(color) {
        return Some(hsl);
    }
    let rgb = parse_hex(color)?;
    Some(rgb_to_hsl(rgb.0, rgb.1, rgb.2))
}

fn parse_hsl(value: &str) -> Option<(f32, f32, f32)> {
    let value = value.trim();
    let open = value.find('(')?;
    let close = value.rfind(')')?;
    let prefix = value[..open].trim().to_ascii_lowercase();
    if prefix != "hsl" && prefix != "hsla" {
        return None;
    }
    let inner = &value[open + 1..close];
    let parts: Vec<&str> = inner.split(',').collect();
    if parts.len() < 3 {
        return None;
    }
    let h = parts[0].trim().parse::<f32>().ok()?;
    let s = parts[1].trim().trim_end_matches('%').parse::<f32>().ok()?;
    let l = parts[2].trim().trim_end_matches('%').parse::<f32>().ok()?;
    Some((h, s, l))
}

fn parse_hex(value: &str) -> Option<(f32, f32, f32)> {
    let hex = value.strip_prefix('#')?;
    if !hex.is_ascii() {
        return None;
    }
    let digits = match hex.len() {
        3 => {
            let mut expanded = String::new();
            for ch in hex.chars() {
                expanded.push(ch);
                expanded.push(ch);
            }
            expanded
        }
        6 => hex.to_string(),
        8 => hex[..6].to_string(),
        _ => return None,
    };
    let r = u8::from_str_radix(&digits[0..2], 16).ok()?;
    let g = u8::from_str_radix(&digits[2..4], 16).ok()?;
    let b = u8::from_str_radix(&digits[4..6], 16).ok()?;
    Some((r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0))
}

fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
    let max = r.max(g.max(b));
    let min = r.min(g.min(b));
    let mut h = 0.0;
    let l = (max + min) / 2.0;
    let d = max - min;
    let s = if d == 0.0 {
        0.0
    } else {
        d / (1.0 - (2.0 * l - 1.0).abs())
    };
    if d != 0.0 {
        if max == r {
            h = ((g - b) / d) % 6.0;
        } else if max == g {
            h = (b - r) / d + 2.0;
        } else {
            h = (r - g) / d + 4.0;
        }
        h *= 60.0;
        if h < 0.0 {
            h += 360.0;
        }
    }
    (h, s * 100.0, l * 100.0)
}

#[cfg(all(test, feature = "mermaid_engine_internal_tests"))]
mod tests {
    use super::*;

    #[test]
    fn parse_hex_rejects_multibyte_utf8() {
        // 3-byte char
        assert_eq!(parse_hex("#\u{1000}"), None);
        // 2-byte char inside a 6-byte string
        assert_eq!(parse_hex("#a\u{00FF}bcd"), None);
        // 2-byte char inside an 8-byte string
        assert_eq!(parse_hex("#abcde\u{0100}f"), None);
    }

    #[test]
    fn parse_hex_valid_colors() {
        assert_eq!(parse_hex("#fff"), Some((1.0, 1.0, 1.0)));
        assert_eq!(parse_hex("#ff0000"), Some((1.0, 0.0, 0.0)));
        assert_eq!(parse_hex("#00ff0080"), Some((0.0, 1.0, 0.0)));
    }
}