aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--internal/notify/icons.go158
-rw-r--r--internal/notify/icons_test.go70
2 files changed, 228 insertions, 0 deletions
diff --git a/internal/notify/icons.go b/internal/notify/icons.go
new file mode 100644
index 0000000..b874581
--- /dev/null
+++ b/internal/notify/icons.go
@@ -0,0 +1,158 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// IconThemeName reads the desktop's current icon theme: qt6ct is the truth on
+// this desktop, then the GTK3 setting, then hicolor. A missing or empty value
+// falls through.
+func IconThemeName() string {
+ if v := iniValue(filepath.Join(homeDir(), ".config", "qt6ct", "qt6ct.conf"), "icon_theme"); v != "" {
+ return v
+ }
+ if v := iniValue(filepath.Join(homeDir(), ".config", "gtk-3.0", "settings.ini"), "gtk-icon-theme-name"); v != "" {
+ return v
+ }
+ return "hicolor"
+}
+
+// ResolveIcon turns an app_icon or image-path value into a file path. A URI is
+// trimmed, a path is returned unchanged, and a bare name is looked up in the
+// icon theme. Nothing found is an empty string, which renders no image.
+func ResolveIcon(value string) string {
+ if value == "" {
+ return ""
+ }
+ if strings.HasPrefix(value, "file://") {
+ return strings.TrimPrefix(value, "file://")
+ }
+ if strings.Contains(value, "/") {
+ return value
+ }
+ return lookupThemeIcon(value, IconThemeName())
+}
+
+// XDGIconDirs is the icon search path: the user's dir then each data dir.
+func XDGIconDirs() []string {
+ home := os.Getenv("XDG_DATA_HOME")
+ if home == "" {
+ home = filepath.Join(homeDir(), ".local", "share")
+ }
+ dirs := []string{filepath.Join(home, "icons")}
+ for _, d := range filepath.SplitList(os.Getenv("XDG_DATA_DIRS")) {
+ if d != "" {
+ dirs = append(dirs, filepath.Join(d, "icons"))
+ }
+ }
+ if len(dirs) == 1 {
+ dirs = append(dirs, "/usr/local/share/icons", "/usr/share/icons")
+ }
+ return dirs
+}
+
+// lookupThemeIcon searches the theme, then its Inherits chain, then hicolor.
+func lookupThemeIcon(name, theme string) string {
+ seen := map[string]bool{}
+ for theme != "" && !seen[theme] {
+ seen[theme] = true
+ found, next := "", ""
+ for _, root := range XDGIconDirs() {
+ base := filepath.Join(root, theme)
+ if p := findInTheme(base, name); p != "" {
+ found = p
+ break
+ }
+ if next == "" {
+ next = inheritsOf(base)
+ }
+ }
+ if found != "" {
+ return found
+ }
+ theme = next
+ }
+ for _, root := range XDGIconDirs() {
+ if p := findInTheme(filepath.Join(root, "hicolor"), name); p != "" {
+ return p
+ }
+ }
+ return ""
+}
+
+// findInTheme prefers scalable then the largest raster under apps/.
+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 _, ext := range []string{"svg", "png", "xpm"} {
+ p := filepath.Join(base, "apps", size, name+"."+ext)
+ if fileExists(p) {
+ return p
+ }
+ }
+ }
+ return ""
+}
+
+func inheritsOf(base string) string {
+ v := iniValue(filepath.Join(base, "index.theme"), "Inherits")
+ if i := strings.IndexByte(v, ','); i >= 0 {
+ v = v[:i]
+ }
+ return strings.TrimSpace(v)
+}
+
+func iniValue(path, key string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
+ continue
+ }
+ k, v, _ := strings.Cut(line, "=")
+ if strings.TrimSpace(k) == key {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func homeDir() string {
+ if h := os.Getenv("HOME"); h != "" {
+ return h
+ }
+ return os.TempDir()
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
diff --git a/internal/notify/icons_test.go b/internal/notify/icons_test.go
new file mode 100644
index 0000000..527b330
--- /dev/null
+++ b/internal/notify/icons_test.go
@@ -0,0 +1,70 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveIconPathAndURI(t *testing.T) {
+ if got := ResolveIcon("/usr/share/icons/x/apps/48/firefox.png"); got != "/usr/share/icons/x/apps/48/firefox.png" {
+ t.Fatalf("path passthrough: %q", got)
+ }
+ if got := ResolveIcon("file:///tmp/shot.png"); got != "/tmp/shot.png" {
+ t.Fatalf("uri: %q", got)
+ }
+ if got := ResolveIcon(""); got != "" {
+ t.Fatalf("empty: %q", got)
+ }
+}
+
+func TestResolveIconThemeName(t *testing.T) {
+ root := t.TempDir()
+ // Theme "Plum" inherits "Base"; the icon is only in Base.
+ theme := filepath.Join(root, "icons", "Plum")
+ base := filepath.Join(root, "icons", "Base")
+ plumIndex := filepath.Join(theme, "index.theme")
+ baseApp := filepath.Join(base, "apps", "48")
+ if err := os.MkdirAll(filepath.Join(theme, "apps", "scalable"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(baseApp, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(plumIndex, []byte("[Icon Theme]\nInherits=Base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(baseApp, "firefox.svg"), []byte("<svg/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("XDG_DATA_HOME", root)
+ t.Setenv("XDG_DATA_DIRS", "")
+ t.Setenv("HOME", filepath.Join(root, "home"))
+ if err := os.MkdirAll(filepath.Join(root, "home", ".config", "qt6ct"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "home", ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme=Plum\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := ResolveIcon("firefox")
+ want := filepath.Join(root, "icons", "Base", "apps", "48", "firefox.svg")
+ if got != want {
+ t.Fatalf("resolved %q, want %q", got, want)
+ }
+ if got := ResolveIcon("no-such-icon-xyz"); got != "" {
+ t.Fatalf("missing name must be empty, got %q", got)
+ }
+}