diff options
Diffstat (limited to 'internal/notify')
| -rw-r--r-- | internal/notify/icons.go | 96 | ||||
| -rw-r--r-- | internal/notify/icons_test.go | 88 |
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) + } +} |
