Compare commits
9 Commits
5b9c1ed1ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c337da2a | |||
| e5d6091000 | |||
| c0e5cb7db3 | |||
| 39e6d0187e | |||
| c465ff6d1e | |||
| 19b442598d | |||
| e9ff323f69 | |||
| 8b4f77b223 | |||
| 3b1620b5ef |
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
|
||||
@@ -3,6 +3,4 @@
|
||||
[](https://pkg.go.dev/git.tebibyte.media/tomo/xdg)
|
||||
|
||||
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
|
||||
notifcation-spec, may in the future be implemented in a separate module that
|
||||
depends on this one.
|
||||
on other software.
|
||||
|
||||
@@ -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
|
||||
@@ -94,7 +97,8 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
|
||||
instances := []string { }
|
||||
for _, dir := range path {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil { return Theme { }, err }
|
||||
if err != nil { continue }
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == name {
|
||||
instances = append (
|
||||
@@ -129,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
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func findTheme (name string, trail []string, warn bool, path ...string) (Theme,
|
||||
if err != nil {
|
||||
if warn {
|
||||
log.Printf (
|
||||
"xdg: could not parse inherited theme %s: %v",
|
||||
"xdg: could not parse inherited theme '%s': %v",
|
||||
parent, err)
|
||||
}
|
||||
continue
|
||||
@@ -182,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 }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
@@ -297,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 {
|
||||
@@ -311,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
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,9 +64,21 @@ 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) { return file, nil }
|
||||
if errors.Is(err, io.EOF) { break }
|
||||
if err != nil { return nil, err }
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -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)
|
||||
@@ -109,15 +116,23 @@ func Parse (reader io.Reader) (File, error) {
|
||||
group[key] = entry
|
||||
} else {
|
||||
// default value
|
||||
_, exists := group[key]
|
||||
if exists { return nil, ErrDuplicateEntry }
|
||||
entry := newEntry()
|
||||
_, specified := specifiedValues[key]
|
||||
if specified { return nil, ErrDuplicateEntry }
|
||||
|
||||
entry, exists := group[key]
|
||||
if !exists { entry = newEntry() }
|
||||
|
||||
entry.Value = value
|
||||
group[key] = entry
|
||||
specifiedValues[key] = struct { } { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := checkGroupIntegrity()
|
||||
if err != nil { return File { }, err }
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func newEntry () Entry {
|
||||
@@ -268,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.
|
||||
@@ -332,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 { }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -168,3 +177,66 @@ File {
|
||||
"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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user