diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/notify/classify.go | 58 | ||||
| -rw-r--r-- | internal/notify/classify_test.go | 95 | ||||
| -rw-r--r-- | internal/notify/service.go | 11 |
3 files changed, 163 insertions, 1 deletions
diff --git a/internal/notify/classify.go b/internal/notify/classify.go new file mode 100644 index 0000000..c7e0dc8 --- /dev/null +++ b/internal/notify/classify.go @@ -0,0 +1,58 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" +) + +// IconMaxPixels is the long-side bound below which a content image is treated +// as an icon rather than a preview. A screenshot is far larger; a themed icon +// or an application logo is not. +const IconMaxPixels = 128 + +// IsIconImage reports whether a content image should fill the app-icon slot +// instead of the large preview. Clients disagree about where the icon goes: +// kitty, mail and opencode send it through the content-image hint, while a +// screenshot uses the same hint. The hint is one of the icon forms when the +// client sent a theme name, when the resolved file is an SVG, or when a raster +// is small on both sides. raw is the value before ResolveIcon; resolved is the +// path it resolved to. +func IsIconImage(raw, resolved string) bool { + if raw != "" && !strings.Contains(raw, "/") && !strings.HasPrefix(raw, "file:") { + return true + } + if resolved == "" { + return false + } + if strings.EqualFold(filepath.Ext(resolved), ".svg") { + return true + } + f, err := os.Open(resolved) + if err != nil { + return false + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + // A format without a registered decoder is not something we can size, + // so it stays a preview rather than being silently dropped. + return false + } + return cfg.Width <= IconMaxPixels && cfg.Height <= IconMaxPixels +} diff --git a/internal/notify/classify_test.go b/internal/notify/classify_test.go new file mode 100644 index 0000000..6234008 --- /dev/null +++ b/internal/notify/classify_test.go @@ -0,0 +1,95 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +func writeTestPNG(t *testing.T, path string, w, h int) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, w, h))); err != nil { + t.Fatal(err) + } +} + +func TestIsIconImage(t *testing.T) { + dir := t.TempDir() + small := filepath.Join(dir, "logo.png") + large := filepath.Join(dir, "shot.png") + svg := filepath.Join(dir, "icon.svg") + writeTestPNG(t, small, 32, 32) + writeTestPNG(t, large, 800, 600) + if err := os.WriteFile(svg, []byte("<svg/>"), 0o644); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + raw, resolved string + want bool + }{ + {"theme name", "utilities-terminal", "/x/apps/16/utilities-terminal.svg", true}, + {"svg", svg, svg, true}, + {"small raster", small, small, true}, + {"large raster", large, large, false}, + {"missing file", "/no/such.png", "/no/such.png", false}, + {"empty", "", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsIconImage(c.raw, c.resolved); got != c.want { + t.Fatalf("IsIconImage(%q, %q) = %v, want %v", c.raw, c.resolved, got, c.want) + } + }) + } +} + +func TestNotifyRoutesContentImageByKind(t *testing.T) { + dir := t.TempDir() + small := filepath.Join(dir, "logo.png") + large := filepath.Join(dir, "shot.png") + writeTestPNG(t, small, 32, 32) + writeTestPNG(t, large, 800, 600) + + run := func(path string) Popup { + var live []Popup + s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}} + s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage) + hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant(path)} + if _, err := s.Notify("app", 0, "", "s", "b", nil, hints, -1); err != nil { + t.Fatal(err) + } + if len(live) != 1 { + t.Fatalf("published %d popups", len(live)) + } + return live[0] + } + + if p := run(small); p.Icon != small || p.Image != "" { + t.Fatalf("small image: icon=%q image=%q, want icon only", p.Icon, p.Image) + } + if p := run(large); p.Icon != "" || p.Image != large { + t.Fatalf("large image: icon=%q image=%q, want image only", p.Icon, p.Image) + } +} diff --git a/internal/notify/service.go b/internal/notify/service.go index 86918d4..a2bf103 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -120,7 +120,16 @@ func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, bo if r, ok := ImageDataFromHints(hints); ok { raw = r } else if path, ok := ImagePathFromHints(hints); ok { - n.Image = ResolveIcon(path) + resolved := ResolveIcon(path) + // An icon-like content image (a theme name, an SVG, a small raster) is + // the app icon clients like kitty send through this hint; a larger + // raster is a screenshot and stays the balloon preview. An app_icon + // already provided keeps the slot. + if IsIconImage(path, resolved) && n.Icon == "" { + n.Icon = resolved + } else { + n.Image = resolved + } } else if r, ok := IconDataFromHints(hints); ok { raw = r } |
