376 lines
12 KiB
Go
376 lines
12 KiB
Go
package gui
|
||
|
||
import (
|
||
"cmp"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/yetone/magpie/internal/provider"
|
||
"github.com/yetone/magpie/internal/settings"
|
||
)
|
||
|
||
// Settings → Allowances in the menu bar: the windows of the subscriptions and
|
||
// plans ticked there, beside the tray icon, for keeping an eye on them
|
||
// without opening magpie. The Mac's menu bar draws each as its logo with
|
||
// its two windows stacked, "42%" over "18%", side by side (trayimage), or
|
||
// as the windows alone, a thin line between cards, when Settings turns the
|
||
// logos off; a tray elsewhere gets them as text ("42% · 18% | 10% · 5%"), Windows' in
|
||
// the icon's tooltip only, having no room for any.
|
||
|
||
// trayUsageEvery is how often the text is brought up to date, as Settings
|
||
// says (every 3 minutes unless told otherwise). The vendors are asked no
|
||
// more than the Usage page asks them: their answers are cached for a
|
||
// minute, and this reads the cache.
|
||
func trayUsageEvery() time.Duration {
|
||
return time.Duration(settings.Load().TrayUsageEvery) * time.Minute
|
||
}
|
||
|
||
// trayCardID names a card for settings.TrayUsage: its provider, and the
|
||
// account when there is one, as two of one vendor can be signed in.
|
||
func trayCardID(q provider.SubscriptionQuota) string {
|
||
if q.User == "" {
|
||
return q.Provider
|
||
}
|
||
return q.Provider + "|" + q.User
|
||
}
|
||
|
||
// trayWindows are the windows a card's text shows: those that stop the
|
||
// account (not on-demand spending, nor one that counts a single model's
|
||
// use), the shortest first where the vendor says how long they run.
|
||
func trayWindows(q provider.SubscriptionQuota) []provider.QuotaWindow {
|
||
var ws []provider.QuotaWindow
|
||
for _, w := range q.Windows {
|
||
if !w.Aside && w.Model == "" {
|
||
ws = append(ws, w)
|
||
}
|
||
}
|
||
slices.SortStableFunc(ws, func(a, b provider.QuotaWindow) int {
|
||
if a.Span == 0 || b.Span == 0 {
|
||
return 0
|
||
}
|
||
return cmp.Compare(a.Span, b.Span)
|
||
})
|
||
return ws
|
||
}
|
||
|
||
// trayUsageText is the menu bar's text for a card and the tooltip that
|
||
// spells it out: each window's use, or what is left of it (left), and when
|
||
// it starts again.
|
||
func trayUsageText(q provider.SubscriptionQuota, now time.Time, left bool) (label, tip string) {
|
||
if q.Error != "" {
|
||
return "", q.Name + ": " + q.Error
|
||
}
|
||
ws := trayWindows(q)
|
||
if len(ws) == 0 {
|
||
if q.Balance == "" {
|
||
return "", ""
|
||
}
|
||
return q.Balance, q.Name + " · " + q.Balance + trayAsOf(q)
|
||
}
|
||
var short, long []string
|
||
for _, w := range ws {
|
||
used := math.Max(0, math.Min(100, w.Used))
|
||
n := int(math.Round(used))
|
||
word := "used"
|
||
if left {
|
||
used = 100 - used
|
||
n, word = 100-n, "left"
|
||
}
|
||
pct := fmt.Sprintf("%d%%", n)
|
||
short = append(short, pct)
|
||
tipPct := pct
|
||
if used != math.Trunc(used) {
|
||
tipPct = fmt.Sprintf("%.1f%%", used)
|
||
}
|
||
line := w.Name + " " + tipPct + " " + word
|
||
if c := w.Count(left); c != "" {
|
||
line = w.Name + " " + c + " · " + tipPct + " " + word
|
||
}
|
||
if at := resetAt(w, now); !at.IsZero() && at.After(now) {
|
||
line += " · resets in " + until(at.Sub(now))
|
||
}
|
||
long = append(long, line)
|
||
}
|
||
if len(short) > 2 {
|
||
short = short[:2]
|
||
}
|
||
return strings.Join(short, " · "), q.Name + "\n" + strings.Join(long, "\n") + trayAsOf(q)
|
||
}
|
||
|
||
// trayAsOf is the tooltip's word that a card stands in for one that
|
||
// couldn't be read just now, and when it was read (the Usage page's
|
||
// "As of …"); nothing for a reading just made.
|
||
func trayAsOf(q provider.SubscriptionQuota) string {
|
||
if q.AsOf == nil {
|
||
return ""
|
||
}
|
||
return "\nas of " + q.AsOf.Local().Format("Jan 2 15:04") + ", couldn't be read just now"
|
||
}
|
||
|
||
func resetAt(w provider.QuotaWindow, now time.Time) time.Time {
|
||
if w.ResetsAt != nil {
|
||
return *w.ResetsAt
|
||
}
|
||
if w.ResetSecs > 0 {
|
||
return now.Add(time.Duration(w.ResetSecs) * time.Second)
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
// until says a wait in the largest two units: 3d 4h, 2h 10m, 7m.
|
||
func until(d time.Duration) string {
|
||
d = d.Round(time.Minute)
|
||
days, hours, mins := int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour), int(d%time.Hour/time.Minute)
|
||
switch {
|
||
case days > 0:
|
||
return fmt.Sprintf("%dd %dh", days, hours)
|
||
case hours > 0:
|
||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||
}
|
||
return fmt.Sprintf("%dm", max(mins, 1))
|
||
}
|
||
|
||
// trayUsageCards are the cards settings.TrayUsages names, in its order,
|
||
// among the Usage page's (cached as there); one gone is left out.
|
||
func trayUsageCards(ctx context.Context, ids []string) []provider.SubscriptionQuota {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
return trayPick(provider.Quotas(ctx), ids)
|
||
}
|
||
|
||
func trayPick(cards []provider.SubscriptionQuota, ids []string) []provider.SubscriptionQuota {
|
||
var out []provider.SubscriptionQuota
|
||
for _, id := range ids {
|
||
if pid, ok := strings.CutSuffix(id, trayInUse); ok {
|
||
if q, ok := trayInUseCard(cards, pid); ok {
|
||
out = append(out, q)
|
||
}
|
||
continue
|
||
}
|
||
if i := slices.IndexFunc(cards, func(q provider.SubscriptionQuota) bool { return trayCardID(q) == id }); i >= 0 {
|
||
out = append(out, cards[i])
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// trayInUse ends a card id that follows a subscription's account in use
|
||
// ("claude|*") rather than naming one: with several accounts, the one the
|
||
// gateway goes to first is the one worth watching, whichever it is now.
|
||
const trayInUse = "|*"
|
||
|
||
// trayInUseAccount is the account of an agent's the gateway goes to first;
|
||
// a var for tests.
|
||
var trayInUseAccount = provider.InUseLogin
|
||
|
||
// trayInUseCard is the card of the provider's account in use, named with
|
||
// the account so the tooltip says which it is; the provider's first card
|
||
// when magpie can't tell (the account in use first among them).
|
||
func trayInUseCard(cards []provider.SubscriptionQuota, pid string) (provider.SubscriptionQuota, bool) {
|
||
var mine []provider.SubscriptionQuota
|
||
for _, q := range cards {
|
||
if q.Provider == pid {
|
||
mine = append(mine, q)
|
||
}
|
||
}
|
||
if len(mine) == 0 {
|
||
return provider.SubscriptionQuota{}, false
|
||
}
|
||
q := mine[0]
|
||
if len(mine) > 1 {
|
||
if user := trayInUseAccount(pid); user != "" {
|
||
if i := slices.IndexFunc(mine, func(q provider.SubscriptionQuota) bool { return strings.EqualFold(q.User, user) }); i >= 0 {
|
||
q = mine[i]
|
||
}
|
||
}
|
||
}
|
||
if q.User != "" {
|
||
q.Name += " · " + q.User
|
||
}
|
||
return q, true
|
||
}
|
||
|
||
// trayCell is a card as the Mac's menu bar draws it: its logo, and its
|
||
// 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
|
||
Plain bool // no logo nor letter, as Settings says: the rows alone, a line before
|
||
Rows []string
|
||
}
|
||
|
||
// trayPlain are the cells without their logos (settings.TrayNoLogos):
|
||
// each its rows alone, told from the one before it by a thin line.
|
||
func trayPlain(cells []trayCell) []trayCell {
|
||
out := make([]trayCell, len(cells))
|
||
for i, c := range cells {
|
||
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, 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 {
|
||
l, t := trayUsageText(q, now, left)
|
||
if t != "" {
|
||
tips = append(tips, t)
|
||
}
|
||
if l == "" {
|
||
continue
|
||
}
|
||
labels = append(labels, l)
|
||
c := trayCell{ID: trayCardID(q), Rows: strings.Split(l, " · ")}
|
||
for i, r := range c.Rows {
|
||
c.Rows[i] = trayRow(r)
|
||
}
|
||
c.Icon, c.Mono = trayIconFile(q.Icon)
|
||
if r := []rune(strings.TrimSpace(q.Name)); len(r) > 0 {
|
||
c.Letter = strings.ToUpper(string(r[0]))
|
||
}
|
||
cells = append(cells, c)
|
||
}
|
||
return cells, strings.Join(labels, " | "), strings.Join(tips, "\n\n")
|
||
}
|
||
|
||
// sniTip is the tooltip as a Linux tray item's ToolTip title carries it.
|
||
// Omarchy's bar shows the title as plain text and waybar as Pango markup,
|
||
// where a bare "&" or "<" loses the whole tooltip; their look-alikes read
|
||
// the same in both.
|
||
func sniTip(tip string) string {
|
||
return strings.NewReplacer("&", "&", "<", "‹", ">", "›").Replace(tip)
|
||
}
|
||
|
||
// trayRow is a cell's row cut to fit the menu bar: a balance is a few
|
||
// figures ("¥12345.67"), but one read from a plan's own page can be any
|
||
// text, and the whole of it is in the tooltip.
|
||
func trayRow(r string) string {
|
||
if rs := []rune(r); len(rs) > trayRowMax {
|
||
return strings.TrimSpace(string(rs[:trayRowMax-1])) + "…"
|
||
}
|
||
return r
|
||
}
|
||
|
||
const trayRowMax = 10
|
||
|
||
// trayIconFile is a card's logo among the page's icons, as its icon() has
|
||
// it: a "-color" one and the PNGs in their own colours, the others a black
|
||
// glyph (a mask there); nil when there is no such file (a picture the user
|
||
// gave their own provider isn't one).
|
||
func trayIconFile(name string) (b []byte, mono bool) {
|
||
if name == "" || strings.ContainsAny(name, `/\:`) {
|
||
return nil, false
|
||
}
|
||
if b, err := assets.ReadFile("assets/icons/" + name + ".png"); err == nil {
|
||
return b, false
|
||
}
|
||
b, err := assets.ReadFile("assets/icons/" + name + ".svg")
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return svgArcFlags(b), !strings.HasSuffix(name, "-color")
|
||
}
|
||
|
||
var svgPathData = regexp.MustCompile(`\sd="([^"]*[aA][^"]*)"`)
|
||
|
||
// svgArcFlags spaces out the flags of a path's arcs ("a4.5 4.5 0 004.5 0",
|
||
// the flags run into the number after them), which browsers read and the
|
||
// Mac's own SVG drawing doesn't: it draws a line for such an arc, and
|
||
// Codex's logo comes out a blot.
|
||
func svgArcFlags(svg []byte) []byte {
|
||
return svgPathData.ReplaceAllFunc(svg, func(m []byte) []byte {
|
||
d := string(svgPathData.FindSubmatch(m)[1])
|
||
var out []string
|
||
cmd, arg := byte(0), 0
|
||
for i := 0; i < len(d); {
|
||
c := d[i]
|
||
switch {
|
||
case c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r':
|
||
i++
|
||
case c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z':
|
||
if c == 'e' || c == 'E' { // not a command: a number's exponent, never alone
|
||
return m
|
||
}
|
||
cmd, arg = c, 0
|
||
out = append(out, string(c))
|
||
i++
|
||
case (cmd == 'a' || cmd == 'A') && (arg%7 == 3 || arg%7 == 4):
|
||
if c != '0' && c != '1' {
|
||
return m
|
||
}
|
||
out = append(out, string(c))
|
||
arg++
|
||
i++
|
||
default:
|
||
j := svgNumber(d, i)
|
||
if j == i {
|
||
return m
|
||
}
|
||
out = append(out, d[i:j])
|
||
arg++
|
||
i = j
|
||
}
|
||
}
|
||
return []byte(` d="` + strings.Join(out, " ") + `"`)
|
||
})
|
||
}
|
||
|
||
// svgNumber is where the number at d[i:] ends: a sign, digits with at most
|
||
// one point, an exponent.
|
||
func svgNumber(d string, i int) int {
|
||
j := i
|
||
if j < len(d) && (d[j] == '-' || d[j] == '+') {
|
||
j++
|
||
}
|
||
digits, point := 0, false
|
||
for j < len(d) && (d[j] >= '0' && d[j] <= '9' || d[j] == '.' && !point) {
|
||
if d[j] == '.' {
|
||
point = true
|
||
} else {
|
||
digits++
|
||
}
|
||
j++
|
||
}
|
||
if digits == 0 {
|
||
return i
|
||
}
|
||
if j < len(d) && (d[j] == 'e' || d[j] == 'E') {
|
||
k := j + 1
|
||
if k < len(d) && (d[k] == '-' || d[k] == '+') {
|
||
k++
|
||
}
|
||
if k < len(d) && d[k] >= '0' && d[k] <= '9' {
|
||
for j = k; j < len(d) && d[j] >= '0' && d[j] <= '9'; j++ {
|
||
}
|
||
}
|
||
}
|
||
return j
|
||
}
|
||
|
||
// 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) + ")"
|
||
}
|