mirror of
https://github.com/i12bp8/ESLPwn.git
synced 2026-09-27 05:37:56 +00:00
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:
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user