chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

875 lines · 31.4 KB Raw History
//! Social cards: the picture a link to a page unfurls into, in a chat or a
//! post. Each is an SVG written here, as the pages are, and rasterized in
//! process: no browser, and no fonts but those compiled in, Inter and Noto
//! Color Emoji for what Inter lacks, so a card looks the same on every host.
//!
//! A card's URL carries a hash of its SVG, so the picture can be cached
//! forever: when what it shows changes, so does the URL its page names.

use resvg::{tiny_skia, usvg};
use sha2::{Digest, Sha256};
use skrifa::MetadataProvider;
use std::collections::{HashMap, VecDeque};
use std::fmt::Write;
use std::sync::{Arc, LazyLock, Mutex};

/// Icons, each a 16-unit path: GitHub's Octicons, as the pages draw them.
mod icon {
    pub const ISSUE: &str = "M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z";
    pub const DONE: &str = "M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5ZM16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z";
    pub const PULL: &str = "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z";
    pub const PULL_CLOSED: &str = "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z";
    pub const MERGE: &str = "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z";
}

fn esc(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            c => out.push(c),
        }
    }
    out
}

pub const WIDTH: u32 = 1200;
pub const HEIGHT: u32 = 630;
const PAD: f32 = 80.0;
/// Where the text of the last row sits.
const FOOT: f32 = 550.0;

const FG: &str = "#1c1d20";
const MUTE: &str = "#6a6d75";
const ACCENT: &str = "#2f6af5";

static REGULAR: &[u8] = include_bytes!("../fonts/InterDisplay-Regular.ttf");
static SEMIBOLD: &[u8] = include_bytes!("../fonts/InterDisplay-SemiBold.ttf");
static EMOJI: &[u8] = include_bytes!("../fonts/NotoColorEmoji-COLRv1.ttf");

/// Read where they are compiled in, not copied.
static FONTS: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
    let mut db = usvg::fontdb::Database::new();
    for data in [REGULAR, SEMIBOLD, EMOJI] {
        db.load_font_source(usvg::fontdb::Source::Binary(Arc::new(data)));
    }
    Arc::new(db)
});

fn emoji() -> skrifa::FontRef<'static> {
    skrifa::FontRef::new(EMOJI).expect("the fonts compiled in parse")
}

#[derive(Clone, Copy)]
enum Weight {
    Regular,
    Semibold,
}

impl Weight {
    fn font(self) -> skrifa::FontRef<'static> {
        let data = match self {
            Weight::Regular => REGULAR,
            Weight::Semibold => SEMIBOLD,
        };
        skrifa::FontRef::new(data).expect("the fonts compiled in parse")
    }

    fn css(self) -> u16 {
        match self {
            Weight::Regular => 400,
            Weight::Semibold => 600,
        }
    }
}

/// Whether `c` draws as part of the character before it, `prev`, rather
/// than on its own: the joiners, selectors, skin tones and tags of an emoji
/// sequence, a keycap, what a joiner joins, and the second letter of a flag
/// (`flag` being whether `prev` is a flag's first).
fn joins(prev: Option<char>, c: char, flag: bool) -> bool {
    let Some(prev) = prev else {
        return false;
    };
    matches!(
        c,
        '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{20E3}' | '\u{1F3FB}'..='\u{1F3FF}' | '\u{E0020}'..='\u{E007F}'
    ) || prev == '\u{200D}'
        || (flag && is_flag_letter(c))
}

fn is_flag_letter(c: char) -> bool {
    ('\u{1F1E6}'..='\u{1F1FF}').contains(&c)
}

/// `text` split where a line may break inside a word: between the
/// characters drawn apart, each with the sequence it heads.
fn clusters(text: &str) -> Vec<&str> {
    let mut out = Vec::new();
    let mut start = 0;
    let mut prev = None;
    let mut flag = false;
    for (i, c) in text.char_indices() {
        if joins(prev, c, flag) {
            flag = false;
        } else {
            if i > start {
                out.push(&text[start..i]);
            }
            start = i;
            flag = is_flag_letter(c);
        }
        prev = Some(c);
    }
    if start < text.len() {
        out.push(&text[start..]);
    }
    out
}

