diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml
index e271579..ac2ad73 100644
--- a/.github/workflows/profile.yml
+++ b/.github/workflows/profile.yml
@@ -40,10 +40,16 @@ on:
description: "What he says when you don't."
type: string
default: "...i'll fix that, promise"
+ stats_banner:
+ description: "Path for the standalone stats banner (no character). Empty
+ = don't build it. Set it and the workflow fills your all-time numbers
+ and draws it — you add nothing to awan.json."
+ type: string
+ default: ""
env:
# The release this copy of the file was cut with. Bump it when you tag.
- AWAN_VERSION: v0.0.5
+ AWAN_VERSION: v0.0.6
jobs:
awan:
@@ -135,14 +141,90 @@ jobs:
' "$CONFIG" > awan.tmp && mv awan.tmp "$CONFIG"
rm -f cal.json wall.json
+ # The stats banner wants numbers the one-year calendar can't give — an
+ # all-time commit count from your first commit, and your longest streak
+ # ever — so this walks the contribution calendar year by year, from the
+ # year you joined to now, and writes stat_boxes into your config. Only runs
+ # when you asked for the banner, so nobody else pays for the extra calls.
+ - name: Fill the stats banner numbers
+ if: inputs.stats_banner != ''
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ USER: ${{ inputs.user || github.repository_owner }}
+ CONFIG: ${{ inputs.config }}
+ run: |
+ set -euo pipefail
+ FROM=$(gh api "users/$USER" --jq '.created_at' | cut -c1-4)
+ TO=$(date -u +%Y)
+ COMMITS=0
+ echo '[]' > days.json
+ for Y in $(seq "$FROM" "$TO"); do
+ gh api graphql -f query='
+ { user(login: "'"$USER"'") { contributionsCollection(
+ from: "'"$Y"'-01-01T00:00:00Z", to: "'"$Y"'-12-31T23:59:59Z") {
+ totalCommitContributions
+ contributionCalendar { weeks { contributionDays { date contributionCount } } }
+ } } }' > y.json || continue
+ C=$(jq '.data.user.contributionsCollection.totalCommitContributions // 0' y.json)
+ COMMITS=$((COMMITS + C))
+ jq -s '.[0] + (.[1].data.user.contributionsCollection.contributionCalendar.weeks
+ | map(.contributionDays[]?))' days.json y.json > d.tmp && mv d.tmp days.json
+ done
+
+ # sort the days, then in one pass: the earliest active day (first
+ # commit), and the longest run of consecutive active days with its ends.
+ jq 'sort_by(.date) | unique_by(.date)
+ | (map(select(.contributionCount > 0)) | .[0].date) as $first
+ | reduce .[] as $d ({run: [], best: []};
+ if $d.contributionCount > 0
+ then (.run + [$d.date]) as $r
+ | {run: $r, best: (if ($r|length) > (.best|length) then $r else .best end)}
+ else {run: [], best: .best} end) as $s
+ | {first: $first, len: ($s.best|length),
+ from: ($s.best[0] // ""), to: ($s.best[-1] // "")}' \
+ days.json > life.json
+
+ fmt() { date -u -d "$1" +"%-d %b %Y" 2>/dev/null || echo "$1"; }
+ fmts() { date -u -d "$1" +"%-d %b" 2>/dev/null || echo "$1"; }
+
+ STREAK=$(jq -r '.streak // 0' "$CONFIG")
+ if [ "$STREAK" -gt 0 ]; then
+ SSTART="since $(date -u -d "-$((STREAK-1)) days" +"%-d %b")"
+ else
+ SSTART="not today"
+ fi
+ FIRST=$(jq -r '.first // ""' life.json)
+ LFROM=$(jq -r '.from // ""' life.json); LTO=$(jq -r '.to // ""' life.json)
+ LLEN=$(jq -r '.len // 0' life.json)
+
+ jq --argjson n "$COMMITS" --arg fc "since $(fmt "$FIRST")" \
+ --arg cs "$STREAK days" --arg css "$SSTART" \
+ --arg ls "$LLEN days" --arg lss "$(fmts "$LFROM") - $(fmt "$LTO")" '
+ def commas: tostring | . as $s | ($s | length) as $L
+ | if $L <= 3 then $s else ($s[:$L-3] | commas) + "," + $s[$L-3:] end;
+ .stat_boxes = [
+ {value: ($n | commas), label: "ALL COMMITS", note: $fc},
+ {value: $cs, label: "CURRENT STREAK", note: $css},
+ {value: $ls, label: "LONGEST STREAK", note: $lss}
+ ]' "$CONFIG" > awan.tmp && mv awan.tmp "$CONFIG"
+ rm -f y.json days.json life.json
+
- name: Draw it
run: awan-profile whoami --config "${{ inputs.config }}"
- - name: Shrink the GIF
+ - name: Draw the stats banner
+ if: inputs.stats_banner != ''
+ run: awan-profile stats --config "${{ inputs.config }}" --out "${{ inputs.stats_banner }}"
+
+ - name: Shrink the GIFs
run: |
sudo apt-get update && sudo apt-get install -y gifsicle
OUT=$(jq -r '.output // "assets/awan.gif"' "${{ inputs.config }}")
gifsicle -O3 --colors 64 "$OUT" -o "$OUT"
+ BANNER="${{ inputs.stats_banner }}"
+ if [ -n "$BANNER" ] && [ "${BANNER##*.}" = "gif" ]; then
+ gifsicle -O3 --colors 64 "$BANNER" -o "$BANNER"
+ fi
- name: Commit the result
run: |
@@ -150,5 +232,6 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$OUT" "${{ inputs.config }}"
+ [ -n "${{ inputs.stats_banner }}" ] && git add "${{ inputs.stats_banner }}" || true
git commit -m "chore: refresh awan profile" || echo "no changes"
git push
diff --git a/Cargo.lock b/Cargo.lock
index e539e25..a5f526c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -96,6 +102,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "ctrlc"
version = "3.5.2"
@@ -125,6 +140,25 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
[[package]]
name = "font8x8"
version = "0.3.1"
@@ -159,6 +193,7 @@ dependencies = [
"gif",
"moxcms",
"num-traits",
+ "png",
]
[[package]]
@@ -189,6 +224,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -235,6 +280,19 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -311,6 +369,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
[[package]]
name = "syn"
version = "2.0.118"
diff --git a/README.md b/README.md
index d9e25c2..eaa3370 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,10 @@ cp -r profile/sample/. my-profile/ # awan.json + a GitHub Action + a profile R
cargo run -p awan-profile -- whoami --config my-profile/awan.json
```
+Want the numbers without the character too? A one-line workflow input also draws
+a **stats banner** — all-time commits, current and longest streak, with dates —
+as its own image. See [`profile/`](profile#the-stats-banner).
+
Full walkthrough and the `awan.json` format: **[`profile/`](profile)**. Built as
a separate, opt-in crate, so the core `awan` stays untouched.
@@ -218,6 +222,7 @@ library — no Rust, no rebuild of the engine.
The banner is the product; everything else is how it's made.
- **Shipping** — profile banners with live numbers, the contribution year, a
+ standalone stats banner (all-time commits and streaks, no character), a
browser editor with a character studio, and a reusable workflow you call
rather than copy
- **Next** — more beats worth watching, and somewhere to show the characters
diff --git a/assets/stats-banner.gif b/assets/stats-banner.gif
new file mode 100644
index 0000000..1a52a7f
Binary files /dev/null and b/assets/stats-banner.gif differ
diff --git a/assets/stats-banner.png b/assets/stats-banner.png
new file mode 100644
index 0000000..6dc9b39
Binary files /dev/null and b/assets/stats-banner.png differ
diff --git a/docs/PROFILE.md b/docs/PROFILE.md
index 7a2d543..d42bb51 100644
--- a/docs/PROFILE.md
+++ b/docs/PROFILE.md
@@ -75,6 +75,26 @@ the `stats` readout — live in `awan.json`; the CI job fetches the real values
(`gh api` → `jq`) and writes them in before rendering, so the binary stays
deterministic and offline.
+## The stats banner
+
+A separate output (`awan-profile stats`), and the only one with no character:
+three metrics — all-time commits, current streak, longest streak — in a row,
+split by vertical rules, each with the dates it covers. It answers a different
+question ("the numbers, at a glance") and wants the full width, so it isn't a
+scene in the reel.
+
+The split mirrors the rest: the engine knows nothing about it. The renderer lays
+out `stat_boxes` (three `{value, label, note}` strings) in the 8×8 pixel font, so
+it matches the character banner beside it; CI formats the strings. A `.gif` drifts
+soft clouds behind the numbers and loops (two parallax clouds, each crossing a
+whole number of spans per loop, so `frame(0) == frame(N)`); a `.png` is the still.
+
+The numbers it wants reach past the one-year calendar the walking banner uses —
+an all-time count from the first commit, the longest streak *ever* — so the
+workflow walks the contribution calendar year by year from the year the account
+was created, and only when the `stats_banner` input is set, so the extra API
+calls are opt-in.
+
## Rendering to GIF
The generator lives in the `publish = false` `profile/` crate, so the `image` /
diff --git a/profile/Cargo.toml b/profile/Cargo.toml
index bfc19a2..a9a5a0e 100644
--- a/profile/Cargo.toml
+++ b/profile/Cargo.toml
@@ -16,7 +16,7 @@ path = "src/main.rs"
[dependencies]
awan-core = { workspace = true }
-image = { version = "0.25", default-features = false, features = ["gif"] }
+image = { version = "0.25", default-features = false, features = ["gif", "png"] }
font8x8 = "0.3"
serde = { workspace = true }
serde_json = "1"
diff --git a/profile/README.md b/profile/README.md
index b0219d8..956cc31 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -33,6 +33,42 @@ Same acts, same config shape — only the content differs. Pick a sample and edi
repo's numbers.* Drop `{ "act": "stats" }` into **either** config — it reads the
same `stats` array.
+## The stats banner
+
+A third output, and the only one with **no character**: three numbers in a row,
+split by vertical rules — your all-time commits, your current streak, your
+longest streak — each with the dates it covers. It wants the whole width to
+itself, so it's a separate image from the walking banner.
+
+
+
+
+
+```sh
+awan-profile stats --config awan.json --out assets/stats-banner.gif # drifting clouds, loops
+awan-profile stats --config awan.json --out assets/stats-banner.png # a still
+```
+
+The `.gif` drifts soft clouds behind the numbers and loops; the `.png` is a
+still. Same command — the extension picks.
+
+The three boxes come from `stat_boxes` in your config, which **CI fills** (like
+`stats` and the streak) — you don't write them by hand. Each box is a
+pre-formatted `value`, a `label`, and a `note` for the dates:
+
+```jsonc
+"stat_boxes": [
+ { "value": "1,247", "label": "ALL COMMITS", "note": "since 12 Mar 2021" },
+ { "value": "12 days", "label": "CURRENT STREAK", "note": "since 8 Jul" },
+ { "value": "38 days", "label": "LONGEST STREAK", "note": "3 Feb - 12 Mar 2024" }
+]
+```
+
+To get it drawn and refreshed nightly, set the `stats_banner` input on the
+workflow (see [Auto-update](#auto-update-on-github)). The all-time figures need
+the calendar walked year by year from the year you joined, so the workflow only
+does that when you ask for the banner.
+
## Get started
The [`sample/`](sample) folder is a ready-to-copy setup:
@@ -82,6 +118,9 @@ its own — no Ctrl+C.
// so run the workflow once and look
"contrib_year": 2060, // ↳ both filled in by the workflow too
"contrib_recent": 183,
+ "stat_boxes": [], // the stats banner's 3 boxes (no
+ // character). CI fills these too — see
+ // "The stats banner". Leave it empty.
"output": "assets/awan.gif", // where the GIF is written
"scenes": [ // ← reorder / add / remove freely
{ "act": "wave", "say": "hi there! i'm {name}" },
@@ -175,9 +214,16 @@ jobs:
brag_over: 100 # your bar for "a good month"
brag_say: "i'm so excited!"
cope_say: "...i'll fix that, promise"
+ stats_banner: assets/stats-banner.gif # optional; see "The stats banner"
+```
+
+Then reference the outputs in your profile `README.md`:
+
+```markdown
+
+
```
-Then reference the GIF in your profile `README.md`: ``.
No secrets to set up: the stock `GITHUB_TOKEN` reads everything this needs.
| input | default | what it does |
@@ -186,6 +232,7 @@ No secrets to set up: the stock `GITHUB_TOKEN` reads everything this needs.
| `config` | `awan.json` | where your config lives |
| `brag_over` | `100` | last-30 contributions that count as a good month |
| `brag_say` / `cope_say` | see above | what he says either side of that bar |
+| `stats_banner` | *(off)* | path for the stats banner; set it to draw one |
### Which tag to point at
diff --git a/profile/sample/.github/workflows/awan.yml b/profile/sample/.github/workflows/awan.yml
index a7d527a..3a1d1e1 100644
--- a/profile/sample/.github/workflows/awan.yml
+++ b/profile/sample/.github/workflows/awan.yml
@@ -24,3 +24,7 @@ jobs:
brag_over: 100 # your bar for "a good month"
brag_say: "i'm so excited!"
cope_say: "...i'll fix that, promise"
+ # Also draw the standalone stats banner (all-time commits, current and
+ # longest streak, with dates). Delete this line to skip it — you add
+ # nothing to awan.json either way, the workflow fills the numbers.
+ stats_banner: assets/stats-banner.gif
diff --git a/profile/sample/README.md b/profile/sample/README.md
index 9230abe..fcf3691 100644
--- a/profile/sample/README.md
+++ b/profile/sample/README.md
@@ -7,6 +7,8 @@

+
+
# hi, i'm Muhammad Ridwan 👋
fullstack engineer · Indonesia · building with Rust, Go & TypeScript
diff --git a/profile/sample/awan.json b/profile/sample/awan.json
index f01d8b5..0c5a6bd 100644
--- a/profile/sample/awan.json
+++ b/profile/sample/awan.json
@@ -16,6 +16,7 @@
"contributions": "",
"contrib_year": 0,
"contrib_recent": 0,
+ "stat_boxes": [],
"output": "assets/awan.gif",
"scenes": [
{
diff --git a/profile/src/main.rs b/profile/src/main.rs
index 89a1204..803623a 100644
--- a/profile/src/main.rs
+++ b/profile/src/main.rs
@@ -15,6 +15,7 @@ use awan_core::{Character, Reel, Size};
mod draw;
mod gif;
mod script;
+mod statsbanner;
mod story;
mod wall;
@@ -25,19 +26,37 @@ const FRAME_DELAY: Duration = Duration::from_millis(90);
fn main() {
let args: Vec = std::env::args().skip(1).collect();
- if args.first().map(String::as_str) != Some("whoami") {
- eprintln!("usage: awan-profile whoami --config awan.json");
- eprintln!(" or: awan-profile whoami [--name ..] [--gif out.gif]");
- std::process::exit(2);
+ match args.first().map(String::as_str) {
+ Some("whoami") => whoami(&args),
+ Some("stats") => stats(&args),
+ _ => {
+ eprintln!("usage: awan-profile whoami --config awan.json [--gif out.gif]");
+ eprintln!(" or: awan-profile stats --config awan.json [--out banner.png]");
+ std::process::exit(2);
+ }
}
+}
+
+/// Render the standalone stats banner (no character) to a PNG.
+fn stats(args: &[String]) {
+ let path = flag(args, "--config").unwrap_or_else(|| fail("stats needs --config awan.json"));
+ let profile = load(&path);
+ let out = flag(args, "--out").unwrap_or_else(|| "stats.png".to_string());
+ match statsbanner::render_stats(&profile, &out) {
+ Ok(()) => eprintln!("wrote {out}"),
+ Err(e) => fail(&e.to_string()),
+ }
+}
- let (profile, output) = match flag(&args, "--config") {
+/// Play or render the character reel.
+fn whoami(args: &[String]) {
+ let (profile, output) = match flag(args, "--config") {
Some(path) => {
let p = load(&path);
- let out = flag(&args, "--gif").or_else(|| non_empty(&p.output));
+ let out = flag(args, "--gif").or_else(|| non_empty(&p.output));
(p, out)
}
- None => (from_flags(&args), flag(&args, "--gif")),
+ None => (from_flags(args), flag(args, "--gif")),
};
let reel = Reel::story(character_of(&profile), &profile.acts()).with_size(Size::Seamless);
diff --git a/profile/src/script.rs b/profile/src/script.rs
index b82e9b3..d741f13 100644
--- a/profile/src/script.rs
+++ b/profile/src/script.rs
@@ -55,6 +55,22 @@ pub struct Profile {
pub lyrics: Vec,
pub output: String,
pub scenes: Vec,
+ /// Boxes for the standalone stats banner (`awan-profile stats`) — no
+ /// character, just three metrics with their date context. CI fills these.
+ pub stat_boxes: Vec,
+}
+
+/// One box on the stats banner: a headline number, what it counts, and the
+/// span it covers. All three are plain strings so CI can format them (commas,
+/// dates) and the renderer just lays them out.
+#[derive(Clone, Default, serde::Deserialize)]
+pub struct StatBox {
+ /// The number, pre-formatted: `"1,247"`, `"12 days"`.
+ pub value: String,
+ /// What it is: `"ALL COMMITS"`, `"CURRENT STREAK"`.
+ pub label: String,
+ /// The date context: `"since 12 Mar 2021"`, `"3 Feb - 12 Mar 2024"`.
+ pub note: String,
}
/// One narration line: an optional icon and its text.
diff --git a/profile/src/statsbanner.rs b/profile/src/statsbanner.rs
new file mode 100644
index 0000000..448e433
--- /dev/null
+++ b/profile/src/statsbanner.rs
@@ -0,0 +1,131 @@
+//! The standalone stats banner: three metrics in a row, split by vertical
+//! rules, no character. A separate output from the reel — `awan-profile stats`
+//! writes it — because it answers a different question ("the numbers, at a
+//! glance") and wants the whole width to itself.
+//!
+//! Each box is `value` (the headline number, gold), `label` (what it counts),
+//! and `note` (the span it covers). The renderer only lays them out; CI formats
+//! the strings — commas in the count, the dates in the note — so nothing here
+//! knows anything about GitHub.
+//!
+//! A `.gif` path drifts soft clouds behind the numbers and loops; a `.png` is
+//! the still. The clouds are the one nod to the character's world — awan means
+//! cloud — without a character walking through the readout.
+
+use std::fs::File;
+
+use image::codecs::gif::{GifEncoder, Repeat};
+use image::{Delay, Frame, Rgba, RgbaImage};
+
+use crate::draw::{draw_text, fill};
+use crate::gif::BG;
+use crate::script::Profile;
+
+const W: u32 = 1056;
+const H: u32 = 210;
+/// The headline number, in the accent every awan value uses.
+const GOLD: [u8; 3] = [230, 180, 100];
+/// The label under it.
+const INK: [u8; 3] = [150, 150, 160];
+/// The date note — quieter still.
+const DIM: [u8; 3] = [96, 100, 112];
+/// The vertical rules between boxes.
+const RULE: [u8; 3] = [52, 56, 66];
+/// Drifting clouds — barely above the backdrop, so they read as depth, not data.
+/// `#` is the core, `+` a fainter edge that softens the silhouette.
+const CLOUD: [u8; 3] = [30, 37, 50];
+const CLOUD_EDGE: [u8; 3] = [20, 26, 36];
+/// Frames in one loop, and the per-frame delay (~11 fps, matching the reel).
+const LOOP: i32 = 90;
+const FRAME_MS: u32 = 90;
+
+/// A soft cloud sprite, blitted big and faint behind the numbers — rounded, with
+/// a `+` edge so it doesn't read as a staircase of grey blocks.
+const PUFF: &[&str] = &[
+ " ++####++ ",
+ " +########+ ",
+ " +##########+ ",
+ "+############+",
+ " +##########+ ",
+ " ++####++ ",
+];
+
+/// Render the stats banner from `profile.stat_boxes`. A `.gif` path animates
+/// (drifting clouds, looping); anything else is written as a still PNG.
+pub fn render_stats(profile: &Profile, path: &str) -> std::io::Result<()> {
+ if let Some(dir) = std::path::Path::new(path).parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ if path.ends_with(".gif") {
+ let mut enc = GifEncoder::new(File::create(path)?);
+ let _ = enc.set_repeat(Repeat::Infinite);
+ for t in 0..LOOP {
+ let delay = Delay::from_numer_denom_ms(FRAME_MS, 1);
+ let frame = Frame::from_parts(banner(profile, Some(t)), 0, 0, delay);
+ enc.encode_frame(frame).map_err(err)?;
+ }
+ Ok(())
+ } else {
+ banner(profile, None).save(path).map_err(err)
+ }
+}
+
+fn err(e: image::ImageError) -> std::io::Error {
+ std::io::Error::other(e.to_string())
+}
+
+/// One frame: the backdrop, drifting clouds if animated, the rules, the numbers.
+fn banner(profile: &Profile, t: Option) -> RgbaImage {
+ let mut img = RgbaImage::from_pixel(W, H, Rgba([BG[0], BG[1], BG[2], 255]));
+ if let Some(t) = t {
+ // two clouds, parallax: the near one crosses twice per loop. Each drifts
+ // a whole number of spans over LOOP, so frame 0 and frame LOOP match.
+ cloud(&mut img, t, 1, 22, 17, W / 5);
+ cloud(&mut img, t, 2, 116, 13, 3 * W / 5);
+ }
+ let boxes = &profile.stat_boxes;
+ let n = boxes.len().max(1) as u32;
+ let col = W / n;
+ for i in 1..n {
+ fill(&mut img, i * col - 1, 34, 2, H - 68, RULE);
+ }
+ for (i, b) in boxes.iter().enumerate() {
+ let cx = i as u32 * col + col / 2;
+ centered(&mut img, &b.value, cx, 46, 5, GOLD);
+ centered(&mut img, &b.label, cx, 122, 2, INK);
+ centered(&mut img, &b.note, cx, 160, 1, DIM);
+ }
+ img
+}
+
+/// One drifting cloud: `spans` crossings per loop, top at `y`, `px` per art
+/// pixel, starting at `start`. Wraps seamlessly because the drift over `LOOP` is
+/// exactly `spans` whole spans.
+fn cloud(img: &mut RgbaImage, t: i32, spans: i32, y: u32, px: u32, start: u32) {
+ let cw = PUFF[0].len() as u32 * px;
+ let span = (W + cw) as i32;
+ let x = (start as i32 - spans * span * t / LOOP).rem_euclid(span) - cw as i32;
+ for (r, row) in PUFF.iter().enumerate() {
+ for (c, ch) in row.chars().enumerate() {
+ let colour = match ch {
+ '#' => CLOUD,
+ '+' => CLOUD_EDGE,
+ _ => continue,
+ };
+ let px0 = x + (c as u32 * px) as i32;
+ if px0 + (px as i32) <= 0 || px0 >= W as i32 {
+ continue;
+ }
+ let x0 = px0.max(0) as u32;
+ let w = (px0 + px as i32).min(W as i32) as u32 - x0;
+ fill(img, x0, y + r as u32 * px, w, px, colour);
+ }
+ }
+}
+
+/// Draw `text` centred on `cx` at `scale`. font8x8 advances a fixed 8·scale per
+/// glyph, so the width is just the character count.
+fn centered(img: &mut RgbaImage, text: &str, cx: u32, y: u32, scale: u32, colour: [u8; 3]) {
+ let w = text.chars().count() as u32 * 8 * scale;
+ draw_text(img, text, cx.saturating_sub(w / 2), y, scale, colour);
+}