//! the cards' layout: svfe's frame, ribbon and dither band around each kind's lines
use super::canvas::{Canvas, Face, Point, Rgb, boxed, mix};
use super::{Card, Diff, Language, Ribbon, Theme};
const FRAME: (f32, f32, f32, f32) = (44.0, 38.0, 1156.0, 592.0);
const BORDER: f32 = 3.0;
const CUT: f32 = 22.0;
const LEFT: f32 = 91.0;
const RIGHT: f32 = 1109.0;
const RIBBON_TOP: f32 = 75.0;
const RIBBON_HEIGHT: f32 = 46.0;
const BAND: f32 = 36.0;
/// the footer row's baseline, a line above the band
const FOOT: f32 = 524.0;
/// svfe's 8x12 bottom dither, in 2px cells, drawn at 3x
const BAND_CELLS: [(f32, f32); 9] = [
(0.0, 10.0),
(2.0, 10.0),
(4.0, 10.0),
(2.0, 8.0),
(6.0, 8.0),
(0.0, 6.0),
(4.0, 6.0),
(6.0, 4.0),
(0.0, 2.0),
];
const SCALE: f32 = 3.0;
/// a patch card's punch holes, the files take the first 14 and a merge the last
const HOLE_SLOTS: usize = 16;
/// svfe's diff cells: 500 changed lines fill all five
const DIFF_CELLS: usize = 5;
pub(super) fn draw(card: &Card, theme: &Theme) -> anyhow::Result<Vec<u8>> {
let mut c = Canvas::new(theme.bg);
match card {
Card::Repo {
ribbon,
name,
description,
topics,
open_tickets,
open_pulls,
branch,
languages,
} => {
frame(&mut c, theme, false);
draw_ribbon(&mut c, theme, ribbon, None);
let mut y = title(&mut c, theme, Face::MonoBold, 58.0, None, name, 1);
y = lines(&mut c, theme, description, y + 20.0, 1018.0);
if !topics.is_empty() {
chips(&mut c, theme, topics, y + 22.0, theme.accent);
}
let mut stats = vec![];
for (count, word) in [(open_tickets, "open ticket"), (open_pulls, "open pull")] {
stats.push((count.to_string(), plural(*count, word)));
}
let at = match languages.is_empty() {
true => FOOT,
false => {
language_bar(&mut c, theme, languages);
FOOT - 60.0
}
};
let end = counts(&mut c, theme, &stats, at);
c.text(Face::Mono, 24.0, end + 26.0, at, branch, theme.muted);
kaomoji(&mut c, theme, "(˘ω˘)");
}
Card::Ticket {
ribbon,
number,
title: heading,
state,
author,
date,
body,
} => {
frame(&mut c, theme, false);
draw_ribbon(&mut c, theme, ribbon, Some("ticket"));
let y = title(
&mut c,
theme,
Face::SansBold,
60.0,
Some(number),
heading,
2,
);
let y = meta(
&mut c,
theme,
(state, state_fill(theme, state)),
&[author, date],
y,
);
lines(&mut c, theme, body, y + 20.0, 960.0);
kaomoji(&mut c, theme, "(°▽°)");
}
Card::Pull {
ribbon,
number,
title: heading,
state,
author,
hue,
seed,
date,
from,
into,
body,
diff,
} => {
frame(&mut c, theme, true);
holes(
&mut c,
theme,
seed,
diff.as_ref().map_or(0, |diff| diff.files),
state == "merged",
);
rail(&mut c, theme.hues[*hue]);
draw_ribbon(&mut c, theme, ribbon, Some("pull"));
let y = title(
&mut c,
theme,
Face::SansBold,
60.0,
Some(number),
heading,
2,
);
let branches = format!("{from} → {into}");
let y = meta(
&mut c,
theme,
(state, state_fill(theme, state)),
&[author, &branches, date],
y,
);
lines(&mut c, theme, body, y + 20.0, 960.0);
if let Some(diff) = diff {
diffstat(&mut c, theme, diff);
}
kaomoji(&mut c, theme, "(^_^)/");
}
Card::Commit {
ribbon,
subject,
short,
author,
date,
branch,
body,
diff,
} => {
frame(&mut c, theme, false);
draw_ribbon(&mut c, theme, ribbon, Some("commit"));
let y = title(&mut c, theme, Face::SansBold, 60.0, None, subject, 2);
let y = meta(
&mut c,
theme,
(short, theme.accent),
&[author, date, branch],
y,
);
lines(&mut c, theme, body, y + 20.0, 960.0);
if let Some(diff) = diff {
diffstat(&mut c, theme, diff);
}
kaomoji(&mut c, theme, "(•_•)");
}
Card::User {
host,
handle,
about,
repos,
repo_count,
avatar,
} => {
frame(&mut c, theme, false);
draw_ribbon(
&mut c,
theme,
&Ribbon {
host: host.clone(),
path: String::new(),
},
Some("user"),
);
let tile = 150.0;
let top = 155.0;
match avatar {
Some(rgb) => {
c.image(LEFT as i32, top as i32, tile as u32, rgb);
c.outline(
LEFT - BORDER,
top - BORDER,
tile + 2.0 * BORDER,
tile + 2.0 * BORDER,
BORDER,
theme.border,
);
}
None => letter_tile(&mut c, theme, handle, LEFT, top, tile),
}
let x = LEFT + tile + 34.0;
let size = 58.0;
let name = Face::MonoBold.fit(size, RIGHT - x, handle);
let baseline = Face::MonoBold.baseline(size, top + 18.0, size * 1.1);
c.text(Face::MonoBold, size, x, baseline, &name, theme.text);
let about_top = top + 18.0 + size * 1.1 + 10.0;
let about_lines = Face::Sans.wrap(30.0, RIGHT - x, RIGHT - x, 2, about);
for (index, line) in about_lines.iter().enumerate() {
let baseline = Face::Sans.baseline(30.0, about_top + index as f32 * 40.5, 40.5);
c.text(Face::Sans, 30.0, x, baseline, line, theme.muted);
}
let below = (top + tile).max(about_top + about_lines.len() as f32 * 40.5);
chips(&mut c, theme, repos, below + 30.0, theme.accent);
counts(
&mut c,
theme,
&[(repo_count.to_string(), plural(*repo_count, "repo"))],
FOOT,
);
kaomoji(&mut c, theme, "(=^ω^=)");
}
}
band(&mut c, theme);
c.png()
}
/// the frame's own shape: a box with its top-left and, unless it's a patch, bottom-right corners cut
fn shape(inset: f32, patch: bool) -> impl Fn(Point) -> f32 {
let (x0, y0, x1, y1) = FRAME;
let (x0, y0, x1, y1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset);
let cut = CUT - inset * (std::f32::consts::SQRT_2 - 1.0);
move |(x, y)| {
let edge = boxed(((x0, y0), (x1, y1)), (x, y));
let top_left = (x0 + y0 + cut - x - y) / std::f32::consts::SQRT_2;
let bottom_right = match patch {
true => f32::MIN,
false => (x + y - (x1 + y1 - cut)) / std::f32::consts::SQRT_2,
};
edge.max(top_left).max(bottom_right)
}
}
fn frame(c: &mut Canvas, theme: &Theme, patch: bool) {
let (x0, y0, x1, y1) = FRAME;
let bounds = ((x0, y0), (x1, y1));
let (edge, inside) = match patch {
true => (theme.pedge, theme.patch),
false => (theme.border, theme.card),
};
c.fill(bounds, edge, shape(0.0, patch));
c.fill(bounds, inside, shape(BORDER, patch));
}
/// a ribbon segment: its text, face, colour and fill
type Segment = (String, Face, Rgb, Rgb);
/// the knot, then the owner or the repo, then what the page is, like svfe's ribbons
fn draw_ribbon(c: &mut Canvas, theme: &Theme, ribbon: &Ribbon, kind: Option<&str>) {
let (size, pad) = (25.0, 16.0);
let mut segments: Vec<Segment> = vec![(
ribbon.host.clone(),
Face::MonoBold,
theme.card,
theme.accent,
)];
if !ribbon.path.is_empty() {
segments.push((ribbon.path.clone(), Face::MonoBold, theme.text, theme.soft));
}
if let Some(kind) = kind {
segments.push((kind.to_string(), Face::Mono, theme.muted, theme.soft));
}
let fixed: f32 = segments
.iter()
.enumerate()
.filter(|(index, _)| *index != 1 || ribbon.path.is_empty())
.map(|(_, (text, face, ..))| face.width(size, text) + 2.0 * pad + 2.0)
.sum();
if !ribbon.path.is_empty() {
let room = RIGHT - LEFT - fixed - 2.0 * pad - 2.0;
segments[1].0 = Face::MonoBold.fit(size, room, &ribbon.path);
}
strip(c, theme, &segments, RIBBON_TOP, RIBBON_HEIGHT, size, pad);
}
/// segments side by side from `top`, a rule between them and a border around. returns where it ends
fn strip(
c: &mut Canvas,
theme: &Theme,
segments: &[Segment],
top: f32,
height: f32,
size: f32,
pad: f32,
) -> f32 {
let mut x = LEFT;
let baseline = Face::Mono.baseline(size, top, height);
for (index, (text, face, colour, fill)) in segments.iter().enumerate() {
let width = face.width(size, text) + 2.0 * pad;
c.rect(x, top, width, height, *fill);
c.text(*face, size, x + pad, baseline, text, *colour);
x += width;
if index + 1 < segments.len() {
c.rect(x, top, 2.0, height, theme.border);
x += 2.0;
}
}
c.outline(
LEFT - 2.0,
top - 2.0,
x - LEFT + 4.0,
height + 4.0,
2.0,
theme.border,
);
top + height
}
/// the page's meta as a ribbon from `top`, `lead` its filled segment. past the edge the
/// segments drop from the end, and the last one left is cut to fit. returns where it ends
fn meta(c: &mut Canvas, theme: &Theme, lead: (&str, Rgb), rest: &[&String], top: f32) -> f32 {
let (size, pad) = (24.0, 14.0);
let width = |face: Face, text: &str| face.width(size, text) + 2.0 * pad + 2.0;
let mut segments: Vec<Segment> = vec![(lead.0.to_string(), Face::MonoBold, theme.card, lead.1)];
segments.extend(
rest.iter()
.map(|text| (text.to_string(), Face::Mono, theme.muted, theme.soft)),
);
let total = |segments: &[Segment]| -> f32 {
segments
.iter()
.map(|(text, face, ..)| width(*face, text))
.sum()
};
while segments.len() > 2 && total(&segments) > RIGHT - LEFT {
segments.pop();
}
if let [first, last] = segments.as_mut_slice() {
let room = RIGHT - LEFT - width(first.1, &first.0) - 2.0 * pad - 2.0;
last.0 = Face::Mono.fit(size, room, &last.0);
}
strip(c, theme, &segments, top + 22.0, 40.0, size, pad)
}
fn state_fill(theme: &Theme, state: &str) -> Rgb {
match state {
"open" => theme.teal,
"merged" | "completed" => theme.lav,
_ => theme.muted,
}
}
/// `word` with an s unless there's one
fn plural(count: usize, word: &str) -> String {
match count {
1 => word.to_string(),
_ => format!("{word}s"),
}
}
/// the title in up to `rows` lines under the ribbon, `number` in the accent before it.
/// returns where it ends
fn title(
c: &mut Canvas,
theme: &Theme,
face: Face,
size: f32,
number: Option<&String>,
text: &str,
rows: usize,
) -> f32 {
let line_height = size * 1.08;
let top = RIBBON_TOP + RIBBON_HEIGHT + 30.0;
let gap = size * 0.3;
let lead = number.map(|number| format!("#{number}"));
let indent = lead
.as_deref()
.map_or(0.0, |lead| Face::MonoBold.width(size, lead) + gap);
let wrapped = face.wrap(size, RIGHT - LEFT - indent, RIGHT - LEFT, rows, text);
for (index, line) in wrapped.iter().enumerate() {
let baseline = face.baseline(size, top + index as f32 * line_height, line_height);
let mut x = LEFT;
if index == 0
&& let Some(lead) = &lead
{
x = c.text(Face::MonoBold, size, x, baseline, lead, theme.accent) + gap;
}
c.text(face, size, x, baseline, line, theme.text);
}
top + wrapped.len().max(1) as f32 * line_height
}
/// muted prose in up to two lines from `top`. returns where it ends
fn lines(c: &mut Canvas, theme: &Theme, text: &str, top: f32, max: f32) -> f32 {
let line_height = 40.5;
let wrapped = Face::Sans.wrap(30.0, max, max, 2, text);
for (index, line) in wrapped.iter().enumerate() {
let baseline = Face::Sans.baseline(30.0, top + index as f32 * line_height, line_height);
c.text(Face::Sans, 30.0, LEFT, baseline, line, theme.muted);
}
top + wrapped.len() as f32 * line_height
}
/// bordered mono chips in one row from `top`, the ones past the edge counted in a last chip
fn chips(c: &mut Canvas, theme: &Theme, items: &[String], top: f32, colour: Rgb) {
let size = 22.0;
let (pad, gap, height) = (12.0, 10.0, 36.0);
let width = |text: &str| Face::Mono.width(size, text) + 2.0 * pad + 4.0;
let mut shown = items.len();
while shown > 0 {
let more = items.len() - shown;
let total: f32 = items[..shown]
.iter()
.map(|item| width(item) + gap)
.sum::<f32>()
+ if more > 0 {
width(&format!("+{more}"))
} else {
0.0
};
if total <= RIGHT - LEFT {
break;
}
shown -= 1;
}
let more = items.len() - shown;
let baseline = Face::Mono.baseline(size, top, height);
let mut x = LEFT;
let rest = format!("+{more}");
let extra = (more > 0).then_some(&rest);
for item in items[..shown].iter().chain(extra) {
let item = Face::Mono.fit(size, RIGHT - LEFT - 2.0 * pad - 4.0, item);
let w = width(&item);
c.outline(x, top, w, height, 2.0, theme.border);
c.text(Face::Mono, size, x + pad + 2.0, baseline, &item, colour);
x += w + gap;
}
}
/// `count word` pairs on the footer line, the count bold. returns where they end
fn counts(c: &mut Canvas, theme: &Theme, pairs: &[(String, String)], baseline: f32) -> f32 {
let mut x = LEFT;
for (index, (count, word)) in pairs.iter().enumerate() {
if index > 0 {
x += 26.0;
}
x = c.text(Face::MonoBold, 24.0, x, baseline, count, theme.text);
x = c.text(Face::Mono, 24.0, x + 12.0, baseline, word, theme.muted);
}
x
}
fn language_bar(c: &mut Canvas, theme: &Theme, languages: &[Language]) {
let (width, height, top) = (520.0, 14.0, FOOT - 38.0);
let total: u64 = languages
.iter()
.map(|language| language.size)
.sum::<u64>()
.max(1);
let mut x = LEFT;
for (index, language) in languages.iter().enumerate() {
let share = width * language.size as f32 / total as f32;
let share = match index + 1 == languages.len() {
true => LEFT + width - x,
false => share,
};
c.rect(
x,
top,
share,
height,
theme.languages[index.min(theme.languages.len() - 1)],
);
x += share;
}
let mut x = LEFT;
for (index, language) in languages.iter().take(4).enumerate() {
let colour = theme.languages[index.min(theme.languages.len() - 1)];
c.rect(x, FOOT - 13.0, 14.0, 14.0, colour);
let percent = (language.size as f64 * 100.0 / total as f64)
.round()
.max(1.0);
let label = format!("{} {percent}%", language.name);
x = c.text(Face::Mono, 20.0, x + 21.0, FOOT, &label, theme.muted) + 18.0;
if x > LEFT + 900.0 {
break;
}
}
}
/// insertions, deletions, svfe's five diff cells and the file count
fn diffstat(c: &mut Canvas, theme: &Theme, diff: &Diff) {
let mut x = c.text(
Face::MonoBold,
24.0,
LEFT,
FOOT,
&format!("+{}", diff.insertions),
theme.add,
);
x = c.text(
Face::MonoBold,
24.0,
x + 24.0,
FOOT,
&format!("−{}", diff.deletions),
theme.del,
);
x += 24.0;
let total = diff.insertions + diff.deletions;
let filled = (((total as f64 + 1.0).log10() * DIFF_CELLS as f64 / 501f64.log10()).ceil()
as usize)
.clamp(1, DIFF_CELLS);
let added = match total {
0 => 0,
_ => ((filled as u64 * diff.insertions) as f64 / total as f64).round() as usize,
};
for cell in 0..DIFF_CELLS {
let colour = match cell {
cell if cell < added => theme.add,
cell if cell < filled => theme.del,
_ => theme.border,
};
c.rect(x, FOOT - 20.0, 22.0, 22.0, colour);
x += 26.0;
}
let files = match diff.files {
1 => "1 file".to_string(),
files => format!("{files} files"),
};
c.text(Face::Mono, 24.0, x + 18.0, FOOT, &files, theme.muted);
}
fn kaomoji(c: &mut Canvas, theme: &Theme, face: &str) {
let width = Face::Wide.width(30.0, face);
c.text(Face::Wide, 30.0, RIGHT - width, FOOT, face, theme.accent);
}
/// the accent dither along the frame's bottom, inside its border
fn band(c: &mut Canvas, theme: &Theme) {
let (x0, _, x1, y1) = FRAME;
let inside = shape(BORDER, false);
let top = y1 - BORDER - BAND;
let mut x = x0 + BORDER;
while x < x1 - BORDER {
for (cx, cy) in BAND_CELLS {
let (px, py) = (x + cx * SCALE, top + cy * SCALE);
let cell = ((px, py), (px + 2.0 * SCALE, py + 2.0 * SCALE));
c.fill(cell, theme.accent, |point| {
boxed(cell, point).max(inside(point))
});
}
x += 8.0 * SCALE;
}
}
/// a patch's punch holes along the top, notched for its files and stamped once merged
fn holes(c: &mut Canvas, theme: &Theme, seed: &str, files: u64, merged: bool) {
let start = fnv(seed, 0x811c_9dc5) as usize;
let mut notched = std::collections::BTreeSet::new();
for file in 0..(files as usize).min(HOLE_SLOTS - 2) {
notched.insert((start + file * 5) % (HOLE_SLOTS - 2));
}
if merged {
notched.insert(HOLE_SLOTS - 1);
}
let radius = 10.0;
let y = FRAME.1 + BORDER + radius - 2.0;
let step = (RIGHT - LEFT - 2.0 * radius) / (HOLE_SLOTS - 1) as f32;
for slot in 0..HOLE_SLOTS {
let x = LEFT + radius + slot as f32 * step;
let rim = match slot == HOLE_SLOTS - 1 {
true => mix(theme.accent, theme.patch, 0.6),
false => theme.rim,
};
let hole = move |(px, py): Point| (px - x).hypot(py - y) - radius;
let ring = (
(x - radius - 2.0, y - radius - 2.0),
(x + radius + 2.0, y + radius + 2.0),
);
c.fill(ring, rim, move |point| hole(point) - 1.5);
if notched.contains(&slot) {
let slit = ((x - radius - 1.5, FRAME.1), (x + radius + 1.5, y));
c.fill(slit, theme.rim, move |point| boxed(slit, point));
let slot_box = ((x - radius, FRAME.1), (x + radius, y));
c.fill(slot_box, theme.lane, move |point| boxed(slot_box, point));
}
c.fill(ring, theme.lane, hole);
// the hole's shadow, its top edge a shade darker
let shade = mix([0, 0, 0], theme.lane, 0.3);
c.fill(ring, shade, move |point| {
hole(point).max(point.1 - (y - radius + 3.0))
});
}
}
/// the author's stripe down the left edge
fn rail(c: &mut Canvas, colour: Rgb) {
let (x0, y0, _, y1) = FRAME;
let inside = shape(BORDER, true);
let (left, top, bottom) = (x0 + BORDER, y0 + BORDER, y1 - BORDER - BAND);
let stripe = ((left, top), (left + 8.0, bottom));
c.fill(stripe, colour, |point| {
boxed(stripe, point).max(inside(point))
});
}
/// the owner's first letter on a soft tile, for an owner without an avatar
fn letter_tile(c: &mut Canvas, theme: &Theme, handle: &str, x: f32, y: f32, size: f32) {
c.rect(x, y, size, size, theme.soft);
c.outline(
x - BORDER,
y - BORDER,
size + 2.0 * BORDER,
size + 2.0 * BORDER,
BORDER,
theme.border,
);
let letter: String = handle
.trim_start_matches('@')
.chars()
.next()
.unwrap_or('?')
.to_lowercase()
.collect();
let width = Face::MonoBold.width(70.0, &letter);
let baseline = Face::MonoBold.baseline(70.0, y, size);
c.text(
Face::MonoBold,
70.0,
x + (size - width) / 2.0,
baseline,
&letter,
theme.accent,
);
}
/// 32-bit fnv-1a from `basis`, svfe's `hashString`
pub(super) fn fnv(text: &str, basis: u32) -> u32 {
let mut hash = basis;
for unit in text.encode_utf16() {
hash ^= u32::from(unit);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}