- Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support
206 lines
4.3 KiB
Go
206 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/png"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
func iconThemeDirs() []string {
|
|
dirs := []string{}
|
|
|
|
if home := os.Getenv("HOME"); home != "" {
|
|
dirs = append(dirs, filepath.Join(home, ".local/share/icons"))
|
|
}
|
|
|
|
xdgDirs := os.Getenv("XDG_DATA_DIRS")
|
|
if xdgDirs == "" {
|
|
xdgDirs = "/usr/local/share:/usr/share"
|
|
}
|
|
for _, d := range filepath.SplitList(xdgDirs) {
|
|
dirs = append(dirs, filepath.Join(d, "icons"))
|
|
}
|
|
|
|
return uniquePaths(dirs)
|
|
}
|
|
|
|
func uniquePaths(paths []string) []string {
|
|
seen := make(map[string]bool)
|
|
res := make([]string, 0, len(paths))
|
|
for _, p := range paths {
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
res = append(res, p)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func preferredThemes() []string {
|
|
theme := detectGtkTheme()
|
|
if theme != "" {
|
|
return []string{theme, "hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
|
}
|
|
return []string{"hicolor", "Adwaita", "Papirus", "Humanity", "breeze", "gnome"}
|
|
}
|
|
|
|
func detectGtkTheme() string {
|
|
data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-4.0/settings.ini"))
|
|
if err != nil {
|
|
data, err = os.ReadFile(filepath.Join(os.Getenv("HOME"), ".config/gtk-3.0/settings.ini"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "gtk-icon-theme-name=") {
|
|
return strings.TrimSpace(line[len("gtk-icon-theme-name="):])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sizeDirs(target int) []string {
|
|
sizes := []int{}
|
|
|
|
for _, base := range []int{16, 22, 24, 32, 48, 64, 72, 96, 128, 192, 256} {
|
|
sizes = append(sizes, base)
|
|
}
|
|
|
|
sort.Slice(sizes, func(i, j int) bool {
|
|
di := abs(sizes[i] - target)
|
|
dj := abs(sizes[j] - target)
|
|
if di != dj {
|
|
return di < dj
|
|
}
|
|
return sizes[i] > sizes[j]
|
|
})
|
|
|
|
seen := make(map[int]bool)
|
|
res := make([]string, 0, len(sizes))
|
|
for _, s := range sizes {
|
|
if seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
res = append(res, fmt.Sprintf("%dx%d", s, s))
|
|
}
|
|
res = append(res, "scalable")
|
|
return res
|
|
}
|
|
|
|
func abs(x int) int {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
func iconCategories() []string {
|
|
return []string{"actions", "apps", "categories", "devices", "emblems", "mimetypes", "places", "status"}
|
|
}
|
|
|
|
func findSystemIcon(name string, targetSize int) (string, error) {
|
|
themes := preferredThemes()
|
|
dirs := iconThemeDirs()
|
|
sDirs := sizeDirs(targetSize)
|
|
cats := iconCategories()
|
|
|
|
for _, base := range dirs {
|
|
for _, theme := range themes {
|
|
themeDir := filepath.Join(base, theme)
|
|
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
for _, sd := range sDirs {
|
|
for _, cat := range cats {
|
|
for _, ext := range []string{"png", "xpm"} {
|
|
p := filepath.Join(themeDir, sd, cat, name+"."+ext)
|
|
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
|
return p, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, sd := range sDirs {
|
|
for _, cat := range cats {
|
|
p := filepath.Join(themeDir, sd, cat, name+".svg")
|
|
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
|
return p, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("system icon %q not found", name)
|
|
}
|
|
|
|
func svgToPNG(svgPath string, targetSize int) (image.Image, error) {
|
|
cmd := exec.Command("rsvg-convert",
|
|
"-w", strconv.Itoa(targetSize),
|
|
"-h", strconv.Itoa(targetSize),
|
|
"-f", "png",
|
|
svgPath,
|
|
)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rsvg-convert: %w", err)
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(out))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode converted svg: %w", err)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
var iconSizeCache sync.Map
|
|
|
|
func loadSystemIcon(name string, targetSize int) (string, error) {
|
|
type cacheKey struct {
|
|
name string
|
|
size int
|
|
}
|
|
key := cacheKey{name, targetSize}
|
|
if cached, ok := iconSizeCache.Load(key); ok {
|
|
return cached.(string), nil
|
|
}
|
|
path, err := findSystemIcon(name, targetSize)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
iconSizeCache.Store(key, path)
|
|
return path, nil
|
|
}
|
|
|
|
func isSystemIconRef(path string) bool {
|
|
return strings.HasPrefix(path, "@")
|
|
}
|
|
|
|
func systemIconName(path string) string {
|
|
return strings.TrimPrefix(path, "@")
|
|
}
|
|
|
|
func parseSizeDir(dirName string) (int, bool) {
|
|
parts := strings.SplitN(dirName, "x", 2)
|
|
if len(parts) != 2 {
|
|
return 0, false
|
|
}
|
|
s, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return s, true
|
|
}
|