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 @@
Menu bar logos
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/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