// Copyright (C) 2026 Danilo M. // // 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. It searches both the // KDE/Papirus apps/ layout and the freedesktop /apps layout. func findInTheme(base, name string) string { for _, dir := range themeSizeDirs(base) { for _, ext := range []string{"svg", "png", "xpm"} { p := filepath.Join(dir, name+"."+ext) if fileExists(p) { return p } } } 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// 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: /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 { 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() }