/// How wide `text` is set at `size`: each cluster as wide as its first
/// character, in Inter, or the emoji if Inter lacks it. Kerning is left
/// out, which errs wide.
fn measure(text: &str, size: f32, weight: Weight) -> f32 {
    let size = skrifa::instance::Size::new(size);
    let at = skrifa::instance::LocationRef::default();
    let (text_font, emoji) = (weight.font(), emoji());
    let (text_map, emoji_map) = (text_font.charmap(), emoji.charmap());
    let text_metrics = text_font.glyph_metrics(size, at);
    let emoji_metrics = emoji.glyph_metrics(size, at);
    clusters(text)
        .into_iter()
        .map(|cluster| {
            let c = cluster.chars().next().expect("a cluster is not empty");
            let advance = match (text_map.map(c), emoji_map.map(c)) {
                (None, Some(glyph)) => emoji_metrics.advance_width(glyph),
                // What Inter lacks and the emoji too is drawn as Inter's box.
                (glyph, _) => text_metrics.advance_width(glyph.unwrap_or_default()),
            };
            advance.unwrap_or_default()
        })
        .sum()
}

/// `text` broken into at most `max` lines no wider than `width`, the last
/// ending in an ellipsis if it had to be cut.
fn wrap(text: &str, size: f32, weight: Weight, width: f32, max: usize) -> Vec<String> {
    let fits = |s: &str| measure(s, size, weight) <= width;
    let mut lines: Vec<String> = Vec::new();
    let mut line = String::new();
    for word in text.split_whitespace() {
        if lines.len() > max {
            break;
        }
        let joined = format!("{line} {word}");
        let joined = joined.trim_start();
        if fits(joined) {
            line = joined.to_string();
            continue;
        }
        if !line.is_empty() {
            lines.push(std::mem::take(&mut line));
        }
        // A word wider than a line is broken where it overflows, between
        // what is drawn apart.
        for cluster in clusters(word) {
            let alone = line.is_empty();
            line.push_str(cluster);
            if !alone && !fits(&line) {
                line.truncate(line.len() - cluster.len());
                lines.push(std::mem::replace(&mut line, cluster.to_string()));
            }
        }
    }
    if !line.is_empty() {
        lines.push(line);
    }
    if lines.len() > max {
        lines.truncate(max);
        let last = lines.last_mut().expect("max is at least one");
        while !fits(&format!("{last}…")) && !last.is_empty() {
            let end = clusters(last).last().expect("not empty").len();
            last.truncate(last.len() - end);
        }
        let kept = last.trim_end().len();
        last.truncate(kept);
        last.push('…');
    }
    lines
}

/// Whether `c` is drawn from the emoji, as Inter lacks it.
fn is_emoji(c: char) -> bool {
    Weight::Regular.font().charmap().map(c).is_none() && emoji().charmap().map(c).is_some()
}

