chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! 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("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
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>"#),
"<a href="x">'&'</a>"
);
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));
}
}