tray: the Mac menu bar can show the allowances without magpie's icon (KevinXC on Discord: 能否支持隐藏状态栏里的图标,只显示各个供应商的剩余额度?) — Settings → Usage has a Menu bar magpie icon row (On by default) under Menu bar logos, on a Mac with a card in the menu bar. Off (settings.TrayNoBird) draws the cards alone: the image starts at the first card, with no room left for the bird; a click on a card still opens the panel at it, a modified click opens it as before, and the flap leaves the image alone. With no cards up the bird is shown as before, so the item never goes blank. It is the computer's own, like the other menu-bar settings (KeepOwn), and a change redraws at once. Menu bar logos is 菜单栏订阅图标 in Chinese now, so the two rows read apart. TestTrayImageNoBird (the image 48 px narrower at 2x, the rest the same cells, ink at its left edge, logos and plain, light and dark) fails on the old code; tray-bird.test.cjs (WebKit, en and zh) and tray-logos pass; the birdless image was looked at in light and dark. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestClientGoneRecordedAsCanceled failed once under the full run's load and passes alone, five times, and in the gateway package's own run). Not tried in the real menu bar.

This commit is contained in:
yetone committed 2026-10-04 20:01:57 +08:00
1 parent f24ee8b06d
commit 9aa7a3dca6
12 files changed
+273 -29

No files matched your search

