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. + +

+ all commits, current streak, longest streak — with dates +

+ +```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 +![awan](assets/awan.gif) +![my github, at a glance](assets/stats-banner.gif) ``` -Then reference the GIF in your profile `README.md`: `![awan](assets/awan.gif)`. 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 @@ ![awan](assets/awan.gif) +![my github, at a glance](assets/stats-banner.gif) + # 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); +}