Skip to content

Commit f1b86df

Browse files
authored
Merge pull request #81 from codeGROOVE-dev/icons
Add dynamic systray icons for non-macOS platforms
2 parents 0f9173e + dcb2e2c commit f1b86df

File tree

11 files changed

+466
-86
lines changed

11 files changed

+466
-86
lines changed

cmd/goose/icons.go

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import (
44
"log/slog"
55
)
66

7-
// Icon variables are defined in platform-specific files:
8-
// - icons_windows.go: uses .ico files.
9-
// - icons_unix.go: uses .png files.
7+
// Icon implementations are in platform-specific files:
8+
// - icons_darwin.go: macOS (static PNG icons, counts shown in title)
9+
// - icons_badge.go: Linux/BSD/Windows (dynamic circle badges with counts)
1010

1111
// IconType represents different icon states.
1212
type IconType int
@@ -21,33 +21,20 @@ const (
2121
IconLock // Authentication error
2222
)
2323

24-
// getIcon returns the icon bytes for the given type.
25-
func getIcon(iconType IconType) []byte {
26-
switch iconType {
27-
case IconGoose, IconBoth:
28-
// For both, we'll use the goose icon as primary
29-
return iconGoose
30-
case IconPopper:
31-
return iconPopper
32-
case IconCockroach:
33-
return iconCockroach
34-
case IconWarning:
35-
return iconWarning
36-
case IconLock:
37-
return iconLock
38-
default:
39-
return iconSmiling
40-
}
41-
}
24+
// getIcon returns icon bytes for the given type and counts.
25+
// Implementation is platform-specific:
26+
// - macOS: returns static icons (counts displayed in title bar)
27+
// - Linux/Windows: generates dynamic badges with embedded counts.
28+
// Implemented in icons_darwin.go and icons_badge.go.
4229

