coding-usage-bar 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/burn.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { BurnAnalysis, BurnProfile, ProviderUsage } from "./types.js";
2
2
  export declare function estimateConversionRate(samples: ProviderUsage[]): number | null;
3
+ export declare function analyzeUsageWithConversionRate(usage: ProviderUsage, conversionRate: number | null, profile?: BurnProfile, now?: Date): BurnAnalysis;
3
4
  export declare function analyzeUsage(usage: ProviderUsage, samples: ProviderUsage[], profile?: BurnProfile, now?: Date): BurnAnalysis;
package/dist/burn.js CHANGED
@@ -74,7 +74,7 @@ function messageForState(provider, state, fiveUsed, target) {
74
74
  }
75
75
  return `${label} burn pace is on track (${round(fiveUsed)}%, target ${round(target.min)}%-${round(target.max)}%).`;
76
76
  }
77
- export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
77
+ function analyzeUsageWithRateResolver(usage, resolveConversionRate, profile = "low", now = new Date()) {
78
78
  const fiveHour = findWindow(usage, "five_hour");
79
79
  const sevenDay = findWindow(usage, "seven_day");
80
80
  if (usage.balance && usage.windows.length === 0) {
@@ -118,7 +118,7 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
118
118
  message,
119
119
  };
120
120
  }
121
- const conversionRate = estimateConversionRate(samples);
121
+ const conversionRate = resolveConversionRate();
122
122
  if (conversionRate === null) {
123
123
  return {
124
124
  provider: usage.provider,
@@ -162,3 +162,9 @@ export function analyzeUsage(usage, samples, profile = "low", now = new Date())
162
162
  }),
163
163
  };
164
164
  }
165
+ export function analyzeUsageWithConversionRate(usage, conversionRate, profile = "low", now = new Date()) {
166
+ return analyzeUsageWithRateResolver(usage, () => conversionRate, profile, now);
167
+ }
168
+ export function analyzeUsage(usage, samples, profile = "low", now = new Date()) {
169
+ return analyzeUsageWithRateResolver(usage, () => estimateConversionRate(samples), profile, now);
170
+ }
package/dist/cli.js CHANGED
@@ -8,7 +8,9 @@ import { runDaemonOnce } from "./daemon.js";
8
8
  import { doctorHasFailures, formatDoctor, runDoctor } from "./doctor.js";
9
9
  import { installMenuBar, renderMenuBar, uninstallMenuBar, toggleCompactMode } from "./menubar.js";
10
10
  import { buildPaths } from "./paths.js";
11
+ import { bakeGlyphAtlases } from "./glyphs.js";
11
12
  import { maybePromptForStar } from "./star.js";
13
+ import { updateStatusSnapshotUsage } from "./status.js";
12
14
  function hasFlag(args, flag) {
13
15
  return args.includes(flag);
14
16
  }
@@ -24,6 +26,7 @@ Usage:
24
26
  coding-usage-bar menubar install [--dry-run]
25
27
  coding-usage-bar menubar uninstall [--dry-run]
26
28
  coding-usage-bar menubar toggle-compact
29
+ coding-usage-bar menubar bake-glyphs [--dry-run]
27
30
  coding-usage-bar ingest claude-statusline
28
31
  coding-usage-bar daemon [--once] [--dry-run]
