Files
ESLPwn/cloud-plugins/src/plugins/github.ts
T
i12bp8 287481453e 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.
2026-04-27 12:49:12 +02:00

364 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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;
},
};