diff --git a/cmd/goose/icons.go b/cmd/goose/icons.go index be91416..3658320 100644 --- a/cmd/goose/icons.go +++ b/cmd/goose/icons.go @@ -4,9 +4,9 @@ import ( "log/slog" ) -// Icon variables are defined in platform-specific files: -// - icons_windows.go: uses .ico files. -// - icons_unix.go: uses .png files. +// Icon implementations are in platform-specific files: +// - icons_darwin.go: macOS (static PNG icons, counts shown in title) +// - icons_badge.go: Linux/BSD/Windows (dynamic circle badges with counts) // IconType represents different icon states. type IconType int @@ -21,33 +21,20 @@ const ( IconLock // Authentication error ) -// getIcon returns the icon bytes for the given type. -func getIcon(iconType IconType) []byte { - switch iconType { - case IconGoose, IconBoth: - // For both, we'll use the goose icon as primary - return iconGoose - case IconPopper: - return iconPopper - case IconCockroach: - return iconCockroach - case IconWarning: - return iconWarning - case IconLock: - return iconLock - default: - return iconSmiling - } -} +// getIcon returns icon bytes for the given type and counts. +// Implementation is platform-specific: +// - macOS: returns static icons (counts displayed in title bar) +// - Linux/Windows: generates dynamic badges with embedded counts. +// Implemented in icons_darwin.go and icons_badge.go. -// setTrayIcon updates the system tray icon based on PR counts. -func (app *App) setTrayIcon(iconType IconType) { - iconBytes := getIcon(iconType) +// setTrayIcon updates the system tray icon. +func (app *App) setTrayIcon(iconType IconType, counts PRCounts) { + iconBytes := getIcon(iconType, counts) if len(iconBytes) == 0 { - slog.Warn("Icon bytes are empty, skipping icon update", "type", iconType) + slog.Warn("icon bytes empty, skipping update", "type", iconType) return } app.systrayInterface.SetIcon(iconBytes) - slog.Debug("[TRAY] Setting icon", "type", iconType) + slog.Debug("tray icon updated", "type", iconType, "incoming", counts.IncomingBlocked, "outgoing", counts.OutgoingBlocked) } diff --git a/cmd/goose/icons_badge.go b/cmd/goose/icons_badge.go new file mode 100644 index 0000000..d8cb3e0 --- /dev/null +++ b/cmd/goose/icons_badge.go @@ -0,0 +1,71 @@ +//go:build (linux || freebsd || openbsd || netbsd || dragonfly || solaris || illumos || aix || windows) && !darwin + +package main + +import ( + _ "embed" + "log/slog" + "sync" + + "github.com/codeGROOVE-dev/goose/pkg/icon" +) + +// Linux, BSD, and Windows use dynamic circle badges since they don't support title text. + +//go:embed icons/smiling-face.png +var iconSmilingSource []byte + +//go:embed icons/warning.png +var iconWarning []byte + +//go:embed icons/lock.png +var iconLock []byte + +var ( + cache = icon.NewCache() + + smiling []byte + smilingOnce sync.Once +) + +func getIcon(iconType IconType, counts PRCounts) []byte { + // Static icons for error states + if iconType == IconWarning { + return iconWarning + } + if iconType == IconLock { + return iconLock + } + + incoming := counts.IncomingBlocked + outgoing := counts.OutgoingBlocked + + // Happy face when nothing is blocked + if incoming == 0 && outgoing == 0 { + smilingOnce.Do(func() { + scaled, err := icon.Scale(iconSmilingSource) + if err != nil { + slog.Error("failed to scale happy face icon", "error", err) + smiling = iconSmilingSource + return + } + smiling = scaled + }) + return smiling + } + + // Check cache + if cached, ok := cache.Get(incoming, outgoing); ok { + return cached + } + + // Generate badge + badge, err := icon.Badge(incoming, outgoing) + if err != nil { + slog.Error("failed to generate badge", "error", err, "incoming", incoming, "outgoing", outgoing) + return smiling + } + + cache.Put(incoming, outgoing, badge) + return badge +} diff --git a/cmd/goose/icons_darwin.go b/cmd/goose/icons_darwin.go new file mode 100644 index 0000000..194b02e --- /dev/null +++ b/cmd/goose/icons_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package main + +import _ "embed" + +// macOS displays counts in the title bar, so icons remain static. + +//go:embed icons/goose.png +var iconGoose []byte + +//go:embed icons/popper.png +var iconPopper []byte + +//go:embed icons/smiling-face.png +var iconSmiling []byte + +//go:embed icons/lock.png +var iconLock []byte + +//go:embed icons/warning.png +var iconWarning []byte + +//go:embed icons/cockroach.png +var iconCockroach []byte + +func getIcon(iconType IconType, counts PRCounts) []byte { + switch iconType { + case IconGoose, IconBoth: + return iconGoose + case IconPopper: + return iconPopper + case IconCockroach: + return iconCockroach + case IconWarning: + return iconWarning + case IconLock: + return iconLock + default: + return iconSmiling + } +} diff --git a/cmd/goose/icons_unix.go b/cmd/goose/icons_unix.go deleted file mode 100644 index 9a82e46..0000000 --- a/cmd/goose/icons_unix.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !windows - -package main - -import ( - _ "embed" -) - -// Embed PNG files for Unix-like systems (macOS, Linux) -// -//go:embed icons/goose.png -var iconGoose []byte - -//go:embed icons/popper.png -var iconPopper []byte - -//go:embed icons/smiling-face.png -var iconSmiling []byte - -//go:embed icons/lock.png -var iconLock []byte - -//go:embed icons/warning.png -var iconWarning []byte - -//go:embed icons/cockroach.png -var iconCockroach []byte diff --git a/cmd/goose/icons_windows.go b/cmd/goose/icons_windows.go deleted file mode 100644 index 4a9af74..0000000 --- a/cmd/goose/icons_windows.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build windows - -package main - -import ( - _ "embed" -) - -// Embed Windows ICO files at compile time -// -//go:embed icons/goose.ico -var iconGoose []byte - -//go:embed icons/popper.ico -var iconPopper []byte - -//go:embed icons/smiling-face.ico -var iconSmiling []byte - -//go:embed icons/warning.ico -var iconWarning []byte - -//go:embed icons/cockroach.ico -var iconCockroach []byte - -// lock.ico not yet created, using warning as fallback -var iconLock = iconWarning diff --git a/cmd/goose/main.go b/cmd/goose/main.go index 5cd30c9..944f112 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -466,7 +466,7 @@ func (app *App) onReady(ctx context.Context) { // Check if we have an auth error if app.authError != "" { systray.SetTitle("") - app.setTrayIcon(IconLock) + app.setTrayIcon(IconLock, PRCounts{}) systray.SetTooltip("Goose - Authentication Error") // Create initial error menu app.rebuildMenu(ctx) @@ -476,7 +476,7 @@ func (app *App) onReady(ctx context.Context) { } systray.SetTitle("") - app.setTrayIcon(IconSmiling) // Start with smiling icon while loading + app.setTrayIcon(IconSmiling, PRCounts{}) // Start with smiling icon while loading // Set tooltip based on whether we're using a custom user tooltip := "Goose - Loading PRs..." @@ -500,7 +500,7 @@ func (app *App) updateLoop(ctx context.Context) { // Set error state in UI systray.SetTitle("") - app.setTrayIcon(IconWarning) + app.setTrayIcon(IconWarning, PRCounts{}) systray.SetTooltip("Goose - Critical error") // Update failure count @@ -588,7 +588,7 @@ func (app *App) updatePRs(ctx context.Context) { } systray.SetTitle("") - app.setTrayIcon(iconType) + app.setTrayIcon(iconType, PRCounts{}) // Include time since last success and user info timeSinceSuccess := "never" @@ -769,7 +769,7 @@ func (app *App) updatePRsWithWait(ctx context.Context) { } systray.SetTitle("") - app.setTrayIcon(iconType) + app.setTrayIcon(iconType, PRCounts{}) systray.SetTooltip(tooltip) // Create or update menu to show error state diff --git a/cmd/goose/ui.go b/cmd/goose/ui.go index 47a6e6f..d7b22fd 100644 --- a/cmd/goose/ui.go +++ b/cmd/goose/ui.go @@ -262,7 +262,7 @@ func (app *App) setTrayTitle() { "outgoing_total", counts.OutgoingTotal, "outgoing_blocked", counts.OutgoingBlocked) app.systrayInterface.SetTitle(title) - app.setTrayIcon(iconType) + app.setTrayIcon(iconType, counts) } // addPRSection adds a section of PRs to the menu. diff --git a/go.mod b/go.mod index 3b0815f..ecb7cb4 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/gen2brain/beeep v0.11.1 github.com/godbus/dbus/v5 v5.1.0 github.com/google/go-github/v57 v57.0.0 + golang.org/x/image v0.33.0 golang.org/x/oauth2 v0.33.0 ) @@ -27,4 +28,5 @@ require ( github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 3d6a821..a66f7d7 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG0 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw= github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/icon/icon.go b/pkg/icon/icon.go new file mode 100644 index 0000000..8b90881 --- /dev/null +++ b/pkg/icon/icon.go @@ -0,0 +1,232 @@ +// Package icon generates system tray icons for the Goose application. +// +// On platforms that don't support dynamic title text (Linux, Windows), +// icons are rendered as colored circle badges with white numbers: +// - Red circle: incoming PRs needing review +// - Green circle: outgoing PRs blocked +// - Both: red (top-left) + green (bottom-right) +// +// Generated icons are 48×48 pixels for optimal display on KDE and GNOME. +package icon + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "math" + "strconv" + "sync" + + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/gomonobold" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +// Size is the standard system tray icon size (48×48 for KDE/GNOME). +const Size = 48 + +// Color scheme for PR state indicators. +var ( + red = color.RGBA{220, 53, 69, 255} // Incoming PRs (needs attention) + green = color.RGBA{40, 167, 69, 255} // Outgoing PRs (in progress) + white = color.RGBA{255, 255, 255, 255} // Text color +) + +// Badge generates a badge icon showing PR counts. +// +// Visual design for accessibility: +// - Incoming only: Red CIRCLE (helps color-blind users distinguish) +// - Outgoing only: Green SQUARE +// - Both: Diagonal split (red top-left, green bottom-right) +// +// Returns nil if both counts are zero (caller should use happy face icon). +// Numbers are capped at 99 for display purposes. +func Badge(incoming, outgoing int) ([]byte, error) { + if incoming == 0 && outgoing == 0 { + return nil, nil + } + + img := image.NewRGBA(image.Rect(0, 0, Size, Size)) + + switch { + case incoming > 0 && outgoing > 0: + // Both: diagonal split with bold numbers + drawDiagonalSplit(img, format(incoming), format(outgoing)) + case incoming > 0: + // Incoming only: large red circle with bold number + drawCircle(img, red, format(incoming)) + default: + // Outgoing only: large green square with bold number + drawSquare(img, green, format(outgoing)) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + return buf.Bytes(), nil +} + +// Scale resizes an icon to the standard tray size. +func Scale(iconData []byte) ([]byte, error) { + src, err := png.Decode(bytes.NewReader(iconData)) + if err != nil { + return nil, fmt.Errorf("decode png: %w", err) + } + + dst := image.NewRGBA(image.Rect(0, 0, Size, Size)) + draw.NearestNeighbor.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + return buf.Bytes(), nil +} + +// drawCircle renders a large filled circle with bold centered text. +func drawCircle(img *image.RGBA, fill color.RGBA, text string) { + radius := float64(Size) / 2 + cx := radius + cy := radius + + // Draw filled circle + for py := range Size { + for px := range Size { + dx := float64(px) - cx + 0.5 + dy := float64(py) - cy + 0.5 + dist := math.Sqrt(dx*dx + dy*dy) + + if dist <= radius { + img.Set(px, py, fill) + } + } + } + + // Draw large bold centered text + drawBoldText(img, text, Size/2, Size/2) +} + +// drawSquare renders a solid square with bold centered text. +func drawSquare(img *image.RGBA, fill color.RGBA, text string) { + // Fill entire image with color + for py := range Size { + for px := range Size { + img.Set(px, py, fill) + } + } + + // Draw large bold centered text + drawBoldText(img, text, Size/2, Size/2) +} + +// drawDiagonalSplit renders a diagonal split with two numbers. +func drawDiagonalSplit(img *image.RGBA, incomingText, outgoingText string) { + // Fill with diagonal split: red top-left, green bottom-right + for py := range Size { + for px := range Size { + if px < Size-py { + img.Set(px, py, red) + } else { + img.Set(px, py, green) + } + } + } + + // Draw incoming number in top-left quadrant (lowered 1 pixel) + drawBoldText(img, incomingText, Size/4, Size/4+1) + + // Draw outgoing number in bottom-right quadrant (raised 1 pixel) + drawBoldText(img, outgoingText, 3*Size/4, 3*Size/4-1) +} + +// drawBoldText renders large, professional text using Go's monospace bold font. +func drawBoldText(img *image.RGBA, text string, centerX, centerY int) { + // Parse Go's embedded monospace bold font + face, err := opentype.Parse(gomonobold.TTF) + if err != nil { + return // Graceful fallback: show colored badge without text + } + + // Create font face at large size (32 points = ~42 pixels tall) + fontSize := 32.0 + fontFace, err := opentype.NewFace(face, &opentype.FaceOptions{ + Size: fontSize, + DPI: 72, + }) + if err != nil { + return + } + defer fontFace.Close() //nolint:errcheck // Close error is not critical for rendering + + // Measure text bounds + bounds, advance := font.BoundString(fontFace, text) + textWidth := advance.Ceil() + + // Calculate baseline position to center text vertically + // The visual center of text is at (bounds.Max.Y + bounds.Min.Y) / 2 above baseline + // So baseline Y = centerY - visualCenter + visualCenter := (bounds.Max.Y + bounds.Min.Y) / 2 + baselineY := fixed.I(centerY) - visualCenter + + // Center horizontally + x := fixed.I(centerX - textWidth/2) + + // Draw the text + drawer := &font.Drawer{ + Dst: img, + Src: image.NewUniform(white), + Face: fontFace, + Dot: fixed.Point26_6{X: x, Y: baselineY}, + } + drawer.DrawString(text) +} + +// format converts a count to display text. +// Shows single digits 1-9, or "+" for 10 or more. +func format(n int) string { + if n > 9 { + return "+" + } + return strconv.Itoa(n) +} + +// Cache stores generated icons to avoid redundant rendering. +type Cache struct { + icons map[string][]byte + mu sync.RWMutex +} + +// NewCache creates an icon cache. +func NewCache() *Cache { + return &Cache{icons: make(map[string][]byte)} +} + +// Get retrieves a cached icon. +func (c *Cache) Get(incoming, outgoing int) ([]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + data, ok := c.icons[key(incoming, outgoing)] + return data, ok +} + +// Put stores an icon in the cache. +func (c *Cache) Put(incoming, outgoing int, data []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + // Simple size limit + if len(c.icons) > 100 { + c.icons = make(map[string][]byte) + } + + c.icons[key(incoming, outgoing)] = data +} + +func key(incoming, outgoing int) string { + return strconv.Itoa(incoming) + ":" + strconv.Itoa(outgoing) +} diff --git a/pkg/icon/icon_test.go b/pkg/icon/icon_test.go new file mode 100644 index 0000000..5238b9b --- /dev/null +++ b/pkg/icon/icon_test.go @@ -0,0 +1,96 @@ +package icon + +import ( + "bytes" + "image/png" + "testing" +) + +func TestBadge(t *testing.T) { + tests := []struct { + name string + incoming int + outgoing int + wantNil bool + }{ + {"no PRs", 0, 0, true}, + {"incoming only", 3, 0, false}, + {"outgoing only", 0, 5, false}, + {"both", 2, 1, false}, + {"large numbers", 150, 200, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := Badge(tt.incoming, tt.outgoing) + if err != nil { + t.Fatalf("Badge() error = %v", err) + } + + if tt.wantNil { + if data != nil { + t.Error("Badge() should return nil for 0/0") + } + return + } + + if data == nil { + t.Fatal("Badge() returned nil when badge expected") + } + + // Verify it's valid PNG + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("invalid PNG: %v", err) + } + + // Verify dimensions + bounds := img.Bounds() + if bounds.Dx() != Size || bounds.Dy() != Size { + t.Errorf("wrong dimensions: got %dx%d, want %dx%d", + bounds.Dx(), bounds.Dy(), Size, Size) + } + }) + } +} + +func TestCache(t *testing.T) { + c := NewCache() + + // Cache miss + if _, ok := c.Get(1, 2); ok { + t.Error("expected cache miss") + } + + // Cache hit + data := []byte("test") + c.Put(1, 2, data) + got, ok := c.Get(1, 2) + if !ok { + t.Error("expected cache hit") + } + if !bytes.Equal(got, data) { + t.Error("cached data mismatch") + } +} + +func TestFormat(t *testing.T) { + tests := []struct { + input int + want string + }{ + {0, "0"}, + {1, "1"}, + {9, "9"}, + {10, "+"}, + {99, "+"}, + {100, "+"}, + } + + for _, tt := range tests { + got := format(tt.input) + if got != tt.want { + t.Errorf("format(%d) = %q, want %q", tt.input, got, tt.want) + } + } +}