aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 17:57:25 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 17:57:25 +0200
commita248608721e813d5e6d19fc79d2505a2a8c2716a (patch)
treed3d5b2154f9aa94a8a30aeb42bcf5ff5a1bbcd5f
parent4eb086eeb44023aec23f5a48bd643b86024e3717 (diff)
downloadnotifyd-a248608721e813d5e6d19fc79d2505a2a8c2716a.tar.gz
notifyd-a248608721e813d5e6d19fc79d2505a2a8c2716a.zip
fix(notify): search freedesktop icon layout and rank by effective size
The hicolor fallback scanned <theme>/apps/<size>, but freedesktop themes use <size>/apps, so hicolor never resolved. Both layouts are now searched, and rasters are ranked by effective pixel size (N@2x is 2N) with a deterministic name tie-break.
-rw-r--r--internal/notify/icons.go96
-rw-r--r--internal/notify/icons_test.go88
2 files changed, 170 insertions, 14 deletions
diff --git a/internal/notify/icons.go b/internal/notify/icons.go
index b874581..d68c171 100644
--- a/internal/notify/icons.go
+++ b/internal/notify/icons.go
@@ -95,22 +95,12 @@ func lookupThemeIcon(name, theme string) string {
return ""
}
-// findInTheme prefers scalable then the largest raster under apps/.
+// findInTheme prefers scalable, then the largest raster. It searches both the
+// KDE/Papirus apps/<size> layout and the freedesktop <size>/apps layout.
func findInTheme(base, name string) string {
- sizes := []string{"scalable"}
- matches, _ := filepath.Glob(filepath.Join(base, "apps", "[0-9]*"))
- for _, m := range matches {
- sizes = append(sizes, filepath.Base(m))
- }
- numeric := sizes[1:]
- sort.Slice(numeric, func(i, j int) bool {
- a, _ := strconv.Atoi(strings.TrimSuffix(numeric[i], "@2x"))
- b, _ := strconv.Atoi(strings.TrimSuffix(numeric[j], "@2x"))
- return a > b
- })
- for _, size := range sizes {
+ for _, dir := range themeSizeDirs(base) {
for _, ext := range []string{"svg", "png", "xpm"} {
- p := filepath.Join(base, "apps", size, name+"."+ext)
+ p := filepath.Join(dir, name+"."+ext)
if fileExists(p) {
return p
}
@@ -119,6 +109,84 @@ func findInTheme(base, name string) string {
return ""
}
+// sizeDir is one candidate icon directory inside a theme.
+type sizeDir struct {
+ path string
+ scalable bool
+ size int
+ name string
+}
+
+// themeSizeDirs lists a theme's candidate directories, scalable first, then
+// rasters by effective pixel size descending, name as a deterministic
+// tie-break. Both icon layouts are covered.
+func themeSizeDirs(base string) []string {
+ var found []sizeDir
+ add := func(name, path string) {
+ if name == "scalable" {
+ found = append(found, sizeDir{path: path, scalable: true, name: name})
+ return
+ }
+ if n, ok := pixelSize(name); ok {
+ found = append(found, sizeDir{path: path, size: n, name: name})
+ }
+ }
+ // KDE/Papirus: apps/<size>/
+ if entries, err := os.ReadDir(filepath.Join(base, "apps")); err == nil {
+ for _, e := range entries {
+ if e.IsDir() {
+ add(e.Name(), filepath.Join(base, "apps", e.Name()))
+ }
+ }
+ }
+ // freedesktop: <size>/apps/
+ if entries, err := os.ReadDir(base); err == nil {
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ apps := filepath.Join(base, e.Name(), "apps")
+ if info, err := os.Stat(apps); err == nil && info.IsDir() {
+ add(e.Name(), apps)
+ }
+ }
+ }
+ sort.Slice(found, func(i, j int) bool {
+ a, b := found[i], found[j]
+ if a.scalable != b.scalable {
+ return a.scalable
+ }
+ if a.size != b.size {
+ return a.size > b.size
+ }
+ return a.name < b.name
+ })
+ dirs := make([]string, len(found))
+ for i, d := range found {
+ dirs[i] = d.path
+ }
+ return dirs
+}
+
+// pixelSize turns a size directory name into its effective pixel size. "48"
+// and "48x48" styles are recognised, and a trailing @2x doubles the size.
+func pixelSize(name string) (int, bool) {
+ s := name
+ mult := 1
+ if strings.HasSuffix(s, "@2x") {
+ s = strings.TrimSuffix(s, "@2x")
+ mult = 2
+ }
+ if i := strings.IndexByte(s, 'x'); i >= 0 {
+ s = s[:i]
+ }
+ n, err := strconv.Atoi(s)
+ if err != nil {
+ return 0, false
+ }
+ return n * mult, true
+}
+
func inheritsOf(base string) string {
v := iniValue(filepath.Join(base, "index.theme"), "Inherits")
if i := strings.IndexByte(v, ','); i >= 0 {
diff --git a/internal/notify/icons_test.go b/internal/notify/icons_test.go
index 527b330..9a6824a 100644
--- a/internal/notify/icons_test.go
+++ b/internal/notify/icons_test.go
@@ -68,3 +68,91 @@ func TestResolveIconThemeName(t *testing.T) {
t.Fatalf("missing name must be empty, got %q", got)
}
}
+
+// iconEnv builds a throwaway XDG tree, points qt6ct at theme, and returns the
+// root. HOME, XDG_DATA_HOME and XDG_DATA_DIRS are restored after the test.
+func iconEnv(t *testing.T, theme string) string {
+ t.Helper()
+ root := t.TempDir()
+ home := filepath.Join(root, "home")
+ if err := os.MkdirAll(filepath.Join(home, ".config", "qt6ct"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(home, ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme="+theme+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("XDG_DATA_HOME", root)
+ t.Setenv("XDG_DATA_DIRS", "")
+ t.Setenv("HOME", home)
+ return root
+}
+
+// putIcon creates a placeholder icon file, and its parents.
+func putIcon(t *testing.T, path string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("<svg/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// writeIndex writes a theme's index.theme with a single Inherits value.
+func writeIndex(t *testing.T, dir, inherits string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte("[Icon Theme]\nInherits="+inherits+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestResolveIconFreedesktopLayout(t *testing.T) {
+ root := iconEnv(t, "Flat")
+ putIcon(t, filepath.Join(root, "icons", "Flat", "scalable", "apps", "firefox.svg"))
+ putIcon(t, filepath.Join(root, "icons", "Flat", "48x48", "apps", "firefox.png"))
+ want := filepath.Join(root, "icons", "Flat", "scalable", "apps", "firefox.svg")
+ if got := ResolveIcon("firefox"); got != want {
+ t.Fatalf("freedesktop scalable: got %q want %q", got, want)
+ }
+
+ root = iconEnv(t, "Grid")
+ putIcon(t, filepath.Join(root, "icons", "Grid", "48x48", "apps", "gimp.png"))
+ want = filepath.Join(root, "icons", "Grid", "48x48", "apps", "gimp.png")
+ if got := ResolveIcon("gimp"); got != want {
+ t.Fatalf("freedesktop raster: got %q want %q", got, want)
+ }
+}
+
+func TestResolveIconSizePreference(t *testing.T) {
+ root := iconEnv(t, "Sized")
+ base := filepath.Join(root, "icons", "Sized")
+ putIcon(t, filepath.Join(base, "apps", "48", "app.png"))
+ putIcon(t, filepath.Join(base, "apps", "32@2x", "app.png"))
+ want := filepath.Join(base, "apps", "32@2x", "app.png")
+ if got := ResolveIcon("app"); got != want {
+ t.Fatalf("@2x beats plain 48 (64px vs 48px): got %q want %q", got, want)
+ }
+
+ root = iconEnv(t, "Tied")
+ base = filepath.Join(root, "icons", "Tied")
+ putIcon(t, filepath.Join(base, "32x32@2x", "apps", "app.png"))
+ putIcon(t, filepath.Join(base, "64x64", "apps", "app.png"))
+ want = filepath.Join(base, "32x32@2x", "apps", "app.png")
+ if got := ResolveIcon("app"); got != want {
+ t.Fatalf("equal 64px must break ties deterministically: got %q want %q", got, want)
+ }
+}
+
+func TestResolveIconInheritsCycle(t *testing.T) {
+ root := iconEnv(t, "LoopA")
+ writeIndex(t, filepath.Join(root, "icons", "LoopA"), "LoopB")
+ writeIndex(t, filepath.Join(root, "icons", "LoopB"), "LoopA")
+ putIcon(t, filepath.Join(root, "icons", "hicolor", "48x48", "apps", "app.png"))
+ want := filepath.Join(root, "icons", "hicolor", "48x48", "apps", "app.png")
+ if got := ResolveIcon("app"); got != want {
+ t.Fatalf("cycle must terminate and fall through to hicolor: got %q want %q", got, want)
+ }
+}