Compare commits

...

5 Commits

3 changed files with 243 additions and 31 deletions

View File

@@ -6,6 +6,7 @@ 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"
@@ -34,6 +35,8 @@ const (
// 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
@@ -130,7 +133,9 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
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)
directory.Paths[pathIndex] = filepath.Join (
instances[pathIndex],
path, directory.Name)
}
theme.Directories[index] = directory
}
@@ -183,9 +188,9 @@ func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err erro
// Inherits (optional)
if entry, ok := iconThemeGroup["Inherits"]; ok {
parents, err = keyValue.ParseMultipleComma (
parents, err = keyValue.ParseMultiple (
keyValue.ParseString,
entry.Value)
entry.Value, ',')
if !ok { return Theme { }, nil, err }
}
@@ -217,7 +222,7 @@ func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err erro
}
func parseDirectories (listEntry keyValue.Entry, file keyValue.File) ([]Directory, error) {
names, err := keyValue.ParseMultipleComma(keyValue.ParseString, listEntry.Value)
names, err := keyValue.ParseMultiple(keyValue.ParseString, listEntry.Value, ',')
if err != nil { return nil, err }
directories := make([]Directory, len(names))
for index, name := range names {
@@ -298,6 +303,68 @@ type Theme struct {
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 {
@@ -312,6 +379,79 @@ type Directory struct {
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
@@ -343,9 +483,66 @@ func IconSizeTypeFromString (value string) (IconSizeType, error) {
}
}
// Icon represents data in <icon-name>.icon files
// Icon represents an icon, and its associated data (if applicable).
type Icon struct {
DisplayName string
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
}

View File

@@ -64,6 +64,18 @@ func Parse (reader io.Reader) (File, error) {
var file = File { }
var group Group
var specifiedValues map[string] struct { }
checkGroupIntegrity := func () error {
if group != nil {
for key := range group {
if _, ok := specifiedValues[key]; !ok {
return ErrNoDefaultValue
}
}
}
return nil
}
for {
line, err := buffer.ReadString('\n')
if errors.Is(err, io.EOF) { break }
@@ -78,13 +90,8 @@ func Parse (reader io.Reader) (File, error) {
// group header
case strings.HasPrefix(line, "["):
// check integrity of prev. group
if group != nil {
for key := range group {
if _, ok := specifiedValues[key]; !ok {
return nil, ErrNoDefaultValue
}
}
}
err := checkGroupIntegrity()
if err != nil { return File { }, err }
// create new group
name, err := parseGroupHeader(line)
@@ -122,6 +129,9 @@ func Parse (reader io.Reader) (File, error) {
}
}
err := checkGroupIntegrity()
if err != nil { return File { }, err }
return file, nil
}
@@ -273,8 +283,6 @@ func (entry Entry) Unescape () (Entry, error) {
return localizedValue, nil
}
// TODO have functions to parse/validate all data types
// ParseString parses a value of type string.
// Values of type string may contain all ASCII characters except for control
// characters.
@@ -337,22 +345,11 @@ func ParseNumeric (value string) (float64, error) {
}
// ParseMultiple parses multiple of a value type. Any value parsing function can
// be specified. The multiple values should be separated by a semicolon and the
// input string may be optionally terminated by a semicolon. Trailing empty
// strings must always be terminated with a semicolon. Semicolons in these
// values need to be escaped using \;.
func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T, error) {
return parseMultiple(parser, value, ';')
}
// ParseMultipleComma is like ParseMultiple, but uses a comma as a separator
// instead of a semicolon. This is used to parse icon theme files because the
// freedesktop people haven't yet learned the word "consistency".
func ParseMultipleComma[T any] (parser func (string) (T, error), value string) ([]T, error) {
return parseMultiple(parser, value, ',')
}
func parseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]T, error) {
// be specified. The multiple values should be separated by a separator and the
// input string may be optionally terminated by a separator. Trailing empty
// strings must always be terminated with a separator. Separators in these
// values need to be escaped using \<sep>.
func ParseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]T, error) {
values := []T { }
builder := strings.Builder { }

View File

@@ -52,6 +52,15 @@ func testParseString (test *testing.T, input string, correct File) {
}
}
func testParseErr (test *testing.T, input string, correct error) {
_, err := Parse(strings.NewReader(input))
if err != correct {
test.Fatalf (
"errors are different\n---correct---\n%v\n---got---\n%v\n",
err, correct)
}
}
func testEntry (value string) Entry {
entry := newEntry()
entry.Value = value
@@ -222,3 +231,12 @@ File {
},
},
})}
func TestParseErrNoDefaultValue (test *testing.T) {
testParseErr(test, `
[Group 0]
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name[zz_ZZ]=yet another thing
`, ErrNoDefaultValue)
}