/// `text`, left on `x`, escaped: in Inter, and each run of what Inter
/// lacks in the emoji. Each run is placed on its own, so it is shaped in
/// its font alone: the renderer, given runs in two fonts to shape together,
/// drops sequences of emoji, or fails on them.
fn spans(text: &str, x: f32, size: f32, weight: Weight) -> String {
    // Inter has all of ASCII.
    if text.is_ascii() {
        return esc(text);
    }
    let mut runs: Vec<(bool, String)> = Vec::new();
    for cluster in clusters(text) {
        let emoji = is_emoji(cluster.chars().next().expect("a cluster is not empty"));
        match runs.last_mut() {
            Some((was, run)) if *was == emoji => run.push_str(cluster),
            _ => runs.push((emoji, cluster.to_string())),
        }
    }
    if runs.iter().all(|(emoji, _)| !emoji) {
        return esc(text);
    }
    let mut out = String::new();
    let mut x = x;
    for (emoji, run) in runs {
        let family = if emoji {
            r#" font-family="Noto Color Emoji""#
        } else {
            ""
        };
        write!(out, r#"<tspan x="{x}"{family}>{}</tspan>"#, esc(&run)).unwrap();
        x += measure(&run, size, weight);
    }
    out
}

/// Lines of text, `leading` apart, the first on `y`.
fn lines(
    out: &mut String,
    lines: &[String],
    x: f32,
    y: f32,
    leading: f32,
    (size, weight, fill): (f32, Weight, &str),
) {
    for (i, line) in lines.iter().enumerate() {
        write!(
            out,
            r#"<text x="{x}" y="{}" {}>{}</text>"#,
            y + leading * i as f32,
            style(size, weight, fill),
            spans(line, x, size, weight)
        )
        .unwrap();
    }
}

fn style(size: f32, weight: Weight, fill: &str) -> String {
    format!(
        r#"font-size="{size}" font-weight="{}" fill="{fill}""#,
        weight.css()
    )
}

/// A 16-unit icon, `size` across, its top left at `x`, `y`.
fn icon(out: &mut String, path: &str, x: f32, y: f32, size: f32, fill: &str) {
    write!(
        out,
        r#"<path transform="translate({x} {y}) scale({})" fill="{fill}" d="{path}"/>"#,
        size / 16.0
    )
    .unwrap();
}

/// The mark, `size` across, and the name beside it, its left at `x`.
fn brand(out: &mut String, x: f32, baseline: f32, size: f32) {
    let top = baseline - size * 0.84;
    write!(
        out,
        r##"<g transform="translate({x} {top}) scale({})"><rect width="24" height="24" rx="6.5" fill="{FG}"/><path d="M7 12h10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="12" r="2.6" fill="#fff"/><circle cx="17" cy="12" r="2.6" fill="#fff"/></g>"##,
        size / 24.0
    )
    .unwrap();
    write!(
        out,
        r#"<text x="{}" y="{baseline}" {}>knotless</text>"#,
        x + size * 1.3,
        style(size * 0.78, Weight::Semibold, FG)
    )
    .unwrap();
}

/// How wide `brand` draws at `size`.
fn brand_width(size: f32) -> f32 {
    size * 1.3 + measure("knotless", size * 0.78, Weight::Semibold)
}

/// The hue a name's mark is drawn in.
pub(crate) fn hue(name: &str) -> u32 {
    name.bytes()
        .fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32))
        % 360
}

/// The letter a name's mark shows.
pub(crate) fn initial(name: &str) -> char {
    name.chars().find(|c| c.is_alphanumeric()).unwrap_or('?')
}

/// A name's round mark, `r` in radius, centered on `cx`, `cy`.
fn avatar(out: &mut String, name: &str, cx: f32, cy: f32, r: f32) {
    write!(
        out,
        r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="hsl({}, 45%, 52%)"/><text x="{cx}" y="{}" text-anchor="middle" {}>{}</text>"#,
        hue(name),
        cy + r * 0.36,
        style(r, Weight::Semibold, "#fff"),
        esc(&initial(name).to_uppercase().to_string())
    )
    .unwrap();
}

/// The color of a pull's or an issue's state, as the pages draw it.
fn state_color(pull: bool, state: &str) -> &'static str {
    match (pull, state) {
        (_, "open") => "#1a7f37",
        (_, "merged") => "#7048b5",
        (true, _) => "#cf222e",
        (false, _) => "#636363",
    }
}

fn plural(n: usize, one: &str, many: &str) -> String {
    format!("{n} {}", if n == 1 { one } else { many })
}

/// What a card shows.
#[derive(Debug, Clone, PartialEq)]
pub enum Card {
    /// The front page.
    Home,
    Repo {
        owner: String,
        name: String,
        description: Option<String>,
        /// Open pulls and issues.
        pulls: usize,
        issues: usize,
    },
    /// A pull or an issue.
    Item {
        owner: String,
        name: String,
        pull: bool,
        state: String,
        title: String,
        author: String,
        comments: usize,
    },
}