29
32
  `);
@@ -108,11 +111,22 @@ async function main() {
108
111
  console.log(isCompact ? "Compact" : "Expanded");
109
112
  return;
110
113
  }
114
+ if (command === "menubar" && subcommand === "bake-glyphs") {
115
+ console.log(bakeGlyphAtlases(buildPaths(), { dryRun }).join("\n"));
116
+ return;
117
+ }
111
118
  if (command === "ingest" && subcommand === "claude-statusline") {
112
119
  const input = await readStdin();
113
120
  const parsed = JSON.parse(input);
114
121
  const usage = usageFromClaudeStatusLine(parsed);
115
122
  saveUsage(usage);
123
+ try {
124
+ updateStatusSnapshotUsage(usage);
125
+ }
126
+ catch {
127
+ // The provider cache is authoritative. A later daemon run can rebuild
128
+ // status.json if this best-effort display update loses lock contention.
129
+ }
116
130
  return;
117
131
  }
118
132
  if (command === "daemon") {
package/dist/daemon.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { collectStatusSnapshot } from "./runtime.js";
2
- import { markNotified, sendNotification, shouldNotify } from "./notifier.js";
2
+ import { markNotified, markRecovered, sendNotification, shouldNotify } from "./notifier.js";
3
3
  export async function runDaemonOnce(options = {}) {
4
4
  const snapshot = await collectStatusSnapshot();
5
5
  const messages = [];
6
6
  for (const { analysis } of snapshot.providers) {
7
7
  if (!shouldNotify(analysis)) {
8
+ if (!options.dryRun) {
9
+ markRecovered(analysis);
10
+ }
8
11
  continue;
9
12
  }
10
13
  messages.push(sendNotification(analysis, options.dryRun));
package/dist/glm.d.ts CHANGED
@@ -4,7 +4,7 @@ interface GlmLimit {
4
4
  unit?: number;
5
5
  number?: number;
6
6
  percentage: number;
7
- nextResetTime: number;
7
+ nextResetTime?: number;
8
8
  }
9
9
  interface GlmQuotaResponse {
10
10
  success: boolean;
package/dist/glm.js CHANGED
@@ -16,24 +16,54 @@ export async function fetchGlmQuota(config) {
16
16
  }
17
17
  return (await response.json());
18
18
  }
19
+ function resetTimeIso(value) {
20
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
21
+ return null;
22
+ }
23
+ const date = new Date(value);
24
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
25
+ }
26
+ // GLM identifies windows by shape: unit 3 + number 5 is the 5-hour window,
27
+ // unit 6 + number 1 is the weekly window. Matching on shape (with a
28
+ // reset-time sort as fallback) matters because sorting alone breaks when a
29
+ // window omits nextResetTime — which GLM does for a fresh window with zero
30
+ // usage (same proto-style field omission as Kimi's zero `used`). The zero
31
+ // percentage is a real signal and must not be dropped; a missing reset time
32
+ // falls back to `observedAt`, which renders as "reset due".
33
+ function pickTokenWindows(limits) {
34
+ const tokenLimits = limits.filter((limit) => limit.type === "TOKENS_LIMIT");
35
+ if (tokenLimits.length < 2) {
36
+ return null;
37
+ }
38
+ let five = tokenLimits.find((limit) => limit.unit === 3 && limit.number === 5) ?? null;
39
+ let seven = tokenLimits.find((limit) => limit.unit === 6 && limit.number === 1) ?? null;
40
+ if (!five || !seven) {
41
+ const rest = tokenLimits
42
+ .filter((limit) => limit !== five && limit !== seven)
43
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
44
+ five = five ?? rest.shift() ?? null;
45
+ seven = seven ?? rest.shift() ?? null;
46
+ }
47
+ return five && seven ? { five, seven } : null;
48
+ }
19
49
  export function usageFromGlmQuota(quota, options) {
20
50
  if (!quota.success || !quota.data) {
21
51
  return null;
22
52
  }
23
- const tokenLimits = quota.data.limits.filter((limit) => limit.type === "TOKENS_LIMIT");
24
- if (tokenLimits.length < 2) {
53
+ const picked = pickTokenWindows(quota.data.limits);
54
+ if (!picked) {
25
55
  return null;
26
56
  }
27
- const sortedByReset = [...tokenLimits].sort((a, b) => a.nextResetTime - b.nextResetTime);
28
- const fiveHourRaw = sortedByReset[0];
29
- const sevenDayRaw = sortedByReset[1];
57
+ // new Date(undefined).toISOString() throws RangeError("Invalid time value"),
58
+ // which used to fail the whole live collection; never convert unchecked.
59
+ const fallbackIso = options.observedAt ?? new Date().toISOString();
30
60
  const fiveHour = normalizeWindow("five_hour", {
31
- used_percent: fiveHourRaw.percentage,
32
- resets_at: new Date(fiveHourRaw.nextResetTime).toISOString(),
61
+ used_percent: picked.five.percentage,
62
+ resets_at: resetTimeIso(picked.five.nextResetTime) ?? fallbackIso,
33
63
  }, 300);
34
64
  const sevenDay = normalizeWindow("seven_day", {
35
- used_percent: sevenDayRaw.percentage,
36
- resets_at: new Date(sevenDayRaw.nextResetTime).toISOString(),
65
+ used_percent: picked.seven.percentage,
66
+ resets_at: resetTimeIso(picked.seven.nextResetTime) ?? fallbackIso,
37
67
  }, 10080);
38
68
  if (!fiveHour || !sevenDay) {
39
69
  return null;
@@ -0,0 +1,68 @@
1
+ import { DecodedPNG } from "./png.js";
2
+ import { RuntimePaths } from "./types.js";
3
+ export declare const GLYPH_STYLES: readonly [{
4
+ readonly key: "r22";
5
+ readonly px: 22;
6
+ readonly weight: "regular";
7
+ }, {
8
+ readonly key: "r24";
9
+ readonly px: 24;
10
+ readonly weight: "regular";
11
+ }, {
12
+ readonly key: "s22";
13
+ readonly px: 22;
14
+ readonly weight: "semibold";
15
+ }, {
16
+ readonly key: "s28";
17
+ readonly px: 28;
18
+ readonly weight: "semibold";
19
+ }, {
20
+ readonly key: "b20";
21
+ readonly px: 20;
22
+ readonly weight: "bold";
23
+ }, {
24
+ readonly key: "b24";
25
+ readonly px: 24;
26
+ readonly weight: "bold";
27
+ }, {
28
+ readonly key: "b26";
29
+ readonly px: 26;
30
+ readonly weight: "bold";
31
+ }, {
32
+ readonly key: "b38";
33
+ readonly px: 38;
34
+ readonly weight: "bold";
35
+ }];
36
+ export type GlyphStyleKey = (typeof GLYPH_STYLES)[number]["key"];
37
+ export declare function glyphCharset(): string[];
38
+ export interface GlyphCell {
39
+ x: number;
40
+ w: number;
41
+ adv: number;
42
+ }
43
+ export interface GlyphStyleMetrics {
44
+ px: number;
45
+ weight: string;
46
+ ascent: number;
47
+ descent: number;
48
+ pad: number;
49
+ cellH: number;
50
+ glyphs: Record<string, GlyphCell>;
51
+ }
52
+ export interface GlyphMetricsFile {
53
+ version: number;
54
+ styles: Record<string, GlyphStyleMetrics>;
55
+ }
56
+ export interface LoadedGlyphStyle {
57
+ metrics: GlyphStyleMetrics;
58
+ atlas: DecodedPNG;
59
+ }
60
+ export type GlyphSet = Record<string, LoadedGlyphStyle>;
61
+ export declare function glyphMetricsPath(paths: RuntimePaths): string;
62
+ export declare function glyphAtlasPath(paths: RuntimePaths, styleKey: string): string;
63
+ export declare function bakeGlyphAtlases(paths: RuntimePaths, options?: {
64
+ dryRun?: boolean;
65
+ }): string[];
66
+ export declare function loadGlyphSet(paths: RuntimePaths): GlyphSet | null;
67
+ export declare function measureText(style: LoadedGlyphStyle, text: string): number;
68
+ export declare function ellipsize(style: LoadedGlyphStyle, text: string, maxWidth: number): string;
package/dist/glyphs.js ADDED
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { decodePNG } from "./png.js";
5
+ // Glyph atlases let the pure-JS card renderer draw native-looking SF text
6
+ // without ever spawning AppKit at render time. Baking runs osascript exactly
7
+ // once, at install time, on the user's own machine (the SF fonts live there;
8
+ // nothing font-derived ships in the npm package). Render-time consumers only
9
+ // read the baked PNG + metrics files; when they are missing the menu bar
10
+ // falls back to text-only rows, so a failed bake can never break rendering.
11
+ // All sizes are physical pixels (2x point sizes; the card renders at scale 2).
12
+ export const GLYPH_STYLES = [
13
+ { key: "r22", px: 22, weight: "regular" },
14
+ { key: "r24", px: 24, weight: "regular" },
15
+ { key: "s22", px: 22, weight: "semibold" },
16
+ { key: "s28", px: 28, weight: "semibold" },
17
+ { key: "b20", px: 20, weight: "bold" },
18
+ { key: "b24", px: 24, weight: "bold" },
19
+ { key: "b26", px: 26, weight: "bold" },
20
+ { key: "b38", px: 38, weight: "bold" },
21
+ ];
22
+ // Printable ASCII plus the few extras the card actually renders.
23
+ export function glyphCharset() {
24
+ const chars = [];
25
+ for (let code = 32; code <= 126; code++) {
26
+ chars.push(String.fromCharCode(code));
27
+ }
28
+ chars.push("¥", "·", "—", "…");
29
+ return chars;
30
+ }
31
+ export function glyphMetricsPath(paths) {
32
+ return path.join(paths.glyphsDir, "metrics.json");
33
+ }
34
+ export function glyphAtlasPath(paths, styleKey) {
35
+ return path.join(paths.glyphsDir, `${styleKey}.png`);
36
+ }
37
+ // JXA program executed by `osascript -l JavaScript`. It renders every charset
38
+ // glyph for every style into one horizontal PNG strip per style (white text on
39
+ // transparent, monospacedDigit variant so numbers stay tabular) and returns
40
+ // the metrics JSON on stdout. Kept dependency-free and side-effect-scoped to
41
+ // the output directory it is handed.
42
+ const BAKE_JXA = `
43
+ ObjC.import("Cocoa");
44
+ function run(argv) {
45
+ const spec = JSON.parse(argv[0]);
46
+ const weights = {
47
+ regular: $.NSFontWeightRegular,
48
+ semibold: $.NSFontWeightSemibold,
49
+ bold: $.NSFontWeightBold,
50
+ };
51
+ const out = { version: 1, styles: {} };
52
+ for (const style of spec.styles) {
53
+ const font = $.NSFont.monospacedDigitSystemFontOfSizeWeight(style.px, weights[style.weight]);
54
+ const attrs = $.NSDictionary.dictionaryWithObjectsForKeys(
55
+ $([font, $.NSColor.whiteColor]),
56
+ $([$.NSFontAttributeName, $.NSForegroundColorAttributeName]),
57
+ );
58
+ const ascent = Math.ceil(font.ascender);
59
+ const descent = Math.ceil(Math.abs(font.descender));
60
+ const pad = 2;
61
+ const cellH = ascent + descent + pad * 2;
62
+ const cells = [];
63
+ let stripW = 0;
64
+ for (const ch of spec.chars) {
65
+ const adv = $(ch).sizeWithAttributes(attrs).width;
66
+ const w = Math.ceil(adv) + pad * 2;
67
+ cells.push({ ch, x: stripW, w, adv });
68
+ stripW += w;
69
+ }
70
+ const rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(
71
+ null, stripW, cellH, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0,
72
+ );
73
+ $.NSGraphicsContext.saveGraphicsState;
74
+ $.NSGraphicsContext.setCurrentContext($.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep));
75
+ for (const cell of cells) {
76
+ $(cell.ch).drawAtPointWithAttributes($.NSMakePoint(cell.x + pad, pad), attrs);
77
+ }
78
+ $.NSGraphicsContext.restoreGraphicsState;
79
+ const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary);
80
+ png.writeToFileAtomically($(spec.outDir + "/" + style.key + ".png"), true);
81
+ const glyphs = {};
82
+ for (const cell of cells) {
83
+ glyphs[cell.ch] = { x: cell.x, w: cell.w, adv: cell.adv };
84
+ }
85
+ out.styles[style.key] = {
86
+ px: style.px,
87
+ weight: style.weight,
88
+ ascent, descent, pad, cellH,
89
+ glyphs,
90
+ };
91
+ }
92
+ return JSON.stringify(out);
93
+ }
94
+ `;
95
+ export function bakeGlyphAtlases(paths, options = {}) {
96
+ if (process.platform !== "darwin") {
97
+ return ["Glyph atlas baking requires macOS; menu bar card rendering will use the text fallback."];
98
+ }
99
+ if (options.dryRun) {
100
+ return [`[dry-run] would bake ${GLYPH_STYLES.length} glyph atlases into ${paths.glyphsDir}`];
101
+ }
102
+ fs.mkdirSync(paths.glyphsDir, { recursive: true });
103
+ const spec = {
104
+ outDir: paths.glyphsDir,
105
+ chars: glyphCharset(),
106
+ styles: GLYPH_STYLES.map((style) => ({ key: style.key, px: style.px, weight: style.weight })),
107
+ };
108
+ const stdout = execFileSync("osascript", ["-l", "JavaScript", "-e", BAKE_JXA, JSON.stringify(spec)], {
109
+ encoding: "utf8",
110
+ timeout: 60_000,
111
+ });
112
+ const metrics = JSON.parse(stdout.trim());
113
+ for (const style of GLYPH_STYLES) {
114
+ if (!metrics.styles[style.key] || !fs.existsSync(glyphAtlasPath(paths, style.key))) {
115
+ throw new Error(`Glyph bake incomplete: missing atlas for style ${style.key}`);
116
+ }
117
+ }
118
+ fs.writeFileSync(glyphMetricsPath(paths), `${JSON.stringify(metrics)}\n`, "utf8");
119
+ return [`Baked ${GLYPH_STYLES.length} glyph atlases: ${paths.glyphsDir}`];
120
+ }
121
+ // Returns null when any piece is missing or unreadable; callers treat null as
122
+ // "use the text-only fallback", never as an error.
123
+ export function loadGlyphSet(paths) {
124
+ let metrics;
125
+ try {
126
+ metrics = JSON.parse(fs.readFileSync(glyphMetricsPath(paths), "utf8"));
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ const set = {};
132
+ for (const style of GLYPH_STYLES) {
133
+ const styleMetrics = metrics.styles?.[style.key];
134
+ if (!styleMetrics) {
135
+ return null;
136
+ }
137
+ try {
138
+ const atlas = decodePNG(fs.readFileSync(glyphAtlasPath(paths, style.key)));
139
+ set[style.key] = { metrics: styleMetrics, atlas };
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ return set;
146
+ }
147
+ export function measureText(style, text) {
148
+ let width = 0;
149
+ for (const ch of text) {
150
+ const cell = style.metrics.glyphs[ch] ?? style.metrics.glyphs["?"];
151
+ if (cell) {
152
+ width += cell.adv;
153
+ }
154
+ }
155
+ return width;
156
+ }
157
+ // Trims `text` with a trailing ellipsis so it fits within maxWidth pixels.
158
+ export function ellipsize(style, text, maxWidth) {
159
+ if (measureText(style, text) <= maxWidth) {
160
+ return text;
161
+ }
162
+ const ellipsis = "…";
163
+ const ellipsisWidth = measureText(style, ellipsis);
164
+ let result = "";
165
+ let width = 0;
166
+ for (const ch of text) {
167
+ const cell = style.metrics.glyphs[ch] ?? style.metrics.glyphs["?"];
168
+ const adv = cell ? cell.adv : 0;
169
+ if (width + adv + ellipsisWidth > maxWidth) {
170
+ break;
171
+ }
172
+ result += ch;
173
+ width += adv;
174
+ }
175
+ return `${result.trimEnd()}${ellipsis}`;
176
+ }
package/dist/install.js CHANGED
@@ -6,6 +6,7 @@ import { claudeStatusLineHasIngest, getClaudeStatusLineCommand, readClaudeSettin
6
6
  import { ensureConfig, readConfig } from "./config.js";
7
7
  import { ensureDir, isFile, readJsonFile, writeJsonAtomic } from "./fs-util.js";
8
8
  import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar } from "./menubar.js";
9
+ import { bakeGlyphAtlases } from "./glyphs.js";
9
10
  import { buildPaths } from "./paths.js";
10
11
  import { stableNodeExecutable } from "./node-runtime.js";
11
12
  export const LAUNCHD_LABEL = "com.duying.coding-usage-bar";
@@ -354,6 +355,15 @@ export function install(options = {}) {
354
355
  messages.push(" Get your key at: https://open.bigmodel.cn");
355
356
  }
356
357
  }
358
+ // The dropdown card needs baked glyph atlases; a failed bake must not fail
359
+ // the install — rendering falls back to text-only rows until a bake works.
360
+ try {
361
+ messages.push(...bakeGlyphAtlases(paths, { dryRun }));
362
+ }
363
+ catch (error) {
364
+ messages.push(`Glyph atlas bake failed: ${error instanceof Error ? error.message : String(error)}`);
365
+ messages.push("The menu bar dropdown will use the text-only fallback; retry with: coding-usage-bar menubar bake-glyphs");
366
+ }
357
367
  messages.push(...ensureSwiftBarInstalled({ dryRun }));
358
368
  messages.push(...installMenuBar({ dryRun }));
359
369
  if (!dryRun) {
@@ -400,6 +410,15 @@ export function uninstall(options = {}) {
400
410
  messages.push(`CLI shim is user-managed; leaving it unchanged: ${paths.cliBinFile}`);
401
411
  }
402
412
  messages.push(...uninstallMenuBar({ dryRun }));
413
+ if (fs.existsSync(paths.glyphsDir)) {
414
+ if (dryRun) {
415
+ messages.push(`[dry-run] would remove glyph atlases: ${paths.glyphsDir}`);
416
+ }
417
+ else {
418
+ fs.rmSync(paths.glyphsDir, { recursive: true, force: true });
419
+ messages.push(`Removed glyph atlases: ${paths.glyphsDir}`);
420
+ }
421
+ }
403
422
  const settings = readClaudeSettings(paths.claudeSettingsFile);
404
423
  const existing = getClaudeStatusLineCommand(settings);
405
424
  if (existing === paths.claudeStatusLineScript && settings) {
@@ -0,0 +1,30 @@
1
+ import { GlyphSet } from "./glyphs.js";
2
+ export interface CardBarRow {
3
+ label: string;
4
+ pct: number;
5
+ resetLabel: string;
6
+ }
7
+ export interface CardProviderBlock {
8
+ name: string;
9
+ iconPath: string | null;
10
+ iconPathDark: string | null;
11
+ stateLabel: string;
12
+ lightColor: string;
13
+ darkColor: string;
14
+ headline: string;
15
+ rows: CardBarRow[];
16
+ note: string | null;
17
+ message: string | null;
18
+ updated: string | null;
19
+ }
20
+ export interface UsageCardPayload {
21
+ profile: string;
22
+ updatedLabel: string;
23
+ providers: CardProviderBlock[];
24
+ scale: number;
25
+ }
26
+ export declare function renderUsageCard(payload: UsageCardPayload, glyphs: GlyphSet, mode: "light" | "dark"): {
27
+ image: string;
28
+ width: number;
29
+ height: number;
30
+ };
@@ -0,0 +1,189 @@
1
+ import { Canvas, parseHex, loadIcon } from "./menubar-render.js";
2
+ import { measureText, ellipsize } from "./glyphs.js";
3
+ const LIGHT = {
4
+ text: parseHex("111827"),
5
+ muted: parseHex("6B7280"),
6
+ msg: parseHex("8E8E93"),
7
+ track: parseHex("000000"),
8
+ trackAlpha: 0.10,
9
+ sep: parseHex("000000"),
10
+ sepAlpha: 0.08,
11
+ };
12
+ const DARK = {
13
+ text: parseHex("F9FAFB"),
14
+ muted: parseHex("A1A1AA"),
15
+ msg: parseHex("7C7C82"),
16
+ track: parseHex("FFFFFF"),
17
+ trackAlpha: 0.12,
18
+ sep: parseHex("FFFFFF"),
19
+ sepAlpha: 0.09,
20
+ };
21
+ // Layout in points (multiplied by payload.scale when drawing). PAD_X is tiny
22
+ // because the menu item's own content inset already provides the left/right
23
+ // margin; the image's content edge then lines up with native item text.
24
+ const W = 460;
25
+ const PAD_X = 2;
26
+ const TOP_PAD = 8;
27
+ const HEADER_H = 18;
28
+ const HEADER_GAP = 12;
29
+ const BLOCK_PAD_Y = 10;
30
+ const HEAD_H = 24;
31
+ const ROW_H = 21;
32
+ const MSG_GAP = 6;
33
+ const MSG_H = 14;
34
+ const BOTTOM_PAD = 4;
35
+ const RIGHT_EDGE = W - PAD_X;
36
+ const RESET_COL_W = 104;
37
+ const PCT_COL_W = 36;
38
+ const COL_GAP = 8;
39
+ const TRACK_X = PAD_X + 18 + 16;
40
+ const PILL_H = 16;
41
+ const PILL_PAD_X = 7;
42
+ const PILL_RADIUS = 8;
43
+ function blockHeight(block) {
44
+ let h = BLOCK_PAD_Y * 2 + HEAD_H;
45
+ h += block.rows.length * ROW_H;
46
+ if (block.note) {
47
+ h += ROW_H;
48
+ }
49
+ if (block.message) {
50
+ h += MSG_GAP + MSG_H;
51
+ }
52
+ return h;
53
+ }
54
+ function cardHeight(payload) {
55
+ let h = TOP_PAD + HEADER_H + HEADER_GAP;
56
+ payload.providers.forEach((block, idx) => {
57
+ h += blockHeight(block);
58
+ if (idx < payload.providers.length - 1) {
59
+ h += 1;
60
+ }
61
+ });
62
+ return h + BOTTOM_PAD;
63
+ }
64
+ function style(ctx, key) {
65
+ const loaded = ctx.glyphs[key];
66
+ if (!loaded) {
67
+ throw new Error(`Glyph style missing from atlas set: ${key}`);
68
+ }
69
+ return loaded;
70
+ }
71
+ // `baselineTopPt` is the distance from the card's top edge to the text
72
+ // baseline, in points; conversion to the canvas' bottom-left origin happens
73
+ // here so the layout code can think strictly top-down.
74
+ function drawText(ctx, styleKey, text, xPt, baselineTopPt, color, alpha = 1) {
75
+ const loaded = style(ctx, styleKey);
76
+ const { atlas, metrics } = loaded;
77
+ const s = ctx.scale;
78
+ const cellBottom = ctx.heightPt * s - (baselineTopPt * s + metrics.descent + metrics.pad);
79
+ let cursor = xPt * s;
80
+ for (const ch of text) {
81
+ const cell = metrics.glyphs[ch] ?? metrics.glyphs["?"];
82
+ if (!cell) {
83
+ continue;
84
+ }
85
+ ctx.canvas.drawGlyphCell(atlas, cell.x, cell.w, cursor - metrics.pad, cellBottom, color, alpha);
86
+ cursor += cell.adv;
87
+ }
88
+ return cursor / s;
89
+ }
90
+ function textWidthPt(ctx, styleKey, text) {
91
+ return measureText(style(ctx, styleKey), text) / ctx.scale;
92
+ }
93
+ function fillRect(ctx, xPt, topPt, wPt, hPt, radiusPt, color, alpha) {
94
+ const s = ctx.scale;
95
+ ctx.canvas.fillRoundedRect(xPt * s, (ctx.heightPt - topPt - hPt) * s, wPt * s, hPt * s, radiusPt * s, color, alpha);
96
+ }
97
+ function drawPill(ctx, xPt, topPt, text, color) {
98
+ const textW = textWidthPt(ctx, "b20", text);
99
+ const pillW = textW + PILL_PAD_X * 2;
100
+ fillRect(ctx, xPt, topPt, pillW, PILL_H, PILL_RADIUS, color, 0.13);
101
+ drawText(ctx, "b20", text, xPt + PILL_PAD_X, topPt + 11.5, color);
102
+ return pillW;
103
+ }
104
+ function drawProviderBlock(ctx, block, topPt, mode, variant) {
105
+ const color = parseHex(mode === "dark" ? block.darkColor : block.lightColor);
106
+ let y = topPt + BLOCK_PAD_Y;
107
+ const iconFile = mode === "dark" && block.iconPathDark ? block.iconPathDark : block.iconPath;
108
+ if (iconFile) {
109
+ const icon = loadIcon(iconFile);
110
+ if (icon) {
111
+ const s = ctx.scale;
112
+ ctx.canvas.drawIcon(icon, PAD_X * s, (ctx.heightPt - (y + 3) - 18) * s, 18 * s);
113
+ }
114
+ }
115
+ const nameEnd = drawText(ctx, "s28", block.name, PAD_X + 26, y + 17, variant.text);
116
+ drawPill(ctx, nameEnd + 8, y + 4, block.stateLabel, color);
117
+ const headlineW = textWidthPt(ctx, "b38", block.headline);
118
+ drawText(ctx, "b38", block.headline, RIGHT_EDGE - headlineW, y + 19, color);
119
+ y += HEAD_H;
120
+ for (const row of block.rows) {
121
+ const baseline = y + 14;
122
+ drawText(ctx, "s22", row.label, PAD_X, baseline, variant.muted);
123
+ const trackW = RIGHT_EDGE - RESET_COL_W - COL_GAP - PCT_COL_W - COL_GAP - TRACK_X;
124
+ const trackTop = y + 7.5;
125
+ fillRect(ctx, TRACK_X, trackTop, trackW, 6, 3, variant.track, variant.trackAlpha);
126
+ const pct = Math.max(0, Math.min(100, row.pct));
127
+ if (pct > 0) {
128
+ const fillW = Math.max(6, (trackW * pct) / 100);
129
+ fillRect(ctx, TRACK_X, trackTop, fillW, 6, 3, color, 1);
130
+ }
131
+ const pctText = `${Math.round(pct)}%`;
132
+ const pctW = textWidthPt(ctx, "b24", pctText);
133
+ drawText(ctx, "b24", pctText, RIGHT_EDGE - RESET_COL_W - COL_GAP - pctW, baseline, color);
134
+ const resetW = textWidthPt(ctx, "r22", row.resetLabel);
135
+ drawText(ctx, "r22", row.resetLabel, RIGHT_EDGE - resetW, baseline, variant.muted);
136
+ y += ROW_H;
137
+ }
138
+ if (block.note) {
139
+ drawText(ctx, "r24", block.note, PAD_X, y + 14, variant.muted);
140
+ if (block.updated) {
141
+ const updatedW = textWidthPt(ctx, "r22", block.updated);
142
+ drawText(ctx, "r22", block.updated, RIGHT_EDGE - updatedW, y + 14, variant.msg);
143
+ }
144
+ y += ROW_H;
145
+ }
146
+ if (block.message) {
147
+ const baseline = y + MSG_GAP + 11;
148
+ let messageMaxPt = W - PAD_X * 2;
149
+ if (block.updated) {
150
+ const updatedW = textWidthPt(ctx, "r22", block.updated);
151
+ drawText(ctx, "r22", block.updated, RIGHT_EDGE - updatedW, baseline, variant.msg);
152
+ messageMaxPt -= updatedW + 12;
153
+ }
154
+ const fitted = ellipsize(style(ctx, "r22"), block.message, messageMaxPt * ctx.scale);
155
+ drawText(ctx, "r22", fitted, PAD_X, baseline, variant.msg);
156
+ }
157
+ }
158
+ function drawCard(payload, glyphs, mode) {
159
+ const variant = mode === "dark" ? DARK : LIGHT;
160
+ const heightPt = cardHeight(payload);
161
+ const s = payload.scale || 2;
162
+ const canvas = new Canvas(Math.round(W * s), Math.round(heightPt * s));
163
+ const ctx = { canvas, glyphs, scale: s, heightPt };
164
+ const titleEnd = drawText(ctx, "b26", "Coding Usage Bar", PAD_X, TOP_PAD + 14, variant.text);
165
+ drawPill(ctx, titleEnd + 8, TOP_PAD + 1, payload.profile, variant.muted);
166
+ const updatedW = textWidthPt(ctx, "r22", payload.updatedLabel);
167
+ drawText(ctx, "r22", payload.updatedLabel, RIGHT_EDGE - updatedW, TOP_PAD + 13, variant.muted);
168
+ let top = TOP_PAD + HEADER_H + HEADER_GAP;
169
+ payload.providers.forEach((block, idx) => {
170
+ drawProviderBlock(ctx, block, top, mode, variant);
171
+ top += blockHeight(block);
172
+ if (idx < payload.providers.length - 1) {
173
+ fillRect(ctx, PAD_X, top, W - PAD_X * 2, 0.5, 0, variant.sep, variant.sepAlpha * 2);
174
+ top += 1;
175
+ }
176
+ });
177
+ return {
178
+ image: canvas.toPNGBase64(),
179
+ width: W,
180
+ height: Math.round(heightPt),
181
+ };
182
+ }
183
+ // Renders a single appearance variant: the caller picks light or dark from
184
+ // the current system appearance (see systemPrefersDark in menubar.ts) instead
185
+ // of shipping a SwiftBar light,dark pair, whose selection is broken in
186
+ // several SwiftBar versions.
187
+ export function renderUsageCard(payload, glyphs, mode) {
188
+ return drawCard(payload, glyphs, mode);
189
+ }