diff --git a/internal/davsync/davsync_test.go b/internal/davsync/davsync_test.go
index dd927b03..eb5de3a7 100644
--- a/internal/davsync/davsync_test.go
+++ b/internal/davsync/davsync_test.go
@@ -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 {
diff --git a/internal/gui/api.go b/internal/gui/api.go
index e00ab9ba..af4c8970 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -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
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index b6cd509b..18ae8805 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -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 };
}
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 6dbb2c86..9a8d6a62 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -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": "每个周期显示已用额度或剩余额度,用量页、面板和菜单栏一致",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index 16c4a0e4..12bacd9f 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -296,6 +296,10 @@
Allowance display
How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index ecdcd2a5..e067a16e 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -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
diff --git a/internal/gui/tests/tray-bird.test.cjs b/internal/gui/tests/tray-bird.test.cjs
new file mode 100644
index 00000000..8379ddc0
--- /dev/null
+++ b/internal/gui/tests/tray-bird.test.cjs
@@ -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();
+ }
+ });
+ }
+ });
+}
diff --git a/internal/gui/tests/tray-logos.test.cjs b/internal/gui/tests/tray-logos.test.cjs
index 695470fb..bcffe927 100644
--- a/internal/gui/tests/tray-logos.test.cjs
+++ b/internal/gui/tests/tray-logos.test.cjs
@@ -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 = [];
diff --git a/internal/gui/trayimage_darwin.go b/internal/gui/trayimage_darwin.go
index 8182842e..6ca07efc 100644
--- a/internal/gui/trayimage_darwin.go
+++ b/internal/gui/trayimage_darwin.go
@@ -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)
diff --git a/internal/gui/trayimage_nobird_darwin_test.go b/internal/gui/trayimage_nobird_darwin_test.go
new file mode 100644
index 00000000..55f7e5dc
--- /dev/null
+++ b/internal/gui/trayimage_nobird_darwin_test.go
@@ -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)
+ }
+ }
+ }
+}
diff --git a/internal/gui/trayusage_tray.go b/internal/gui/trayusage_tray.go
index 64a3eea0..1f4fdc10 100644
--- a/internal/gui/trayusage_tray.go
+++ b/internal/gui/trayusage_tray.go
@@ -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)
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index 39d51da3..6848ad3a 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -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