diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 1ad0a8e7..ed2f0538 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -503,7 +503,7 @@ body.window .field.solo { grid-column: auto; } .field .v { min-width: 0; flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; font-weight: 500; } .field .chev { color: var(--faint); display: inline-flex; margin-left: auto; padding-left: 1px; } .field:hover .chev, .field.open .chev { color: var(--muted); } -.field.flash { animation: flash 1.2s ease-out; } +.field.flash, .subscription-card.flash, .pq-card.flash { animation: flash 1.2s ease-out; } @keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} } /* ---- usage ---------------------------------------------------------------- */ diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2522f12a..e5a5a2a1 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -4,6 +4,8 @@ const $$ = (s) => document.querySelectorAll(s); const params = new URLSearchParams(location.search); const mode = params.get("mode") || "window"; document.body.classList.add(mode); +// The panel loads Wails for ExecJS readiness, but stays attached to the tray. +if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no-drag"); // `magpie web`: the page in a browser tab, with no window of the app's // around it — it opens links itself, and what is the desktop's is left out const web = !!window.bootPrefs?.web; @@ -26,7 +28,8 @@ if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win // Outside the app — a browser on the gateway's page, or on `magpie web` — // there is no runtime, and it isn't asked for (a 404 in the browser's // console, Jorben on Discord). -const winRuntime = mode === "window" && !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null); +// The panel also needs runtime readiness to drain Go's queued ExecJS calls. +const winRuntime = !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null); if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme"); // the saved language and theme from boot.js, so the first paint is in them if (window.bootPrefs) { @@ -9032,6 +9035,7 @@ const tokensOf = (t) => t.input + t.output; function renderQuotas() { renderPanelQuota(); const subscriptions = $("#subscriptionUsage"); + const restoreFlash = keepQuotaFlash(subscriptions); subscriptions.replaceChildren(); // the allowances' own heading, apart from the period's cost: used or // left turns their meters, and is only there when some card has one (a @@ -9073,6 +9077,8 @@ function renderQuotas() { const pick = folded ? usageShown(subs) : null; const card = el("div", "subscription-card" + (first.user ? " several" : "")); card.dataset.key = first.provider; + card.dataset.provider = first.provider; + if (!first.user) card.dataset.card = first.provider; const head = el("div", "subscription-head"); head.append(groups.length > 1 ? usageHandle(first, card) : icon(first.icon), el("b", "", first.name)); if (!first.user && (first.plan || first.until)) head.append(planSpan(first)); @@ -9084,6 +9090,7 @@ function renderQuotas() { const [meters, every] = familyQuota(sub); if (sub.user) { const who = el("div", "subscription-account"); + who.dataset.card = trayCardID(sub); const u = el("span", "user", sub.user); u.title = sub.user; who.append(u); @@ -9123,6 +9130,8 @@ function renderQuotas() { if (subs.length > 1) card.append(usageMore(first.provider, subs.length - 1, folded, "text quota-more")); subscriptions.append(card); } + restoreFlash(); + requestAnimationFrame(focusQuotaCard); } // A subscription with several accounts shows one of them, the others behind @@ -9146,13 +9155,16 @@ function usageMore(provider, more, folded, cls) { b.type = "button"; b.setAttribute("aria-expanded", String(!folded)); b.onclick = () => { - if (folded) usageOpen.add(provider); else usageOpen.delete(provider); - try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {} - renderQuotas(); + setUsageOpen(provider, folded); if (mode !== "panel") backToReader($("#view-usage")); }; return b; } +function setUsageOpen(provider, open) { + if (open) usageOpen.add(provider); else usageOpen.delete(provider); + try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {} + renderQuotas(); +} // The Usage page's cards in the order they were dragged to (settings // usageOrder, by provider id), as the Agents page's rows are; one it doesn't @@ -9514,6 +9526,80 @@ function setPanelTab(tab) { queueMicrotask(panelUseShown); fit(); } +// Scroll to a requested card, or resume its highlight after a redraw. +function flashCard(card, target = card, elapsed = 0) { + if (target) { + // The native menu-bar click has no DOM event; permit this requested scroll. + readerScrolls(1000); + target.scrollIntoView({ block: "nearest", behavior: "instant" }); + } + card.classList.remove("flash"); + void card.offsetWidth; // restart even if this card is still highlighted + card.classList.add("flash"); + const animation = card.getAnimations().find((a) => a.animationName === "flash"); + if (animation) animation.currentTime = elapsed; + card.onanimationend = (e) => { + if (e.target !== card || e.animationName !== "flash") return; + card.classList.remove("flash"); + card.onanimationend = null; + }; +} + +// A focus refresh rebuilds quota cards. Carry the active animation's time +// to its replacement, preserving scroll position and the remaining duration. +function keepQuotaFlash(box) { + const view = box.closest(".view"), scroll = view?.scrollTop; + const flashes = [...box.querySelectorAll(".subscription-card.flash, .pq-card.flash")].flatMap((card) => { + const animation = card.getAnimations().find((a) => a.animationName === "flash"); + if (!animation) return []; + const selector = card.matches(".pq-card") ? `.pq-card[data-card="${CSS.escape(card.dataset.card)}"]` + : `.subscription-card[data-provider="${CSS.escape(card.dataset.provider)}"]`; + return [{ selector, elapsed: animation.currentTime || 0 }]; + }); + return () => { + for (const { selector, elapsed } of flashes) { + const card = box.querySelector(selector); + if (card) flashCard(card, null, elapsed); + } + if (flashes.length && view) { + view.scrollTop = scroll; + readerLeaves(view); + } + }; +} + +// Keep the request until quota data has produced the target's DOM node. +function focusQuotaCard() { + if (!quotaFocus) return; + if (performance.now() > quotaFocusUntil) { quotaFocus = ""; return; } + const box = mode === "panel" ? $("#panelQuota") : $("#subscriptionUsage"); + if (mode === "panel") setPanelTab("usage"); + else if (view !== "usage" || usageTab !== "usage") return; + const id = CSS.escape(quotaFocus); + const target = box.querySelector(`[data-card="${id}"]`) || box.querySelector(`[data-provider="${id}"]`); + // The panel omits folded accounts; the main window renders them hidden. + const quota = quotas?.find((q) => trayCardID(q) === quotaFocus); + if (quota?.user && !usageOpen.has(quota.provider) && (!target || target.hidden)) { + setUsageOpen(quota.provider, true); + return; // the redraw queues focus once the account is visible + } + if (!target) { + if (quotas && !quotasLoading) quotaFocus = ""; + return; + } + quotaFocus = ""; + flashCard(target.closest(".subscription-card, .pq-card"), target); +} + +// Go calls this when a menu-bar quota cell opens the panel. +function panelQuotaFocus(id) { + if (mode !== "panel") return; + quotaFocus = id; + quotaFocusUntil = performance.now() + 5000; + setPanelTab("usage"); + requestAnimationFrame(focusQuotaCard); +} + if (mode === "panel") { const tabs = $("#ptabs"); tabs.hidden = false; @@ -9738,8 +9824,9 @@ function renderPanelQuota() { } if (none && panelTab === "usage") setPanelTab("agents"); box.hidden = none; + const restoreFlash = keepQuotaFlash(box); box.replaceChildren(); - if (none) { fit(); return; } + if (none) { quotaFocus = ""; fit(); return; } if (!quotas) { for (let i = 0; i < 2; i++) { const card = el("div", "pq-card"); @@ -9795,6 +9882,7 @@ function renderPanelQuota() { const grid = el("div", "pq-bals"); for (const q of bals) { const card = el("div", "pq-card bal"); + card.dataset.card = trayCardID(q); card.title = [q.name, q.user].filter(Boolean).join(" · "); // whose balance, at a glance: the provider's logo before its name const who = el("span", "pq-sub pq-bn"); @@ -9827,8 +9915,10 @@ function renderPanelQuota() { g.append(grid); box.append(g); } + restoreFlash(); panelAge(); fit(); + requestAnimationFrame(focusQuotaCard); } // asOfText: an allowance standing in for one that couldn't be read just @@ -9848,6 +9938,7 @@ function shortWindow(name) { function panelQuotaCard(q) { const card = el("div", "pq-card"); + card.dataset.card = trayCardID(q); card.append(el("span", "pq-user", q.user || q.name)); card.title = [q.name, q.user, q.plan, q.until ? planTerm(q) : "", q.balance && t("Balance") + " " + q.balance].filter(Boolean).join(" · "); if (q.error) { @@ -11188,6 +11279,8 @@ function renderLedger() { const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"]]; let usageTab = "usage"; +let quotaFocus = ""; // pending provider/account card requested by the menu bar +let quotaFocusUntil = 0; // expire before a late quota response can move the reader try { const k = localStorage.getItem("magpie.usageTab"); if (USAGE_TABS.some(([id]) => id === k)) usageTab = k; } catch {} let sessions = null; // { sessions, terminal, dirs } let sessAgent = "all"; @@ -14057,7 +14150,8 @@ function savePrefs(body) { // (from a load, a timer, a helper that other clicks share) it is refused, // and whatever scroll follows is put back. let purposeUntil = 0, held = null; -const readerScrolls = (ms) => { purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; }; +// A new purposeful scroll supersedes any still-pending quota navigation. +const readerScrolls = (ms) => { quotaFocus = ""; purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; }; function scrollOnPurpose(e, ms = 1000) { if (!e?.isTrusted || performance.now() - e.timeStamp > 1000) { console.warn("magpie: a scroll not asked for by the reader was refused"); @@ -14723,12 +14817,16 @@ if (mode === "window" && params.get("import")) { if (mode === "window" && params.get("view") === "providers" && params.get("edit")) editing = params.get("edit"); // opened from the tray panel's Usage tab: on the Requests of one provider or agent if (mode === "window" && params.get("view") === "usage") { - if (params.get("tab") === "requests") usageTab = "requests"; + // An explicit destination takes precedence over the remembered tab. + const tab = params.get("tab"); + if (tab === "requests" || tab === "usage") usageTab = tab; + quotaFocus = usageTab === "usage" ? (params.get("card") || params.get("provider") || "") : ""; + quotaFocusUntil = performance.now() + 5000; ledProvider = params.get("provider") || ""; ledAgent = params.get("agent") || ""; ledComputer = params.get("computer") || ""; const u = new URL(location.href); - for (const k of ["tab", "provider", "agent", "computer"]) u.searchParams.delete(k); + for (const k of ["tab", "provider", "agent", "computer", "card"]) u.searchParams.delete(k); history.replaceState(null, "", u); } if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view")); diff --git a/internal/gui/showmain_url_test.go b/internal/gui/showmain_url_test.go index 1ea4b2a2..b99ae0e9 100644 --- a/internal/gui/showmain_url_test.go +++ b/internal/gui/showmain_url_test.go @@ -16,6 +16,8 @@ func TestMainURL(t *testing.T) { {mainView(url.Values{"view": {"routing"}, "req": {"42"}}), "/?view=routing&req=42&lang=en"}, {mainView(url.Values{"view": {"settings"}}), "/?view=settings&lang=en"}, {mainView(url.Values{"view": {"usage"}, "tab": {"requests"}, "provider": {"relay team"}, "agent": {"claude-desktop"}}), "/?view=usage&tab=requests&provider=relay+team&agent=claude-desktop&lang=en"}, + {mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "provider": {"codex"}}), "/?view=usage&tab=usage&provider=codex&lang=en"}, + {mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "card": {"codex|a+b&c@test"}}), "/?view=usage&tab=usage&card=codex%7Ca%2Bb%26c%40test&lang=en"}, {argView("settings"), "/?view=settings&lang=en"}, {argView("usage&tab=x"), "/?view=usage%26tab%3Dx&lang=en"}, } { @@ -32,3 +34,18 @@ func TestMainURL(t *testing.T) { } } } + +// Quota destinations validate provider names and escape account IDs. +func TestQuotaView(t *testing.T) { + for _, c := range []struct{ id, want string }{ + {"codex", "usage&tab=usage&provider=codex"}, + {"relay team", "usage&tab=usage&provider=relay+team"}, + {"codex|a+b@例子.test", "usage&tab=usage&provider=codex&card=codex%7Ca%2Bb%40%E4%BE%8B%E5%AD%90.test"}, + {"bad&name", "usage&tab=usage"}, + {"", "usage&tab=usage"}, + } { + if got := quotaView(c.id); got != c.want { + t.Errorf("quotaView(%q): %q, want %q", c.id, got, c.want) + } + } +} diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 657d934e..5b466ba4 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -449,6 +449,25 @@ 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-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 +load. It checks that the panel loads Wails so native ExecJS can run. +A focus-triggered redraw preserves the remaining highlight animation in both +panel and main window. It expands and remembers folded target accounts, +disables panel-header dragging, +and cancels navigation after five seconds or a purposeful user scroll. +It runs in English and Chinese on Chromium and WebKit, with a mocked API. Run with +`node --test internal/gui/tests/tray-cell-click.test.cjs`. + +`TestTrayCellClickReleasedPanel` (darwin, cgo, GUI) runs a separate AppKit +process with an isolated config and a minimal page: a quota click recreates +the released panel, queued ExecJS selects the account once Wails is ready, +the next click closes it, and another release can be reopened and focused. +It requires a macOS graphical session and does not start the magpie backend +or create a status item. Run with +`go test ./internal/gui -run '^TestTrayCellClickReleasedPanel$' -count=1`. + `text-size.test.cjs` checks the Settings page's Text size row (100, 110, 125 and 150%): a pick posts to /api/settings/text-size without scrolling the page, stays picked after a reload (boot.js carries it, and the page's diff --git a/internal/gui/tests/tray-cell-click.test.cjs b/internal/gui/tests/tray-cell-click.test.cjs new file mode 100644 index 00000000..db8c55a4 --- /dev/null +++ b/internal/gui/tests/tray-cell-click.test.cjs @@ -0,0 +1,212 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Native ExecJS and main-window URLs select the requested allowance/account, +// including delayed data, focus refreshes, real scrolling and repeated openings. APIs are mocked. +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 = (empty) => empty ? [] : [ + { provider: "claude", name: "Claude Code", icon: "claude-color", user: "a@b.c", windows: [{ name: "Weekly", used: 17.6 }, { name: "5-hour", used: 42.2 }] }, + { provider: "codex", name: "Codex", icon: "codex-color", user: "x@y.z", windows: [{ name: "5-hour", used: 8 }, { name: "Weekly", used: 100 }] }, + ...Array.from({ length: 6 }, (_, i) => ({ provider: "codex", name: "Codex", icon: "codex-color", user: `other-${i}@例子.test`, windows: [{ name: "Weekly", used: 25 }] })), + { provider: "deepseek", name: "DeepSeek", icon: "deepseek-color", windows: [], balance: "¥12.30" }, + { provider: "kimi", name: "Kimi", icon: "kimi-color", windows: [{ name: "Weekly", used: 30 }] }, +]; + +function serve(lang, empty, ready) { + const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" }; + return async (route) => { + const url = new URL(route.request().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: "window.__runtimeReady = true; export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings }); + if (url.pathname === "/api/settings") return json(settings); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/usage/quotas") { await ready; return json(quotas(empty)); } + if (url.pathname === "/api/usage") return json({ days: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + 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)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: a menu-bar cell's click opens the panel's Allowances tab at that agent's card`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(async () => { await browser.close(); }); + const errors = []; + const open = async (url, viewport, init, empty, ready) => { + // no reduced-motion emulation here: it makes every animation a + // frame long, and the flash the cards light up with is one + const context = await browser.newContext({ viewport }); + // what a click lands on is said by its scrolling there + await context.addInitScript(() => { + window.__scrolled = []; + const scroll = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function (options) { window.__scrolled.push(this.dataset.card || this.dataset.provider || this.tagName); scroll.call(this, options); }; + }); + for (const script of init || []) await context.addInitScript(script); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, empty, ready)); + await page.goto(url); + return page; + }; + + const visible = async (page, selector) => { + await page.waitForFunction((selector) => { + const e = document.querySelector(selector); + if (!e) return false; + const r = e.getBoundingClientRect(), v = e.closest(".view").getBoundingClientRect(); + return r.top >= v.top - 1 && r.bottom <= v.bottom + 1; + }, selector).catch(async (err) => { + const info = await page.locator(selector).evaluate((e) => { + const v = e.closest(".view"); + return { target: e.getBoundingClientRect().toJSON(), view: v.getBoundingClientRect().toJSON(), scroll: v.scrollTop, max: v.scrollHeight - v.clientHeight, viewport: innerHeight }; + }); + throw new Error(err.message + " " + JSON.stringify(info)); + }); + }; + + // the panel: Go's ExecJS runs panelQuotaFocus, the global Go names — + // a click on the Codex cell is the Allowances tab at Codex's card + const panel = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 }); + await panel.waitForFunction(() => document.querySelectorAll("#panelQuota .pq-card").length >= 4); + await panel.waitForFunction(() => window.__runtimeReady === true); // native ExecJS can now drain + assert.equal(await panel.locator("header.top").evaluate((e) => getComputedStyle(e).getPropertyValue("--wails-draggable").trim()), "no-drag", "the runtime must not make the panel draggable"); + assert.equal(await panel.evaluate(() => typeof panelQuotaFocus), "function", "panelQuotaFocus is Go's to call by name"); + assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "agents", "the panel starts on its agents"); + await panel.evaluate(() => panelQuotaFocus("codex|x@y.z")); + await panel.waitForFunction(() => document.body.dataset.ptab === "usage"); + assert(await panel.locator('#ptabs [data-ptab="usage"]').evaluate((e) => e.classList.contains("on")), "the Allowances tab is the one picked"); + assert.equal(await panel.evaluate(() => localStorage.getItem("magpie.panelTab")), "usage"); + await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card[data-card="codex|x@y.z"]')?.classList.contains("flash")); + assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["codex|x@y.z"], "the card is scrolled to"); + assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((card) => { + flashCard(card); // repeated focus restarts the same card + card.firstElementChild.dispatchEvent(new AnimationEvent("animationend", { bubbles: true, animationName: "flash" })); + card.dispatchEvent(new AnimationEvent("animationend", { animationName: "unrelated" })); + return card.classList.contains("flash"); + }), "child or unrelated animations must not end the card's highlight"); + // Showing a native panel gives it focus, which refreshes its state and quotas. + // The first click must remain highlighted across that refresh. + await panel.evaluate(() => { + window.__beforeFocusRefresh = document.querySelector('#panelQuota [data-card="codex|x@y.z"]'); + window.__beforeFocusRefresh.getAnimations().find((a) => a.animationName === "flash").currentTime = 600; + window.dispatchEvent(new Event("focus")); + }); + await panel.waitForFunction(() => document.querySelector('#panelQuota [data-card="codex|x@y.z"]') !== window.__beforeFocusRefresh); + assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.classList.contains("flash")), "first-open focus refresh must preserve the highlight"); + + assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "refresh must continue the animation rather than restart it"); + await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|x@y.z"]').classList.contains("flash")); + + // a balance alone is a cell too, and its card is among the Balances + await panel.evaluate(() => panelQuotaFocus("deepseek")); + await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card.bal[data-card="deepseek"]')?.classList.contains("flash")); + assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["deepseek"]); + await visible(panel, '#panelQuota [data-card="deepseek"]'); + // a card that isn't there picks the tab and stops: nothing flashed, + // nothing broken + await panel.evaluate(() => panelQuotaFocus("nobody")); + await panel.waitForFunction(() => !document.querySelector('#panelQuota .pq-card[data-card="deepseek"]')?.classList.contains("flash")); + assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "usage"); + assert.equal(await panel.evaluate(() => window.__scrolled.filter((s) => s === "nobody").length), 0); + + // A click before the first quota response must survive the loading cards. + let release; + const ready = new Promise((resolve) => { release = resolve; }); + const slow = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, ready); + await slow.waitForFunction(() => typeof panelQuotaFocus === "function"); + await slow.evaluate(() => panelQuotaFocus("kimi")); + await slow.evaluate(() => new Promise(requestAnimationFrame)); + release(); + await slow.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]')?.classList.contains("flash")); + + await visible(slow, '#panelQuota [data-card="kimi"]'); + await slow.waitForFunction(() => !document.querySelector('#panelQuota [data-card="kimi"]').classList.contains("flash")); + await visible(slow, '#panelQuota [data-card="kimi"]'); + + await panel.emulateMedia({ reducedMotion: "reduce" }); + assert.equal(await panel.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0, "the requested account starts folded"); + await panel.evaluate(() => panelQuotaFocus("codex|other-5@例子.test")); + await panel.waitForFunction(() => window.__scrolled.at(-1) === "codex|other-5@例子.test"); + await visible(panel, '#panelQuota [data-card="codex|other-5@例子.test"]'); + assert.deepEqual(await panel.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"], "navigation remembers the expanded provider"); + await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|other-5@例子.test"]').classList.contains("flash")); + + // no allowances to show: no tab to pick, the panel keeps its agents + const none = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 }, [], true); + await none.waitForFunction(() => document.querySelectorAll("#agents").length === 1); + await none.evaluate(() => panelQuotaFocus("codex|x@y.z")); + await none.waitForFunction(() => document.body.dataset.ptab === "agents"); + + // the window: opened on its allowances (view=usage&tab=usage), the + // Overview is the pane whatever the page was last left on, at the + // provider's card + const win = await open("http://magpie.test/?view=usage&tab=usage&provider=kimi", { width: 900, height: 700 }, [ + () => { try { localStorage.setItem("magpie.usageTab", "requests"); } catch {} }, + ]); + await win.waitForFunction(() => !document.querySelector("#usagePane").hidden); + assert.equal(await win.evaluate(() => document.querySelector("#ledgerPane").hidden), true, "the Requests the page was left on are not the pane"); + await win.waitForFunction(() => document.querySelector('.subscription-card[data-provider="kimi"]')?.classList.contains("flash")); + assert.deepEqual(await win.evaluate(() => window.__scrolled.slice(-1)), ["kimi"], "the provider's card is scrolled to"); + await win.evaluate(async () => { + document.querySelector('.subscription-card[data-provider="kimi"]').getAnimations().find((a) => a.animationName === "flash").currentTime = 600; + await loadQuotas(); + }); + assert(await win.locator('.subscription-card[data-provider="kimi"]').evaluate((e) => e.classList.contains("flash") && e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "main-window quota refresh must preserve the remaining highlight"); + assert.equal(await win.evaluate(() => location.search.includes("tab=")), false, "the parameters are the page's own again"); + await visible(win, '.subscription-card[data-provider="kimi"]'); + + // Same vendor, a different account: scroll within the tall vendor card. + const id = "codex|other-5@例子.test"; + const account = await open("http://magpie.test/?view=usage&tab=usage&provider=codex&card=" + encodeURIComponent(id), { width: 900, height: 500 }); + await account.waitForFunction(() => document.querySelector('.subscription-card[data-provider="codex"]')?.classList.contains("flash")); + assert.deepEqual(await account.evaluate(() => window.__scrolled.slice(-1)), [id]); + await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]'); + assert.deepEqual(await account.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"]); + assert.equal(await account.evaluate(() => location.search.includes("card=")), false); + await account.waitForFunction(() => !document.querySelector('.subscription-card[data-provider="codex"]').classList.contains("flash")); + await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]'); + + // A slow response must not pull the reader back after the request expires. + let finish; + const expired = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { finish = resolve; })); + await expired.waitForFunction(() => typeof panelQuotaFocus === "function"); + await expired.clock.install(); + await expired.evaluate(() => panelQuotaFocus("codex|other-5@例子.test")); + await expired.clock.fastForward(6000); + finish(); + await expired.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]')); + await expired.evaluate(() => new Promise(requestAnimationFrame)); + assert.deepEqual(await expired.evaluate(() => window.__scrolled), [], "expired requests cannot scroll or expand accounts"); + assert.equal(await expired.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0); + + // A purposeful user scroll supersedes a still-pending menu-bar request. + let resume; + const scrolled = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { resume = resolve; })); + await scrolled.waitForFunction(() => typeof panelQuotaFocus === "function"); + await scrolled.evaluate(() => panelQuotaFocus("kimi")); + await scrolled.mouse.move(200, 150); + await scrolled.mouse.wheel(0, 200); + // Flush input delivery before allowing the data to arrive. + await scrolled.waitForFunction(() => !quotaFocus); + resume(); + await scrolled.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]')); + await scrolled.evaluate(() => new Promise(requestAnimationFrame)); + assert.deepEqual(await scrolled.evaluate(() => window.__scrolled), [], "manual scrolling cancels pending navigation"); + + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/trace.go b/internal/gui/trace.go index e0752029..9a3b78a0 100644 --- a/internal/gui/trace.go +++ b/internal/gui/trace.go @@ -6,6 +6,7 @@ import ( "net/url" "regexp" "strconv" + "strings" "time" "github.com/yetone/magpie/internal/gateway" @@ -67,8 +68,9 @@ func pricedRoutes(routes []gateway.Route) []routeJSON { // mainView is the tab the window is asked to open on, as the page's view // parameter: the Routing page on one request (req, its id) when the tray // panel's Routing tab asks for it, or the Usage page's Requests on one -// provider or agent when its Usage tab does. Anything but an id, or a name -// of the kind a provider or an agent has, is dropped. +// provider or agent when its Usage tab does, its Overview (the allowances) +// when a menu-bar cell's click does. Anything but an id, or a name of the +// kind a provider or an agent has, is dropped. func mainView(q url.Values) string { view := q.Get("view") switch view { @@ -77,8 +79,11 @@ func mainView(q url.Values) string { view += "&req=" + strconv.FormatInt(id, 10) } case "usage": - if q.Get("tab") == "requests" { - view += "&tab=requests" + if tab := q.Get("tab"); tab == "requests" || tab == "usage" { + view += "&tab=" + tab + } + if card := q.Get("card"); q.Get("tab") == "usage" && card != "" { + view += "&card=" + url.QueryEscape(card) } for _, k := range []string{"provider", "agent"} { if v := q.Get(k); mainName.MatchString(v) { @@ -106,6 +111,20 @@ func mainURL(view, query string) string { return "/?view=" + view + query } // restart to update) as ShowMain takes it: a name, never parameters. func argView(s string) string { return url.QueryEscape(s) } +// quotaView opens the Usage overview at a provider/account card. +// Invalid provider names fall back to the overview without a target. +func quotaView(id string) string { + provider, _, hasAccount := strings.Cut(id, "|") + view := "usage&tab=usage" + if mainName.MatchString(provider) { + view += "&provider=" + url.QueryEscape(provider) + if hasAccount { + view += "&card=" + url.QueryEscape(id) + } + } + return view +} + // traceRoutes serves the routing trace for the Gateway view to play: it // waits up to 25 s for something to change after the seq it is given, so // the page hears of a request as it happens. diff --git a/internal/gui/trayimage_click_darwin.go b/internal/gui/trayimage_click_darwin.go new file mode 100644 index 00000000..ba4c1bf6 --- /dev/null +++ b/internal/gui/trayimage_click_darwin.go @@ -0,0 +1,15 @@ +//go:build darwin && cgo + +package gui + +import "C" + +// The export lives separately because its cgo preamble must not define functions. +// Copy the native string before returning; a refresh can replace the drawn cells. +// +//export mpCellClicked +func mpCellClicked(id *C.char) { + if onTrayCellClick != nil { + onTrayCellClick(C.GoString(id)) // the handler schedules work after startup + } +} diff --git a/internal/gui/trayimage_darwin.go b/internal/gui/trayimage_darwin.go index 61a4c69f..8182842e 100644 --- a/internal/gui/trayimage_darwin.go +++ b/internal/gui/trayimage_darwin.go @@ -13,6 +13,7 @@ typedef struct { int iconLen; int mono; int plain; // no logo: the rows alone, a thin line before + const char *id; const char *letter; const char *rows; // one or two, a newline between } mpTrayCell; @@ -41,6 +42,7 @@ static NSArray *mpCells(mpTrayCell *cells, int n) { if (im != nil && im.representations.count > 0) c[@"icon"] = im; [im release]; } + c[@"id"] = [NSString stringWithUTF8String:cells[i].id ? cells[i].id : ""]; c[@"mono"] = @(cells[i].mono != 0); c[@"plain"] = @(cells[i].plain != 0); c[@"letter"] = [NSString stringWithUTF8String:cells[i].letter ? cells[i].letter : ""]; @@ -234,6 +236,40 @@ static NSImage *mpImage(NSArray *cells, NSImage *bird, CGFloat h) { return im; } +// 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; + BOOL first = YES; + int i = 0; + for (NSDictionary *c in cells) { + if ([c[@"plain"] boolValue]) { + out[i] = first ? pos : pos + mpSepGap; + pos += mpLead(c, first); + } else { + pos += mpLead(c, first) - mpLogo - mpLogoGap; + out[i] = pos; + pos += mpLogo + mpLogoGap; + } + pos += mpColumn(c); + i++; + first = NO; + } +} + +// 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; + int n = (int)cells.count; + CGFloat starts[n]; + mpStarts(cells, h, starts); + for (int i = 0; i < n; i++) { + if (x < (i + 1 < n ? starts[i + 1] : w)) return i; + } + return -1; +} + static NSStatusBarButton *mpFind(NSView *v) { if ([v isKindOfClass:[NSStatusBarButton class]]) return (NSStatusBarButton *)v; for (NSView *s in v.subviews) { @@ -256,12 +292,29 @@ static NSStatusBarButton *mpButton(void) { static NSArray *mpShown; static NSImage *mpBird; +// mpCellClicked copies the drawn card's identity to Go before it can be replaced. +extern void mpCellClicked(char *id); + +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) { + if (flags & (NSEventModifierFlagCommand | NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagShift)) return NO; + int i = mpCellAt(cells, h, x, w); + if (i < 0) return NO; + mpCellClicked((char *)[cells[i][@"id"] UTF8String]); + return YES; +} + static void mpApply(void) { NSStatusBarButton *b = mpButton(); if (b == nil || mpShown == nil) return; b.title = @""; - b.image = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]); + NSImage *im = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]); + b.image = im; b.imagePosition = NSImageOnly; + mpHitW = im.size.width; } static NSImage *mpBirdImage(const void *bird, int len) { @@ -317,6 +370,7 @@ static void mpHide(void) { dispatch_async(dispatch_get_main_queue(), ^{ [mpShown release]; mpShown = nil; + mpHitW = 0; // every click is the item's whole one again }); } @@ -338,6 +392,12 @@ static void mpOwnClicks(void) { [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown handler:^NSEvent *(NSEvent *e) { NSStatusBarButton *b = mpButton(); if (b == nil || e.window != b.window) return e; + NSPoint p = [b convertPoint:e.locationInWindow fromView:nil]; + if (!NSPointInRect(p, b.bounds)) return e; + // 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; [NSApp sendAction:b.action to:b.target from:b]; return nil; }]; @@ -383,6 +443,33 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat } return out; } + +// Native hit-testing helpers for Go tests, using the actual image layout. +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)); + } +} + +static CGFloat mpCellStartAt(mpTrayCell *cells, int n, CGFloat h, int i) { + @autoreleasepool { + NSArray *cs = mpCells(cells, n); + if (i < 0 || i >= n) return -1; + CGFloat starts[n]; + mpStarts(cs, h, starts); + return starts[i]; + } +} + +// Exercise the same identity lookup and C-to-Go callback without a live status item. +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); + } +} + */ import "C" @@ -406,8 +493,8 @@ func withCells(cells []trayCell, f func(*C.mpTrayCell, C.int)) { cs := (*[1 << 16]C.mpTrayCell)(C.malloc(C.size_t(len(cells)) * C.size_t(unsafe.Sizeof(C.mpTrayCell{}))))[:len(cells):len(cells)] var owned []unsafe.Pointer for i, c := range cells { - cs[i] = C.mpTrayCell{letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))} - owned = append(owned, unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows)) + cs[i] = C.mpTrayCell{id: C.CString(c.ID), letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))} + owned = append(owned, unsafe.Pointer(cs[i].id), unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows)) if len(c.Icon) > 0 { cs[i].icon, cs[i].iconLen = C.CBytes(c.Icon), C.int(len(c.Icon)) owned = append(owned, cs[i].icon) @@ -486,3 +573,31 @@ func cBool(b bool) C.int { } return 0 } + +// These native test wrappers stay here because Go does not support cgo +// imports in _test.go files. They do not install monitors or create windows. +// trayImageCellAt tests the native hit map; -1 means no quota cell. +func trayImageCellAt(cells []trayCell, h, x float64) int { + at := -1 + withCells(cells, func(cs *C.mpTrayCell, count C.int) { + at = int(C.mpCellAtCells(cs, count, C.CGFloat(h), C.CGFloat(x))) + }) + return at +} + +// trayImageCellStart returns cell i's start in image points, or -1 if absent. +func trayImageCellStart(cells []trayCell, h float64, i int) float64 { + s := -1.0 + withCells(cells, func(cs *C.mpTrayCell, count C.int) { + s = float64(C.mpCellStartAt(cs, count, C.CGFloat(h), C.int(i))) + }) + return s +} + +func trayImageClickAt(cells []trayCell, h, x float64, modifiers uint64) bool { + hit := false + withCells(cells, func(cs *C.mpTrayCell, count C.int) { + hit = C.mpClickCellsAt(cs, count, C.CGFloat(h), C.CGFloat(x), C.ulong(modifiers)) != 0 + }) + return hit +} diff --git a/internal/gui/trayimage_darwin_test.go b/internal/gui/trayimage_darwin_test.go index 5cd890ae..202c55b2 100644 --- a/internal/gui/trayimage_darwin_test.go +++ b/internal/gui/trayimage_darwin_test.go @@ -82,6 +82,63 @@ func TestTrayImage(t *testing.T) { } } +// Hit-testing follows the image layout for both logo and plain cells. +func TestTrayImageCellAt(t *testing.T) { + bird, err := os.ReadFile("tray.png") + if err != nil { + t.Fatal(err) + } + cells, _, _ := trayUsageView(trayCards(), time.Now(), false) + const h = 22.0 + _, w, _ := trayImagePNG(cells, bird, h, 2, false, false) + width := float64(w) / 2 + if at := trayImageCellAt(cells, h, 5); at != -1 { + t.Errorf("a click by the bird says cell %d", at) + } + if at := trayImageCellAt(cells, h, width-0.5); at != len(cells)-1 { + t.Errorf("a click at the image's end says cell %d, want %d", at, len(cells)-1) + } + if at := trayImageCellAt(cells, h, width+1); at != -1 { + t.Errorf("a click past the image says cell %d", at) + } + if at := trayImageCellAt(nil, h, 5); at != -1 { + t.Errorf("no cells: cell %d", at) + } + from := make([]float64, len(cells)) + for i := range cells { + from[i] = trayImageCellStart(cells, h, i) + if from[i] <= h || from[i] >= width { + t.Fatalf("cell %d begins at %g of %g", i, from[i], width) + } + if i > 0 && from[i] <= from[i-1] { + t.Fatalf("cell %d begins at %g, before the one before it at %g", i, from[i], from[i-1]) + } + } + for i := range cells { + if at := trayImageCellAt(cells, h, from[i]+0.5); at != i { + t.Errorf("a click at %g (cell %d's start) says cell %d", from[i], i, at) + } + if i+1 < len(cells) { + if at := trayImageCellAt(cells, h, from[i+1]-0.5); at != i { + t.Errorf("a click at %g (before cell %d) says cell %d", from[i+1]-0.5, i+1, at) + } + } + } + if s := trayImageCellStart(cells, h, len(cells)); s != -1 { + t.Errorf("a cell past them all begins at %g", s) + } + // logos off (Settings): the same walk, the digits alone + plain := trayPlain(cells) + for i := range plain { + if s := trayImageCellStart(plain, h, i); s <= h || s >= width { + t.Errorf("plain cell %d begins at %g of %g", i, s, width) + } + } + if at := trayImageCellAt(plain, h, trayImageCellStart(plain, h, 0)+0.5); at != 0 { + t.Errorf("the first plain cell's click says cell %d", at) + } +} + // inkIn counts the pixels in r drawn in the bar's text colour. func inkIn(img image.Image, r image.Rectangle, dark bool) int { n := 0 @@ -164,3 +221,38 @@ func TestTrayImageLogoSizes(t *testing.T) { } } } + +// The native callback must carry the account ID before the drawing is freed +// or a refresh changes the order, including the logo-free layout. +func TestTrayImageClickIdentity(t *testing.T) { + prior := onTrayCellClick + t.Cleanup(func() { onTrayCellClick = prior }) + clicked := make(chan string, 1) + onTrayCellClick = func(id string) { clicked <- id } + cards := trayCards() + cards[1].User = "a\"b\\c@例子.test" + cells, _, _ := trayUsageView(cards, time.Now(), false) + for _, layout := range [][]trayCell{cells, trayPlain(cells)} { + if trayImageClickAt(layout, 22, 5, 0) { + t.Fatal("bird intercepted") + } + x := trayImageCellStart(layout, 22, 1) + 1 + // AppKit's Shift, Control, Option and Command modifier bits. + for _, modifiers := range []uint64{1 << 17, 1 << 18, 1 << 19, 1 << 20} { + if trayImageClickAt(layout, 22, x, modifiers) { + t.Fatal("modified click intercepted") + } + } + if !trayImageClickAt(layout, 22, x, 0) { + t.Fatal("cell missed") + } + select { + case id := <-clicked: + if id != trayCardID(cards[1]) { + t.Fatalf("clicked %q", id) + } + case <-time.After(time.Second): + t.Fatal("native click callback lost") + } + } +} diff --git a/internal/gui/trayusage.go b/internal/gui/trayusage.go index ddeb9560..20782768 100644 --- a/internal/gui/trayusage.go +++ b/internal/gui/trayusage.go @@ -3,6 +3,7 @@ package gui import ( "cmp" "context" + "encoding/json" "fmt" "math" "regexp" @@ -200,6 +201,7 @@ func trayInUseCard(cards []provider.SubscriptionQuota, pid string) (provider.Sub // label's parts stacked, the shortest window over the longer ("42%" over // "18%"), or its balance alone. type trayCell struct { + ID string // stable provider/account identity, captured with the drawn cell Icon []byte // the logo as the Usage page has it (SVG or PNG); nil for none Mono bool // a black glyph, drawn in the menu bar's text colour Letter string // drawn in its place when there is no logo, or it can't be read @@ -212,15 +214,14 @@ type trayCell struct { func trayPlain(cells []trayCell) []trayCell { out := make([]trayCell, len(cells)) for i, c := range cells { - out[i] = trayCell{Plain: true, Rows: c.Rows} + out[i] = trayCell{ID: c.ID, Plain: true, Rows: c.Rows} } return out } // trayUsageView is what the tray shows for the cards: a cell for each -// that has a label; the labels as one line of text, " | " between cards, -// for a tray that shows text and not the cells; and the tooltip spelling -// each out. +// that has a label, with its stable identity for clicks; labels joined by +// " | " for a tray that shows text; and a tooltip spelling each out. func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) (cells []trayCell, label, tip string) { var labels, tips []string for _, q := range cards { @@ -232,7 +233,7 @@ func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) continue } labels = append(labels, l) - c := trayCell{Rows: strings.Split(l, " · ")} + c := trayCell{ID: trayCardID(q), Rows: strings.Split(l, " · ")} for i, r := range c.Rows { c.Rows[i] = trayRow(r) } @@ -354,3 +355,13 @@ func svgNumber(d string, i int) int { // onTrayUsage brings the tray's text up to date at once, when the Settings // page changes which card it shows; set by the process that has the tray. var onTrayUsage func() + +// onTrayCellClick receives the stable card ID from the native image. +// Set by the tray process; it must schedule window work without blocking. +var onTrayCellClick func(id string) + +// panelQuotaJS safely quotes the card ID for the panel's focus handler. +func panelQuotaJS(card string) string { + id, _ := json.Marshal(card) // quoted and escaped: a JSON string is a JS one + return "panelQuotaFocus(" + string(id) + ")" +} diff --git a/internal/gui/trayusage_cells_test.go b/internal/gui/trayusage_cells_test.go index 159625b1..508481c7 100644 --- a/internal/gui/trayusage_cells_test.go +++ b/internal/gui/trayusage_cells_test.go @@ -2,6 +2,7 @@ package gui import ( "slices" + "strconv" "strings" "testing" "time" @@ -114,6 +115,31 @@ func TestTrayUsageCells(t *testing.T) { } } +// Every rendered cell carries its own identity, even with logos disabled +// or unreadable cards skipped. JavaScript arguments preserve unusual account IDs. +func TestTrayCellsCards(t *testing.T) { + cards := trayCards() + cards[0].User = "a\"b\\c@例子.test" + cards[1].Error = "signed out" + cells, _, _ := trayUsageView(cards, time.Now(), false) + plain := trayPlain(cells) + if len(cells) != len(cards)-1 { + t.Fatalf("cells: %d", len(cells)) + } + for i, cell := range cells { + at := i + if i > 0 { + at++ + } + if cell.ID != trayCardID(cards[at]) || plain[i].ID != cell.ID { + t.Fatalf("cell %d: %q, plain %q", i, cell.ID, plain[i].ID) + } + if got := panelQuotaJS(cell.ID); got != "panelQuotaFocus("+strconv.Quote(cell.ID)+")" { + t.Fatalf("JS: %s", got) + } + } +} + // An arc's flags run into the number after them in many of the logos // (Codex's among them); the Mac's SVG drawing needs them apart. func TestSVGArcFlags(t *testing.T) { diff --git a/internal/gui/trayusage_panel_darwin_test.go b/internal/gui/trayusage_panel_darwin_test.go new file mode 100644 index 00000000..a5374325 --- /dev/null +++ b/internal/gui/trayusage_panel_darwin_test.go @@ -0,0 +1,97 @@ +//go:build darwin && cgo && !nogui + +package gui + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// A separate process runs AppKit on its main thread. It uses an isolated +// config and a minimal page, without starting magpie's backend or status item. +func TestTrayCellClickReleasedPanel(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, exe) + cmd.Env = append(os.Environ(), "MAGPIE_TEST_TRAY_PANEL=1", "XDG_CONFIG_HOME="+t.TempDir()) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "tray quota: recreated and focused twice") { + t.Fatalf("released panel: %v\n%s", err, out) + } +} + +func init() { + if os.Getenv("MAGPIE_TEST_TRAY_PANEL") != "1" { + return + } + seen := make(chan string, 2) + app := application.New(application.Options{ + Name: "magpie tray regression", + Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyProhibited}, + Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/seen" { + seen <- r.URL.Query().Get("id") + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ``) + })}, + }) + h := &host{app: app, tray: &application.SystemTray{}} + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + application.InvokeAsync(func() { h.trayCellClick("codex|first@test") }) + }) + go func() { + if id := <-seen; id != "codex|first@test" { + panic("wrong initial target: " + id) + } + var first *application.WebviewWindow + application.InvokeSync(func() { + first = h.panel + if !first.IsVisible() { + panic("initial quota click did not open the panel") + } + h.trayCellClick("codex|first@test") // the second click closes it + }) + // Native hide dispatches asynchronously, even from the main thread. + deadline := time.Now().Add(2 * time.Second) + for application.InvokeSyncWithResult(first.IsVisible) { + if time.Now().After(deadline) { + panic("second quota click did not close the panel") + } + time.Sleep(10 * time.Millisecond) + } + application.InvokeSync(func() { + // Same release state as lighten: the closed panel no longer exists. + first.Close() + h.panel = nil + h.tray.AttachWindow(nil) + h.trayCellClick("codex|second@test") + if h.panel == nil || h.panel == first { + panic("panel was not recreated") + } + }) + if id := <-seen; id != "codex|second@test" { + panic("wrong recreated target: " + id) + } + fmt.Println("tray quota: recreated and focused twice") + app.Quit() + }() + if err := app.Run(); err != nil { + panic(err) + } + os.Exit(0) +} diff --git a/internal/gui/trayusage_tray.go b/internal/gui/trayusage_tray.go index ce963b39..2fd13279 100644 --- a/internal/gui/trayusage_tray.go +++ b/internal/gui/trayusage_tray.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/settings" ) @@ -25,6 +27,10 @@ func (h *host) watchTrayUsage() { // a card read while stale is refreshed behind it: read it again once // that lands, not a tick later, so the menu bar says what the panel does provider.OnSubscriptionUsage = onTrayUsage + // Schedule native cell clicks on the application thread after startup. + onTrayCellClick = func(id string) { + h.whenReady(func() { application.InvokeAsync(func() { h.trayCellClick(id) }) }) + } go func() { <-h.ready // the tray is made once the app runs shown, drawn := "", false @@ -58,12 +64,6 @@ func (h *host) watchTrayUsage() { // the Mac's cells as an image, the text where it can't be if len(cells) > 0 { h.tray.SetLabel("") - if trayImageShow(cells, trayIcon) { - drawn = true - } else { - h.tray.SetLabel(label) - shown = "" // and try again next time - } } else { if drawn { drawn = false @@ -74,6 +74,16 @@ func (h *host) watchTrayUsage() { } h.tray.SetTooltip(tip) } + // 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) { + drawn = true + } else { + h.tray.SetLabel(label) + shown = "" // and try again next time + } + } // the first answer can take a while; look again soon after it next := trayUsageEvery() if label == "" && len(s.TrayUsages) > 0 { @@ -86,3 +96,20 @@ func (h *host) watchTrayUsage() { } }() } + +// trayCellClick opens the allowance represented by the clicked image cell. +// A visible panel closes on the next click, as clicking the bird does. +func (h *host) trayCellClick(id string) { + if id == "" { + return + } + if settings.Load().Tray == "window" { + h.ShowMain(quotaView(id)) + return + } + w, _ := h.panelWin() + if !w.IsVisible() { + w.ExecJS(panelQuotaJS(id)) + } + h.togglePanel() +}