impl Card {
    pub fn svg(&self) -> String {
        let mut out = format!(
            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}"><rect width="{WIDTH}" height="{HEIGHT}" fill="#fff"/>"##
        );
        let wide = WIDTH as f32 - PAD * 2.0;
        let bar = match self {
            Card::Home => {
                brand(&mut out, PAD, 168.0, 88.0);
                let head = wrap(
                    "Git that lives in your atmosphere account.",
                    80.0,
                    Weight::Semibold,
                    wide,
                    2,
                );
                let at = 330.0;
                lines(&mut out, &head, PAD, at, 92.0, (80.0, Weight::Semibold, FG));
                let sub = wrap(
                    "Repositories as records on your own PDS. Clone and push with plain git; agents read, review and merge over MCP.",
                    36.0,
                    Weight::Regular,
                    wide,
                    2,
                );
                lines(
                    &mut out,
                    &sub,
                    PAD,
                    at + 92.0 + 80.0,
                    50.0,
                    (36.0, Weight::Regular, MUTE),
                );
                ACCENT.to_string()
            }
            Card::Repo {
                owner,
                name,
                description,
                pulls,
                issues,
            } => {
                top(&mut out, owner, None);
                let title = wrap(name, 84.0, Weight::Semibold, wide, 2);
                lines(
                    &mut out,
                    &title,
                    PAD,
                    268.0,
                    96.0,
                    (84.0, Weight::Semibold, FG),
                );
                let below = 268.0 + 96.0 * (title.len() - 1) as f32 + 72.0;
                // As many lines as fit above the last row.
                let room = ((470.0 - below) / 48.0) as usize + 1;
                let about = wrap(
                    description.as_deref().unwrap_or_default(),
                    36.0,
                    Weight::Regular,
                    wide,
                    room,
                );
                lines(
                    &mut out,
                    &about,
                    PAD,
                    below,
                    48.0,
                    (36.0, Weight::Regular, MUTE),
                );
                brand(&mut out, PAD, FOOT, 44.0);
                // The counts, from the right edge leftward.
                let mut right = WIDTH as f32 - PAD;
                for (path, text) in [
                    (icon::ISSUE, plural(*issues, "open issue", "open issues")),
                    (icon::PULL, plural(*pulls, "open pull", "open pulls")),
                ] {
                    let w = measure(&text, 32.0, Weight::Regular);
                    write!(
                        out,
                        r#"<text x="{right}" y="{FOOT}" text-anchor="end" {}>{}</text>"#,
                        style(32.0, Weight::Regular, MUTE),
                        text
                    )
                    .unwrap();
                    icon(&mut out, path, right - w - 42.0, FOOT - 28.0, 32.0, MUTE);
                    right -= w + 42.0 + 40.0;
                }
                format!("hsl({}, 45%, 52%)", hue(owner))
            }
            Card::Item {
                owner,
                name,
                pull,
                state,
                title,
                author,
                comments,
            } => {
                top(&mut out, owner, Some(name));
                let head = wrap(title, 72.0, Weight::Semibold, wide, 3);
                lines(
                    &mut out,
                    &head,
                    PAD,
                    262.0,
                    86.0,
                    (72.0, Weight::Semibold, FG),
                );
                let color = state_color(*pull, state);
                let label = {
                    let mut c = state.chars();
                    c.next()
                        .map(|f| f.to_uppercase().chain(c).collect::<String>())
                        .unwrap_or_default()
                };
                let path = match (*pull, state.as_str()) {
                    (true, "merged") => icon::MERGE,
                    (true, "open") => icon::PULL,
                    (true, _) => icon::PULL_CLOSED,
                    (false, "open") => icon::ISSUE,
                    _ => icon::DONE,
                };
                let pill = 70.0 + measure(&label, 30.0, Weight::Semibold) + 28.0;
                write!(
                    out,
                    r#"<rect x="{PAD}" y="{}" width="{pill}" height="60" rx="30" fill="{color}"/>"#,
                    FOOT - 41.0,
                )
                .unwrap();
                let label = [label];
                lines(
                    &mut out,
                    &label,
                    PAD + 70.0,
                    FOOT,
                    0.0,
                    (30.0, Weight::Semibold, "#fff"),
                );
                icon(&mut out, path, PAD + 26.0, FOOT - 27.0, 32.0, "#fff");
                let mark = brand_width(44.0);
                let x = PAD + pill + 28.0;
                let said = plural(*comments, "comment", "comments");
                let room = WIDTH as f32 - PAD - mark - 48.0 - x;
                let by = wrap(
                    &format!("{author} · {said}"),
                    32.0,
                    Weight::Regular,
                    room,
                    1,
                );
                lines(&mut out, &by, x, FOOT, 0.0, (32.0, Weight::Regular, MUTE));
                brand(&mut out, WIDTH as f32 - PAD - mark, FOOT, 44.0);
                color.to_string()
            }
        };
        write!(
            out,
            r#"<rect y="{}" width="{WIDTH}" height="14" fill="{bar}"/></svg>"#,
            HEIGHT - 14
        )
        .unwrap();
        out
    }
}

