Compare commits

...

9 Commits

5 changed files with 322 additions and 40 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 8
charset = utf-8
[*.md]
indent_style = space
indent_size = 2

View File

@@ -3,6 +3,4 @@
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/xdg.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/xdg) [![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/xdg.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/xdg)
This module seeks to implement parts of the XDG specification that do not rely This module seeks to implement parts of the XDG specification that do not rely
on other software. Parts of the specification that do, eg. mpris-spec and on other software.
notifcation-spec, may in the future be implemented in a separate module that
depends on this one.

View File

@@ -6,6 +6,7 @@ import "io"
import "fmt" import "fmt"
import "log" import "log"
import "image" import "image"
import "strings"
import "path/filepath" import "path/filepath"
import "git.tebibyte.media/tomo/xdg/basedir" import "git.tebibyte.media/tomo/xdg/basedir"
import "git.tebibyte.media/tomo/xdg/key-value" import "git.tebibyte.media/tomo/xdg/key-value"
@@ -34,6 +35,8 @@ const (
// ErrCircularDependency indicates that an icon theme depends on one of // ErrCircularDependency indicates that an icon theme depends on one of
// its dependents. // its dependents.
ErrCircularDependency = iconThemeError("circular dependency") 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 // ThemeDirs returns the set of directories in which themes should be looked for
@@ -94,7 +97,8 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
instances := []string { } instances := []string { }
for _, dir := range path { for _, dir := range path {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { return Theme { }, err } if err != nil { continue }
for _, entry := range entries { for _, entry := range entries {
if entry.Name() == name { if entry.Name() == name {
instances = append ( instances = append (
@@ -129,7 +133,9 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
for index, directory := range theme.Directories { for index, directory := range theme.Directories {
directory.Paths = make([]string, len(instances)) directory.Paths = make([]string, len(instances))
for pathIndex, path := range directory.Paths { 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 theme.Directories[index] = directory
} }
@@ -143,7 +149,7 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
if err != nil { if err != nil {
if warn { if warn {
log.Printf ( log.Printf (
"xdg: could not parse inherited theme %s: %v", "xdg: could not parse inherited theme '%s': %v",
parent, err) parent, err)
} }
continue continue
@@ -182,9 +188,9 @@ func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err erro
// Inherits (optional) // Inherits (optional)
if entry, ok := iconThemeGroup["Inherits"]; ok { if entry, ok := iconThemeGroup["Inherits"]; ok {
parents, err = keyValue.ParseMultipleComma ( parents, err = keyValue.ParseMultiple (
keyValue.ParseString, keyValue.ParseString,
entry.Value) entry.Value, ',')
if !ok { return Theme { }, nil, err } if !ok { return Theme { }, nil, err }
} }
@@ -216,7 +222,7 @@ func parseThemeIndex (reader io.Reader) (theme Theme, parents []string, err erro
} }
func parseDirectories (listEntry keyValue.Entry, file keyValue.File) ([]Directory, error) { 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 } if err != nil { return nil, err }
directories := make([]Directory, len(names)) directories := make([]Directory, len(names))
for index, name := range names { for index, name := range names {
@@ -297,6 +303,68 @@ type Theme struct {
Example string 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 // Directory represents a directory of an icon theme which directly contains
// icons. // icons.
type Directory struct { type Directory struct {
@@ -311,6 +379,79 @@ type Directory struct {
Threshold 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 // 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, // 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 // and threshold icons can be scaled to any size within a certain distance of
@@ -342,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 { type Icon struct {
DisplayName string Path string
FileType IconFileType
DisplayName keyValue.Entry
EmbeddedTextRectangle image.Rectangle EmbeddedTextRectangle image.Rectangle
AttachPoints []image.Point 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,9 +64,21 @@ func Parse (reader io.Reader) (File, error) {
var file = File { } var file = File { }
var group Group var group Group
var specifiedValues map[string] struct { } 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 { for {
line, err := buffer.ReadString('\n') line, err := buffer.ReadString('\n')
if errors.Is(err, io.EOF) { return file, nil } if errors.Is(err, io.EOF) { break }
if err != nil { return nil, err } if err != nil { return nil, err }
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -78,13 +90,8 @@ func Parse (reader io.Reader) (File, error) {
// group header // group header
case strings.HasPrefix(line, "["): case strings.HasPrefix(line, "["):
// check integrity of prev. group // check integrity of prev. group
if group != nil { err := checkGroupIntegrity()
for key := range group { if err != nil { return File { }, err }
if _, ok := specifiedValues[key]; !ok {
return nil, ErrNoDefaultValue
}
}
}
// create new group // create new group
name, err := parseGroupHeader(line) name, err := parseGroupHeader(line)
@@ -109,15 +116,23 @@ func Parse (reader io.Reader) (File, error) {
group[key] = entry group[key] = entry
} else { } else {
// default value // default value
_, exists := group[key] _, specified := specifiedValues[key]
if exists { return nil, ErrDuplicateEntry } if specified { return nil, ErrDuplicateEntry }
entry := newEntry()
entry, exists := group[key]
if !exists { entry = newEntry() }
entry.Value = value entry.Value = value
group[key] = entry group[key] = entry
specifiedValues[key] = struct { } { } specifiedValues[key] = struct { } { }
} }
} }
} }
err := checkGroupIntegrity()
if err != nil { return File { }, err }
return file, nil
} }
func newEntry () Entry { func newEntry () Entry {
@@ -268,8 +283,6 @@ func (entry Entry) Unescape () (Entry, error) {
return localizedValue, nil return localizedValue, nil
} }
// TODO have functions to parse/validate all data types
// ParseString parses a value of type string. // ParseString parses a value of type string.
// Values of type string may contain all ASCII characters except for control // Values of type string may contain all ASCII characters except for control
// characters. // characters.
@@ -332,22 +345,11 @@ func ParseNumeric (value string) (float64, error) {
} }
// ParseMultiple parses multiple of a value type. Any value parsing function can // 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 // be specified. The multiple values should be separated by a separator and the
// input string may be optionally terminated by a semicolon. Trailing empty // input string may be optionally terminated by a separator. Trailing empty
// strings must always be terminated with a semicolon. Semicolons in these // strings must always be terminated with a separator. Separators in these
// values need to be escaped using \;. // values need to be escaped using \<sep>.
func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T, error) { func ParseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]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) {
values := []T { } values := []T { }
builder := strings.Builder { } 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 { func testEntry (value string) Entry {
entry := newEntry() entry := newEntry()
entry.Value = value entry.Value = value
@@ -168,3 +177,66 @@ File {
"Icon": testEntry("fooview-new"), "Icon": testEntry("fooview-new"),
}, },
})} })}
func TestParseLocalized (test *testing.T) {
testParseString(test, `
[Group 0]
Name=something
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name[zz_ZZ]=yet another thing
[Group 1]
Name[xx_XX]=other thing
Name[yy_YY]=another thing
Name=something
Name[zz_ZZ]=yet another thing
`,
File {
"Group 0": Group {
"Name": Entry {
Value: "something",
Localized: map[locale.Locale] string {
locale.Locale {
Lang: "xx",
Country: "XX",
}: "other thing",
locale.Locale {
Lang: "yy",
Country: "YY",
}: "another thing",
locale.Locale {
Lang: "zz",
Country: "ZZ",
}: "yet another thing",
},
},
},
"Group 1": Group {
"Name": Entry {
Value: "something",
Localized: map[locale.Locale] string {
locale.Locale {
Lang: "xx",
Country: "XX",
}: "other thing",
locale.Locale {
Lang: "yy",
Country: "YY",
}: "another thing",
locale.Locale {
Lang: "zz",
Country: "ZZ",
}: "yet another thing",
},
},
},
})}
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)
}