// tray-renderer.js — animatable, live-editable macOS menu-bar (tray) icon for
// Aesthetic Computer, mirroring the Menuband / slab renderer structure:
// • a PURE render function state -> nativeImage (composites pals + badge)
// • an animation TICKER that advances a phase and re-renders
// • a live-reload WATCHER on a JSON config so the design iterates without
// restarting the app (edit ~/.ac-os/tray.json → icon updates instantly)
//
// The base art is the PALS pink line-art (build/icons/pals.svg) rendered exactly
// like main.js's buildPalsTrayImage. A small label (e.g. "USB", in the Menuband
// 7pt-mono-bold style) can ride to the right, with an optional animation effect.
const fs = require('fs');
const os = require('os');
const path = require('path');
const { nativeImage, systemPreferences } = require('electron');
// Normalize any "#RRGGBB[AA]" to "#RRGGBB" (librsvg/sharp wants 6-digit hex).
const hex6 = (s) => (typeof s === 'string' && s[0] === '#') ? '#' + s.slice(1, 7) : s;
// Resolve a color spec to a concrete SVG color so the badge integrates with the
// live macOS system colors. Keywords:
// "accent" → the user's system accent color (badge background)
// "accent-text" → the system text color used ON accent/selected fills (white-ish)
// "label" → the system primary label color (adapts light/dark)
// Anything else passes straight through (e.g. an explicit hex).
function resolveColor(c) {
if (typeof c !== 'string') return c;
const k = c.toLowerCase();
try {
if (k === 'accent') {
const a = systemPreferences.getAccentColor && systemPreferences.getAccentColor();
if (a && a.length >= 6) return hex6('#' + a);
return '#0a84ff';
}
if (k === 'accent-text' || k === 'on-accent')
return hex6(systemPreferences.getColor?.('alternate-selected-control-text')) || '#ffffff';
if (k === 'label' || k === 'text')
return hex6(systemPreferences.getColor?.('label') || systemPreferences.getColor?.('text')) || '#ffffff';
} catch (e) { /* fall through to passthrough */ }
return c;
}
let sharp = null;
try { sharp = require('sharp'); } catch (e) { /* fall back to static icon */ }
const PINK = '#cd5c9b';
const STATE_FILE = path.join(os.homedir(), '.ac-os', 'tray.json');
// Default state. The watched JSON shallow-merges over this.
const DEFAULTS = {
// ── Base logo art. 'spin' = the blinged low-poly amethyst pals turntable
// (pre-baked frames under build/icons/pals-spin, cycled continuously);
// 'pals' = the classic flat pink line-art. Spin is the default. ──
logoMode: 'spin', // 'spin' | 'pals'
spinFps: 20, // spin animation tick rate
// The gem turns SLOWLY when the Mac is idle and speeds UP as CPU load rises —
// a little ambient system monitor. Period (ms per 360°) eases between these
// two bounds by the live CPU busy fraction (0 = idle → spinIdleMs, 1 = pegged
// → spinBusyMs). Set spinReactive:false to hold a constant spinIdleMs.
spinReactive: true,
spinIdleMs: 7000, // slow drift at rest
spinBusyMs: 1100, // whirs when the CPU is pinned
spinPeriodMs: 7000, // back-compat: constant period when spinReactive is false
show: false, // show the badge?
badge: 'USB', // label text (Menuband-style)
logoScale: 0.86, // pals logo height as a fraction of the 22px bar (smaller
// than full so the number has room top-right)
logoColor: PINK, // pals line-art color; "accent" follows the system accent
color: 'accent', // badge text color
bg: '#ffffff', // bumper background (white/neutral); null/"none" = text only
outline: 'accent', // bumper outline color; null/"none" = no outline
outlineWidth: 1, // outline width (px @1x)
pt: 8, // label point size (Menuband uses ~7 for digits)
badgeYOffset: 0, // nudge the bumper up/down (px @1x) over the logo
effect: 'blink', // 'none' | 'pulse' | 'blink'(binary on/off)
periodMs: 1500, // full animation cycle (ms) — blink = on for half
fps: 8, // animation tick rate
// ── Top-right superscript count (independent of the centered badge). ──
// Rides the top-right corner of the logo like the Menuband instrument
// number: bare glyph (no pill), monospaced-digit heavy at 7pt, tight kern.
// Used to surface the live KidLisp keeps count. Shown whenever `count`
// is a non-empty value and `showCount` is not false.
showCount: true, // master toggle for the superscript
count: null, // the number/text to ride top-right (null → hidden)
countColor: 'logo', // glyph color; 'logo' matches the pals logo color,
// 'label' follows the system label, or pass a hex/'accent'
countPt: 8, // point size — Menuband's instrument number reads ~this
// big because SF's system digits sit larger than SF Mono
countYOffset: 0, // nudge the superscript down (px @1x) from the top edge
countXOffset: -2, // nudge the number horizontally (px @1x; negative = left)
countGap: 1, // gap (px @1x) between the logo and the number column
countHalo: false, // draw a faint contrast halo for legibility over the art
};
class TrayRenderer {
constructor(tray) {
this.tray = tray;
this.state = { ...DEFAULTS };
this.master = null; // cached pals pink raster (sharp buffer)
this.spin = null; // cached spin frames: {1:[{data,info}],2:[…],3:[…]}
this.spinCount = 0; // number of spin frames (per scale)
this.spinLoading = null; // in-flight load promise (load once)
this.phase = 0; // 0..1 animation phase
this.cpu = 0; // smoothed CPU busy fraction 0..1 (drives spin speed)
this._cpuPrev = null; // last os.cpus() times snapshot for delta sampling
this.timer = null;
this.watcher = null;
this.rendering = false;
this.dirty = false;
}
// ── Spin frames: a pre-baked transparent turntable, stored as one
// horizontal sprite sheet per scale (sheet-1x/2x/3x.png at tray heights
// 22/44/66) + manifest.json. Sliced once into per-frame raw RGBA and
// cached. Dev reads build/icons/pals-spin; the packaged app ships it as
// an extraResource at Resources/pals-spin. Returns null if absent (the
// renderer then falls back to the flat pals line-art). ──
loadSpin() {
if (this.spin || this.spin === false) return Promise.resolve(this.spin || null);
if (this.spinLoading) return this.spinLoading;
this.spinLoading = (async () => {
if (!sharp) { this.spin = false; return null; }
const base = [
path.join(__dirname, 'build', 'icons', 'pals-spin'),
path.join(process.resourcesPath || '', 'pals-spin'),
].find((p) => p && fs.existsSync(path.join(p, 'manifest.json')));
if (!base) { this.spin = false; return null; }
const spin = {};
try {
const man = JSON.parse(fs.readFileSync(path.join(base, 'manifest.json'), 'utf8'));
const N = man.frames;
for (const scale of [1, 2, 3]) {
const sheet = sharp(path.join(base, `sheet-${scale}x.png`)).ensureAlpha();
const meta = await sheet.metadata();
const fw = (man.frameW && man.frameW[scale]) || Math.round(meta.width / N);
const fh = meta.height;
spin[scale] = [];
for (let i = 0; i < N; i++) {
// extract() consumes the pipeline, so re-open per frame.
spin[scale].push(await sharp(path.join(base, `sheet-${scale}x.png`)).ensureAlpha()
.extract({ left: i * fw, top: 0, width: fw, height: fh })
.raw().toBuffer({ resolveWithObject: true }));
}
}
} catch (e) { console.warn('[tray] spin load failed:', e.message); this.spin = false; return null; }
if (!spin[1]?.length) { this.spin = false; return null; }
this.spin = spin;
this.spinCount = spin[1].length;
console.log(`[tray] spin frames loaded: ${this.spinCount} × 3 scales`);
return spin;
})();
// Once frames resolve (or fail), re-sync the ticker and redraw.
this.spinLoading.then(() => { this.syncTicker(); this.refresh(); }).catch(() => {});
return this.spinLoading;
}
// ── PALS master line-art, recolored to `logoColor` (default pals pink, or
// "accent" to follow the system accent). Cached; rebuilt only when the
// resolved color changes (e.g. accent-color-changed). ──
async buildMaster() {
if (!sharp) return null;
const color = resolveColor(this.state.logoColor || PINK);
if (this.master && this._masterColor === color) return this.master;
// Dev runs from source (build/icons/pals.svg); the packaged app ships the
// SVG as an extraResource at Resources/pals.svg (build/ is electron-builder's
// buildResources dir and is excluded from the asar), so try both.
const svgPath = [
path.join(__dirname, 'build', 'icons', 'pals.svg'),
path.join(process.resourcesPath || '', 'pals.svg'),
].find((p) => p && fs.existsSync(p));
if (!svgPath) return null;
const svg = fs.readFileSync(svgPath, 'utf8');
const recolored = svg.replace(
/fill="#[0-9a-fA-F]{3,8}"/g,
`fill="${color}" stroke="${color}" stroke-width="0.5" stroke-linejoin="round"`
);
this.master = await sharp(Buffer.from(recolored), { density: 1200 }).trim().png().toBuffer();
this._masterColor = color;
return this.master;
}
// Rasterize the badge to a raw RGBA buffer: a rounded "warning bumper"
// background pill (bg) with mono-bold text (fg) on top — Menuband-style label,
// but as a chip we superimpose on the logo. If bg is falsy/"none" it's just
// text. Fixed W/H (no trim) so centering over the logo is predictable.
async renderBadge(text, fg, bg, outline, outlineWAt1x, ptAt1x, scale, opacity) {
if (!sharp || !text) return null;
fg = resolveColor(fg);
bg = resolveColor(bg);
const ol = resolveColor(outline);
const px = Math.max(1, Math.round(ptAt1x * scale));
const padX = Math.max(1, Math.round(px * 0.30)); // tighter pill
const padY = Math.max(1, Math.round(px * 0.15));
const charW = px * 0.62; // monospace advance
const tw = Math.max(1, Math.round(charW * text.length));
const W = tw + padX * 2, H = px + padY * 2;
const hasBg = bg && bg !== 'none';
const hasOl = ol && ol !== 'none' && outlineWAt1x > 0;
const sw = hasOl ? Math.max(1, Math.round(outlineWAt1x * scale)) : 0;
const r = Math.round(H * 0.34);
const op = opacity.toFixed(3);
const baseY = Math.round(padY + px * 0.82); // text baseline
// Inset the pill by half the stroke so the outline stays inside W×H.
const rect = (hasBg || hasOl)
? ``
: ``;
const svg =
``;
return sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
}
// Rasterize the top-right superscript count to a tight raw RGBA buffer.
// Mirrors Menuband's instrument-number style (KeyboardIconRenderer.swift):
// monospaced digits, heavy weight, 7pt, −0.4 kern, no background. Optional
// faint halo (paint-order stroke) keeps digits legible over the line-art.
async renderCount(text, fg, ptAt1x, scale, halo) {
if (!sharp || text == null || text === '') return null;
fg = resolveColor(fg);
const px = Math.max(1, Math.round(ptAt1x * scale));
const charW = px * 0.66; // advance for the system font
const kern = (-0.4 * scale).toFixed(2); // tighten like Menuband
const padX = Math.max(1, Math.round(px * 0.18));
const padY = Math.max(1, Math.round(px * 0.14));
const tw = Math.max(1, Math.round(charW * String(text).length));
const W = tw + padX * 2, H = px + padY * 2;
const baseY = Math.round(padY + px * 0.82); // text baseline
const haloAttrs = halo
? `paint-order="stroke" stroke="${resolveColor('accent-text')}" ` +
`stroke-width="${Math.max(1, Math.round(px * 0.16))}" stroke-opacity="0.6" stroke-linejoin="round" `
: ``;
const svg =
``;
return sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
}
// ── PURE render: current state + phase -> nativeImage (multi-scale). ──
async render() {
const s = this.state;
// Resolve the base art. Spin mode uses the pre-baked turntable frames;
// if they're missing it falls back to the flat pals line-art.
let mode = s.logoMode || 'spin';
if (mode === 'spin') { await this.loadSpin(); if (!this.spin) mode = 'pals'; }
if (mode !== 'spin' && !(await this.buildMaster())) return null;
// Animation → a 0..1 multiplier applied to the label (opacity here; easy to
// extend to position/scale). Static when effect is 'none' or badge hidden.
let labelOpacity = 1;
if (s.show) {
if (s.effect === 'pulse') labelOpacity = 0.35 + 0.65 * (0.5 - 0.5 * Math.cos(this.phase * 2 * Math.PI));
else if (s.effect === 'blink') labelOpacity = this.phase < 0.5 ? 1 : 0; // binary on/off
}
const out = nativeImage.createEmpty();
for (const scale of [1, 2, 3]) {
try {
const H = 22 * scale;
// Base logo layer for this scale: a spin frame (already tray-height)
// or the recolored pals master resized to the shrunk logo height.
let logo;
if (mode === 'spin') {
const frames = this.spin[scale];
const idx = frames.length ? Math.floor(this.phase * frames.length) % frames.length : 0;
logo = frames[idx]; // {data, info:{width,height}}
} else {
const SHRINK = s.logoScale || 0.94;
logo = await sharp(this.master)
.resize({ height: Math.max(1, Math.round(22 * SHRINK * scale)), kernel: 'lanczos3' })
.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
}
const lw = logo.info.width;
const lh = logo.info.height;
// Superscript count rides its OWN column to the right of the logo so it
// never overlaps the art. Render it first to size that column.
let cnt = null, cw = 0, ch = 0;
const countShown = s.showCount !== false && s.count != null && s.count !== '';
if (countShown) {
// 'logo' → the pals logo color so the number matches the figure.
const countColor = (!s.countColor || s.countColor === 'logo')
? (s.logoColor || PINK) : s.countColor;
cnt = await this.renderCount(String(s.count), countColor, s.countPt, scale, s.countHalo);
if (cnt) { cw = cnt.info.width; ch = cnt.info.height; }
}
const gap = cnt ? Math.max(0, Math.round((s.countGap != null ? s.countGap : 2) * scale)) : 0;
const xOff = cnt ? Math.round((s.countXOffset || 0) * scale) : 0;
const cx = cnt ? lw + gap + xOff : 0; // number's left edge (nudgeable)
// The logo box is fixed at `lw`; the badge superimposes ON it (centered).
// The count column (if any) extends the icon just far enough to hold the
// number at its nudged position — no dead space on the right, and the
// plain icon (no number) keeps its original width.
const W = cnt ? Math.max(lw, cx + cw) : lw;
const canvas = Buffer.alloc(W * H * 4, 0);
const put = (x, y, b, g, r, a) => {
if (x < 0 || y < 0 || x >= W || y >= H || a <= 0) return;
const idx = (y * W + x) * 4, sa = a / 255, da = canvas[idx + 3] / 255;
const oa = sa + da * (1 - sa); if (oa <= 0) return;
canvas[idx] = Math.round((b * sa + canvas[idx] * da * (1 - sa)) / oa);
canvas[idx + 1] = Math.round((g * sa + canvas[idx + 1] * da * (1 - sa)) / oa);
canvas[idx + 2] = Math.round((r * sa + canvas[idx + 2] * da * (1 - sa)) / oa);
canvas[idx + 3] = Math.round(oa * 255);
};
const oy = Math.max(0, Math.round((H - lh) / 2));
const lb = logo.data; // RGBA
for (let y = 0; y < lh; y++)
for (let x = 0; x < lw; x++) {
const j = (y * lw + x) * 4, a = lb[j + 3];
if (a > 0) put(x, oy + y, lb[j + 2], lb[j + 1], lb[j], a);
}
if (s.show && s.badge) {
const badge = await this.renderBadge(s.badge, s.color, s.bg, s.outline, s.outlineWidth, s.pt, scale, labelOpacity);
if (badge) {
const bw = badge.info.width, bh = badge.info.height;
const bx = Math.round((lw - bw) / 2); // centered over the logo box
const by = Math.round((H - bh) / 2) + (s.badgeYOffset || 0) * scale; // centered (nudgeable)
const d = badge.data;
for (let y = 0; y < bh; y++)
for (let x = 0; x < bw; x++) {
const j = (y * bw + x) * 4, a = d[j + 3];
if (a > 0) put(bx + x, by + y, d[j + 2], d[j + 1], d[j], a);
}
}
}
// Superscript count (keeps kept) in its reserved right column — beside
// the logo, top-anchored, full opacity (independent of the badge blink).
if (cnt) {
const cy = Math.max(0, Math.round((s.countYOffset || 0) * scale)); // top (superscript)
const cd = cnt.data;
for (let y = 0; y < ch; y++)
for (let x = 0; x < cw; x++) {
const j = (y * cw + x) * 4, a = cd[j + 3];
if (a > 0) put(cx + x, cy + y, cd[j + 2], cd[j + 1], cd[j], a);
}
}
out.addRepresentation({ scaleFactor: scale, width: W, height: H, buffer: canvas });
} catch (e) { /* skip this scale */ }
}
return out.isEmpty() ? null : out;
}
async refresh() {
if (this.rendering) { this.dirty = true; return; }
this.rendering = true;
try {
const img = await this.render();
if (img && this.tray && !this.tray.isDestroyed?.()) this.tray.setImage(img);
} finally {
this.rendering = false;
if (this.dirty) { this.dirty = false; this.refresh(); }
}
}
// ── CPU sampler — busy fraction 0..1 from os.cpus() times deltas, eased so
// the spin speed glides instead of jittering. Cheap; called a few times a
// second from the ticker (not every frame). ──
sampleCpu() {
let idle = 0, total = 0;
for (const c of os.cpus()) {
for (const k in c.times) total += c.times[k];
idle += c.times.idle;
}
if (this._cpuPrev) {
const dTotal = total - this._cpuPrev.total;
const dIdle = idle - this._cpuPrev.idle;
if (dTotal > 0) {
const busy = Math.max(0, Math.min(1, 1 - dIdle / dTotal));
this.cpu += (busy - this.cpu) * 0.4; // ease toward the new reading
}
}
this._cpuPrev = { idle, total };
}
// ── Animation ticker — runs while the gem spins, or while a badge effect
// is active. Spin mode drives `phase` continuously so the gem turns; its
// rotation period is recomputed each tick from the live CPU load (slow at
// rest, faster under load) when spinReactive is on. ──
syncTicker() {
const s = this.state;
const spinning = (s.logoMode || 'spin') === 'spin' && this.spin !== false;
const badgeAnim = s.show && s.effect !== 'none';
const animating = spinning || badgeAnim;
if (animating && !this.timer) {
const fps = spinning ? (s.spinFps || 20) : s.fps;
const dt = 1000 / Math.max(1, Math.min(30, fps));
let ticks = 0;
this.timer = setInterval(() => {
const st = this.state;
let period;
if (spinning) {
const reactive = st.spinReactive !== false;
if (reactive) {
if (ticks % 12 === 0) this.sampleCpu(); // ~ every 0.6s @20fps
const idleMs = Math.max(400, st.spinIdleMs || 7000);
const busyMs = Math.max(300, st.spinBusyMs || 1100);
period = idleMs + (busyMs - idleMs) * this.cpu; // load → faster
} else {
period = Math.max(400, st.spinPeriodMs || st.spinIdleMs || 7000);
}
} else {
period = Math.max(200, st.periodMs || 1500); // badge blink/pulse
}
ticks++;
this.phase = (this.phase + dt / period) % 1;
this.refresh();
}, dt);
} else if (!animating && this.timer) {
clearInterval(this.timer); this.timer = null; this.phase = 0;
this._cpuPrev = null;
}
}
setState(partial) {
this.state = { ...this.state, ...partial };
this.syncTicker();
this.refresh();
}
// ── Live-reload: watch ~/.ac-os/tray.json and apply on change. ──
loadStateFile() {
try {
if (fs.existsSync(STATE_FILE)) {
const j = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
this.setState(j);
return;
}
} catch (e) { console.warn('[tray] bad tray.json:', e.message); }
this.setState({ show: false }); // file gone/invalid → hide badge
}
startWatching() {
const dir = path.dirname(STATE_FILE);
try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
this.loadStateFile();
// Watch the directory (robust to editors that replace the file).
try {
this.watcher = fs.watch(dir, (_evt, fn) => {
if (!fn || fn === path.basename(STATE_FILE)) {
clearTimeout(this._deb);
this._deb = setTimeout(() => this.loadStateFile(), 80);
}
});
console.log('[tray] live-reload watching', STATE_FILE);
} catch (e) { console.warn('[tray] watch failed:', e.message); }
// Re-render when the macOS accent color changes (badge uses system colors).
try { systemPreferences.on?.('accent-color-changed', () => this.refresh()); } catch (e) {}
}
dispose() {
if (this.timer) clearInterval(this.timer);
if (this.watcher) this.watcher.close();
}
}
module.exports = { TrayRenderer, STATE_FILE };