/// The first row: the owner's mark and name, and the repository's after.
fn top(out: &mut String, owner: &str, name: Option<&str>) {
    avatar(out, owner, PAD + 32.0, 112.0, 32.0);
    let x = PAD + 84.0;
    let room = WIDTH as f32 - PAD - x;
    let Some(name) = name else {
        let shown = wrap(owner, 36.0, Weight::Regular, room, 1);
        lines(out, &shown, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE));
        return;
    };
    let owner = wrap(&format!("{owner}/"), 36.0, Weight::Regular, room / 2.0, 1);
    let after = x + measure(&owner[0], 36.0, Weight::Regular);
    let name = wrap(name, 36.0, Weight::Semibold, WIDTH as f32 - PAD - after, 1);
    lines(out, &owner, x, 125.0, 0.0, (36.0, Weight::Regular, MUTE));
    lines(out, &name, after, 125.0, 0.0, (36.0, Weight::Semibold, FG));
}

/// What names a card's picture: a hash of its SVG.
pub fn version(svg: &str) -> String {
    let mut out = String::with_capacity(16);
    for b in &Sha256::digest(svg)[..8] {
        write!(out, "{b:02x}").unwrap();
    }
    out
}

/// The SVG drawn.
pub fn raster(svg: &str) -> tiny_skia::Pixmap {
    let options = usvg::Options {
        font_family: "Inter Display".into(),
        fontdb: FONTS.clone(),
        ..Default::default()
    };
    let tree = usvg::Tree::from_str(svg, &options).expect("a card's SVG parses");
    let mut pixmap = tiny_skia::Pixmap::new(WIDTH, HEIGHT).expect("a card has a size");
    resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
    pixmap
}

/// The drawing as a PNG. A card is a few flat colors and the edges between
/// them, not 256 in all, so it is written exactly with a palette: a third
/// of the bytes to compress, and a fast compressor makes it smaller than
/// a thorough one makes RGB. With more colors, it is RGB.
pub fn encode(pixmap: &tiny_skia::Pixmap) -> Vec<u8> {
    let (w, h) = (pixmap.width(), pixmap.height());
    // Opaque, so its premultiplied colors are its colors.
    let pixels = pixmap.data().chunks_exact(4).map(|p| [p[0], p[1], p[2]]);
    let mut palette: Vec<u8> = Vec::new();
    let mut known: HashMap<[u8; 3], u8> = HashMap::new();
    let mut indexed = Vec::with_capacity((w * h) as usize);
    // Neighbours are mostly the same color: it is looked up once a run.
    let mut last = None;
    for rgb in pixels.clone() {
        let i = match last {
            Some((seen, i)) if seen == rgb => i,
            _ => {
                let i = match known.get(&rgb) {
                    Some(&i) => i,
                    None => {
                        let Ok(i) = u8::try_from(known.len()) else {
                            let rgb: Vec<u8> = pixels.flatten().collect();
                            return png(w, h, png::ColorType::Rgb, None, &rgb);
                        };
                        known.insert(rgb, i);
                        palette.extend_from_slice(&rgb);
                        i
                    }
                };
                last = Some((rgb, i));
                i
            }
        };
        indexed.push(i);
    }
    png(w, h, png::ColorType::Indexed, Some(palette), &indexed)
}

fn png(w: u32, h: u32, color: png::ColorType, palette: Option<Vec<u8>>, data: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(data.len() / 16);
    let mut encoder = png::Encoder::new(&mut out, w, h);
    encoder.set_color(color);
    encoder.set_depth(png::BitDepth::Eight);
    if let Some(palette) = palette {
        encoder.set_palette(palette);
    }
    encoder.set_compression(png::Compression::Fast);
    encoder.set_filter(png::Filter::Adaptive);
    let mut writer = encoder.write_header().expect("writing to memory");
    writer.write_image_data(data).expect("writing to memory");
    writer.finish().expect("writing to memory");
    out
}

/// The SVG as a PNG.
pub fn render(svg: &str) -> Vec<u8> {
    encode(&raster(svg))
}

/// Cards drawn, by version, the oldest dropped past a number of them; and
/// a cap on how many are drawn at once, as each takes a core.
pub struct Cache {
    drawn: Mutex<Drawn>,
    cap: usize,
    drawing: tokio::sync::Semaphore,
}

