// 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 ( instances[pathIndex], 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.ParseMultiple ( 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.ParseMultiple(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 { data.Path = path 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.ParseMultiple(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 }