diff --git a/internal/gui/traytip_linux.go b/internal/gui/traytip_linux.go new file mode 100644 index 00000000..70c42a55 --- /dev/null +++ b/internal/gui/traytip_linux.go @@ -0,0 +1,72 @@ +//go:build linux && !nogui + +package gui + +import ( + "log" + "reflect" + "unsafe" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// setTrayTip puts the tooltip on the tray's StatusNotifierItem. Wails +// (v3.0.0-beta.24) leaves SetTooltip on Linux a no-op ("TBD"): the item's +// ToolTip stays the "magpie" it was made with and no NewToolTip is sent, so +// a bar never had the quotas to show (atie on Discord: Omarchy's bar showed +// none on hover with 菜单栏显示额度 on). This sets the property Wails +// exported, through its own prop.Properties (which keeps the (sa(iiay)ss) +// type), and sends NewToolTip from the same connection: waybar reads ToolTip +// again only on that signal, Quickshell on it or PropertiesChanged. +func setTrayTip(tray *application.SystemTray, tip string) bool { + props := trayProps(tray) + if props == nil { + return false + } + v := struct { + V0 string // icon name + V1 []application.PX // icon pixmaps + V2 string // title: what Omarchy's bar shows, and waybar + V3 string // description: waybar only, under the title + }{V2: sniTip(tip), V1: []application.PX{}} + if err := props.Set("org.kde.StatusNotifierItem", "ToolTip", dbus.MakeVariant(v)); err != nil { + log.Println("tray: tooltip:", err) + return false + } + conn, err := dbus.SessionBus() // the connection Wails' tray is on + if err != nil { + return false + } + if err := conn.Emit("/StatusNotifierItem", "org.kde.StatusNotifierItem.NewToolTip"); err != nil { + log.Println("tray: tooltip signal:", err) + } + return true +} + +// trayProps is the item's exported properties inside Wails' Linux tray +// (SystemTray.impl.props), nil once a Wails version keeps them elsewhere. +func trayProps(tray *application.SystemTray) (props *prop.Properties) { + defer func() { + if recover() != nil { + props = nil + } + }() + if tray == nil { + return nil + } + impl := reflect.ValueOf(tray).Elem().FieldByName("impl") + if !impl.IsValid() || impl.Kind() != reflect.Interface || impl.IsNil() { + return nil + } + p := impl.Elem() + if p.Kind() != reflect.Pointer || p.IsNil() || p.Elem().Kind() != reflect.Struct { + return nil + } + f := p.Elem().FieldByName("props") + if !f.IsValid() || f.Type() != reflect.TypeOf((*prop.Properties)(nil)) { + return nil + } + return *(**prop.Properties)(unsafe.Pointer(f.UnsafeAddr())) +} diff --git a/internal/gui/traytip_other.go b/internal/gui/traytip_other.go new file mode 100644 index 00000000..5b2d2be9 --- /dev/null +++ b/internal/gui/traytip_other.go @@ -0,0 +1,8 @@ +//go:build !linux && !nogui + +package gui + +import "github.com/wailsapp/wails/v3/pkg/application" + +// setTrayTip: Wails' SetTooltip does it here. +func setTrayTip(*application.SystemTray, string) bool { return true } diff --git a/internal/gui/traytip_test.go b/internal/gui/traytip_test.go new file mode 100644 index 00000000..13e6806b --- /dev/null +++ b/internal/gui/traytip_test.go @@ -0,0 +1,12 @@ +package gui + +import "testing" + +// A Linux tray's tooltip title is Pango markup in waybar: a bare & or < +// would lose all of it. +func TestSNITipIsSafeMarkup(t *testing.T) { + got := sniTip("Claude Max 5h: 42% left\n\nR&D ") + if want := "Claude Max 5h: 42% left\n\nR&D ‹team›"; got != want { + t.Fatalf("sniTip = %q, want %q", got, want) + } +} diff --git a/internal/gui/trayusage.go b/internal/gui/trayusage.go index 20782768..ae27c601 100644 --- a/internal/gui/trayusage.go +++ b/internal/gui/trayusage.go @@ -246,6 +246,14 @@ func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) 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. diff --git a/internal/gui/trayusage_tray.go b/internal/gui/trayusage_tray.go index 2fd13279..64a3eea0 100644 --- a/internal/gui/trayusage_tray.go +++ b/internal/gui/trayusage_tray.go @@ -73,6 +73,10 @@ func (h *host) watchTrayUsage() { h.tray.SetLabel(label) } h.tray.SetTooltip(tip) + // Wails' is a no-op on Linux; until the tray is up, again next time + if !setTrayTip(h.tray, tip) { + shown = "" + } } // Reapply even unchanged cells: a system menu-bar rebuild can // replace the composed image with Wails' bird icon.