#[derive(Default)]
struct Drawn {
    by_version: HashMap<String, Arc<[u8]>>,
    /// Oldest first.
    order: VecDeque<String>,
}

impl Cache {
    pub fn new(cap: usize) -> Cache {
        let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
        Cache {
            drawn: Mutex::default(),
            cap,
            drawing: tokio::sync::Semaphore::new(cores),
        }
    }

    /// The PNG of `svg`, whose version is `version`.
    pub async fn png(&self, version: &str, svg: String) -> Arc<[u8]> {
        if let Some(png) = self.drawn.lock().unwrap().by_version.get(version) {
            return png.clone();
        }
        let _turn = self.drawing.acquire().await.expect("never closed");
        let png: Arc<[u8]> = tokio::task::spawn_blocking(move || render(&svg))
            .await
            .expect("drawing a card does not panic")
            .into();
        let mut drawn = self.drawn.lock().unwrap();
        let Drawn { by_version, order } = &mut *drawn;
        if by_version
            .insert(version.to_string(), png.clone())
            .is_none()
        {
            order.push_back(version.to_string());
        }
        while order.len() > self.cap {
            let oldest = order.pop_front().expect("longer than the cap");
            by_version.remove(&oldest);
        }
        png
    }

    pub fn len(&self) -> usize {
        self.drawn.lock().unwrap().order.len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wrapping() {
        let w = |t: &str, width: f32, max: usize| wrap(t, 40.0, Weight::Regular, width, max);
        assert_eq!(w("one two three", 2000.0, 1), ["one two three"]);
        assert_eq!(w("one two three", 170.0, 3), ["one two", "three"]);
        let cut = w("one two three four five six", 170.0, 2);
        assert_eq!(cut.len(), 2);
        assert!(cut[1].ends_with('…'), "{cut:?}");
        // A word longer than a line breaks where it overflows.
        let long = w(&"x".repeat(40), 200.0, 5);
        assert!(long.len() > 1 && long.concat().len() == 40, "{long:?}");
        assert!(
            long.iter()
                .all(|l| measure(l, 40.0, Weight::Regular) <= 200.0)
        );
        assert!(w("", 100.0, 2).is_empty());
        assert_eq!(w("a b c d e f g h", 1.0, 1), ["…"]);
        // Narrower than one letter, a line still takes one.
        assert_eq!(w("ab", 1.0, 3), ["a", "b"]);
    }

    #[test]
    fn marks() {
        assert_eq!(
            esc(r#"<a href="x">'&'</a>"#),
            "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;"
        );
        assert_eq!(initial("_alice"), 'a');
        assert_eq!(initial("…"), '?');
        assert!(hue("alice") < 360);
        assert_eq!(state_color(true, "closed"), "#cf222e");
        assert_eq!(state_color(false, "closed"), "#636363");
        // Both weights are one family, so asking for 600 gets the bolder.
        let faces: Vec<(String, u16)> = FONTS
            .faces()
            .map(|f| (f.families[0].0.clone(), f.weight.0))
            .collect();
        let family = || "Inter Display".to_string();
        let emoji = ("Noto Color Emoji".to_string(), 400);
        assert_eq!(faces, [(family(), 400), (family(), 600), emoji]);
        assert!(measure("W", 40.0, Weight::Semibold) > measure("i", 40.0, Weight::Semibold));
        // A letter the fonts lack is measured as the box drawn for it.
        assert!(measure("漢", 40.0, Weight::Regular) > 0.0);
    }

    #[test]
    fn emoji() {
        let one = measure("😀", 40.0, Weight::Regular);
        // As wide as Noto draws it, not as Inter's box.
        assert!(one > measure("W", 40.0, Weight::Regular), "{one}");
        assert_ne!(one, measure("漢", 40.0, Weight::Regular));
        // A sequence is drawn as one, however many characters it takes.
        for one_of in [
            "👩‍💻",
            "👋🏽",
            "❤️",
            "🇫🇷",
            "#️⃣",
            "🏴\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}",
            "👨‍👩‍👧‍👦",
        ] {
            assert_eq!(clusters(one_of), [one_of]);
        }
        assert_eq!(measure("👨‍👩‍👧‍👦", 40.0, Weight::Regular), one);
        // Flags pair off; a letter left over stands alone.
        assert_eq!(clusters("🇫🇷🇩🇪🇮"), ["🇫🇷", "🇩🇪", "🇮"]);
        assert_eq!(clusters("a😀b"), ["a", "😀", "b"]);
        // A selector with nothing before it stands alone.
        assert_eq!(clusters("\u{FE0F}x"), ["\u{FE0F}", "x"]);
        // Lines break, and are cut, between sequences, never inside one.
        let family = "👨‍👩‍👧‍👦".repeat(12);
        let broken = wrap(&family, 40.0, Weight::Regular, one * 5.0, 2);
        assert_eq!(broken[0], "👨‍👩‍👧‍👦".repeat(5));
        assert!(broken[1].ends_with('…'), "{broken:?}");
        assert!(clusters(&broken[1]).iter().all(|c| *c == "👨‍👩‍👧‍👦" || *c == "…"));
        let repo = |description: &str| {
            render(
                &Card::Repo {
                    owner: "alice.test".into(),
                    name: "party".into(),
                    description: Some(description.into()),
                    pulls: 0,
                    issues: 0,
                }
                .svg(),
            )
        };
        // Drawn in Noto's colors, not as boxes in the text's.
        let drawn = repo("Flags 🇫🇷🇯🇵, people 👩🏽‍🔬 and hearts ❤️");
        assert!(drawn.len() > repo("Flags ab, people c and hearts d").len());
        // What no keyboard makes draws too, rather than failing the card.
        for odd in [
            "😀\u{200D}a",
            "a\u{200D}😀",
            "\u{200D}\u{FE0F}",
            "🇫 x",
            "#\u{FE0F}\u{20E3}x",
            "😀\u{200D}漢 é 😀",
            "\u{1F3FD}\u{1F3FD}",
            "<😀&>",
        ] {
            repo(odd);
        }
    }

    fn item(state: &str, pull: bool) -> Card {
        Card::Item {
            owner: "alice.test".into(),
            name: "tracked".into(),
            pull,
            state: state.into(),
            title: "Broken <thing> & more".into(),
            author: "bob.test".into(),
            comments: 1,
        }
    }

    #[test]
    fn every_card_draws() {
        let cards = [
            Card::Home,
            Card::Repo {
                owner: "alice.test".into(),
                name: "a-repository-name-long-enough-to-wrap-onto-two-lines".into(),
                description: Some("Tracks <things>.".into()),
                pulls: 1,
                issues: 2,
            },
            Card::Repo {
                owner: "alice.test".into(),
                name: "bare".into(),
                description: None,
                pulls: 0,
                issues: 0,
            },
            item("open", true),
            item("merged", true),
            item("closed", true),
            item("open", false),
            item("closed", false),
            item("", false),
        ];
        let mut versions = std::collections::HashSet::new();
        for card in &cards {
            let svg = card.svg();
            assert!(!svg.contains("<thing>"), "{svg}");
            assert!(versions.insert(version(&svg)));
            let png = render(&svg);
            assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
        }
        assert_eq!(version("x").len(), 16);
    }

    #[test]
    fn more_than_a_palette_holds() {
        let mut pixmap = tiny_skia::Pixmap::new(32, 32).unwrap();
        for (i, p) in pixmap.data_mut().chunks_exact_mut(4).enumerate() {
            p.copy_from_slice(&[i as u8, (i >> 8) as u8, 0, 255]);
        }
        let rgb = encode(&pixmap);
        // The header's color type: 2 is RGB, 3 a palette.
        assert_eq!(rgb[25], 2);
        assert_eq!(render(&Card::Home.svg())[25], 3);
    }

    #[tokio::test]
    async fn the_cache_keeps_the_newest() {
        let cache = Cache::new(2);
        assert!(cache.is_empty());
        let svg = Card::Home.svg();
        let first = cache.png("a", svg.clone()).await;
        let again = cache.png("a", svg.clone()).await;
        assert!(Arc::ptr_eq(&first, &again));
        cache.png("b", svg.clone()).await;
        cache.png("c", svg.clone()).await;
        assert_eq!(cache.len(), 2);
        let redrawn = cache.png("a", svg).await;
        assert!(!Arc::ptr_eq(&first, &redrawn));
    }
}