xdg/icon-theme/icon-theme.go
2024-04-28 22:59:20 -04:00

544 lines
16 KiB
Go

// Package iconTheme implements icon-theme-spec version 0.13.
package iconTheme
import "os"
import "io"
import "fmt"
import "log"
import "image"
import "strings"
import "path/filepath"
import "git.tebibyte.media/tomo/xdg/basedir"
import "git.tebibyte.media/tomo/xdg/key-value"
type iconThemeError string
func (err iconThemeError) Error () string { return string(err) }
const (
// ErrThemeNotFound indicates that a theme could not be found.
ErrThemeNotFound = iconThemeError("theme not found")
// ErrGroupMissing indicates that a required group was not found in an
// index.theme file.
ErrGroupMissing = iconThemeError("group missing")
// ErrIconThemeGroupMissing indicates that the [Icon Theme] group was
// not found while parsing an index.theme file.
ErrIconThemeGroupMissing = iconThemeError("icon theme group missing")
// ErrDirectoryGroupMissing indicates that a directory group was not
// found while parsing an index.Theme file.
ErrDirectoryGroupMissing = iconThemeError("directory group missing")
// ErrEntryMissing indicates that a required entry was not found in an
// index.theme file.
ErrEntryMissing = iconThemeError("entry missing")
// ErrBadIconSizeType indicates that an invalid value for an icon size
// type was given.
ErrBadIconSizeType = iconThemeError("bad icon size type")
// ErrCircularDependency indicates that an icon theme depends on one of
// its dependents.
ErrCircularDependency = iconThemeError("circular dependency")
// ErrIconNotFound indicates that an icon could not be found.
ErrIconNotFound = iconThemeError("icon not found")
)
// ThemeDirs returns the set of directories in which themes should be looked for
// in order of preference.
//
// It will return $HOME/.icons (for backwards compatibility), in
// $XDG_DATA_DIRS/icons and in /usr/share/pixmaps. Applications may further add
// their own icon directories to this list.
func ThemeDirs () ([]string, error) {
home, err := os.UserHomeDir()
if err != nil { return nil, err }
dataDirs, err := basedir.DataDirs()
if err != nil { return nil, err }
dirs := make([]string, len(dataDirs) + 2)
dirs[0] = filepath.Join(home, ".icons")
dirs[len(dirs) - 1] = "/usr/share/pixmaps"
for index, dataDir := range dataDirs {
dirs[index + 1] = filepath.Join(dataDir, "icons")
}
return dirs, nil
}
// FindTheme returns the theme of the given name located within the given search
// paths. If no search path is provided, the result of ThemeDirs is used. This
// function will also recursively find and read inherited themes. If an
// inherited theme is malformed and cannot be read, hicolor will be used
// instead. The directory structure and inheritence tree of a theme are only
// determined when this function is run, if a theme has been changed and needs
// to be reloaded this function should be called again.
func FindTheme (name string, path ...string) (Theme, error) {
return findTheme(name, nil, false, path...)
}
// FindThemeWarn is like FindTheme, but emits warnings using the log package if
// it encounters a recoverable error (such as failing to parse/find an inherited
// theme).
func FindThemeWarn (name string, path ...string) (Theme, error) {
return findTheme(name, nil, true, path...)
}
func findTheme (name string, trail []string, warn bool, path ...string) (Theme, error) {
for _, step := range trail {
if step == name {
return Theme { }, ErrCircularDependency
}
}
if len(path) == 0 {
themeDirs, err := ThemeDirs()
if err != nil { return Theme { }, nil }
path = themeDirs
}
// find paths to all instances of this theme
instances := []string { }
for _, dir := range path {
entries, err := os.ReadDir(dir)
if err != nil { continue }
for _, entry := range entries {
if entry.Name() == name {
instances = append (
instances,
filepath.Join(dir, entry.Name()))
break
}
}
}
if len(instances) == 0 { return Theme { }, ErrThemeNotFound }
// find an index.theme
var lastParseErr error
var theme Theme
var parents []string
for _, instance := range instances {
indexPath := filepath.Join(instance, "index.theme")
file, err := os.Open(indexPath)
lastParseErr = err
if err != nil { continue }
defer file.Close()
theme, parents, err = parseThemeIndex(file)
lastParseErr = err
if err != nil { continue }
break
}
if lastParseErr != nil { return Theme { }, lastParseErr }
// expand directories to cover all instances
for index, directory := range theme.Directories {
directory.Paths = make([]string, len(instances))
for pathIndex, path := range directory.Paths {
directory.Paths[pathIndex] = filepath.Join(path, directory.Name)
}
theme.Directories[index] = directory
}
// this will not stomp memory its fine
trail = append(trail, name)
// parse parents
for _, parent := range parents {
parentTheme, err := findTheme(parent, trail, warn, path...)
if err != nil {
if warn {
log.Printf (
"xdg: could not parse inherited theme '%s': %v",
parent, err)
}
continue
}
theme.Inherits = append(theme.Inherits, parentTheme)
}
// if no parents were successfuly parsed, inherit from hicolor
if len(theme.Inherits) == 0 && name != "hicolor" {
hicolor, err := findTheme("hicolor", trail, warn, path...)
if err != nil { return Theme { }, err }
theme.Inherits = []Theme { hicolor }
}
return theme, nil
}
func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err error) {
file, err := keyValue.Parse(reader)
if err != nil { return Theme { }, nil, err }
iconThemeGroup, ok := file["Icon Theme"]
if !ok { return Theme { }, nil, ErrIconThemeGroupMissing }
// Name
entry, ok := iconThemeGroup["Name"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Name, err = entry.Unescape()
if err != nil { return Theme { }, nil, err }
// Comment
entry, ok = iconThemeGroup["Comment"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Comment, err = entry.Unescape()
if err != nil { return Theme { }, nil, err }
// Inherits (optional)
if entry, ok := iconThemeGroup["Inherits"]; ok {
parents, err = keyValue.ParseMultipleComma (
keyValue.ParseString,
entry.Value)
if !ok { return Theme { }, nil, err }
}
// Directories
entry, ok = iconThemeGroup["Directories"]
if !ok { return Theme { }, nil, ErrEntryMissing }
theme.Directories, err = parseDirectories(entry, file)
if err != nil { return Theme { }, nil, err }
// ScaledDirectories (optional)
if entry, ok := iconThemeGroup["ScaledDirectories"]; ok {
theme.ScaledDirectories, err = parseDirectories(entry, file)
if err != nil { return Theme { }, nil, err }
}
// Hidden (optional)
if entry, ok := iconThemeGroup["Hidden"]; ok {
theme.Hidden, err = keyValue.ParseBoolean(entry.Value)
if err != nil { return Theme { }, nil, err }
}
// Example (optional)
if entry, ok := iconThemeGroup["Example"]; ok {
theme.Example, err = keyValue.ParseString(entry.Value)
if err != nil { return Theme { }, nil, err }
}
return theme, parents, nil
}
func parseDirectories (listEntry keyValue.Entry, file keyValue.File) ([]Directory, error) {
names, err := keyValue.ParseMultipleComma(keyValue.ParseString, listEntry.Value)
if err != nil { return nil, err }
directories := make([]Directory, len(names))
for index, name := range names {
directoryGroup, ok := file[name]
if !ok { return nil, ErrDirectoryGroupMissing }
directory := Directory {
Name: name,
}
// Size
entry, ok := directoryGroup["Size"]
if !ok { return nil, ErrEntryMissing }
directory.Size, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
// Scale (optional)
if entry, ok := directoryGroup["Scale"]; ok {
directory.Scale, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.Scale = 1
}
// Context (optional)
if entry, ok := directoryGroup["Context"]; ok {
directory.Context, err = keyValue.ParseString(entry.Value)
if err != nil { return nil, err }
}
// Type (optional)
if entry, ok := directoryGroup["Type"]; ok {
stringTy, err := keyValue.ParseString(entry.Value)
if err != nil { return nil, err }
directory.Type, err = IconSizeTypeFromString(stringTy)
if err != nil { return nil, err }
} else {
directory.Type = IconSizeTypeThreshold
}
// MaxSize (optional)
if entry, ok := directoryGroup["MaxSize"]; ok {
directory.MaxSize, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.MaxSize = directory.Size
}
// MinSize (optional)
if entry, ok := directoryGroup["MinSize"]; ok {
directory.MinSize, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.MinSize = directory.Size
}
// Threshold (optional)
if entry, ok := directoryGroup["Threshold"]; ok {
directory.Threshold, err = keyValue.ParseInteger(entry.Value)
if err != nil { return nil, err }
} else {
directory.Threshold = 2
}
directories[index] = directory
}
return directories, nil
}
// Theme represents an icon theme.
type Theme struct {
Name keyValue.Entry
Comment keyValue.Entry
Inherits []Theme
Directories []Directory
ScaledDirectories []Directory
Hidden bool
Example string
}
// FindIcon finds the best icon of the given name for the given size and scale.
// If a list of supported filetypes is not given, { PNG, SVG, XPM } is used
// instead.
func (theme Theme) FindIcon (name string, size, scale int, supported ...IconFileType) (Icon, error) {
// this method is equivalent to FindIconHelper
if len(supported) == 0 { supported = []IconFileType{ PNG, SVG, XPM } }
// try to find icon in this theme first
icon, err := theme.findIconHere(name, size, scale, supported...)
if err == nil { return icon, nil }
// if that didn't work, recurse through parent themes
for _, parent := range theme.Inherits {
icon, err := parent.FindIcon(name, size, scale, supported...)
if err == nil { return icon, nil }
}
return Icon { }, ErrIconNotFound
}
func (theme Theme) findIconHere (name string, size, scale int, supported ...IconFileType) (Icon, error) {
// this method is equivalent to LookupIcon
// stage 1: attempt to find an exact match
for _, directory := range theme.Directories {
if directory.Fits(size, scale) {
for _, path := range directory.Paths {
for _, ty := range supported {
icon, err := parseIcon(filepath.Join(path, ty.Extend(name)))
if err == nil { return icon, err }
}
}
}
}
// stage 2: find the best icon matching the name
minimalDistance := int((^uint(0)) >> 1)
closestFilename := ""
for _, directory := range theme.Directories {
sizeDistance := directory.SizeDistance(size, scale)
if sizeDistance < minimalDistance {
for _, path := range directory.Paths {
for _, ty := range supported {
iconFilename := filepath.Join(path, ty.Extend(name))
if _, err := os.Stat(iconFilename); err == nil {
closestFilename = iconFilename
minimalDistance = sizeDistance
}
}
}
}
}
if closestFilename != "" {
icon, err := parseIcon(closestFilename)
if err == nil { return icon, err }
}
return Icon { }, ErrIconNotFound
}
// Directory represents a directory of an icon theme which directly contains
// icons.
type Directory struct {
Name string
Paths []string
Size int
Scale int
Context string
Type IconSizeType
MaxSize int
MinSize int
Threshold int
}
// Fits returns whether icons in the directory fit the given size and scale.
func (directory Directory) Fits (size, scale int) bool {
// this method is equivalent to DirectoryMatchesSize
if directory.Scale != scale { return false }
switch directory.Type {
case IconSizeTypeFixed:
return directory.Size == size
case IconSizeTypeScalable:
return directory.MinSize <= size && size <= directory.MaxSize
case IconSizeTypeThreshold:
return directory.Size - directory.Threshold <=
size && size <=
directory.Size + directory.Threshold
}
return false
}
// SizeDistance returns how close the size of this directory's icons is to the
// given size. A smaller number is closer.
func (directory Directory) SizeDistance (size, scale int) int {
// this method is equivalent to DirectorySizeDistance
// TODO: the pseudocode for DirectorySizeDistance in the spec seems
// incredibly wrong, and the code here has been altered to correct for
// that. figure out why the spec is the way it is.
scaledSize := size * scale
scaledDir := directory.Size * directory.Scale
scaledMin := directory.MinSize * directory.Scale
scaledMax := directory.MaxSize * directory.Scale
threshMin := (directory.Size - directory.Threshold) * directory.Scale
threshMax := (directory.Size + directory.Threshold) * directory.Scale
switch directory.Type {
case IconSizeTypeFixed:
difference := scaledDir - scaledSize
if difference > 0 {
return difference
} else {
return -difference
}
case IconSizeTypeScalable:
if scaledSize < scaledMin { return scaledMin - scaledSize }
if scaledSize > scaledMax { return scaledSize - scaledMax }
case IconSizeTypeThreshold:
if scaledSize < threshMin { return threshMin - scaledSize }
if scaledSize > threshMax { return scaledSize - threshMin }
}
return 0
}
// IconFileType specifies the file type of an icon.
type IconFileType uint; const (
PNG IconFileType = iota
SVG
XPM
)
// String returns a string representation of the file type.
func (ty IconFileType) String () string {
switch ty {
case PNG: return ".png"
case SVG: return ".svg"
case XPM: return ".xpm"
default: return fmt.Sprintf("IconFileType(%d)", ty)
}
}
// Extend applies the file extension to a file name.
func (ty IconFileType) Extend (name string) string {
return name + ty.String()
}
// IconSizeType specifies how an icon can be scaled. Fixed icons cannot be
// scaled, scalable icons can be scaled to any size within a min and max range,
// and threshold icons can be scaled to any size within a certain distance of
// their normal size.
type IconSizeType int; const (
IconSizeTypeFixed IconSizeType = iota
IconSizeTypeScalable
IconSizeTypeThreshold
)
// String returns a string representation of the type.
func (ty IconSizeType) String () string {
switch ty {
case IconSizeTypeFixed: return "Fixed"
case IconSizeTypeScalable: return "Scalable"
case IconSizeTypeThreshold: return "Threshold"
default: return fmt.Sprintf("IconSizeType(%d)", ty)
}
}
// IconSizeTypeFromString converts a astring into an IconSizeType, returning an
// error if it could not be converted.
func IconSizeTypeFromString (value string) (IconSizeType, error) {
switch value {
case "Fixed": return IconSizeTypeFixed, nil
case "Scalable": return IconSizeTypeScalable, nil
case "Threshold": return IconSizeTypeThreshold, nil
default: return 0, ErrBadIconSizeType
}
}
// Icon represents an icon, and its associated data (if applicable).
type Icon struct {
Path string
FileType IconFileType
DisplayName keyValue.Entry
EmbeddedTextRectangle image.Rectangle
AttachPoints []image.Point
}
func parseIcon (path string) (Icon, error) {
_, err := os.Stat(path)
if err != nil { return Icon { }, err }
ext := filepath.Ext(path)
dataPath := strings.TrimSuffix(path, ext) + ".icon"
if data, err := parseIconData(dataPath); err != nil {
return data, nil
} else {
return Icon {
Path: path,
}, nil
}
}
func parseIconData (path string) (Icon, error) {
reader, err := os.Open(path)
if err != nil { return Icon { }, err }
file, err := keyValue.Parse(reader)
if err != nil { return Icon { }, err }
iconDataGroup, ok := file["Icon Data"]
if !ok { return Icon { }, ErrGroupMissing }
icon := Icon { }
// DisplayName (optional)
if entry, ok := iconDataGroup["DisplayName"]; ok {
icon.DisplayName, err = entry.Unescape()
if err != nil { return Icon { }, err }
}
// EmbeddedTextRectangle (optional)
if entry, ok := iconDataGroup["EmbeddedTextRectangle"]; ok {
embeddedTextRectangle, err := keyValue.ParseMultipleComma(keyValue.ParseInteger, entry.Value)
if len(embeddedTextRectangle) == 4 {
icon.EmbeddedTextRectangle = image.Rect (
embeddedTextRectangle[0],
embeddedTextRectangle[1],
embeddedTextRectangle[2],
embeddedTextRectangle[3])
if err != nil { return Icon { }, err }
}
}
// TODO figure out what the actual fuck the spec is talking about with
// regard to AttatchPoints. What even the fuck is that supposed to mean.
// Ohhhh a list of points separated by |. Mf hoe what do you define as
// a point.
return icon, nil
}