use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use chrono::{Datelike, Days, NaiveDate};
use crate::primer::{Appearance, Legibility, Palette, Season};
pub const GLYPH_ROWS: usize = 5;
pub const GLYPH_COLS: usize = 5;
pub const WEEKDAYS: usize = 7;
const FONT: &[(char, [&str; GLYPH_ROWS])] = &[
('A', [".###.", "#...#", "#####", "#...#", "#...#"]),
('B', ["####.", "#...#", "####.", "#...#", "####."]),
('C', [".###.", "#...#", "#....", "#...#", ".###."]),
('D', ["####.", "#...#", "#...#", "#...#", "####."]),
('E', ["#####", "#....", "####.", "#....", "#####"]),
('F', ["#####", "#....", "####.", "#....", "#...."]),
('G', [".###.", "#....", "#..##", "#...#", ".###."]),
('H', ["#...#", "#...#", "#####", "#...#", "#...#"]),
('I', ["#####", "..#..", "..#..", "..#..", "#####"]),
('J', ["....#", "....#", "....#", "#...#", ".###."]),
('K', ["#...#", "#..#.", "###..", "#..#.", "#...#"]),
('L', ["#....", "#....", "#....", "#....", "#####"]),
('M', ["#...#", "##.##", "#.#.#", "#...#", "#...#"]),
('N', ["#...#", "##..#", "#.#.#", "#..##", "#...#"]),
('O', [".###.", "#...#", "#...#", "#...#", ".###."]),
('P', ["####.", "#...#", "####.", "#....", "#...."]),
('Q', [".###.", "#...#", "#.#.#", "#..#.", ".##.#"]),
('R', ["####.", "#...#", "####.", "#..#.", "#...#"]),
('S', [".####", "#....", ".###.", "....#", "####."]),
('T', ["#####", "..#..", "..#..", "..#..", "..#.."]),
('U', ["#...#", "#...#", "#...#", "#...#", ".###."]),
('V', ["#...#", "#...#", "#...#", ".#.#.", "..#.."]),
('W', ["#...#", "#...#", "#.#.#", "##.##", "#...#"]),
('X', ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"]),
('Y', ["#...#", ".#.#.", "..#..", "..#..", "..#.."]),
('Z', ["#####", "...#.", "..#..", ".#...", "#####"]),
('0', [".###.", "#..##", "#.#.#", "##..#", ".###."]),
('1', ["..#..", ".##..", "..#..", "..#..", ".###."]),
('2', [".###.", "#...#", "..##.", ".#...", "#####"]),
('3', ["####.", "....#", "..##.", "....#", "####."]),
('4', ["#..#.", "#..#.", "#####", "...#.", "...#."]),
('5', ["#####", "#....", "####.", "....#", "####."]),
('6', [".###.", "#....", "####.", "#...#", ".###."]),
('7', ["#####", "....#", "...#.", "..#..", ".#..."]),
('8', [".###.", "#...#", ".###.", "#...#", ".###."]),
('9', [".###.", "#...#", ".####", "....#", ".###."]),
(' ', [".....", ".....", ".....", ".....", "....."]),
('-', [".....", ".....", "#####", ".....", "....."]),
('.', [".....", ".....", ".....", ".....", "..#.."]),
('!', ["..#..", "..#..", "..#..", ".....", "..#.."]),
('?', [".###.", "#...#", "..##.", ".....", "..#.."]),
(',', [".....", ".....", ".....", "..#..", ".#..."]),
('\'', ["..#..", "..#..", ".....", ".....", "....."]),
('"', [".#.#.", ".#.#.", ".....", ".....", "....."]),
('+', [".....", "..#..", "#####", "..#..", "....."]),
('=', [".....", "#####", ".....", "#####", "....."]),
('<', ["...#.", "..#..", ".#...", "..#..", "...#."]),
('>', [".#...", "..#..", "...#.", "..#..", ".#..."]),
('(', ["..##.", ".#...", ".#...", ".#...", "..##."]),
(')', [".##..", "...#.", "...#.", "...#.", ".##.."]),
('/', ["....#", "...#.", "..#..", ".#...", "#...."]),
('\\', ["#....", ".#...", "..#..", "...#.", "....#"]),
('*', [".....", "#.#.#", ".###.", "#.#.#", "....."]),
('_', [".....", ".....", ".....", ".....", "#####"]),
('@', [".###.", "#...#", "#.##.", "#....", ".###."]),
('&', [".##..", "#..#.", ".##..", "#..#.", ".##.#"]),
('#', [".#.#.", "#####", ".#.#.", "#####", ".#.#."]),
('%', ["#...#", "...#.", "..#..", ".#...", "#...#"]),
('\u{2605}', ["..#..", ".###.", "#####", ".#.#.", "#...#"]),
('\u{2665}', [".#.#.", "#####", "#####", ".###.", "..#.."]),
('\u{263a}', [".###.", "#.#.#", "#####", ".###.", "#...#"]),
('\u{2639}', [".###.", "#.#.#", "#####", "#...#", ".###."]),
('\u{2713}', [".....", "....#", "...#.", "#.#..", ".#..."]),
('\u{25cf}', [".###.", "#####", "#####", "#####", ".###."]),
('\u{25a1}', ["#####", "#...#", "#...#", "#...#", "#####"]),
('\u{25b2}', ["..#..", "..#..", ".###.", ".###.", "#####"]),
('\u{25c6}', ["..#..", ".###.", "#####", ".###.", "..#.."]),
('\u{266a}', ["...##", "...#.", "...#.", ".###.", ".##.."]),
('\u{2600}', ["#.#.#", ".###.", "#####", ".###.", "#.#.#"]),
('\u{263e}', [".###.", "##...", "##...", "##...", ".###."]),
('\u{26a1}', ["...##", "..##.", ".###.", "..#..", ".#..."]),
('\u{2191}', ["..#..", ".###.", "#.#.#", "..#..", "..#.."]),
('\u{2193}', ["..#..", "..#..", "#.#.#", ".###.", "..#.."]),
('\u{2190}', ["..#..", ".#...", "#####", ".#...", "..#.."]),
('\u{2192}', ["..#..", "...#.", "#####", "...#.", "..#.."]),
('\u{2620}', [".###.", "#####", "#.#.#", ".###.", ".#.#."]),
('\u{273f}', [".#.#.", "#####", ".###.", "..#..", "..#.."]),
];
const SHAPES: &[(&str, char)] = &[
("star", '\u{2605}'),
("heart", '\u{2665}'),
("love", '\u{2665}'),
("smile", '\u{263a}'),
("happy", '\u{263a}'),
("sad", '\u{2639}'),
("cry", '\u{2639}'),
("frown", '\u{2639}'),
("check", '\u{2713}'),
("tick", '\u{2713}'),
("circle", '\u{25cf}'),
("dot", '\u{25cf}'),
("square", '\u{25a1}'),
("triangle", '\u{25b2}'),
("diamond", '\u{25c6}'),
("note", '\u{266a}'),
("music", '\u{266a}'),
("sun", '\u{2600}'),
("moon", '\u{263e}'),
("bolt", '\u{26a1}'),
("zap", '\u{26a1}'),
("up", '\u{2191}'),
("down", '\u{2193}'),
("left", '\u{2190}'),
("right", '\u{2192}'),
("skull", '\u{2620}'),
("flower", '\u{273f}'),
];
const FOLD: &[(char, char)] = &[
('\u{2b50}', '\u{2605}'),
('\u{2606}', '\u{2605}'),
('\u{2729}', '\u{2605}'),
('\u{272d}', '\u{2605}'),
('\u{2764}', '\u{2665}'),
('\u{2661}', '\u{2665}'),
('\u{1f499}', '\u{2665}'),
('\u{1f49a}', '\u{2665}'),
('\u{1f49c}', '\u{2665}'),
('\u{1f9e1}', '\u{2665}'),
('\u{263b}', '\u{263a}'),
('\u{1f600}', '\u{263a}'),
('\u{1f603}', '\u{263a}'),
('\u{1f642}', '\u{263a}'),
('\u{1f60a}', '\u{263a}'),
('\u{1f641}', '\u{2639}'),
('\u{1f622}', '\u{2639}'),
('\u{1f62d}', '\u{2639}'),
('\u{2714}', '\u{2713}'),
('\u{2705}', '\u{2713}'),
('\u{25cb}', '\u{25cf}'),
('\u{2b24}', '\u{25cf}'),
('\u{25fb}', '\u{25a1}'),
('\u{25a0}', '\u{25a1}'),
('\u{25fc}', '\u{25a1}'),
('\u{25b3}', '\u{25b2}'),
('\u{25c7}', '\u{25c6}'),
('\u{266b}', '\u{266a}'),
('\u{1f3b5}', '\u{266a}'),
('\u{1f31e}', '\u{2600}'),
('\u{1f319}', '\u{263e}'),
('\u{263d}', '\u{263e}'),
('\u{1f5f2}', '\u{26a1}'),
('\u{1f480}', '\u{2620}'),
('\u{1f338}', '\u{273f}'),
('\u{1f337}', '\u{273f}'),
];
const _: () = {
let mut index = 0;
while index < FONT.len() {
let (character, rows) = FONT[index];
let mut row = 0;
while row < GLYPH_ROWS {
let bytes = rows[row].as_bytes();
assert!(
bytes.len() == GLYPH_COLS,
"every glyph row must be GLYPH_COLS characters wide"
);
let mut column = 0;
while column < bytes.len() {
assert!(
bytes[column] == b'#' || bytes[column] == b'.',
"glyph rows are made of '#' and '.' only"
);
column += 1;
}
row += 1;
}
let mut other = 0;
while other < index {
assert!(
FONT[other].0 as u32 != character as u32,
"the same character is in the font twice"
);
other += 1;
}
index += 1;
}
let mut index = 0;
while index < SHAPES.len() {
let (name, character) = SHAPES[index];
assert!(
in_font(character),
"a shape names a character the font lacks"
);
let bytes = name.as_bytes();
assert!(!bytes.is_empty(), "a shape name must not be empty");
let mut byte = 0;
while byte < bytes.len() {
assert!(
(bytes[byte] >= b'a' && bytes[byte] <= b'z') || bytes[byte] == b'-',
"shape names are lowercase ASCII, so :NAME: folds to one thing"
);
byte += 1;
}
let mut other = 0;
while other < index {
assert!(!same(SHAPES[other].0, name), "the same shape name twice");
other += 1;
}
index += 1;
}
let mut index = 0;
while index < FOLD.len() {
let (from, to) = FOLD[index];
assert!(in_font(to), "a fold points at a character the font lacks");
assert!(!in_font(from), "a folded character is also in the font");
let mut other = 0;
while other < index {
assert!(FOLD[other].0 as u32 != from as u32, "the same fold twice");
other += 1;
}
index += 1;
}
};
const fn in_font(character: char) -> bool {
let mut index = 0;
while index < FONT.len() {
if FONT[index].0 as u32 == character as u32 {
return true;
}
index += 1;
}
false
}
const fn same(left: &str, right: &str) -> bool {
let (left, right) = (left.as_bytes(), right.as_bytes());
if left.len() != right.len() {
return false;
}
let mut index = 0;
while index < left.len() {
if left[index] != right[index] {
return false;
}
index += 1;
}
true
}
pub fn glyph(character: char) -> Option<[&'static str; GLYPH_ROWS]> {
let exact = |wanted: char| {
FONT.iter()
.find(|(candidate, _)| *candidate == wanted)
.map(|(_, rows)| *rows)
};
exact(character)
.or_else(|| folded(character).and_then(exact))
.or_else(|| character.to_uppercase().find_map(exact))
}
fn folded(character: char) -> Option<char> {
FOLD.iter()
.find(|(from, _)| *from == character)
.map(|(_, to)| *to)
}
#[must_use]
pub fn shape(name: &str) -> Option<char> {
SHAPES
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(_, character)| *character)
}
pub fn shapes() -> impl Iterator<Item = (&'static str, char)> {
SHAPES.iter().copied()
}
#[must_use]
pub fn label(character: char) -> String {
match shape_name(character) {
Some(name) => format!(":{name}:"),
None if character == ' ' => "space".to_string(),
None => character.to_string(),
}
}
pub fn canonical(text: &str) -> Result<String, String> {
let text: String = text
.chars()
.filter(|character| !matches!(character, '\u{fe0f}' | '\u{fe0e}'))
.map(|character| folded(character).unwrap_or(character))
.collect();
let mut out = String::with_capacity(text.len());
let mut rest = text.as_str();
while let Some(open) = rest.find(':') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find(':') else {
return Err(format!(
"unclosed ':' — a shape is written :name:, and the font has {}",
describe_shapes()
));
};
let name = &after[..close];
match shape(name) {
Some(character) => out.push(character),
None => {
return Err(format!(
"no shape called {name:?} — the font has {}",
describe_shapes()
))
}
}
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(out)
}
pub fn alphabet() -> impl Iterator<Item = char> {
FONT.iter().map(|(character, _)| *character)
}
pub fn sunday_of(day: NaiveDate) -> NaiveDate {
day - Days::new(u64::from(day.weekday().num_days_from_sunday()))
}
#[derive(Debug, Clone, Copy)]
pub struct Grid {
pub year: i32,
pub first: NaiveDate,
pub last: NaiveDate,
pub start: NaiveDate,
pub weeks: usize,
}
impl Grid {
pub fn new(year: i32) -> Option<Self> {
let first = NaiveDate::from_ymd_opt(year, 1, 1)?;
let last = NaiveDate::from_ymd_opt(year, 12, 31)?;
let start =
first.checked_sub_days(Days::new(u64::from(first.weekday().num_days_from_sunday())))?;
Some(Self {
year,
first,
last,
start,
weeks: ((last - start).num_days() / 7 + 1) as usize,
})
}
pub fn date_at(&self, week: usize, row: usize) -> NaiveDate {
self.start + Days::new((week * WEEKDAYS + row) as u64)
}
pub fn holds(&self, day: NaiveDate) -> bool {
self.first <= day && day <= self.last
}
pub fn usable_weeks(&self) -> usize {
(0..self.weeks)
.filter(|week| (1..=5).all(|row| self.holds(self.date_at(*week, row))))
.count()
}
}
pub fn bitmap(text: &str) -> Result<Vec<[bool; GLYPH_ROWS]>, String> {
let text = canonical(text)?;
let unknown: Vec<char> = text.chars().filter(|c| glyph(*c).is_none()).collect();
if !unknown.is_empty() {
let names: Vec<String> = unknown.iter().map(|c| format!("{c:?}")).collect();
return Err(format!(
"no glyph for: {} — the font has {}",
names.join(" "),
describe_alphabet()
));
}
let mut columns = Vec::new();
for (index, character) in text.chars().enumerate() {
let rows = glyph(character).expect("checked above");
if index > 0 {
columns.push([false; GLYPH_ROWS]);
}
for column in 0..GLYPH_COLS {
let mut lit = [false; GLYPH_ROWS];
for (row, line) in rows.iter().enumerate() {
lit[row] = line.as_bytes()[column] == b'#';
}
columns.push(lit);
}
}
Ok(columns)
}
fn describe_alphabet() -> String {
let printable: String = alphabet()
.filter(|character| !character.is_whitespace())
.filter(|character| shape_name(*character).is_none())
.collect::<Vec<char>>()
.chunks(36)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<String>>()
.join(" ");
format!("{printable} and space, and {}", describe_shapes())
}
fn describe_shapes() -> String {
let names: Vec<String> = SHAPES.iter().map(|(name, _)| format!(":{name}:")).collect();
format!("the shapes {}", names.join(" "))
}
#[must_use]
pub fn shape_name(character: char) -> Option<&'static str> {
SHAPES
.iter()
.find(|(_, candidate)| *candidate == character)
.map(|(name, _)| *name)
}
pub const CANVAS_ROWS: usize = WEEKDAYS;
pub const CANVAS_COLS: usize = 53;
pub const SHADE_CHARS: [(char, char); 5] =
[('0', ' '), ('1', '░'), ('2', '▒'), ('3', '▓'), ('4', '█')];
#[must_use]
pub fn shade_of(character: char) -> Option<u8> {
SHADE_CHARS
.iter()
.position(|(digit, block)| *digit == character || *block == character)
.map(|level| level as u8)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Meta {
pub name: Option<String>,
pub author: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Canvas {
columns: Vec<[u8; CANVAS_ROWS]>,
meta: Meta,
}
impl Canvas {
#[must_use]
pub fn blank(width: usize) -> Option<Self> {
(1..=CANVAS_COLS).contains(&width).then(|| Self {
columns: vec![[0; CANVAS_ROWS]; width],
meta: Meta::default(),
})
}
pub fn parse(text: &str) -> Result<Self, String> {
let mut meta = Meta::default();
let mut rows: Vec<Vec<u8>> = Vec::new();
for (number, line) in text.lines().enumerate() {
let line = line.strip_suffix('\r').unwrap_or(line);
if let Some(comment) = line.strip_prefix('#') {
read_meta(comment, &mut meta);
continue;
}
if line.is_empty() {
continue;
}
if rows.len() == CANVAS_ROWS {
return Err(format!(
"line {}: {} rows already, and a canvas is exactly {}",
number + 1,
CANVAS_ROWS,
CANVAS_ROWS
));
}
let mut row = Vec::with_capacity(line.chars().count());
for (column, character) in line.chars().enumerate() {
let Some(level) = shade_of(character) else {
return Err(format!(
"line {}, column {}: {character:?} is not a shade — use {}",
number + 1,
column + 1,
describe_shades()
));
};
row.push(level);
}
rows.push(row);
}
if rows.len() != CANVAS_ROWS {
return Err(format!(
"a canvas is exactly {CANVAS_ROWS} rows, one per weekday; this has {}",
rows.len()
));
}
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
if !(1..=CANVAS_COLS).contains(&width) {
return Err(format!(
"a canvas is 1 to {CANVAS_COLS} columns wide; this is {width}"
));
}
let mut columns = vec![[0u8; CANVAS_ROWS]; width];
for (row, levels) in rows.iter().enumerate() {
for (column, level) in levels.iter().enumerate() {
columns[column][row] = *level;
}
}
Ok(Self { columns, meta })
}
#[must_use]
pub fn width(&self) -> usize {
self.columns.len()
}
#[must_use]
pub fn meta(&self) -> &Meta {
&self.meta
}
pub fn set_meta(&mut self, meta: Meta) {
self.meta = meta;
}
#[must_use]
pub fn at(&self, week: usize, row: usize) -> u8 {
self.columns
.get(week)
.and_then(|column| column.get(row).copied())
.unwrap_or(0)
}
pub fn set(&mut self, week: usize, row: usize, level: u8) {
if level > 4 {
return;
}
if let Some(cell) = self
.columns
.get_mut(week)
.and_then(|column| column.get_mut(row))
{
*cell = level;
}
}
pub fn levels(&self) -> impl Iterator<Item = u8> + '_ {
self.columns
.iter()
.flat_map(|column| column.iter().copied())
}
#[must_use]
pub fn histogram(&self) -> [usize; 5] {
let mut counts = [0usize; 5];
for level in self.levels() {
counts[usize::from(level).min(4)] += 1;
}
counts
}
#[must_use]
pub fn range(&self) -> Option<(u8, u8)> {
let low = self.levels().min()?;
let high = self.levels().max()?;
(low != high).then_some((low, high))
}
#[must_use]
pub fn palette(&self) -> Vec<u8> {
let histogram = self.histogram();
(0..=4u8)
.filter(|level| histogram[usize::from(*level)] > 0)
.collect()
}
#[must_use]
pub fn closest_pair(&self) -> Option<(u8, u8, Legibility, f32)> {
let used = self.palette();
let mut worst: Option<(u8, u8, f32)> = None;
for (index, low) in used.iter().enumerate() {
for high in used.iter().skip(index + 1) {
let delta = Shades {
ink: *high,
field: *low,
}
.worst()
.1;
if worst.is_none_or(|(_, _, seen)| delta < seen) {
worst = Some((*low, *high, delta));
}
}
}
worst.map(|(low, high, delta)| (low, high, Legibility::of(delta), delta))
}
#[must_use]
pub fn min_peak(&self) -> u32 {
if self.levels().any(|level| (1..4).contains(&level)) {
4
} else {
1
}
}
#[must_use]
pub fn to_art(&self) -> String {
let mut out = String::new();
for (key, value) in [
("name", self.meta.name.as_deref()),
("author", self.meta.author.as_deref()),
("description", self.meta.description.as_deref()),
] {
if let Some(value) = value {
out.push_str(&format!("# {key}: {value}\n"));
}
}
if !out.is_empty() {
out.push('\n');
}
for row in 0..CANVAS_ROWS {
for week in 0..self.width() {
out.push(SHADE_CHARS[usize::from(self.at(week, row)).min(4)].0);
}
out.push('\n');
}
out
}
#[must_use]
pub fn place(&self, grid: &Grid, start: usize) -> (BTreeMap<NaiveDate, u8>, usize) {
let mut levels = BTreeMap::new();
let mut skipped = 0;
for (offset, column) in self.columns.iter().enumerate() {
let week = match start.checked_add(offset) {
Some(week) if week < grid.weeks => week,
_ => {
skipped += column.iter().filter(|level| **level > 0).count();
continue;
}
};
for (row, level) in column.iter().enumerate() {
let date = grid.date_at(week, row);
if grid.holds(date) {
levels.insert(date, *level);
} else if *level > 0 {
skipped += 1;
}
}
}
(levels, skipped)
}
#[must_use]
pub fn centred(&self, grid: &Grid) -> usize {
grid.weeks.saturating_sub(self.width()) / 2
}
}
fn read_meta(comment: &str, meta: &mut Meta) {
let Some((key, value)) = comment.split_once(':') else {
return;
};
let value = value.trim().to_string();
if value.is_empty() {
return;
}
match key.trim().to_ascii_lowercase().as_str() {
"name" => meta.name = Some(value),
"author" => meta.author = Some(value),
"description" => meta.description = Some(value),
_ => {}
}
}
fn describe_shades() -> String {
let digits: String = SHADE_CHARS.iter().map(|(digit, _)| *digit).collect();
let blocks: String = SHADE_CHARS.iter().map(|(_, block)| *block).collect();
format!("{digits} or {blocks:?} (space is level 0)")
}
#[must_use]
pub fn band(level: u8, peak: u32) -> (u32, Option<u32>) {
match level.min(4) {
0 => (0, Some(0)),
4 => (commits_to_reach(4, peak), None),
level => (
commits_to_reach(level, peak),
commits_to_reach(level + 1, peak).checked_sub(1),
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shades {
pub ink: u8,
pub field: u8,
}
impl Default for Shades {
fn default() -> Self {
Self { ink: 4, field: 0 }
}
}
impl Shades {
const READERS: [(Appearance, Season); 9] = [
(Appearance::Light, Season::Default),
(Appearance::Light, Season::Winter),
(Appearance::Light, Season::Halloween),
(Appearance::Dark, Season::Default),
(Appearance::Dark, Season::Winter),
(Appearance::Dark, Season::Halloween),
(Appearance::Dimmed, Season::Default),
(Appearance::Dimmed, Season::Winter),
(Appearance::Dimmed, Season::Halloween),
];
pub fn check(self) -> Result<(), String> {
if self.ink > 4 || self.field > 4 {
return Err(format!(
"shades run 0 to 4; got ink {} and field {}",
self.ink, self.field
));
}
if self.ink == 0 {
return Err("letters cannot be drawn at level 0 — that is an empty day".to_string());
}
if self.field >= self.ink {
return Err(format!(
"the background (level {}) must be darker than the letters (level {}), \
or there is nothing to see",
self.field, self.ink
));
}
Ok(())
}
pub fn separation(self, palette: &Palette) -> f32 {
palette.separation(self.field, self.ink)
}
pub fn worst(self) -> (Legibility, f32) {
let worst = Self::READERS
.iter()
.map(|(appearance, season)| self.separation(&Palette::new(*appearance, *season, true)))
.fold(f32::INFINITY, f32::min);
(Legibility::of(worst), worst)
}
pub fn min_peak(self) -> u32 {
if self.field > 0 {
4
} else {
1
}
}
pub fn commits(self, peak: u32) -> Ink {
Ink {
lit: commits_to_reach(self.ink, peak),
field: match self.field {
0 => 0,
level => commits_to_reach(level, peak),
},
}
}
pub fn ceiling(self, peak: u32) -> Option<u32> {
if self.field == 0 {
return Some(0);
}
commits_to_reach(self.field.saturating_add(1), peak).checked_sub(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Ink {
pub lit: u32,
pub field: u32,
}
#[derive(Debug, Clone, Default)]
pub struct Placed {
pub lit: BTreeMap<NaiveDate, u32>,
pub field: BTreeMap<NaiveDate, u32>,
pub skipped: usize,
pub start_week: usize,
}
impl Placed {
pub fn all(&self) -> BTreeMap<NaiveDate, u32> {
let mut all = self.field.clone();
all.extend(self.lit.iter().map(|(date, count)| (*date, *count)));
all
}
pub fn total(&self) -> u32 {
self.all()
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count))
}
}
pub fn place(
columns: &[[bool; GLYPH_ROWS]],
grid: &Grid,
top: usize,
start: Option<usize>,
ink: Ink,
) -> Result<Placed, String> {
if top > WEEKDAYS - GLYPH_ROWS {
return Err(format!("--top {top} would push the text past row 6"));
}
if columns.len() > grid.weeks {
return Err(format!(
"{} columns needed but {} has only {}; use shorter text",
columns.len(),
grid.year,
grid.weeks
));
}
let start_week = start.unwrap_or((grid.weeks - columns.len()) / 2);
if start_week > grid.weeks - columns.len() {
return Err(format!(
"--start-week {start_week} puts {} columns past the end of {}, \
which has {}; the last one that fits is {}",
columns.len(),
grid.year,
grid.weeks,
grid.weeks - columns.len()
));
}
let mut lit = BTreeMap::new();
let mut skipped = 0;
for (offset, column) in columns.iter().enumerate() {
for (row, on) in column.iter().enumerate() {
if !on {
continue;
}
let day = grid.date_at(start_week + offset, top + row);
if grid.holds(day) {
lit.insert(day, ink.lit);
} else {
skipped += 1;
}
}
}
let mut field = BTreeMap::new();
if ink.field > 0 {
let mut date = grid.first;
loop {
if !lit.contains_key(&date) {
field.insert(date, ink.field);
}
match date.succ_opt() {
Some(next) if next <= grid.last => date = next,
_ => break,
}
}
}
Ok(Placed {
lit,
field,
skipped,
start_week,
})
}
pub fn level(count: u32, peak: u32) -> u8 {
if count == 0 || peak == 0 {
return 0;
}
(u64::from(count) * 4).div_ceil(u64::from(peak)).min(4) as u8
}
pub fn commits_to_reach(level: u8, peak: u32) -> u32 {
let wanted = u64::from(level.saturating_sub(1)) * u64::from(peak) / 4 + 1;
wanted.clamp(1, u64::from(u32::MAX)) as u32
}
pub fn commits_for_level(
days: &[NaiveDate],
existing: &BTreeMap<NaiveDate, u32>,
target: u8,
) -> Option<u32> {
if days.is_empty() {
return None;
}
let held = |day: &NaiveDate| existing.get(day).copied().unwrap_or(0);
let elsewhere = existing
.iter()
.filter(|(day, _)| !days.contains(day))
.map(|(_, count)| *count)
.max()
.unwrap_or(0);
let quietest = days.iter().map(held).min().unwrap_or(0);
let mut added = 0;
for _ in 0..64 {
let peak = days
.iter()
.map(|day| held(day).saturating_add(added))
.chain([elsewhere])
.max()
.unwrap_or(added)
.max(1);
let wanted = commits_to_reach(target, peak).saturating_sub(quietest);
if wanted <= added {
return Some(added.max(1));
}
added = wanted;
}
None
}
pub fn snapshot(counts: &BTreeMap<NaiveDate, u32>, grid: &Grid, login: &str) -> String {
const NAMES: [&str; 5] = [
"NONE",
"FIRST_QUARTILE",
"SECOND_QUARTILE",
"THIRD_QUARTILE",
"FOURTH_QUARTILE",
];
let peak = counts.values().copied().max().unwrap_or(1);
let mut weeks = Vec::with_capacity(grid.weeks);
for week in 0..grid.weeks {
let days: Vec<serde_json::Value> = (0..WEEKDAYS)
.map(|row| grid.date_at(week, row))
.filter(|day| grid.holds(*day))
.map(|day| {
let count = counts.get(&day).copied().unwrap_or(0);
serde_json::json!({
"date": day.to_string(),
"contributionCount": count,
"contributionLevel": NAMES[level(count, peak) as usize],
})
})
.collect();
if !days.is_empty() {
weeks.push(serde_json::json!({ "contributionDays": days }));
}
}
let total = counts
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
let payload = serde_json::json!({
"data": { "user": {
"login": login,
"contributionsCollection": {
"contributionYears": [grid.year],
"contributionCalendar": { "totalContributions": total, "weeks": weeks },
},
}},
"errors": serde_json::Value::Null,
});
serde_json::to_string_pretty(&payload).expect("a tree of numbers and strings")
}
pub fn shading(placed: &Placed, shades: Shades) -> BTreeMap<NaiveDate, u8> {
let mut out: BTreeMap<NaiveDate, u8> = placed
.field
.keys()
.map(|date| (*date, shades.field))
.collect();
out.extend(placed.lit.keys().map(|date| (*date, shades.ink)));
out
}
pub fn preview(levels: &BTreeMap<NaiveDate, u8>, grid: &Grid, palette: Option<&Palette>) -> String {
const NAMES: [&str; WEEKDAYS] = ["", "Mon", "", "Wed", "", "Fri", ""];
const RAMP: [&str; 5] = [" ", "░░", "▒▒", "▓▓", "██"];
let paint = |level: u8| {
let level = usize::from(level).min(4);
match palette {
Some(palette) => {
let colour = palette.levels[level];
format!("\x1b[38;2;{};{};{}m██\x1b[0m", colour.0, colour.1, colour.2)
}
None => RAMP[level].to_string(),
}
};
let mut label = " ".repeat(4);
let mut seen = Vec::new();
for week in 0..grid.weeks {
let Some(first) = (0..WEEKDAYS)
.map(|row| grid.date_at(week, row))
.find(|day| grid.holds(*day))
else {
continue;
};
if seen.contains(&first.month()) {
continue;
}
seen.push(first.month());
let column = 4 + week * 2;
if column >= label.chars().count() {
label.push_str(&" ".repeat(column - label.chars().count()));
label.push_str(&first.format("%b").to_string());
}
}
let mut out = vec![label];
for (row, name) in NAMES.iter().enumerate() {
let mut line = format!("{name:<4}");
for week in 0..grid.weeks {
let day = grid.date_at(week, row);
if !grid.holds(day) {
line.push_str(" ");
} else {
line.push_str(&paint(levels.get(&day).copied().unwrap_or(0)));
}
}
out.push(line);
}
out.join("\n")
}
pub fn identity() -> (String, String) {
let config = |key: &str| {
Command::new("git")
.args(["config", "--get", key])
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|value| !value.is_empty())
};
(
config("user.name").unwrap_or_else(|| "art".to_string()),
config("user.email").unwrap_or_else(|| "art@example.invalid".to_string()),
)
}
pub fn write_commits(
lit: &BTreeMap<NaiveDate, u32>,
repo: &Path,
label: &str,
name: &str,
email: &str,
) -> Result<usize, String> {
for (what, value) in [("name", name), ("email", email)] {
if value.chars().any(|c| c.is_control()) || value.contains(['<', '>']) {
return Err(format!(
"the commit {what} may not contain control characters, '<' or '>': {value:?}"
));
}
}
if !repo.join(".git").is_dir() {
std::fs::create_dir_all(repo).map_err(|e| format!("could not create {repo:?}: {e}"))?;
run(repo, &["init", "-q", "-b", "main"])?;
}
let mut child = Command::new("git")
.args(["fast-import", "--quiet"])
.current_dir(repo)
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("could not run git fast-import: {e}"))?;
let mut stdin = std::io::BufWriter::new(child.stdin.take().expect("piped"));
let feed = |error: std::io::Error| format!("could not feed git fast-import: {error}");
let mut index = 0usize;
for (day, count) in lit {
let stamp = day
.and_hms_opt(12, 0, 0)
.expect("noon exists")
.and_utc()
.timestamp();
for _ in 0..*count {
index += 1;
let message = format!("{label} {day} #{index}\n");
let body = format!("{index}\n");
write!(
stdin,
"commit refs/heads/main\nmark :{index}\n\
author {name} <{email}> {stamp} +0000\n\
committer {name} <{email}> {stamp} +0000\n\
data {}\n{message}",
message.len()
)
.map_err(feed)?;
if index > 1 {
writeln!(stdin, "from :{}", index - 1).map_err(feed)?;
}
write!(
stdin,
"M 100644 inline count.txt\ndata {}\n{body}\n",
body.len()
)
.map_err(feed)?;
}
}
stdin.flush().map_err(feed)?;
drop(stdin);
let status = child
.wait()
.map_err(|e| format!("git fast-import failed: {e}"))?;
if !status.success() {
return Err(format!("git fast-import exited with {status}"));
}
run(repo, &["reset", "--hard", "main"])?;
Ok(index)
}
fn run(repo: &Path, args: &[&str]) -> Result<(), String> {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.map_err(|e| format!("could not run git {}: {e}", args[0]))?;
if out.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args[0],
String::from_utf8_lossy(&out.stderr).trim()
))
}