WiFi plugins: GitHub Profile + DiceBear identicon + tri-state canvas

Worker (cloud-plugins/):
- New plugin 'GitHub Profile': avatar dithered upper-left, display
  name + @handle (with accent star) + word-wrapped bio in the right
  column, followers/repos line, and a 53x7 contribution heatmap
  with an accent palette (red dots for everyday activity, black for
  the rare hottest days). Tuned for 208x112 ESLs and adapts to
  296x128 with a bigger avatar and roomier heatmap.
- Identicon plugin gains DiceBear style selector (pixel-art /
  bottts / lorelei / adventurer / micah / fun-emoji / shapes) on
  top of the original symmetric grid 'local' mode. Both are
  rendered through the new shared image_util pipeline.
- Crypto plugin redesigned: editorial layout with corner brackets,
  bold price headline, white-knockout delta badge with a real
  triangle glyph, currency symbol prefix using the canvas font's
  new euro / pound / yen glyphs, and a stippled accent fill under
  the sparkline.

Canvas:
- setPixel is now last-write-wins across planes, so drawing black
  on top of an accent-filled area produces a clean black pixel
  instead of the muddy plane-conflict the e-paper driver was
  showing previously. Every pixel is therefore exactly one of
  white / black / accent which is what plugin authors intuit.
- New whitePixel + drawTextWhite primitives for knockout text
  (e.g. white labels punched through a coloured badge).
- FONT_EXTRA shipped with the canvas for currency / arrow / shape
  / star / bracket glyphs. Plugins can now write '\u20AC1234' and
  have the euro symbol Just Work.

Image pipeline (new image_util.ts):
- One fetchImageGray() that sniffs PNG vs JPEG, decodes via
  upng-js / jpeg-js, composites alpha over white, and returns a
  tightly-packed grayscale buffer.
- One blitGrayDither() that nearest-neighbour scales the gray
  buffer to a destination rect and serpentine Floyd-Steinberg
  dithers it on the way in. Used by identicon and github.

FAP:
- WiFi BMP writer is now plane-aware: writes the same biPlanes=1,
  biBitCount=2, 3-entry palette layout the rest of the IR TX
  pipeline already understood for web-image-prep BMPs. Tri-colour
  tags now actually receive accent ink from cloud-rendered plugins.
- WiFi run scene un-forces accent: plugins are invoked with the
  tag's actual accent capability and the BMP is opened with the
  matching plane count.
- Run scene now calls bmp_abort on exit so an aborted transfer
  doesn't leak the ~10 KB pixel buffer.
- Plugin manifest cache capped at TT_WIFI_MAX_FAP_PLUGINS=8 (down
  from 16) and the UART RX stream buffer shrunk to 8 KB. Together
  these cut WiFi-flow heap pressure by ~23 KB and fix the OOM
  crashes that occasionally happened when opening WiFi Plugins.
- Broadcast scene auto-transmit fix: enter_callback now filters
  by the >> Transmit << row index, and the cursor defaults to
  row 0 instead of Transmit. Previously a stale OK key-release
  from the prior submenu would land on the Transmit row and
  blast the radio without any user action.
