15 Commits

12 changed files with 566 additions and 107 deletions

View File

@@ -2,12 +2,79 @@
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/nasin.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/nasin)
Nasin provides an easy way to write applications with Tomo. To get started, take
a look at the [examples](examples) directory and the
Nasin builds an application framework on top of Tomo to ease and encourage the
development of consistent and stable application software. It has these
wonderful features, and more:
- Use the Application interface to create applications with relatively low
boilerplate
- CLI argument parsing and URI opening
- Automatic setup/teardown of the backend
- Advanced configuration system that can watch config files for changes
- Default style and icon set, as well as a fully featured stylesheet language
for creating custom styles, and support for XDG icon themes
## Getting Started
Here is a basic "hello world" application, with explanations as comments:
```go
package main
import "image"
import "git.tebibyte.media/tomo/nasin"
import "git.tebibyte.media/tomo/objects"
import "git.tebibyte.media/tomo/objects/layouts"
func main () {
nasin.RunApplication(new(Application))
}
type Application struct { }
// Describe returns the application's name and ID, and optionally what type of
// application it is.
func (this *Application) Describe () nasin.ApplicationDescription {
return nasin.ApplicationDescription {
// This is the name of the application. New application windows
// will have this as their title by default.
Name: "Example",
// This is a "well-known" name, which typically is a domain name
// owned by the application author.
ID: "com.example.Example",
}
}
// Init performs initial setup of the application. Since this is a single-window
// application that doesn't open any files, we create the window here.
func (this *Application) Init () error {
// Passing an empty rectangle when creating a new window will cause it
// to auto-expand to fit the minimum size of its contents.
window, err := nasin.NewApplicationWindow(this, image.Rectangle { })
if err != nil { return err }
// Here we create a new container with a basic vertical layout, place a
// text label that says "Hello world!" in it, and set it as the root
// object of the window.
window.SetRoot(objects.NewOuterContainer (
layouts.ContractVertical,
objects.NewLabel("Hello world!")))
window.SetVisible(true)
// Nasin will not exit until all windows it is "waiting for" have
// been closed.
nasin.WaitFor(window)
return nil
}
// Stop cleanly closes things like system resources or background tasks. We do
// not have any here, so nothing is done.
func (this *Application) Stop () { }
```
To learn more, take a look at the [examples](examples) directory and the
[online documentation](https://pkg.go.dev/git.tebibyte.media/tomo/nasin).
Related repositories:
## Related Repositories
- [Tomo API](https://git.tebibyte.media/tomo/tomo): The API that all other parts
of the toolkit agree on
- [Objects](https://git.tebibyte.media/tomo/objects): A standard collection of
re-usable objects and other GUI components
- [Backend](https://git.tebibyte.media/tomo/backend): The software responsible
for managing and rendering things behind the scenes

View File

@@ -144,15 +144,47 @@ func RunApplication (application Application) {
}
flag.Parse()
// open config
globalConfig, err := ApplicationConfig(GlobalApplicationDescription())
if err != nil { log.Fatalln("nasin: could not open config:", err) }
currentGlobalConfig = globalConfig
defer func () {
globalConfig.Close()
currentGlobalConfig = nil
} ()
styleConfigKey := "Style"
iconSetConfigKey := "IconSet"
// registry
// TODO: rebuild registry around the config
reg := new(registrar.Registrar)
backend, err := reg.SetBackend()
if err != nil { log.Fatalln("nasin: could not register backend:", err) }
err = reg.SetTheme()
if err != nil { log.Fatalln("nasin: could not set theme:", err) }
err = reg.SetIconSet()
if err != nil { log.Fatalln("nasin: could not set icon set:", err) }
err = reg.SetFaceSet()
if err != nil { log.Fatalln("nasin: could not set face set:", err) }
updateStyle := func () {
value, err := globalConfig.GetString(styleConfigKey, "")
if err != nil { log.Fatalln("nasin: could not set theme:", err) }
err = reg.SetStyle(value)
if err != nil { log.Fatalln("nasin: could not set theme:", err) }
}
updateIconSet := func () {
value, err := globalConfig.GetString(iconSetConfigKey, "")
if err != nil { log.Fatalln("nasin: could not set icon set:", err) }
err = reg.SetIconSet(value)
if err != nil { log.Fatalln("nasin: could not set icon set:", err) }
}
updateStyle()
updateIconSet()
globalConfig.OnChange(func (key string) {
switch key {
case styleConfigKey: updateStyle()
case iconSetConfigKey: updateIconSet()
}
})
// init application
err = application.Init()
if err != nil { log.Fatalln("nasin: could not run application:", err) }
@@ -196,6 +228,14 @@ func ApplicationConfig (app ApplicationDescription) (config.ConfigCloser, error)
return config.NewConfig(user, system...)
}
var currentGlobalConfig config.Config
// GlobalConfig returns the global config. It contains options that apply to
// Tomo/Nasin itself, such as the style sheet and the icon set. This is managed
// by Nasin and must not be closed by the application.
func GlobalConfig () config.Config {
return currentGlobalConfig
}
func errorPopupf (title, format string, v ...any) func (func ()) {
return func (callback func ()) {
dialog, err := objects.NewDialogOk (

78
config/README.md Normal file
View File

@@ -0,0 +1,78 @@
# config
Package config provides a configuration system for applications.
## Config File Location
Config files are stored in standard operating system locations. Each application
has exactly one user-level config directory within the user-level config
location, and a system-level subdirectory in each of the system-level config
locations (if applicable). Each subdirectory bears the application's well-known
name as specified in ApplicationDescription. Each subdirectory contains a file
called config.conf, which is where the actual config data is stored.
The user-level configuration file takes precendence over system configuration
files, and system configuration files take precedence over eachother depending
on what order they are specified in. How they are specified depends on the
operating system.
### Linux, Most Unixes
In terms of the XDG Base Directory Specification, an application with the
well-known name com.example.Example would have its config files stored at
`$XDG_CONFIG_DIRS/com.example.Example/config.conf`. On most systems where this
specification is applicable, this will result in a file at
`/etc/xdg/com.example.Example/config.conf` and another at
`$HOME/.config/com.example.Example/config.conf`. The location for config files
on systems that do not make use of this specification is yet to be determined.
## Config File Format
The general format of the file is as follows:
- Encoded in UTF-8
- Consists of lines, separated by \n, or \r\n
- Lines can be any of these:
- Blank line: has only whitespace
- Comment: begins with a '#'
- Entry: a key/value pair separated by an '=' sign
### Entries
For entries, all whitespace on either side of the '=' sign, the key, or the
value is ignored. The key may contain any letter or digit, as well as '-' and
'.'. The value is always identified by its first rune (after the preliminary
whitespace of course) and can be one of:
- String
- Number
- Bool
#### String
A string can be either double-quoted, or any string of runes not identifiable
as any other kind of value. Quoted strings are always unquoted when they are
read. Either way, these escape sequences are supported, and resolved when they
are read:
- '\\\\': a literal backslash
- '\a': alert, bell
- '\b': backspace
- '\t': horizontal tab
- '\n': line feed
- '\v': vertical tab
- '\f': form feed
- '\r': carriage return
- '\\"': double quote
Be aware that some unquoted strings, within reason, are subject to being read
as some other value in the future. For example, if there were suddenly a
third boolean value called glorble, the unquoted string glorble would be read
as a boolean value instead of a string.
#### Number
A number is a floating point value. It can be of the form:
- Inf: positive infinity
- -Inf: negative infinity
- NaN: "not a number"
- [0-9]+: a whole number
- [0-9]+\.[0-9]*: a fractional number
#### Bool
A bool is a boolean value. It can be one of:
- true
- false

View File

@@ -54,11 +54,15 @@ type Config interface {
// not a boolean, it will return fallback.
GetBool (key string, fallback bool) (bool, error)
// Set sets a value in the user-level config file. If the key is
// invalid, it returns ErrMalformedKey.
// invalid, it returns ErrMalformedKey. Note that calling this behavior
// *will* cause a write to disk, and a read from disk for whatever is
// watching the user file.
Set (key string, value Value) error
// Reset removes the value from the user-level config file, resetting it
// to what is described by the system-level config files. If the key is
// invalid, it returns ErrMalformedKey.
// invalid, it returns ErrMalformedKey. Note that calling this behavior
// *will* cause a write to disk if successful , and a read from disk for
// whatever is watching the user file.
Reset (key string) error
// OnChange specifies a function to be called whenever a value is
// changed. The callback is always run within the backend's event loop
@@ -84,12 +88,17 @@ var negativeZero = math.Copysign(0, -1)
type Value interface {
value ()
fmt.Stringer
Equals (Value) bool
}
// ValueString is a string value.
type ValueString string
var _ Value = ValueString("")
func (ValueString) value () { }
func (value ValueString) Equals (other Value) bool {
other, ok := other.(ValueString)
return ok && value == other
}
func (value ValueString) String () string {
return fmt.Sprintf("\"%s\"", escape(string(value)))
}
@@ -98,6 +107,10 @@ func (value ValueString) String () string {
type ValueNumber float64
var _ Value = ValueNumber(0)
func (ValueNumber) value () { }
func (value ValueNumber) Equals (other Value) bool {
other, ok := other.(ValueNumber)
return ok && value == other
}
func (value ValueNumber) String () string {
number := float64(value)
// the requirements I wrote said lossless in all cases. here's lossless
@@ -133,6 +146,10 @@ func (value ValueNumber) String () string {
var _ Value = ValueBool(false)
type ValueBool bool
func (ValueBool) value () { }
func (value ValueBool) Equals (other Value) bool {
other, ok := other.(ValueBool)
return ok && value == other
}
func (value ValueBool) String () string {
if value {
return "true"

View File

@@ -13,6 +13,8 @@ type line any
type comment string
type entry struct { key string; value Value }
var _ io.WriterTo = new(File)
// File represents a config file. It preserves the order of the lines, as well
// as blank lines and comments.
type File struct {
@@ -29,47 +31,8 @@ func NewFile () *File {
// Parse parses a config file from a reader. This function operates on a
// best-effort basis: A file will always be returned, and any errors encountered
// will be joined together.
//
// The general format of the file is as follows:
// - Encoded in UTF-8
// - Consists of lines, separated by \n, or \r\n
// - Lines can be any of these:
// - Blank line: has only whitespace
// - Comment: begins with a '#'
// - Entry: a key/value pair separated by an '=' sign
//
// For entries, all whitespace on either side of the '=' sign, the key, or the
// value is ignored. The key may contain any letter or digit, as well as '-'
// and '.'. The value is always identified by its first rune (after the
// preliminary whitespace of course) and can be one of:
// - String: either a double-quoted string, or any string of runes not
// identifiable as any other kind of value. The quoted string is always
// unquoted when it is read. Either way, these escape sequences are
// supported, and resolved when they are read:
// - '\\': a literal backslash
// - '\a': alert, bell
// - '\b': backspace
// - '\t': horizontal tab
// - '\n': line feed
// - '\v': vertical tab
// - '\f': form feed
// - '\r': carriage return
// - '\"': double quote
// - Number: a floating point value. It can be of the form:
// - Inf
// - -Inf
// - NaN
// - [0-9]+
// - [0-9]+\.[0-9]*
// - Bool: a boolean value. It can be one of:
// - true
// - false
//
// Be aware that some unquoted strings, within reason, are subject to being read
// as some other value in the future. For example, if there were suddenly a
// third boolean value called glorble, the unquoted string glorble would be read
// as a boolean value instead of a string.
// will be joined together. For a description of the format, see the README.md
// of this package.
func Parse (reader io.Reader) (*File, error) {
file := &File {
keys: make(map[string] int),
@@ -143,12 +106,24 @@ func ParseValue (str string) (Value, error) {
}
}
// Has returns whether the key exists. If the key is invalid, it returns false,
// ErrMalformedKey.
func (this *File) Has (key string) (bool, error) {
if !KeyValid(key) { return false, ErrMalformedKey }
if index, ok := this.keys[key]; ok {
if _, ok := this.lines[index].(entry); ok {
return true, nil
}
}
return false, nil
}
// Get gets the keyed value. If the value is unspecified, it returns nil,
// ErrNonexistentEntry. If the key is invalid, it returns nil, ErrMalformedKey.
func (this *File) Get (key string) (Value, error) {
if !KeyValid(key) { return nil, ErrMalformedKey }
if index, ok := this.keys[key]; ok {
if lin := this.lines[index].(entry); ok {
if lin, ok := this.lines[index].(entry); ok {
return lin.value, nil
}
}
@@ -158,14 +133,16 @@ func (this *File) Get (key string) (Value, error) {
// Set sets a value. If the key is invalid, it returns ErrMalformedKey.
func (this *File) Set (key string, value Value) error {
if !KeyValid(key) { return ErrMalformedKey }
ent := entry {
key: key,
value: value,
}
if index, ok := this.keys[key]; ok {
ent := this.lines[index].(entry)
ent.value = value
this.lines[index] = ent
return nil
}
this.keys[key] = len(this.lines)
this.lines = append(this.lines, value)
this.lines = append(this.lines, ent)
return nil
}
@@ -186,7 +163,7 @@ func (this *File) Reset (key string) error {
return nil
}
// Map returns a map of keys to values.
// Map creates and returns a map of keys to values.
func (this *File) Map () map[string] Value {
mp := make(map[string] Value)
for key, index := range this.keys {
@@ -197,9 +174,48 @@ func (this *File) Map () map[string] Value {
return mp
}
// Diff returns a set of keys that are different from the other file.
func (this *File) Diff (other *File) map[string] struct { } {
diff := make(map[string] struct { })
// - keys only we have
// - keys we both have, but are different
for key, index := range this.keys {
thisEntry, ok := this.lines[index].(entry)
if !ok { continue }
otherIndex, ok := other.keys[key]
if !ok {
diff[key] = struct { } { }
continue
}
otherEntry, ok := other.lines[otherIndex].(entry)
if !ok {
diff[key] = struct { } { }
continue
}
if !thisEntry.value.Equals(otherEntry.value) {
diff[key] = struct { } { }
}
}
// - keys only they have
for key := range other.keys {
if otherHas, _ := other.Has(key); !otherHas {
continue
}
if thisHas, _ := this.Has(key); !thisHas {
diff[key] = struct { } { }
}
}
return diff
}
// WriteTo writes the data in this file to an io.Writer.
func (file *File) WriteTo (writer io.Writer) (n int64, err error) {
for _, lin := range file.lines {
func (this *File) WriteTo (writer io.Writer) (n int64, err error) {
for _, lin := range this.lines {
nint := 0
switch lin := lin.(type) {
case comment:

View File

@@ -1,6 +1,7 @@
package config
import "math"
import "maps"
import "strings"
import "testing"
@@ -103,6 +104,81 @@ key1=7
`)
}
func TestDiffNone (test *testing.T) {
str := `
thing = something
otherThing = otherValue
# comment
otherThing = true
otherThing = 234
yetAnotherThing = 0.23498
`
file1 := parseFileString(test, str)
file2 := parseFileString(test, str)
diff := file1.Diff(file2)
if len(diff) != 0 {
test.Fatalf("diff not empty:\n%v", diff)
}
}
func TestDiffReset (test *testing.T) {
file1 := parseFileString(test,
`key4=0
key1=value1
keyToDelete=true
# comment
key2=34`)
file2 := parseFileString(test,
`key1=value2
key2=34
anotherKeyToDelete=false
# comment
key3=0.2`)
file1.Reset("keyToDelete")
file2.Reset("anotherKeyToDelete")
diff := file1.Diff(file2)
correct := map[string] struct { } {
"key1": struct { } { },
"key3": struct { } { },
"key4": struct { } { },
}
if !maps.Equal(diff, correct) {
test.Error("diffs do not match")
test.Errorf("EXPECTED:\n%v", correct)
test.Errorf("GOT:\n%v", diff)
test.Fail()
}
}
func TestDiff (test *testing.T) {
file1 := parseFileString(test,
`key4=0
key1=value1
# comment
key2=34`)
file2 := parseFileString(test,
`key1=value2
key2=34
# comment
key3=0.2`)
diff := file1.Diff(file2)
correct := map[string] struct { } {
"key1": struct { } { },
"key3": struct { } { },
"key4": struct { } { },
}
if !maps.Equal(diff, correct) {
test.Error("diffs do not match")
test.Errorf("EXPECTED:\n%v", correct)
test.Errorf("GOT:\n%v", diff)
test.Fail()
}
}
func testParseEntry (test *testing.T, str string, key string, value Value) {
ent, err := parseEntry(str)
if err != nil { test.Fatal(err) }

View File

@@ -6,6 +6,7 @@ import "sync"
import "slices"
import "path/filepath"
import "github.com/fsnotify/fsnotify"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event"
// Goroutine model:
@@ -15,9 +16,10 @@ import "git.tebibyte.media/tomo/tomo/event"
// directly followed by a deferred call to Unlock.
type config struct {
open bool
watcher *fsnotify.Watcher
lock sync.RWMutex
open bool
watcher *fsnotify.Watcher
lock sync.RWMutex
ignoreNextUserUpdate bool
paths struct {
user string
@@ -67,15 +69,22 @@ func (this *config) lockAndProcessEvent (event fsnotify.Event) {
if _, ok := this.paths.watching[event.Name]; !ok { return }
if event.Name == this.paths.user {
this.reloadUser()
if !this.ignoreNextUserUpdate {
previousUser := this.data.user
this.reloadUser()
newUser := this.data.user
this.processUserDiff(newUser.Diff(previousUser))
}
this.ignoreNextUserUpdate = false
} else {
index := slices.Index(this.paths.system, event.Name)
if index > 0 {
previousSystem := this.data.system[index]
this.reloadSystem(index)
newSystem := this.data.system[index]
this.processSystemDiff(index, diffValueMaps(newSystem, previousSystem))
}
}
// TODO diff and call event handler if changed
}
func (this *config) init () error {
@@ -143,15 +152,44 @@ func (this *config) saveUser () error {
defer file.Close()
_, err = this.data.user.WriteTo(file)
if err != nil { return err }
this.ignoreNextUserUpdate = true
return nil
}
func (this *config) processUserDiff (changed []string) {
// TODO
func (this *config) processUserDiff (changed map[string] struct { }) {
for key := range changed {
// this is the user file, and nothing has precedence over it, so
// the change always matters
this.broadcastChange(key)
}
}
func (this *config) processSystemDiff (index int, changed []string) {
// TODO
func (this *config) processSystemDiff (index int, changed map[string] struct { }) {
for key := range changed {
// if specified in the user file, the change doesn't matter
if this.data.user != nil {
if has, _ := this.data.user.Has(key); has {
continue
}
}
// if specified in any system files with precedence greater than
// this one, the change doesn't matter
for _, system := range this.data.system[:index] {
if _, has := system[key]; has {
continue
}
}
// the change does matter
this.broadcastChange(key)
}
}
func (this *config) broadcastChange (key string) {
for _, listener := range this.on.change.Listeners() {
tomo.Do(func () { listener(key) })
}
}
func (this *config) get (key string, fallback Value) (Value, error) {
@@ -229,7 +267,10 @@ func (this *config) Set (key string, value Value) error {
if this.data.user == nil { this.data.user = NewFile() }
err := this.data.user.Set(key, value)
if err != nil { return err }
return this.saveUser()
err = this.saveUser()
if err != nil { return err }
this.broadcastChange(key)
return nil
}
func (this *config) Reset (key string) error {
@@ -238,7 +279,10 @@ func (this *config) Reset (key string) error {
if this.data.user == nil { this.data.user = NewFile() }
err := this.data.user.Reset(key)
if err != nil { return err }
return this.saveUser()
err = this.saveUser()
if err != nil { return err }
this.broadcastChange(key)
return nil
}
func (this *config) OnChange (callback func (string)) event.Cookie {
@@ -246,3 +290,29 @@ func (this *config) OnChange (callback func (string)) event.Cookie {
defer this.lock.Unlock()
return this.on.change.Connect(callback)
}
func diffValueMaps (first, second map[string] Value) map[string] struct { } {
diff := make(map[string] struct { })
// - keys only first has
// - keys both have, but are different
for key, firstValue := range first {
secondValue, ok := second[key]
if !ok {
diff[key] = struct { } { }
continue
}
if !firstValue.Equals(secondValue) {
diff[key] = struct { } { }
}
}
// - keys only second has
for key := range second {
if _, has := first[key]; !has {
diff[key] = struct { } { }
}
}
return diff
}

53
config/impl_test.go Normal file
View File

@@ -0,0 +1,53 @@
package config
import "maps"
import "testing"
func TestDiffValueMapsNone (test *testing.T) {
str := `
thing = something
otherThing = otherValue
# comment
otherThing = true
otherThing = 234
yetAnotherThing = 0.23498
`
file1 := parseFileString(test, str)
file2 := parseFileString(test, str)
diff := diffValueMaps(file1.Map(), file2.Map())
if len(diff) != 0 {
test.Fatalf("diff not empty:\n%v", diff)
}
}
func TestDiffValueMaps (test *testing.T) {
file1 := parseFileString(test,
`key4=0
key1=value1
# comment
key2=34`)
file2 := parseFileString(test,
`key1=value2
key2=34
# comment
key3=0.2`)
diff := diffValueMaps(file1.Map(), file2.Map())
correct := map[string] struct { } {
"key1": struct { } { },
"key3": struct { } { },
"key4": struct { } { },
}
if !maps.Equal(diff, correct) {
test.Error("diffs do not match")
test.Errorf("EXPECTED:\n%v", correct)
test.Errorf("GOT:\n%v", diff)
test.Fail()
}
}
// TODO we need way more tests!
// need to test watching files. maybe make a temp dir and do it there. remember
// to defer cleaning up the dir and closing of the config.

View File

@@ -6,9 +6,10 @@ import _ "embed"
import _ "image/png"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/data"
import "git.tebibyte.media/tomo/nasin/internal/util"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/tomo/canvas"
import "git.tebibyte.media/tomo/backend/style"
import "git.tebibyte.media/tomo/nasin/internal/util"
//go:embed assets/icons-small.png
var atlasSmallBytes []byte
@@ -35,7 +36,7 @@ const (
iconXOfficeSpreadsheet = tomo.Icon("x-office-spreadsheet")
)
func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
func generateSource (data []byte, width int) (canvas.TextureCloser, map[tomo.Icon] canvas.Texture) {
atlasImage, _, err := image.Decode(bytes.NewReader(data))
if err != nil { panic(err) }
atlasTexture := tomo.NewTexture(atlasImage)
@@ -448,23 +449,26 @@ func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
col(tomo.IconWeatherSnow)
col(tomo.IconWeatherStorm)
return source
return atlasTexture, source
}
type iconSet struct {
atlasSmall canvas.TextureCloser
atlasLarge canvas.TextureCloser
texturesSmall map[tomo.Icon] canvas.Texture
texturesLarge map[tomo.Icon] canvas.Texture
}
// New creates a new fallback icon set.
func New () style.IconSet {
return new(iconSet)
func New () (style.IconSet, event.Cookie) {
iconSet := new(iconSet)
return iconSet, iconSet
}
func (this *iconSet) ensure () {
if this.texturesSmall != nil { return }
this.texturesSmall = generateSource(atlasSmallBytes, 16)
this.texturesLarge = generateSource(atlasLargeBytes, 32)
this.atlasSmall, this.texturesSmall = generateSource(atlasSmallBytes, 16)
this.atlasLarge, this.texturesLarge = generateSource(atlasLargeBytes, 32)
}
func (this *iconSet) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.Texture {
@@ -500,3 +504,12 @@ func (this *iconSet) MimeIcon (mime data.Mime, size tomo.IconSize) canvas.Textur
return source[tomo.Icon(iconApplicationXGeneric)]
}
}
func (this *iconSet) Close () {
if this.atlasSmall != nil {
this.atlasSmall.Close()
}
if this.atlasLarge != nil {
this.atlasLarge.Close()
}
}

View File

@@ -9,6 +9,7 @@ import "strings"
import _ "image/png"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/data"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/tomo/canvas"
import "git.tebibyte.media/tomo/backend/style"
import "git.tebibyte.media/tomo/nasin/internal/util"
@@ -17,27 +18,27 @@ import xdgIconTheme "git.tebibyte.media/tomo/xdg/icon-theme"
type iconTheme struct {
xdg xdgIconTheme.Theme
fallback style.IconSet
texturesSmall map[tomo.Icon] canvas.Texture
texturesMedium map[tomo.Icon] canvas.Texture
texturesLarge map[tomo.Icon] canvas.Texture
texturesSmall map[tomo.Icon] canvas.TextureCloser
texturesMedium map[tomo.Icon] canvas.TextureCloser
texturesLarge map[tomo.Icon] canvas.TextureCloser
}
func FindThemeWarn (name string, fallback style.IconSet, path ...string) (style.IconSet, error) {
func FindThemeWarn (name string, fallback style.IconSet, path ...string) (style.IconSet, event.Cookie, error) {
this := &iconTheme {
fallback: fallback,
texturesLarge: make(map[tomo.Icon] canvas.Texture),
texturesMedium: make(map[tomo.Icon] canvas.Texture),
texturesSmall: make(map[tomo.Icon] canvas.Texture),
texturesLarge: make(map[tomo.Icon] canvas.TextureCloser),
texturesMedium: make(map[tomo.Icon] canvas.TextureCloser),
texturesSmall: make(map[tomo.Icon] canvas.TextureCloser),
}
xdg, err := xdgIconTheme.FindThemeWarn(name, path...)
if err != nil { return nil, err }
if err != nil { return nil, nil, err }
this.xdg = xdg
return this, nil
return this, this, nil
}
func (this *iconTheme) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.Texture {
func (this *iconTheme) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.TextureCloser {
switch size {
case tomo.IconSizeMedium: return this.texturesMedium
case tomo.IconSizeLarge: return this.texturesLarge
@@ -45,7 +46,7 @@ func (this *iconTheme) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.T
}
}
func (this *iconTheme) xdgIcon (name string, size tomo.IconSize) (canvas.Texture, bool) {
func (this *iconTheme) xdgIcon (name string, size tomo.IconSize) (canvas.TextureCloser, bool) {
// TODO use scaling factor instead of 1
// find icon file
icon, err := this.xdg.FindIcon(name, iconSizePixels(size), 1, xdgIconTheme.PNG)
@@ -100,7 +101,20 @@ func (this *iconTheme) MimeIcon (mime data.Mime, size tomo.IconSize) canvas.Text
}
}
func (this *iconTheme) icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture {
func (this *iconTheme) Close () {
closeAllIn := func (mp map[tomo.Icon] canvas.TextureCloser) {
for _, texture := range mp {
if texture != nil {
texture.Close()
}
}
}
closeAllIn(this.texturesSmall)
closeAllIn(this.texturesMedium)
closeAllIn(this.texturesLarge)
}
func (this *iconTheme) icon (icon tomo.Icon, size tomo.IconSize) canvas.TextureCloser {
if texture, ok := this.xdgIcon(XdgIconName(icon), size); ok {
return texture
}
@@ -110,7 +124,7 @@ func (this *iconTheme) icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture
return nil
}
func (this *iconTheme) mimeIcon (mime data.Mime, size tomo.IconSize) canvas.Texture {
func (this *iconTheme) mimeIcon (mime data.Mime, size tomo.IconSize) canvas.TextureCloser {
if texture, ok := this.xdgIcon(xdgFormatMime(mime), size); ok {
return texture
}

View File

@@ -1,10 +1,10 @@
//go:build unix && (!darwin)
package registrar
import "os"
import "log"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/backend/x"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/sashakoshka/goparse"
import "git.tebibyte.media/tomo/nasin/internal/icons/xdg"
import "git.tebibyte.media/tomo/nasin/internal/styles/tss"
@@ -14,6 +14,8 @@ import "git.tebibyte.media/tomo/nasin/internal/faces/fallback"
type Registrar struct {
backend *x.Backend
iconSetCookie event.Cookie
styleCookie event.Cookie
}
func (this *Registrar) SetBackend () (tomo.Backend, error) {
@@ -24,38 +26,50 @@ func (this *Registrar) SetBackend () (tomo.Backend, error) {
return backend, nil
}
func (this *Registrar) SetTheme () error {
styleSheetName := os.Getenv("TOMO_STYLE_SHEET")
if styleSheetName != "" {
styl, _, err := tss.LoadFile(styleSheetName)
func (this *Registrar) SetStyle (name string) error {
if this.styleCookie != nil {
this.styleCookie.Close()
this.styleCookie = nil
}
if name != "" {
styl, cookie, err := tss.LoadFile(name)
if err == nil {
this.backend.SetStyle(styl)
this.styleCookie = cookie
return nil
} else {
log.Printf (
"nasin: could not load style sheet '%s'\n%v",
styleSheetName, parse.Format(err))
name, parse.Format(err))
}
}
styl, _ := fallbackStyle.New()
styl, cookie := fallbackStyle.New()
this.styleCookie = cookie
this.backend.SetStyle(styl)
return nil
}
func (this *Registrar) SetIconSet () error {
iconSet := fallbackIcons.New()
iconSetName := os.Getenv("TOMO_XDG_ICON_THEME")
if iconSetName != "" {
xdgIconSet, err := xdgIcons.FindThemeWarn(iconSetName, iconSet)
func (this *Registrar) SetIconSet (name string) error {
if this.iconSetCookie != nil {
this.iconSetCookie.Close()
this.iconSetCookie = nil
}
iconSet, cookie := fallbackIcons.New()
if name != "" {
xdgIconSet, xdgCookie, err := xdgIcons.FindThemeWarn(name, iconSet)
cookie = event.MultiCookie(cookie, xdgCookie)
if err == nil {
iconSet = xdgIconSet
} else {
log.Printf("nasin: could not load icon theme '%s': %v", iconSetName, err)
log.Printf("nasin: could not load icon theme '%s': %v", name, err)
}
}
this.backend.SetIconSet(iconSet)
this.iconSetCookie = cookie
return nil
}

11
path.go
View File

@@ -11,8 +11,9 @@ func ApplicationUserDataDir (app ApplicationDescription) (string, error) {
}
// ApplicationSystemDataDirs returns a list of directory paths where an
// application can look for its system-level data files. These directories may
// or may not exist. This function may return an empty slice on some platforms.
// application can look for its system-level data files. Directories returned
// by this function may or may not actually exist. This function may return an
// empty slice on some platforms.
func ApplicationSystemDataDirs (app ApplicationDescription) ([]string, error) {
return systemDirs(app.ID, systemDataDirs)
}
@@ -24,9 +25,9 @@ func ApplicationUserConfigDir (app ApplicationDescription) (string, error) {
}
// ApplicationSystemDataDirs returns a list of directory paths where an
// application can look for its system-level configuration files. These
// directories may or may not exist. This function may return an empty slice on
// some platforms.
// application can look for its system-level configuration files. Directories
// returned by this function may or may not actually exist. This function may
// return an empty slice on some platforms.
func ApplicationSystemConfigDirs (app ApplicationDescription) ([]string, error) {
return systemDirs(app.ID, systemConfigDirs)
}