43-
// setTrayIcon updates the system tray icon based on PR counts.
44-
func (app *App) setTrayIcon(iconType IconType) {
45-
iconBytes := getIcon(iconType)
30+
// setTrayIcon updates the system tray icon.
31+
func (app *App) setTrayIcon(iconType IconType, counts PRCounts) {
32+
iconBytes := getIcon(iconType, counts)
4633
if len(iconBytes) == 0 {
47-
slog.Warn("Icon bytes are empty, skipping icon update", "type", iconType)
34+
slog.Warn("icon bytes empty, skipping update", "type", iconType)
4835
return
4936
}
5037

5138
app.systrayInterface.SetIcon(iconBytes)
52-
slog.Debug("[TRAY] Setting icon", "type", iconType)
39+
slog.Debug("tray icon updated", "type", iconType, "incoming", counts.IncomingBlocked, "outgoing", counts.OutgoingBlocked)
5340
}

cmd/goose/icons_badge.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//go:build (linux || freebsd || openbsd || netbsd || dragonfly || solaris || illumos || aix || windows) && !darwin
2+
3+
package main
4+
5+
import (
6+
_ "embed"
7+
"log/slog"
8+
"sync"
9+
10+
"github.com/codeGROOVE-dev/goose/pkg/icon"
11+
)
12+
13+
// Linux, BSD, and Windows use dynamic circle badges since they don't support title text.
14+
15+
//go:embed icons/smiling-face.png
16+
var iconSmilingSource []byte
17+
18+
//go:embed icons/warning.png
19+
var iconWarning []byte
20+
21+
//go:embed icons/lock.png
22+
var iconLock []byte
23+
24+
var (
25+
cache = icon.NewCache()
26+
27+
smiling []byte
28+
smilingOnce sync.Once
29+
)
30+
31+
func getIcon(iconType IconType, counts PRCounts) []byte {
32+
// Static icons for error states
33+
if iconType == IconWarning {
34+
return iconWarning
35+
}
36+
if iconType == IconLock {
37+
return iconLock
38+
}
39+
40+
incoming := counts.IncomingBlocked
41+
outgoing := counts.OutgoingBlocked
42+
43+
// Happy face when nothing is blocked
44+
if incoming == 0 && outgoing == 0 {
45+
smilingOnce.Do(func() {
46+
scaled, err := icon.Scale(iconSmilingSource)
47+
if err != nil {
48+
slog.Error("failed to scale happy face icon", "error", err)
49+
smiling = iconSmilingSource
50+
return
51+
}
52+
smiling = scaled
53+
})
54+
return smiling
55+
}
56+
57+
// Check cache
58+
if cached, ok := cache.Get(incoming, outgoing); ok {
59+
return cached
60+
}
61+
62+
// Generate badge
63+
badge, err := icon.Badge(incoming, outgoing)
64+
if err != nil {
65+
slog.Error("failed to generate badge", "error", err, "incoming", incoming, "outgoing", outgoing)
66+
return smiling
67+
}
68+
69+
cache.Put(incoming, outgoing, badge)
70+
return badge
71+
}

cmd/goose/icons_darwin.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import _ "embed"
6+
7+
// macOS displays counts in the title bar, so icons remain static.
8+
9+
//go:embed icons/goose.png
10+
var iconGoose []byte
11+
12+
//go:embed icons/popper.png
13+
var iconPopper []byte
14+
15+
//go:embed icons/smiling-face.png
16+
var iconSmiling []byte
17+
18+
//go:embed icons/lock.png
19+
var iconLock []byte
20+
21+
//go:embed icons/warning.png
22+
var iconWarning []byte
23+
24+
//go:embed icons/cockroach.png
25+
var iconCockroach []byte
26+
27+
func getIcon(iconType IconType, counts PRCounts) []byte {
28+
switch iconType {
29+
case IconGoose, IconBoth:
30+
return iconGoose
31+
case IconPopper:
32+
return iconPopper
33+
case IconCockroach:
34+
return iconCockroach
35+
case IconWarning:
36+
return iconWarning
37+
case IconLock:
38+
return iconLock
39+
default:
40+
return iconSmiling
41+
}
42+
}

cmd/goose/icons_unix.go

Lines changed: 0 additions & 27 deletions
This file was deleted.

cmd/goose/icons_windows.go

Lines changed: 0 additions & 27 deletions
This file was deleted.

cmd/goose/main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ func (app *App) onReady(ctx context.Context) {
466466
// Check if we have an auth error
467467
if app.authError != "" {
468468
systray.SetTitle("")
469-
app.setTrayIcon(IconLock)
469+
app.setTrayIcon(IconLock, PRCounts{})
470470
systray.SetTooltip("Goose - Authentication Error")
471471
// Create initial error menu
472472
app.rebuildMenu(ctx)
@@ -476,7 +476,7 @@ func (app *App) onReady(ctx context.Context) {
476476
}
477477

478478
systray.SetTitle("")
479-
app.setTrayIcon(IconSmiling) // Start with smiling icon while loading
479+
app.setTrayIcon(IconSmiling, PRCounts{}) // Start with smiling icon while loading
480480

481481
// Set tooltip based on whether we're using a custom user
482482
tooltip := "Goose - Loading PRs..."
@@ -500,7 +500,7 @@ func (app *App) updateLoop(ctx context.Context) {
500500

501501
// Set error state in UI
502502
systray.SetTitle("")
503-
app.setTrayIcon(IconWarning)
503+
app.setTrayIcon(IconWarning, PRCounts{})
504504
systray.SetTooltip("Goose - Critical error")
505505

506506
// Update failure count
@@ -588,7 +588,7 @@ func (app *App) updatePRs(ctx context.Context) {
588588
}
589589

590590
systray.SetTitle("")
591-
app.setTrayIcon(iconType)
591+
app.setTrayIcon(iconType, PRCounts{})
592592

593593
// Include time since last success and user info
594594
timeSinceSuccess := "never"
@@ -769,7 +769,7 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
769769
}
770770

771771
systray.SetTitle("")
772-
app.setTrayIcon(iconType)
772+
app.setTrayIcon(iconType, PRCounts{})
773773
systray.SetTooltip(tooltip)
774774

775775
// Create or update menu to show error state

cmd/goose/ui.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ func (app *App) setTrayTitle() {
262262
"outgoing_total", counts.OutgoingTotal,
263263
"outgoing_blocked", counts.OutgoingBlocked)
264264
app.systrayInterface.SetTitle(title)
265-
app.setTrayIcon(iconType)
265+
app.setTrayIcon(iconType, counts)
266266
}
267267

268268
// addPRSection adds a section of PRs to the menu.

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/gen2brain/beeep v0.11.1
1111
github.com/godbus/dbus/v5 v5.1.0
1212
github.com/google/go-github/v57 v57.0.0
13+
golang.org/x/image v0.33.0
1314
golang.org/x/oauth2 v0.33.0
1415
)
1516

@@ -27,4 +28,5 @@ require (
2728
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect
2829
golang.org/x/net v0.46.0 // indirect
2930
golang.org/x/sys v0.37.0 // indirect
31+
golang.org/x/text v0.31.0 // indirect
3032
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG0
5050
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
5151
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw=
5252
github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
53+
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
54+
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
5355
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
5456
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
5557
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
@@ -58,6 +60,8 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w
5860
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5961
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
6062
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
63+
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
64+
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
6165
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
6266
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
6367
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)