This commit is contained in:
i12bp8
2026-04-27 12:49:12 +02:00
parent 99bcc162b8
commit 287481453e
18 changed files with 1298 additions and 195 deletions
+363
View File
@@ -0,0 +1,363 @@
/*
* GitHub Profile plugin.
*
* Layout (designed for 208x112, scales up to 296x128):
*
* ┌────────────────────────────────────────┐
* │ ┌───────┐ Linus Torvalds │ <- display name (scale 1)
* │ │ │ @torvalds ★ 178K │ <- handle + stars (scale 1, accent star)
* │ │AVATAR │ │
* │ │ │ Creator of Linux, │ <- bio (scale 1, word-wrapped,
* │ │ │ unrepentantly cranky, │ multiple lines, can extend
* │ │ │ mostly benevolent dictator │ below the avatar)
* │ └───────┘ │
* │ │
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ <- contribution heatmap
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ (red dots, hottest = black)
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │
* │ 1,234 contribs / yr │ <- caption
* └────────────────────────────────────────┘
*
* Palette (kept light + airy on purpose):
* - Black: typography, avatar frame, hottest-day cells (level 4).
* - Accent: the star glyph and most heatmap cells (levels 1-3).
* - White: paper / bg.
*
* No solid coloured banner, no boxed badges - the card reads as a
* tiny print-magazine page rather than a UI screenshot.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
import { fetchImageGray, blitGrayDither } from "../image_util";
interface GhUser {
login: string;
name: string | null;
bio: string | null;
avatar_url: string;
followers: number;
public_repos: number;
}
interface GhRepoLite { stargazers_count: number; }
interface ContribCell { date: string; count: number; level: 0|1|2|3|4; }
interface ContribResp {
total: Record<string, number>;
contributions: ContribCell[];
}
async function fetchUser(login: string): Promise<GhUser | null> {
try {
const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {
headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" },
});
if (!r.ok) return null;
return await r.json() as GhUser;
} catch { return null; }
}
async function fetchTotalStars(login: string): Promise<number | null> {
try {
const r = await fetch(
`https://api.github.com/users/${encodeURIComponent(login)}/repos` +
`?per_page=100&sort=pushed`,
{ headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" } });
if (!r.ok) return null;
const repos: GhRepoLite[] = await r.json();
let total = 0;
for (const r of repos) total += r.stargazers_count | 0;
return total;
} catch { return null; }
}
async function fetchContributions(login: string): Promise<ContribResp | null> {
try {
const r = await fetch(
`https://github-contributions-api.jogruber.de/v4/${encodeURIComponent(login)}?y=last`,
{ headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) return null;
return await r.json() as ContribResp;
} catch { return null; }
}
function compact(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "K";
return String(n);
}
function ascii(s: string): string {
/* Smart-typography → ASCII so apostrophes / em-dashes survive. */
return s
.replace(/[\u2018\u2019\u02BC]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u2026/g, "...")
.replace(/[^\x20-\x7E]/g, "");
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
if (max <= 3) return s.slice(0, max);
return s.slice(0, max - 3) + "...";
}
/** Greedy word-wrap into at most `maxLines` lines of `maxChars`. The
* last line gets an ellipsis if there's still content left over. */
function wrapText(s: string, maxChars: number, maxLines: number): string[] {
if (maxChars <= 0 || maxLines <= 0) return [];
const words = s.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let cur = "";
let i = 0;
for (; i < words.length; i++) {
const w = words[i];
const cand = cur ? cur + " " + w : w;
if (cand.length <= maxChars) {
cur = cand;
} else {
if (cur) lines.push(cur);
cur = w.length <= maxChars ? w : w.slice(0, maxChars);
if (lines.length >= maxLines) { i++; break; }
}
}
if (cur && lines.length < maxLines) { lines.push(cur); cur = ""; }
/* Anything that didn't fit gets reflected as an ellipsis on the
* last line, so the user can tell text was truncated. */
if (i < words.length || cur) {
if (lines.length === 0) return lines;
const last = lines[lines.length - 1];
if (last.length <= maxChars - 3) lines[lines.length - 1] = last + "...";
else lines[lines.length - 1] = last.slice(0, maxChars - 3) + "...";
}
return lines;
}
export const githubPlugin: Plugin = {
manifest: {
id: "github",
name: "GitHub Profile",
description: "Avatar, bio and contribution heatmap card",
accent_modes: 1 | 2 | 4,
params: [
{ key: "username", label: "Username", type: "string", default: "torvalds" },
],
},
async render(params, W, H, accent: AccentMode) {
const login = ((params.username ?? "torvalds").trim() || "torvalds");
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
const [user, stars, contrib] = await Promise.all([
fetchUser(login),
fetchTotalStars(login),
fetchContributions(login),
]);
if (!user) {
const t1 = "USER NOT FOUND";
const t2 = "@" + login;
const s1 = c.textSize(t1, 2), s2 = c.textSize(t2, 1);
c.drawText((W - s1.w) >> 1, (H >> 1) - s1.h, t1, blk, 2);
c.drawText((W - s2.w) >> 1, (H >> 1) + 2, t2, blk, 1);
c.rect(0, 0, W, H, blk);
return c;
}
/* ── Geometry ─────────────────────────────────────────────
* Tuned for 208×112; scales up to 296×128 with a bigger
* avatar and roomier heatmap cells. */
const isWide = W >= 256;
const margin = 4;
const charW = 6; // FONT_5x7_W + gap
const lineH = 9; // FONT_5x7_H + gap
/* Avatar: dominate the upper-left. Bigger here = more typography
* room AND more pixels for the dithered face. */
const avatarS = isWide ? 64 : 60;
const avX = margin;
const avY = margin;
c.rect(avX, avY, avatarS, avatarS, blk);
const avatar = await fetchImageGray(`${user.avatar_url}&s=${avatarS * 2}`);
if (avatar) {
blitGrayDither(c, avX + 1, avY + 1, avatarS - 2, avatarS - 2, avatar, blk);
} else {
const ini = (user.login.charAt(0) || "?").toUpperCase();
const sz = c.textSize(ini, 5);
c.drawText(avX + ((avatarS - sz.w) >> 1),
avY + ((avatarS - sz.h) >> 1),
ini, blk, 5);
}
/* ── Right column: name / handle+stars / bio ─────────────── */
const tX = avX + avatarS + 6;
const tW = W - tX - margin;
const maxChars = Math.max(1, Math.floor(tW / charW));
/* Heatmap budget computed up-front so we know where the bio is
* allowed to flow into. */
const cols = 53, rows = 7;
const captionH = lineH + 2;
const heatCellTarget = isWide ? 4 : 3;
const heatBudget = rows * heatCellTarget;
const heatBlockH = heatBudget + captionH + 4;
const bioMaxBot = H - heatBlockH - 2;
let ty = avY;
/* Display name first, in primary ink. Falls back to login
* (capitalised) if the user has no display name set. */
const displayName = user.name && user.name.trim()
? truncate(ascii(user.name).trim(), maxChars)
: truncate(user.login, maxChars);
if (displayName) {
c.drawText(tX, ty, displayName, blk, 1);
ty += lineH;
}
/* Handle + stars on a single line. The ★ glyph is drawn in
* accent for a deliberate splash of colour right next to the
* stars number; everything else stays primary. */
{
const handle = "@" + ascii(user.login);
const sNum = `\u2605 ${compact(stars ?? 0)}`;
const hSize = c.textSize(handle, 1);
const sSize = c.textSize(sNum, 1);
const sep = " ";
const sepW = c.textSize(sep, 1).w;
/* If both pieces fit, draw them inline. Else drop the handle
* (the avatar already says who this is). */
if (hSize.w + sepW + sSize.w <= tW) {
c.drawText(tX, ty, handle, blk, 1);
/* Star glyph in accent, number text in primary - splits the
* `★ 178K` string into two drawText calls so the colour break
* is exactly at the glyph. */
const sx = tX + hSize.w + sepW;
c.drawText(sx, ty, "\u2605", acc, 1);
c.drawText(sx + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
} else {
c.drawText(tX, ty, "\u2605", acc, 1);
c.drawText(tX + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
}
ty += lineH + 2;
}
/* Followers / repos line(s) - drawn at the BOTTOM of the right
* column, left-aligned to the same `tX` as name / handle / bio.
* Reserve their vertical room first so the bio knows when to
* stop wrapping. The single-line form is preferred; if it
* doesn't fit the column width, fall back to two stacked lines. */
const statSingle = `${compact(user.followers)} followers \u00B7 ${compact(user.public_repos)} repos`;
const statLines: string[] = c.textSize(statSingle, 1).w <= tW
? [statSingle]
: [`${compact(user.followers)} followers`,
`${compact(user.public_repos)} repos`];
const statsBlockH = statLines.length * lineH;
/* Stats sit just above the heatmap caption / heatmap, butted up
* against the bottom of the right column. */
const statsTop = bioMaxBot - statsBlockH;
const bioBudget = statsTop - 2 - ty; // px the bio is allowed
/* Bio - word-wrapped into the budget left after the stats are
* accounted for. */
if (user.bio && bioBudget >= lineH) {
const maxLines = Math.max(0, Math.floor(bioBudget / lineH));
if (maxLines > 0) {
const lines = wrapText(ascii(user.bio).trim(), maxChars, maxLines);
for (const line of lines) {
c.drawText(tX, ty, line, blk, 1);
ty += lineH;
}
}
}
/* Now drop the stats in left-aligned with everything else. */
{
let sy = statsTop;
for (const line of statLines) {
c.drawText(tX, sy, line, blk, 1);
sy += lineH;
}
}
/* ── Heatmap ─────────────────────────────────────────────── */
const heatTop = H - heatBlockH;
const heatBot = H - captionH;
const heatH = heatBot - heatTop;
let cell = Math.max(1, Math.min(
Math.floor((W - margin * 2 + 1) / cols),
Math.floor((heatH + 1) / rows),
));
let gap = cell >= 3 ? 1 : 0;
while (cell > 1 &&
(cell * cols + gap * (cols - 1) > W - margin * 2 ||
cell * rows + gap * (rows - 1) > heatH)) {
if (gap > 0) gap--;
else cell--;
}
const gw = cell * cols + gap * (cols - 1);
const gh = cell * rows + gap * (rows - 1);
const gx = (W - gw) >> 1;
const gy = heatTop + ((heatH - gh) >> 1);
if (contrib) {
const data = contrib.contributions;
const padN = Math.max(0, data.length - cols * rows);
for (let i = padN; i < data.length; i++) {
const off = i - padN;
const col = Math.floor(off / rows);
const row = off % rows;
const cx = gx + col * (cell + gap);
const cy = gy + row * (cell + gap);
const lvl = data[i].level;
if (lvl === 0) continue;
/* See doc-comment at top of file for the palette story. */
if (lvl >= 4) {
c.fillRect(cx, cy, cell, cell, blk);
} else if (lvl >= 3 || cell <= 2) {
c.fillRect(cx, cy, cell, cell, acc);
} else if (lvl === 2) {
c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
} else {
if (cell >= 4) c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
else c.setPixel(cx + (cell >> 1), cy + (cell >> 1), acc);
}
}
} else {
const t = "GRAPH OFFLINE";
const sz = c.textSize(t, 1);
c.drawText((W - sz.w) >> 1, gy + ((gh - sz.h) >> 1), t, blk, 1);
}
/* ── Caption: tight, abbreviated so it always fits ─────────
* "1.2K contribs / yr" is 17 chars ≈ 102 px at scale 1, fits in
* 200 px easily. We use compact() on the total so a 50K-streak
* pro doesn't break the layout. */
{
const total = contrib
? (contrib.total["lastYear"]
?? Object.values(contrib.total).reduce((a, b) => a + b, 0))
: 0;
const cap = contrib
? `${compact(total)} contribs / yr`
: `last 12 mo \u00B7 offline`;
const cs = c.textSize(cap, 1);
const cx = (W - cs.w) >> 1;
const cy = H - cs.h - 2;
c.drawText(cx, cy, cap, blk, 1);
}
/* Outer 1-px frame. */
c.rect(0, 0, W, H, blk);
return c;
},
};