diff --git a/internal/gui/app.go b/internal/gui/app.go index 81ea7911..7af55965 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -79,6 +79,7 @@ type host struct { hyprRoom int // how tall it may grow under Hyprland's bar; 0 elsewhere clicks sync.Once // Hyprland's clicks heard, to close the panel on one outside it glides atomic.Int64 // the newest panel glide; older ones stop + barTray atomic.Int64 // the newest setTrayInBar; older ones stop query string // what the windows' URLs carry (a forced theme) // closing is set while a full-screen main window, closed, leaves full @@ -448,6 +449,10 @@ func Run(version string, showMain bool, link string) error { h.tray.SetMenu(menu) h.tray.AttachWindow(h.panel).WindowOffset(6) h.watchTrayUsage() + // Omarchy's bar shows magpie once: as its widget (Settings → Bar + // icon) or as the tray's item, not both + onBarIcon = h.setTrayInBar + h.setTrayInBar(barIcon(h)["on"]) go h.lighten() h.watchAlerts() // the quick panel by the icon, or the main window if the user would @@ -619,6 +624,23 @@ var OpenView string // said to have started); until then a signal is magpie's to handle. var Started func() +// setTrayInBar has the tray's item leave the bar's tray while magpie's icon +// is in Omarchy's bar as its widget, and come back once it isn't. The item +// is there to be set a moment after the app has started; the newest call +// wins. +func (h *host) setTrayInBar(inBar bool) { + gen := h.barTray.Add(1) + go func() { + <-h.ready + for range 120 { + if h.barTray.Load() != gen || setTrayPassive(h.tray, inBar) { + return + } + time.Sleep(500 * time.Millisecond) + } + }() +} + // togglePanel opens the quick panel by the tray icon, or closes it. func (h *host) togglePanel() { application.InvokeSync(h.togglePanelNow) } diff --git a/internal/gui/omarchy.go b/internal/gui/omarchy.go index 6b1fa904..4d6766f6 100644 --- a/internal/gui/omarchy.go +++ b/internal/gui/omarchy.go @@ -13,6 +13,16 @@ import ( // take Omarchy's look and its bar's place instead of the tray's. var onOmarchy = omarchy.Detect() +// hyprland is whether magpie runs under Hyprland (omarchy.Hyprland), a +// variable so a test can stand in for it. +var hyprland = omarchy.Hyprland + +// onBarIcon is told whether magpie's icon is in Omarchy's bar once Settings' +// Bar icon has changed it. The app then takes its tray item out of the bar's +// tray (setTrayInBar), so the bar shows one magpie, not the widget and the +// tray icon side by side (Alex on Discord: two icons with Bar icon on). +var onBarIcon func(on bool) + // omarchyTheme is the Omarchy theme the page draws with, when on Omarchy. func omarchyTheme() (omarchy.Theme, bool) { if !onOmarchy { @@ -24,7 +34,7 @@ func omarchyTheme() (omarchy.Theme, bool) { // barIcon is whether magpie can put its icon in Omarchy's bar (the desktop // app on Omarchy's Hyprland; not a browser's page), and whether it has. func barIcon(w Windows) map[string]bool { - ok := onOmarchy && !isWeb(w) && omarchy.Hyprland() + ok := onOmarchy && !isWeb(w) && hyprland() return map[string]bool{"available": ok, "on": ok && omarchy.WidgetOn()} } @@ -53,6 +63,11 @@ func omarchyRoutes(mux *http.ServeMux, w Windows) { } else { err = omarchy.RemoveWidget() } + // what is in the bar now, though the omarchy command failed after + // the files went in or out + if onBarIcon != nil { + onBarIcon(barIcon(w)["on"]) + } if err != nil { fail(rw, err) return diff --git a/internal/gui/omarchy_bar_test.go b/internal/gui/omarchy_bar_test.go new file mode 100644 index 00000000..778b6a7d --- /dev/null +++ b/internal/gui/omarchy_bar_test.go @@ -0,0 +1,57 @@ +package gui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// barWin is the desktop app as a Windows; the Bar icon routes reach no +// method of it. +type barWin struct{ Windows } + +// Settings' Bar icon tells the app whether magpie's icon is in Omarchy's +// bar, so the tray's own item leaves the bar's tray while the widget is +// there and comes back once it is taken out (Alex on Discord: with Bar icon +// on, Omarchy's bar showed two magpies). +func TestBarIconTellsTheTray(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the stand-in omarchy commands are shell scripts") + } + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bin := t.TempDir() + for _, name := range []string{"omarchy", "omarchy-shell"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + priorOm, priorHypr, priorHook := onOmarchy, hyprland, onBarIcon + t.Cleanup(func() { onOmarchy, hyprland, onBarIcon = priorOm, priorHypr, priorHook }) + onOmarchy, hyprland = true, func() bool { return true } + var told []bool + onBarIcon = func(on bool) { told = append(told, on) } + + mux := http.NewServeMux() + omarchyRoutes(mux, &barWin{}) + post := func(body string) { + t.Helper() + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/omarchy/widget", strings.NewReader(body))) + if rec.Code != http.StatusOK { + t.Fatalf("POST %s: %d %s", body, rec.Code, rec.Body) + } + } + post(`{"on":true}`) + if len(told) != 1 || !told[0] { + t.Fatalf("Bar icon on: the tray was told %v, want [true] (it stays in the bar's tray beside the widget)", told) + } + post(`{"on":false}`) + if len(told) != 2 || told[1] { + t.Fatalf("Bar icon off: the tray was told %v, want [true false] (no magpie left in the bar)", told) + } +} diff --git a/internal/gui/traytip_linux.go b/internal/gui/traytip_linux.go index 70c42a55..02cf53e6 100644 --- a/internal/gui/traytip_linux.go +++ b/internal/gui/traytip_linux.go @@ -70,3 +70,35 @@ func trayProps(tray *application.SystemTray) (props *prop.Properties) { } return *(**prop.Properties)(unsafe.Pointer(f.UnsafeAddr())) } + +// setTrayPassive takes the tray's StatusNotifierItem out of the bar's tray +// (Status "Passive") while magpie's icon is in Omarchy's bar as its widget, +// and puts it back ("Active") when it isn't. Omarchy's tray and waybar's both +// leave a Passive item out. Wails exports Status read-only and has no Hide +// on Linux, so it is set through Wails' own properties, as the tooltip is, +// and NewStatus is sent for the hosts that read it again only on that. +func setTrayPassive(tray *application.SystemTray, passive bool) (ok bool) { + props := trayProps(tray) + if props == nil { + return false + } + status := "Active" + if passive { + status = "Passive" + } + defer func() { + if r := recover(); r != nil { + log.Println("tray: status:", r) + ok = false + } + }() + props.SetMust("org.kde.StatusNotifierItem", "Status", status) + conn, err := dbus.SessionBus() // the connection Wails' tray is on + if err != nil { + return false + } + if err := conn.Emit("/StatusNotifierItem", "org.kde.StatusNotifierItem.NewStatus", status); err != nil { + log.Println("tray: status signal:", err) + } + return true +} diff --git a/internal/gui/traytip_other.go b/internal/gui/traytip_other.go index 5b2d2be9..4779d344 100644 --- a/internal/gui/traytip_other.go +++ b/internal/gui/traytip_other.go @@ -6,3 +6,6 @@ import "github.com/wailsapp/wails/v3/pkg/application" // setTrayTip: Wails' SetTooltip does it here. func setTrayTip(*application.SystemTray, string) bool { return true } + +// setTrayPassive: only Linux has Omarchy's bar to put the icon in instead. +func setTrayPassive(*application.SystemTray, bool) bool { return true }