+2 -2
View File
@@ -179,7 +179,7 @@ func TestSync(t *testing.T) {
a, b := newComputer(t), newComputer(t)
a.use(t)
provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k1"})
settings.Save(settings.Settings{Theme: "dark", Proxy: "http://127.0.0.1:7890", Window: []int{900, 700}, TrayUsages: []string{"claude|a@b.c"}, TrayUsageEvery: 5, TrayNoLogos: true})
settings.Save(settings.Settings{Theme: "dark", Proxy: "http://127.0.0.1:7890", Window: []int{900, 700}, TrayUsages: []string{"claude|a@b.c"}, TrayUsageEvery: 5, TrayNoLogos: true, TrayNoBird: true})
profile.Save("work", profile.Profile{Fields: map[string]string{"claude.model": "x"}})
same := cfg
same.Passphrase = cfg.Password
@@ -224,7 +224,7 @@ func TestSync(t *testing.T) {
}
// nor does a's menu bar: what shows beside the icon is b's own (yoooo
// on Discord)
if s := settings.Load(); len(s.TrayUsages) != 0 || s.TrayUsage != "" || s.TrayUsageEvery == 5 || s.TrayNoLogos {
if s := settings.Load(); len(s.TrayUsages) != 0 || s.TrayUsage != "" || s.TrayUsageEvery == 5 || s.TrayNoLogos || s.TrayNoBird {
t.Fatalf("b's menu bar: %+v", s)
}
if ps, _ := profile.Load(); len(ps) != 1 {
+2 -2
View File
@@ -875,9 +875,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
onDock(in)
}
// the cards the menu bar shows, any of them (TrayUsage is only the first),
// how often, and with their logos or not
// how often, with their logos or not, and beside the bird or not
if (!slices.Equal(settings.Load().TrayUsages, cur.TrayUsages) || in.TrayUsageEvery != cur.TrayUsageEvery ||
in.TrayNoLogos != cur.TrayNoLogos) && onTrayUsage != nil {
in.TrayNoLogos != cur.TrayNoLogos || in.TrayNoBird != cur.TrayNoBird) && onTrayUsage != nil {
onTrayUsage()
}
// an alert turned on or moved is looked at now, the Mac asked for its
+5 -1
View File
@@ -15044,6 +15044,10 @@ function renderTrayUsage(s, keep) {
$("#trayLogosRow").hidden = !ids.length || !mac;
$("#trayLogosSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.trayNoLogos ? "off" : "on",
(v) => savePrefs({ ...keep, trayNoLogos: v === "off" })));
// and beside magpie's icon or alone
$("#trayBirdRow").hidden = !ids.length || !mac;
$("#trayBirdSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.trayNoBird ? "off" : "on",
(v) => savePrefs({ ...keep, trayNoBird: v === "off" })));
}
const TRAY_EVERY = [1, 3, 5, 10, 30];
// how often magpie checks for updates by itself, in minutes (settings.UpdateEveries)
@@ -15847,7 +15851,7 @@ function prefsKeep(s) {
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "", workbuddyCheckin: !!s.workbuddyCheckin, traeCheckin: !!s.traeCheckin, noStats: !!s.noStats,
noUpdatePill: !!s.noUpdatePill, noAutoUpdate: !!s.noAutoUpdate, updateEvery: s.updateEvery || 360,
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, trayNoLogos: !!s.trayNoLogos, vision: s.vision || "", imageGen: s.imageGen || "", searcher: s.searcher || "", currency: s.currency || "usd",
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, trayNoLogos: !!s.trayNoLogos, trayNoBird: !!s.trayNoBird, vision: s.vision || "", imageGen: s.imageGen || "", searcher: s.searcher || "", currency: s.currency || "usd",
chineseUnits: !!s.chineseUnits, usageAlert: s.usageAlert || 0, balanceAlert: s.balanceAlert || 0 };
}
+3 -1
View File
@@ -379,7 +379,9 @@ const I18N = {
"{n} subscriptions": "{n} 个订阅",
"Menu bar refresh": "菜单栏刷新频率",
"How often the allowances beside the icon are asked for again": "图标旁的额度多久重新获取一次",
"Menu bar logos": "菜单栏图标",
"Menu bar logos": "菜单栏订阅图标",
"Menu bar magpie icon": "菜单栏 magpie 图标",
"magpie's icon before the allowances; off, the allowances alone, and a click on one still opens the panel": "额度前面的 magpie 图标;关闭后只显示额度,点击额度仍会打开面板",
"Each subscription's logo before its numbers; off, the numbers alone with a thin line between subscriptions": "每个订阅的数字前显示它的图标;关闭后只显示数字,订阅之间以细线分隔",
"Allowance display": "额度显示",
"How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar": "每个周期显示已用额度或剩余额度,用量页、面板和菜单栏一致",
+4
View File
@@ -296,6 +296,10 @@
<div class="who"><div class="name" data-t>Menu bar logos</div><div class="sub" data-t>Each subscription's logo before its numbers; off, the numbers alone with a thin line between subscriptions</div></div>
<div id="trayLogosSegs"></div>
</div>
<div class="row pref" id="trayBirdRow" hidden>
<div class="who"><div class="name" data-t>Menu bar magpie icon</div><div class="sub" data-t>magpie's icon before the allowances; off, the allowances alone, and a click on one still opens the panel</div></div>
<div id="trayBirdSegs"></div>
</div>
<div class="row pref">
<div class="who"><div class="name" data-t>Allowance display</div><div class="sub" data-t>How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar</div></div>
<div id="quotaLeftSegs"></div>
+6
View File
@@ -474,6 +474,12 @@ Off posts `trayNoLogos: true`, a save of another setting keeps it, On posts it
back; the row has no left-border stripe, no click moves the page, and there is
no row with no card picked nor off a Mac; in English and Chinese.
`tray-bird.test.cjs` turns the menu bar's magpie icon off and on in Settings:
on a Mac with a card in the menu bar the Menu bar magpie icon row, under the
logos row, shows On by default; Off posts `trayNoBird: true`, a save of
another setting keeps it, On posts it back; no click moves the page, and there
is no row with no card picked nor off a Mac; in English and Chinese.
`tray-cell-click.test.cjs` checks menu-bar quota navigation in the panel
and main window: selecting Allowances/Overview, scrolling to and highlighting
the matching account or balance, and keeping a click pending while quotas
+155
View File
@@ -0,0 +1,155 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Settings → Menu bar magpie icon (KevinXC on Discord: hide the icon in the
// menu bar, the allowances alone): on a Mac with a card in the menu bar, an
// On/Off row under the logos row; Off posts trayNoBird and On takes it back;
// a save of another setting keeps it; no row without a card, nor off a Mac;
// no click moves the page. English and Chinese; no backend, the API is
// faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const quotas = [
{ provider: "claude", name: "Claude Code", icon: "claude-color", user: "a@b.c", plan: "Max",
windows: [{ name: "5-hour", used: 42 }, { name: "Weekly", used: 18 }] },
];
function settingsPayload(over) {
return {
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
trayUsage: "claude|a@b.c", trayUsages: ["claude|a@b.c"], trayUsageEvery: 3, vision: "", imageGen: "",
version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
proxyNow: "none", proxySource: "none", login: false,
visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
...over,
};
}
function server(lang, posts, over) {
let cur = settingsPayload({ lang, ...over });
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: cur.fx });
if (url.pathname === "/api/settings") {
if (req.method() === "POST") {
const body = req.postDataJSON();
posts.push(body);
cur = { ...cur, ...body, trayUsage: (body.trayUsages || [])[0] || "" };
}
return json(cur);
}
if (url.pathname === "/api/usage/quotas") return json(quotas);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
async function open(browser, lang, posts, platform, over) {
const context = await browser.newContext({ viewport: { width: 900, height: 420 }, reducedMotion: "reduce" });
await context.addInitScript((p) => Object.defineProperty(Navigator.prototype, "platform", { get: () => p }), platform);
const page = await context.newPage();
page.setDefaultTimeout(5000);
await page.route("**/*", server(lang, posts, over));
await page.goto("http://magpie.test/?view=usage");
await page.locator("#prefs").click();
await page.locator("#setTab-usage").click();
await page.locator("#trayUsagePick button").waitFor();
return { context, page };
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": the menu bar's magpie icon on or off", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const pages = [];
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-tray-bird-${i}.png`) });
}
await browser.close();
});
for (const [lang, name, off, on] of [
["en", "Menu bar magpie icon", "Off", "On"],
["zh", "菜单栏 magpie 图标", "关闭", "开启"],
]) {
await t.test(lang, async () => {
const errors = [], posts = [];
const { context, page } = await open(browser, lang, posts, "MacIntel");
pages.push(page);
page.on("pageerror", (e) => errors.push(e.message));
const row = page.locator("#trayBirdRow");
await row.waitFor();
assert.equal((await row.locator(".name").textContent()).trim(), name);
assert.equal(await row.evaluate((r) => r.previousElementSibling.id), "trayLogosRow", "under the logos row");
const opts = row.locator("#trayBirdSegs .opt");
assert.deepEqual((await opts.allTextContents()).map((s) => s.trim()), [off, on]);
// logos are on, as before
assert.equal(await opts.nth(1).evaluate((b) => b.classList.contains("on")), true, "the icon on by default");
assert.equal(await row.evaluate((r) => getComputedStyle(r).borderLeftWidth), "0px");
// scrolled down with a real wheel, once the page has settled, the
// row in sight; then clicked: no scroll
await page.waitForTimeout(500);
await page.locator("#trayUsagePick button").hover();
for (let i = 0; i < 20 && !(await view(page)); i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(20); }
await row.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const before = await view(page);
assert(before > 0, "the settings list must be scrolled");
await opts.nth(0).click();
for (let i = 0; i < 50 && !posts.length; i++) await page.waitForTimeout(50);
assert.equal(posts.at(-1).trayNoBird, true, "Off posts trayNoBird");
await page.waitForFunction(() => document.querySelector("#trayBirdSegs .opt").classList.contains("on"));
await page.waitForTimeout(300);
assert.equal(await view(page), before, "Off moved the page");
// another setting saved keeps it
let sent = posts.length;
await page.locator("#currencySegs .opt").nth(1).evaluate((b) => b.click()); // out of sight, under the footer
for (let i = 0; i < 50 && posts.length === sent; i++) await page.waitForTimeout(50);
assert.equal(posts.at(-1).currency, "cny");
assert.equal(posts.at(-1).trayNoBird, true, "a save of another setting keeps the icon off");
// and On takes it back
await row.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const back = await view(page);
sent = posts.length;
await page.locator("#trayBirdSegs .opt").nth(1).click();
for (let i = 0; i < 50 && posts.length === sent; i++) await page.waitForTimeout(50);
assert.equal(posts.at(-1).trayNoBird, false, "On posts the icon back");
await page.waitForTimeout(300);
assert.equal(await view(page), back, "On moved the page");
assert.deepEqual(errors, []);
await context.close();
// no card in the menu bar, or not a Mac: no row
for (const [platform, over] of [["MacIntel", { trayUsage: "", trayUsages: [] }], ["Win32", {}]]) {
const { context, page } = await open(browser, lang, [], platform, over);
await page.waitForTimeout(200);
assert.equal(await page.locator("#trayBirdRow").isVisible(), false, platform + " " + JSON.stringify(over));
await context.close();
}
});
}
});
}
+1 -1
View File
@@ -88,7 +88,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
for (const [lang, name, off, on] of [
["en", "Menu bar logos", "Off", "On"],
["zh", "菜单栏图标", "关闭", "开启"],
["zh", "菜单栏订阅图标", "关闭", "开启"],
]) {
await t.test(lang, async () => {
const errors = [], posts = [];
+27 -20
View File
@@ -70,8 +70,12 @@ static CGFloat mpLead(NSDictionary *c, BOOL first) {
return (first ? 0 : mpCellGap) + mpLogo + mpLogoGap;
}
static CGFloat mpWidth(NSArray *cells, CGFloat h) {
CGFloat w = h + mpBirdGap;
// mpIn is the room before the first cell: the bird's and the gap after
// it, or none when the bird is left out (settings.TrayNoBird).
static CGFloat mpIn(NSImage *bird, CGFloat h) { return bird != nil ? h + mpBirdGap : 0; }
static CGFloat mpWidth(NSArray *cells, CGFloat h, CGFloat in) {
CGFloat w = in;
BOOL first = YES;
for (NSDictionary *c in cells) {
w += mpLead(c, first) + mpColumn(c);
@@ -187,7 +191,7 @@ static void mpRows(NSDictionary *c, CGFloat x, CGFloat h, NSColor *ink) {
// mpDraw draws the bird, then each cell, top down (a flipped context).
static void mpDraw(NSArray *cells, NSImage *bird, CGFloat h) {
if (bird != nil) mpTinted(bird, NSMakeRect(0, 0, h, h));
CGFloat x = h + mpBirdGap;
CGFloat x = mpIn(bird, h);
NSColor *ink = [NSColor labelColor];
BOOL first = YES;
for (NSDictionary *c in cells) {
@@ -228,7 +232,7 @@ static void mpDraw(NSArray *cells, NSImage *bird, CGFloat h) {
// mpImage is the bird and the cells as one image, drawn afresh each time it
// is shown so its colours follow the menu bar's, and sharp at any scale.
static NSImage *mpImage(NSArray *cells, NSImage *bird, CGFloat h) {
NSImage *im = [NSImage imageWithSize:NSMakeSize(mpWidth(cells, h), h) flipped:YES drawingHandler:^BOOL(NSRect r) {
NSImage *im = [NSImage imageWithSize:NSMakeSize(mpWidth(cells, h, mpIn(bird, h)), h) flipped:YES drawingHandler:^BOOL(NSRect r) {
mpDraw(cells, bird, h);
return YES;
}];
@@ -238,8 +242,8 @@ static NSImage *mpImage(NSArray *cells, NSImage *bird, CGFloat h) {
// mpStarts writes each cell's start in image points, following mpDraw's layout.
// Plain cells start at their separator; others start at their logo.
static void mpStarts(NSArray *cells, CGFloat h, CGFloat *out) {
CGFloat pos = h + mpBirdGap;
static void mpStarts(NSArray *cells, CGFloat in, CGFloat *out) {
CGFloat pos = in;
BOOL first = YES;
int i = 0;
for (NSDictionary *c in cells) {
@@ -259,11 +263,11 @@ static void mpStarts(NSArray *cells, CGFloat h, CGFloat *out) {
// mpCellAt returns the cell at x, including its trailing gap.
// The bird and points outside the image return -1.
static int mpCellAt(NSArray *cells, CGFloat h, CGFloat x, CGFloat w) {
if (cells.count == 0 || x < h + mpBirdGap || x >= w) return -1;
static int mpCellAt(NSArray *cells, CGFloat in, CGFloat x, CGFloat w) {
if (cells.count == 0 || x < in || x >= w) return -1;
int n = (int)cells.count;
CGFloat starts[n];
mpStarts(cells, h, starts);
mpStarts(cells, in, starts);
for (int i = 0; i < n; i++) {
if (x < (i + 1 < n ? starts[i + 1] : w)) return i;
}
@@ -299,9 +303,9 @@ static CGFloat mpHitW = 0;
// Used by the single status-item monitor and native tests. Modified clicks
// retain the button's normal action; an unmodified quota click uses its ID.
static BOOL mpClickCell(NSArray *cells, CGFloat h, CGFloat x, CGFloat w, NSEventModifierFlags flags) {
static BOOL mpClickCell(NSArray *cells, CGFloat in, CGFloat x, CGFloat w, NSEventModifierFlags flags) {
if (flags & (NSEventModifierFlagCommand | NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagShift)) return NO;
int i = mpCellAt(cells, h, x, w);
int i = mpCellAt(cells, in, x, w);
if (i < 0) return NO;
mpCellClicked((char *)[cells[i][@"id"] UTF8String]);
return YES;
@@ -318,6 +322,7 @@ static void mpApply(void) {
}
static NSImage *mpBirdImage(const void *bird, int len) {
if (bird == NULL || len <= 0) return nil; // the cells alone
NSImage *im = [[NSImage alloc] initWithData:[NSData dataWithBytes:bird length:len]];
return im;
}
@@ -355,10 +360,11 @@ static int mpFrame(const void *bird, int len) {
NSImage *bi = mpBirdImage(bird, len);
dispatch_sync(dispatch_get_main_queue(), ^{
if (mpShown == nil) return;
ok = 1;
if (mpBird == nil) return; // shown without the bird: no flap
[mpBird release];
mpBird = [bi retain];
mpApply();
ok = 1;
});
[bi release];
}
@@ -397,7 +403,7 @@ static void mpOwnClicks(void) {
// Route quota clicks before the generic action, within this
// one monitor; local monitor ordering is not guaranteed.
CGFloat x = p.x - (b.bounds.size.width - mpHitW) / 2;
if (mpClickCell(mpShown, [[NSStatusBar systemStatusBar] thickness], x, mpHitW, e.modifierFlags)) return nil;
if (mpClickCell(mpShown, mpIn(mpBird, [[NSStatusBar systemStatusBar] thickness]), x, mpHitW, e.modifierFlags)) return nil;
[NSApp sendAction:b.action to:b.target from:b];
return nil;
}];
@@ -411,8 +417,8 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat
__block void *out = NULL;
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
NSImage *bi = len > 0 ? [mpBirdImage(bird, len) autorelease] : nil;
CGFloat w = mpWidth(cs, h);
NSImage *bi = [mpBirdImage(bird, len) autorelease];
CGFloat w = mpWidth(cs, h, mpIn(bi, h));
NSBitmapImageRep *rep = [[[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:(NSInteger)ceil(w * scale) pixelsHigh:(NSInteger)ceil(h * scale)
bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSDeviceRGBColorSpace bytesPerRow:0 bitsPerPixel:0] autorelease];
rep.size = NSMakeSize(w, h);
@@ -448,7 +454,7 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat
static int mpCellAtCells(mpTrayCell *cells, int n, CGFloat h, CGFloat x) {
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
return mpCellAt(cs, h, x, mpWidth(cs, h));
return mpCellAt(cs, h + mpBirdGap, x, mpWidth(cs, h, h + mpBirdGap));
}
}
@@ -457,7 +463,7 @@ static CGFloat mpCellStartAt(mpTrayCell *cells, int n, CGFloat h, int i) {
NSArray *cs = mpCells(cells, n);
if (i < 0 || i >= n) return -1;
CGFloat starts[n];
mpStarts(cs, h, starts);
mpStarts(cs, h + mpBirdGap, starts);
return starts[i];
}
}
@@ -466,7 +472,7 @@ static CGFloat mpCellStartAt(mpTrayCell *cells, int n, CGFloat h, int i) {
static int mpClickCellsAt(mpTrayCell *cells, int n, CGFloat h, CGFloat x, unsigned long flags) {
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
return mpClickCell(cs, h, x, mpWidth(cs, h), flags);
return mpClickCell(cs, h + mpBirdGap, x, mpWidth(cs, h, h + mpBirdGap), flags);
}
}
@@ -522,8 +528,9 @@ func cBytes(b []byte) (unsafe.Pointer, C.int) {
return C.CBytes(b), C.int(len(b))
}
// trayImageShow puts the cells up beside the bird; false when the item
// can't be found, and the text is to be shown instead.
// trayImageShow puts the cells up beside the bird, or alone when bird is
// nil; false when the item can't be found, and the text is to be shown
// instead.
func trayImageShow(cells []trayCell, bird []byte) bool {
ok := false
b, n := cBytes(bird)
@@ -0,0 +1,58 @@
//go:build darwin && cgo
package gui
import (
"bytes"
"image"
"image/png"
"os"
"testing"
"time"
)
// Without the bird (settings.TrayNoBird, KevinXC on Discord: the icon takes
// room in the menu bar) the cells are drawn alone: the image is the bird
// and its gap narrower, and what is left is the cells as they were, from
// the image's left edge.
func TestTrayImageNoBird(t *testing.T) {
bird, err := os.ReadFile("tray.png")
if err != nil {
t.Fatal(err)
}
cells, _, _ := trayUsageView(trayCards(), time.Now(), false)
for _, cs := range [][]trayCell{cells[:2], trayPlain(cells[:2])} {
for _, dark := range []bool{false, true} {
wb, ww, h := trayImagePNG(cs, bird, 22, 2, dark, false)
nb, nw, nh := trayImagePNG(cs, nil, 22, 2, dark, false)
// the bird 22pt and its 2pt gap, at 2x
if d := ww - nw; d < 47 || d > 49 || nh != h {
t.Fatalf("dark %v: %dx%d with the bird, %dx%d without", dark, ww, h, nw, nh)
}
with, err := png.Decode(bytes.NewReader(wb))
if err != nil {
t.Fatal(err)
}
without, err := png.Decode(bytes.NewReader(nb))
if err != nil {
t.Fatal(err)
}
if ink := inkIn(without, image.Rect(0, 0, 12, h), dark); cs[0].Plain && ink < 10 || !cs[0].Plain && ink < 20 {
t.Errorf("dark %v plain %v: %d px of ink at the left edge, where the first card starts", dark, cs[0].Plain, ink)
}
off, differ := ww-nw, 0
for y := 0; y < h; y++ {
for x := 0; x < nw; x++ {
_, _, _, a := with.At(x+off, y).RGBA()
_, _, _, b := without.At(x, y).RGBA()
if d := int(a) - int(b); d > 0x2000 || d < -0x2000 {
differ++
}
}
}
if differ > nw*h/100 {
t.Errorf("dark %v plain %v: %d of %d px differ from the cells drawn beside the bird", dark, cs[0].Plain, differ, nw*h)
}
}
}
}
+5 -1
View File
@@ -81,7 +81,11 @@ func (h *host) watchTrayUsage() {
// Reapply even unchanged cells: a system menu-bar rebuild can
// replace the composed image with Wails' bird icon.
if len(cells) > 0 {
if trayImageShow(cells, trayIcon) {
bird := trayIcon
if s.TrayNoBird {
bird = nil // the cards alone
}
if trayImageShow(cells, bird) {
drawn = true
} else {
h.tray.SetLabel(label)
+5 -1
View File
@@ -175,6 +175,10 @@ type Settings struct {
// is its windows stacked alone, a thin line between one card and the
// next.
TrayNoLogos bool `json:"trayNoLogos,omitempty"`
// TrayNoBird leaves magpie's bird out of the Mac menu bar while the
// cards are there, the cards alone (KevinXC on Discord); a click on one
// still opens the panel, and with no cards the bird is shown.
TrayNoBird bool `json:"trayNoBird,omitempty"`
// Lightweight lets the webview of a window closed — the tray panel or
// the main window — go once it has stayed closed a while, and makes it
// again when it is opened (#580): less memory, a moment's wait. This
@@ -527,7 +531,7 @@ func (s Settings) Working(n int) int {
// Windows box that shows it).
func (s *Settings) KeepOwn(cur Settings) {
s.Window, s.Proxy, s.Dock, s.DockWindow, s.Lightweight = cur.Window, cur.Proxy, cur.Dock, cur.DockWindow, cur.Lightweight
s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos
s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos, s.TrayNoBird = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos, cur.TrayNoBird
}
// RenamePerModel moves what the user said of a provider's models to the id