67 Commits

Author SHA1 Message Date
8c647d118d Improve doc comments for ApplicationSystem*Dirs 2024-08-23 02:27:49 -04:00
27678b36b9 Improve README.md 2024-08-23 02:23:04 -04:00
1e92134a38 Move config spec into a README.md file for the config package. 2024-08-23 01:50:30 -04:00
0ebf3ff4cc Make sure File is an io.WriterTo 2024-08-23 01:16:17 -04:00
81bd635b09 Improve doc comment for GlobalConfig 2024-08-23 01:14:08 -04:00
4b29820452 Add accessor for global config 2024-08-23 01:12:02 -04:00
f512deb96e Icons and styles use xyz.holanet.Nasin config 2024-08-23 01:10:54 -04:00
a69c726482 Fixed diffing thinking orphaned keys were real 2024-08-22 20:27:22 -04:00
ed77634a50 Add flag to stop re-parsing the user file when we already know its
contents
2024-08-22 20:10:12 -04:00
e489a12a28 I didnt save the file :P 2024-08-22 19:55:10 -04:00
06a593df25 Add warning about disk writes/reads to Config 2024-08-22 19:54:05 -04:00
a952490188 Config impl now diffs files and broadcasts events 2024-08-22 19:50:35 -04:00
1f5cb683fb Add more code for creating/processing diffs 2024-08-22 19:29:20 -04:00
b4328edd73 Add config.File.Diff to diff two config files 2024-08-22 19:13:00 -04:00
a8878e1e20 Add Equals method to config.Value 2024-08-22 16:31:16 -04:00
656be379e4 Fix goroutine issues with config 2024-08-22 16:03:05 -04:00
279471a554 Add partial config implementation
Progress on #3
2024-08-22 13:38:12 -04:00
d2672816cd Add system dirs to path.go 2024-08-22 13:37:38 -04:00
92deac2d56 Windows must be manually managed through the WaitFor function
Closes #5
2024-08-20 23:20:30 -04:00
35636e9ca3 Add Application.Stop
Closes #6
2024-08-19 22:27:09 -04:00
4fa29f2719 Fallback icon set now has general MIME icons support
Specifically, it supports all the XDG x-generic icons.
2024-08-19 03:18:37 -04:00
894f34e3ef Improve fallback icon set 2024-08-18 15:31:10 -04:00
f676c2855e Aluminum has complete styling for tabs 2024-08-16 19:51:55 -04:00
269d302453 Style Scrollbar separately in Aluminum 2024-08-16 18:40:26 -04:00
fbdc285f2e Style ScrollBar separately in fallback 2024-08-16 18:39:00 -04:00
696460f323 Add Aluminum styling for Swatch, ColorPickerMap, Dropdown 2024-08-16 18:23:49 -04:00
ece3a3e2a0 Remove old aluminum style
Archival dirs are pointless, we have git for a reason
2024-08-16 18:12:15 -04:00
18b928acf6 Update dependency versions 2024-08-16 18:12:05 -04:00
7f0c34760d Add new icons to fallback icon set 2024-08-16 17:56:27 -04:00
8810e88422 Remove checkbox icons from fallback icon set 2024-08-16 17:30:20 -04:00
bc09a01aa7 Update Aluminum style 2024-08-16 17:26:47 -04:00
a03eab9898 Add fallback styling for dropdowns 2024-08-16 17:26:20 -04:00
e0f90f8305 Update Tomo API 2024-08-16 17:25:54 -04:00
e46d66885d Slight style tweaks to Wintergreen 2024-08-14 11:59:03 -04:00
7c5de5a370 Fix TSS parsing multiple tags 2024-08-14 11:58:33 -04:00
7da00e990e Nasin now counts application windows to determine when to shut down 2024-08-14 11:58:06 -04:00
bc26e78024 Update backend 2024-08-12 20:57:17 -04:00
5dcfdda5f1 Add checked checkbox texture to aluminum stylesheet 2024-08-12 20:16:45 -04:00
77e335a238 TSS now supports PNG image textures 2024-08-12 20:16:29 -04:00
24357bf19f TSS sheets store their file name 2024-08-12 19:13:25 -04:00
3ed8b70895 Update dependency versions 2024-08-12 19:07:57 -04:00
7db4c95592 Made ColorDot transparent like it was before 2024-08-12 19:06:29 -04:00
c29abe0cb1 Update TSS syntax highlighting file with constants 2024-08-12 17:26:31 -04:00
4b53a5a019 Renamed style directory to styles 2024-08-12 17:12:43 -04:00
ecfb90957a Re-organized style directory 2024-08-12 17:10:43 -04:00
16584abb42 Add micro syntax highlighting file for TSS 2024-08-12 17:07:59 -04:00
3e597404ac Added SetFaceSet to registrar 2024-08-11 10:42:22 -04:00
fa91b4f415 Remove outdated TODO 2024-08-11 10:36:58 -04:00
3a4038dad9 Update registrar 2024-08-11 10:36:29 -04:00
a8bc074aad Remove font logic from TSS 2024-08-10 22:20:34 -04:00
b0672ec8ee Add a fallback face set 2024-08-10 22:12:31 -04:00
961366b00a Rename fallback icon set 2024-08-10 22:12:21 -04:00
3127aad09a Update xdg icon set 2024-08-10 21:56:03 -04:00
1feb5f4ab1 Update fallback style 2024-08-10 21:55:05 -04:00
fee4e584e7 Update fallback icons 2024-08-10 21:54:57 -04:00
98fb8a3e01 We load fonts but it's a bit broken 2024-07-30 13:01:24 -04:00
90072d8a9e Test and fix ValueColor.RGBA 2024-07-29 15:45:17 -04:00
f42dee22f5 Initial stylesheet support 2024-07-29 15:13:02 -04:00
905953b7f9 Progress on stylesheets 2024-07-29 01:50:51 -04:00
e01b3c8e00 Fix application.go and internal registrar 2024-07-28 02:36:28 -04:00
4f96ca51c2 Style tweaks 2024-07-27 21:36:00 -04:00
ac4ced365a Update dependency versions 2024-07-27 15:21:30 -04:00
df845ea592 Update Tomo API to v0.41.1 2024-07-27 15:05:14 -04:00
7d69f32e4e Minor style tweak 2024-07-27 15:04:55 -04:00
753e3e4023 Styling fixes 2024-07-26 17:54:06 -04:00
5c46950224 Fix large icons png 2024-07-26 17:53:59 -04:00
f22c268a10 Update application.go code 2024-07-26 17:53:48 -04:00
43 changed files with 3581 additions and 662 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) [![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 Nasin builds an application framework on top of Tomo to ease and encourage the
a look at the [examples](examples) directory and 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). [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 - [Tomo API](https://git.tebibyte.media/tomo/tomo): The API that all other parts
of the toolkit agree on of the toolkit agree on
- [Objects](https://git.tebibyte.media/tomo/objects): A standard collection of - [Objects](https://git.tebibyte.media/tomo/objects): A standard collection of
re-usable objects and other GUI components 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

@@ -6,9 +6,10 @@ import "flag"
import "image" import "image"
import "strings" import "strings"
import "net/url" import "net/url"
import "path/filepath"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/objects" import "git.tebibyte.media/tomo/objects"
import "git.tebibyte.media/tomo/tomo/canvas" import "git.tebibyte.media/tomo/nasin/config"
import "git.tebibyte.media/tomo/nasin/internal/registrar" import "git.tebibyte.media/tomo/nasin/internal/registrar"
// Application represents an application object. // Application represents an application object.
@@ -17,7 +18,11 @@ type Application interface {
Describe () ApplicationDescription Describe () ApplicationDescription
// Init performs the initial setup of the application. // Init performs the initial setup of the application.
Init () error Init () (error)
// Stop stops the application and does not return until all ongoing
// operations have been completely shut down.
Stop ()
} }
// ApplicationURLOpener is an application that can open a URL. // ApplicationURLOpener is an application that can open a URL.
@@ -33,9 +38,8 @@ type ApplicationURLOpener interface {
OpenURL (*url.URL) error OpenURL (*url.URL) error
// OpenNone is called when the application is launched without any URLs // OpenNone is called when the application is launched without any URLs
// to open. The application may create some sort of default starting // to open.
// window, or call tomo.Stop(). OpenNone () error
OpenNone ()
} }
// ApplicationFlagAdder is an application that supports reading command line // ApplicationFlagAdder is an application that supports reading command line
@@ -66,6 +70,15 @@ type ApplicationDescription struct {
Role ApplicationRole Role ApplicationRole
} }
// GlobalApplicationDescription returns the global application description which
// points to cache, data, config, etc. used by Nasin itself.
func GlobalApplicationDescription () ApplicationDescription {
return ApplicationDescription {
Name: "Nasin",
ID: "xyz.holanet.Nasin",
}
}
// String satisfies the fmt.Stringer interface. // String satisfies the fmt.Stringer interface.
func (application ApplicationDescription) String () string { func (application ApplicationDescription) String () string {
if application.Name == "" { if application.Name == "" {
@@ -120,8 +133,9 @@ func (role ApplicationRole) Icon () tomo.Icon {
} }
} }
// RunApplication is like tomo.Run, but runs an application. If something fails // RunApplication is like tomo.Run, but runs an application. It automatically
// to initialize, an error is written to the standard logger. // sets up a backend. If something fails to initialize, an error is written to
// the standard logger.
func RunApplication (application Application) { func RunApplication (application Application) {
// TODO: see #4 // TODO: see #4
@@ -130,19 +144,59 @@ func RunApplication (application Application) {
} }
flag.Parse() flag.Parse()
err := registrar.RegisterBackend() // open config
if err != nil { log.Fatalln("nasin: could not register backend:", err) } globalConfig, err := ApplicationConfig(GlobalApplicationDescription())
err = tomo.Run(func () { if err != nil { log.Fatalln("nasin: could not open config:", err) }
err := registrar.SetTheme() currentGlobalConfig = globalConfig
if err != nil { log.Fatalln("nasin: could not set theme:", err) } defer func () {
err = application.Init() globalConfig.Close()
if err != nil { log.Fatalln("nasin: could not run application:", err) } currentGlobalConfig = nil
} ()
styleConfigKey := "Style"
iconSetConfigKey := "IconSet"
// open URLs // registry
args := flag.Args() // TODO: rebuild registry around the config
applicationOpenUrls(application, args...) reg := new(registrar.Registrar)
backend, err := reg.SetBackend()
if err != nil { log.Fatalln("nasin: could not register backend:", 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) } if err != nil { log.Fatalln("nasin: could not run application:", err) }
// open URLs
args := flag.Args()
applicationOpenUrls(application, args...)
if manager.count > 0 {
err = backend.Run()
if err != nil { log.Fatalln("nasin: could not run application:", err) }
}
application.Stop()
} }
// NewApplicationWindow creates a window for an application. It will // NewApplicationWindow creates a window for an application. It will
@@ -160,64 +214,101 @@ func NewApplicationWindow (application Application, bounds image.Rectangle) (tom
return window, nil return window, nil
} }
func applicationOpenUrls (application Application, args ...string) { // ApplicationConfig opens a new config for the specified application. It must
if application, ok := application.(ApplicationURLOpener); ok { // be closed when it is no longer needed.
if len(args) <= 0 { func ApplicationConfig (app ApplicationDescription) (config.ConfigCloser, error) {
application.OpenNone() user, err := ApplicationUserConfigDir(app)
} if err != nil { return nil, err }
user = filepath.Join(user, "config.conf")
system, err := ApplicationSystemConfigDirs(app)
if err != nil { return nil, err }
for index, path := range system {
system[index] = filepath.Join(path, "config.conf")
}
return config.NewConfig(user, system...)
}
openedAny := false var currentGlobalConfig config.Config
for _, arg := range flag.Args() { // GlobalConfig returns the global config. It contains options that apply to
ur, err := url.Parse(arg) // Tomo/Nasin itself, such as the style sheet and the icon set. This is managed
if err != nil { // by Nasin and must not be closed by the application.
log.Fatalf ( func GlobalConfig () config.Config {
"nasin: invalid URL %v: %v", return currentGlobalConfig
arg, err) }
}
if ur.Scheme == "" { func errorPopupf (title, format string, v ...any) func (func ()) {
ur.Scheme = "file" return func (callback func ()) {
} dialog, err := objects.NewDialogOk (
err = application.OpenURL(ur) objects.DialogError, nil,
if err != nil { title,
dialog, err := objects.NewDialogOk ( fmt.Sprintf(format, v...),
objects.DialogError, nil, callback)
"Could Not Open URL", if err != nil { log.Fatal(err) }
fmt.Sprintf ( dialog.SetVisible(true)
"Could not open %v: %v", WaitFor(dialog)
arg, err), }
func () { }
if !openedAny {
application.OpenNone() func applicationOpenUrls (app Application, args ...string) {
} application, ok := app.(ApplicationURLOpener)
}) if !ok {
if err != nil { log.Fatal(err) }
dialog.SetVisible(true)
}
}
} else {
if len(args) > 0 { if len(args) > 0 {
log.Fatal("nasin: this application cannot open URLs") log.Fatal("nasin: this application cannot open URLs")
} }
return
}
openNone := func () bool {
err := application.OpenNone()
if err != nil {
log.Fatalf("nasin: could not open main window: %v", err)
return false
}
return true
}
if len(args) <= 0 {
openNone()
return
}
openedAny := false
for _, arg := range flag.Args() {
ur, err := url.Parse(arg)
if err != nil {
log.Fatalf (
"nasin: invalid URL %v: %v",
arg, err)
}
if ur.Scheme == "" {
ur.Scheme = "file"
}
err = application.OpenURL(ur)
if err != nil {
errorPopupf(
"Could Not Open URL",
"Could not open %v: %v",
arg, err,
)(func () {
if !openedAny {
openNone()
}
})
}
} }
} }
func setApplicationWindowIcon (window tomo.Window, description ApplicationDescription) { func setApplicationWindowIcon (window tomo.Window, description ApplicationDescription) {
allSizes := func (icon tomo.Icon) (sizes []canvas.Texture) { iconExists := func (icon tomo.Icon) bool {
small := icon.Texture(tomo.IconSizeSmall) return icon.Texture(tomo.IconSizeMedium) != nil
medium := icon.Texture(tomo.IconSizeMedium)
large := icon.Texture(tomo.IconSizeLarge)
if small != nil { sizes = append(sizes, small) }
if medium != nil { sizes = append(sizes, medium) }
if large != nil { sizes = append(sizes, large) }
return sizes
} }
if sizes := allSizes(tomo.Icon(description.ID)); len(sizes) > 0 { if iconExists(tomo.Icon(description.ID)) {
window.SetIcon(sizes...) window.SetIcon(tomo.Icon(description.ID))
return return
} }
if sizes := allSizes(description.Role.Icon()); len(sizes) > 0 { if iconExists(description.Role.Icon()) {
window.SetIcon(sizes...) window.SetIcon(description.Role.Icon())
return return
} }
} }

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

159
config/config.go Normal file
View File

@@ -0,0 +1,159 @@
// Package config provides a configuration system for applications.
package config
import "fmt"
import "math"
import "strconv"
import "git.tebibyte.media/tomo/tomo/event"
type configError string;
func (err configError) Error () string { return string(err) }
const (
// ErrClosed is returned when Get/Set/Reset is called after the config
// has been closed.
ErrClosed = configError("attempt to access a closed config")
// ErrNonexistentEntry is returned when an entry was not found.
ErrNonexistentEntry = configError("nonexistent entry")
// ErrMalformedEntry is returned when a config entry could not be
// parsed.
ErrMalformedEntry = configError("malformed entry")
// ErrMalformedKey is returned when a key has invalid runes.
ErrMalformedKey = configError("malformed key")
// ErrMalformedValue is returned when a value could not be parsed.
ErrMalformedValue = configError("malformed value")
// ErrMalformedString is returned when a string value could not be
// parsed.
ErrMalformedStringValue = configError("malformed string value")
// ErrMalformedNumber is returned when a number value could not be
// parsed.
ErrMalformedNumberValue = configError("malformed number value")
// ErrMalformedBool is returned when a boolean value could not be
// parsed.
ErrMalformedBoolValue = configError("malformed bool value")
// ErrMalformedEscapeSequence us returned when an escape sequence could
// not be parsed.
ErrMalformedEscapeSequence = configError("malformed escape sequence")
)
// Config provides access to an application's configuration, and can notify an
// application of changes to it.
type Config interface {
// Get gets a value, first considering the user-level config file, and
// then falling back to system level config files. If the value could
// not be found anywhere, the specified fallback value is returned. If
// the key is invalid, it returns nil, ErrMalformedKey.
Get (key string, fallback Value) (Value, error)
// GetString is like Get, but will only return strings. If the value is
// not a string, it will return fallback.
GetString (key string, fallback string) (string, error)
// GetNumber is like Get, but will only return numbers. If the value is
// not a number, it will return fallback.
GetNumber (key string, fallback float64) (float64, error)
// GetBool is like Get, but will only return booleans. If the value is
// 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. 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. 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
// using tomo.Do. This could have been a channel but I didn't want to do
// that to people.
OnChange (func (key string)) event.Cookie
}
// ConfigCloser is a config with a Close behavior, which stops watching the
// config file and causes any subsequent sets/gets to return errors. Anything
// that receives a ConfigCloser must close it when done.
type ConfigCloser interface {
Config
// Close closes the config, causing it to stop watching for changes.
// Reads or writes to the config after this will return an error.
Close () error
}
var negativeZero = math.Copysign(0, -1)
// Value is a config value. Its String behavior produces a lossless and
// syntactically valid representation of the value.
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)))
}
// ValueNumber is a number value.
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
// in all cases!
switch {
case math.IsInf(number, 0):
if math.Signbit(number) {
return "-Inf"
} else {
return "Inf"
}
case math.IsNaN(number):
return "NaN"
case number == 0, number == negativeZero:
if math.Signbit(number) {
return "-0"
} else {
return "0"
}
case math.Round(number) == number:
return strconv.FormatInt(int64(number), 10)
default:
return strconv.FormatFloat(number, 'f', -1, 64)
}
}
// ValueBool is a boolean value.
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"
} else {
return "false"
}
}

97
config/escape.go Normal file
View File

@@ -0,0 +1,97 @@
package config
import "fmt"
import "strings"
// import "unicode"
var escapeCodeToRune = map[rune] rune {
'\\': '\\',
'a': '\a',
'b': '\b',
't': '\t',
'n': '\n',
'v': '\v',
'f': '\f',
'r': '\r',
'"': '"',
}
var runeToEscapeCode = map[rune] rune { }
func init () {
for code, char := range escapeCodeToRune {
runeToEscapeCode[char] = code
}
}
func escape (str string) string {
builder := strings.Builder { }
for _, char := range str {
code, escaped := runeToEscapeCode[char]
switch {
case escaped:
fmt.Fprintf(&builder, "\\%c", code)
// case !unicode.IsPrint(char):
// fmt.Fprintf(&builder, "\\%o", char)
default:
builder.WriteRune(char)
}
}
return builder.String()
}
func unescape (str string) (string, bool) {
runes := []rune(str)
builder := strings.Builder { }
end := func () bool {
return len(runes) < 1
}
next := func () {
if !end() { runes = runes[1:] }
}
for !end() {
if runes[0] == '\\' {
if end() { return "", false }
next()
char, isEscape := escapeCodeToRune[runes[0]]
switch {
case isEscape:
builder.WriteRune(char)
next()
// case isOctalDigit(runes[0]):
// char = 0
// for !end() && isOctalDigit(runes[0]) {
// char *= 8
// char += runes[0] - '0'
// next()
// }
// builder.WriteRune(char)
default:
return "", false
}
} else {
builder.WriteRune(runes[0])
next()
}
}
return builder.String(), true
}
func unquote (str string) (string, bool) {
if len(str) < 2 { return "", false }
if firstRune(str) != '"' { return "", false }
if str[len(str) - 1] != '"' { return "", false }
return str[1:len(str) - 1], true
}
func isOctalDigit (char rune) bool {
return char >= '0' && char <= '7'
}
func firstRune (str string) rune {
for _, char := range str {
return char
}
return 0
}

22
config/escape_test.go Normal file
View File

@@ -0,0 +1,22 @@
package config
import "testing"
func TestEscape (test *testing.T) {
expected := `\\\a\bhello\t\n\vworld!\f\r\"`
got := escape("\\\a\bhello\t\n\vworld!\f\r\"")
if got != expected {
test.Fatalf("expected: [%s]\ngot:[%s]", expected, got)
}
}
func TestUnescape (test *testing.T) {
expected := "\\\a\bhello\t\n\vworld!\f\r\""
got, ok := unescape(`\\\a\bhello\t\n\vworld!\f\r\"`)
if !ok {
test.Fatalf("text could not be unescaped")
}
if got != expected {
test.Fatalf("expected: [%s]\ngot:[%s]", expected, got)
}
}

247
config/file.go Normal file
View File

@@ -0,0 +1,247 @@
package config
import "io"
import "fmt"
import "math"
import "bufio"
import "errors"
import "strconv"
import "strings"
import "unicode"
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 {
lines []line
keys map[string] int
}
// NewFile creates a blank file with nothing in it.
func NewFile () *File {
return &File {
keys: make(map[string] int),
}
}
// 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. 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),
}
errs := []error { }
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
switch {
case text == "", strings.HasPrefix(text, "#"):
file.lines = append(file.lines, comment(text))
default:
entry, err := parseEntry(text)
if err == nil {
file.keys[entry.key] = len(file.lines)
file.lines = append(file.lines, entry)
} else {
errs = append (errs, err )
}
}
}
errs = append(errs, scanner.Err())
return file, errors.Join(errs...)
}
func parseEntry (str string) (entry, error) {
key, value, ok := strings.Cut(str, "=")
if !ok { return entry { }, ErrMalformedEntry }
key = strings.TrimSpace(key)
if !KeyValid(key) { return entry { }, ErrMalformedKey }
value = strings.TrimSpace(value)
parsedValue, err := ParseValue(value)
if err != nil { return entry { }, err }
return entry { key: key, value: parsedValue }, nil
}
// ParseValue parses a value from a string. For any Value v,
// ParseValue(v.String()) should hold data exactly equal to v. This function
// does not trim whitespace.
func ParseValue (str string) (Value, error) {
first := firstRune(str)
switch {
case str == "":
return ValueString(""), nil
case first == '"':
value, ok := unescape(str)
if !ok { return nil, ErrMalformedEscapeSequence }
value, ok = unquote(value)
if !ok { return nil, ErrMalformedStringValue }
return ValueString(value), nil
case first == '-' || (first >= '0' && first <= '9'):
value, err := strconv.ParseFloat(str, 64)
if err != nil { return nil, ErrMalformedNumberValue }
return ValueNumber(value), nil
case str == "false":
return ValueBool(false), nil
case str == "true":
return ValueBool(true), nil
case str == "Inf":
return ValueNumber(math.Inf(1)), nil
case str == "NaN":
return ValueNumber(math.NaN()), nil
default:
value, ok := unescape(str)
if !ok { return nil, ErrMalformedEscapeSequence }
return ValueString(value), nil
}
}
// 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, ok := this.lines[index].(entry); ok {
return lin.value, nil
}
}
return nil, ErrNonexistentEntry
}
// 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 {
this.lines[index] = ent
return nil
}
this.keys[key] = len(this.lines)
this.lines = append(this.lines, ent)
return nil
}
// Reset removes the value from the file. If the value is set again, it will be
// added back at the same location. Note that because of this, the positions of
// lines are not forgotten until the file is written and reloaded. This is why
// the method is called Reset and not Remove. If the key is invalid, it returns
// ErrMalformedKey.
func (this *File) Reset (key string) error {
if !KeyValid(key) { return ErrMalformedKey }
for index, lin := range this.lines {
if lin, ok := lin.(entry); ok {
if lin.key == key {
this.lines[index] = nil
}
}
}
return nil
}
// 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 {
if lin, ok := this.lines[index].(entry); ok {
mp[key] = lin.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 (this *File) WriteTo (writer io.Writer) (n int64, err error) {
for _, lin := range this.lines {
nint := 0
switch lin := lin.(type) {
case comment:
nint, err = fmt.Fprintln(writer, string(lin))
case entry:
nint, err = fmt.Fprintf(writer, "%s=%v\n", lin.key, lin.value)
}
n += int64(nint)
if err != nil { return n, err }
}
return n, nil
}
// KeyValid returns whether a key contains only valid runes. They are:
// - Letters
// - Digits
// - '-'
// - '.'
func KeyValid (key string) bool {
for _, char := range key {
valid :=
char == '.' ||
char == '-' ||
unicode.IsLetter(char) ||
unicode.IsDigit(char)
if !valid { return false }
}
return true
}

222
config/file_test.go Normal file
View File

@@ -0,0 +1,222 @@
package config
import "math"
import "maps"
import "strings"
import "testing"
func TestParseEntryUnquotedString (test *testing.T) {
testParseEntry(test, "stringValue = Unquoted\\nString", "stringValue", ValueString("Unquoted\nString"))
}
func TestParseEntryQuotedString (test *testing.T) {
testParseEntry(test, "stringValue = \"Quoted\\nString\"", "stringValue", ValueString("Quoted\nString"))
}
func TestParseEntryNumber (test *testing.T) {
testParseEntry(test, "numberValue = -349.29034", "numberValue", ValueNumber(-349.29034))
}
func TestParseEntryNumberInf (test *testing.T) {
testParseEntry(test, "numberValue = Inf", "numberValue", ValueNumber(math.Inf(1)))
}
func TestParseEntryNumberNegativeInf (test *testing.T) {
testParseEntry(test, "numberValue = -Inf", "numberValue", ValueNumber(math.Inf(-1)))
}
func TestParseEntryNumberNaN (test *testing.T) {
testParseEntry(test, "numberValue = NaN", "numberValue", ValueNumber(math.NaN()))
}
func TestParseEntryBoolTrue (test *testing.T) {
testParseEntry(test, "boolValue = true", "boolValue", ValueBool(true))
}
func TestParseEntryBoolFalse (test *testing.T) {
testParseEntry(test, "boolValue = false", "boolValue", ValueBool(false))
}
func TestParseEntryComplexKey (test *testing.T) {
testParseEntry (
test, "--Something.OtherThing...another-Thing-=value",
"--Something.OtherThing...another-Thing-", ValueString("value"))
}
func TestParse (test *testing.T) {
testParse(test,
` thing =something
# comment
otherThing = otherValue
otherThing = otherValue1
otherThing = otherValue2 # not a comment
`,
`thing="something"
# comment
otherThing="otherValue"
otherThing="otherValue1"
otherThing="otherValue2 # not a comment"
`)
}
func TestGetValue (test *testing.T) {
file := parseFileString(test,
`key=askdhj
# some comment
key = value
key1 = 7`)
got, err := file.Get("key")
if err != nil { test.Fatal(err) }
testValueString(test, got, `"value"`)
}
func TestModifyValue (test *testing.T) {
file := parseFileString(test,
`key=askdhj
# some comment
key = value
key1 = 7`)
err := file.Set("key", ValueNumber(324980.2349))
if err != nil { test.Fatal(err) }
testFileString(test, file,
`key="askdhj"
# some comment
key=324980.2349
key1=7
`)
}
func TestResetValue (test *testing.T) {
file := parseFileString(test,
`key=askdhj
# some comment
key = value
key1 = 7`)
err := file.Reset("key")
if err != nil { test.Fatal(err) }
testFileString(test, file,
`# some comment
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) }
if ent.key != key {
test.Fatalf("expected key: [%s]\ngot key: [%s]", key, ent.key)
}
if ent.value.String() != value.String() {
test.Fatalf("expected value: [%s]\ngot value: [%s]", value.String(), ent.value.String())
}
}
func testParse (test *testing.T, str, correct string) {
if correct == "" { correct = str }
testFileString(test, parseFileString(test, str), correct)
}
func parseFileString (test *testing.T, str string) *File {
file, err := Parse(strings.NewReader(str))
if err != nil { test.Fatal(err) }
return file
}
func testFileString (test *testing.T, file *File, correct string) {
got := strings.Builder { }
file.WriteTo(&got)
if got.String() != correct {
test.Error("strings do not match")
test.Errorf("EXPECTED:\n%s", correct)
test.Errorf("GOT:\n%s", got.String())
test.Fail()
}
}
func testValueString (test *testing.T, got Value, correct string) {
if got.String() != correct {
test.Error("strings do not match")
test.Errorf("EXPECTED:\n%s", correct)
test.Errorf("GOT:\n%s", got.String())
test.Fail()
}
}

318
config/impl.go Normal file
View File

@@ -0,0 +1,318 @@
package config
import "os"
import "log"
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:
// All private methods (except for lockAndProcessEvent) do not lock the config,
// but all public methods do. Private methods may not call public methods.
// Locking must always be method-level, with a call to Lock at the start,
// directly followed by a deferred call to Unlock.
type config struct {
open bool
watcher *fsnotify.Watcher
lock sync.RWMutex
ignoreNextUserUpdate bool
paths struct {
user string
system []string
watching map[string] struct { }
}
data struct {
user *File
system []map[string] Value
}
on struct {
change event.Broadcaster[func (string)]
}
}
// NewConfig creates a new Config using paths to the user-level config file, and
// a set of system config files. These files need not exist: the user-level file
// will be created when Set is called for the first time if it does not exist
// already, and nonexistent system files are simply ignored (unless the Config
// finds them at any point to have been spontaneously created).
//
// The user file is written to when Set is called, and the system files are only
// read from. Values in the user file override those in the system files, and
// system files specified nearer to the start of the vararg list will override
// those farther down.
func NewConfig (user string, system ...string) (ConfigCloser, error) {
conf := new(config)
conf.paths.user = user
conf.paths.system = system
err := conf.init()
if err != nil { return nil, err }
go func () {
for event := range conf.watcher.Events {
conf.lockAndProcessEvent(event)
}
} ()
return conf, nil
}
// this method may only be run in the goroutine spawned by NewConfig.
func (this *config) lockAndProcessEvent (event fsnotify.Event) {
this.lock.Lock()
defer this.lock.Unlock()
if !(event.Has(fsnotify.Write)) { return }
if _, ok := this.paths.watching[event.Name]; !ok { return }
if event.Name == this.paths.user {
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))
}
}
}
func (this *config) init () error {
watcher, err := fsnotify.NewWatcher()
if err != nil { return err }
this.watcher = watcher
this.paths.watching = make(map[string] struct { })
this.watcher.Add(filepath.Dir(this.paths.user))
this.paths.watching[this.paths.user] = struct { } { }
this.reloadUser()
for index, path := range this.paths.system {
this.watcher.Add(filepath.Dir(path))
this.paths.watching[path] = struct { } { }
this.reloadSystem(index)
}
return nil
}
func (this *config) errIfClosed () error {
if this.open {
return nil
} else {
return ErrClosed
}
}
func (this *config) reloadUser () {
file, err := os.Open(this.paths.user)
if err != nil { return }
defer file.Close()
userFile, err := Parse(file)
if err != nil {
log.Printf("nasin: problems loading user config file %s: %v", this.paths.user, err)
return
}
this.data.user = userFile
}
func (this *config) reloadSystem (index int) {
path := this.paths.system[index]
file, err := os.Open(path)
if err != nil { return }
defer file.Close()
systemFile, err := Parse(file)
if err != nil {
log.Printf("nasin: problems loading system config file %s: %v", path, err)
return
}
this.data.system[index] = systemFile.Map()
}
func (this *config) saveUser () error {
// TODO set some sort of flag to ignore the next inotify event for the
// user file so we dont reload it immediately after. also need to
// broadacast Changed event.
enclosingDir := filepath.Dir(this.paths.user)
err := os.MkdirAll(enclosingDir, 755)
if err != nil { return err }
file, err := os.Create(this.paths.user)
if err != nil { return err }
defer file.Close()
_, err = this.data.user.WriteTo(file)
if err != nil { return err }
this.ignoreNextUserUpdate = true
return nil
}
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 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) {
// try user config
if !KeyValid(key) { return nil, ErrMalformedKey }
if this.data.user != nil {
value, err := this.data.user.Get(key)
if err == nil && err != ErrNonexistentEntry {
return value, nil
}
}
// try system configs
for _, config := range this.data.system {
if value, ok := config[key]; ok {
return value, nil
}
}
// use fallback
return fallback, nil
}
func (this *config) Get (key string, fallback Value) (Value, error) {
this.lock.Lock()
defer this.lock.Unlock()
return this.get(key, fallback)
}
func (this *config) Close () error {
this.lock.Lock()
defer this.lock.Unlock()
this.open = false
return this.watcher.Close()
}
func (this *config) GetString (key string, fallback string) (string, error) {
this.lock.Lock()
defer this.lock.Unlock()
value, err := this.get(key, ValueString(fallback))
if err != nil { return "", err }
if value, ok := value.(ValueString); ok {
return string(value), nil
}
return fallback, nil
}
func (this *config) GetNumber (key string, fallback float64) (float64, error) {
this.lock.Lock()
defer this.lock.Unlock()
value, err := this.get(key, ValueNumber(fallback))
if err != nil { return 0, err }
if value, ok := value.(ValueNumber); ok {
return float64(value), nil
}
return fallback, nil
}
func (this *config) GetBool (key string, fallback bool) (bool, error) {
this.lock.Lock()
defer this.lock.Unlock()
value, err := this.get(key, ValueBool(fallback))
if err != nil { return false, err }
if value, ok := value.(ValueBool); ok {
return bool(value), nil
}
return fallback, nil
}
func (this *config) Set (key string, value Value) error {
this.lock.Lock()
defer this.lock.Unlock()
if this.data.user == nil { this.data.user = NewFile() }
err := this.data.user.Set(key, value)
if err != nil { return err }
err = this.saveUser()
if err != nil { return err }
this.broadcastChange(key)
return nil
}
func (this *config) Reset (key string) error {
this.lock.Lock()
defer this.lock.Unlock()
if this.data.user == nil { this.data.user = NewFile() }
err := this.data.user.Reset(key)
if err != nil { return err }
err = this.saveUser()
if err != nil { return err }
this.broadcastChange(key)
return nil
}
func (this *config) OnChange (callback func (string)) event.Cookie {
this.lock.Lock()
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.

11
go.mod
View File

@@ -1,12 +1,14 @@
module git.tebibyte.media/tomo/nasin module git.tebibyte.media/tomo/nasin
go 1.20 go 1.22.2
require ( require (
git.tebibyte.media/tomo/backend v0.5.0 git.tebibyte.media/sashakoshka/goparse v0.2.0
git.tebibyte.media/tomo/objects v0.20.0 git.tebibyte.media/tomo/backend v0.7.0
git.tebibyte.media/tomo/tomo v0.41.0 git.tebibyte.media/tomo/objects v0.22.0
git.tebibyte.media/tomo/tomo v0.46.1
git.tebibyte.media/tomo/xdg v0.1.0 git.tebibyte.media/tomo/xdg v0.1.0
github.com/fsnotify/fsnotify v1.7.0
golang.org/x/image v0.11.0 golang.org/x/image v0.11.0
) )
@@ -17,4 +19,5 @@ require (
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect
github.com/jezek/xgb v1.1.1 // indirect github.com/jezek/xgb v1.1.1 // indirect
github.com/jezek/xgbutil v0.0.0-20231116234834-47f30c120111 // indirect github.com/jezek/xgbutil v0.0.0-20231116234834-47f30c120111 // indirect
golang.org/x/sys v0.5.0 // indirect
) )

17
go.sum
View File

@@ -1,10 +1,12 @@
git.tebibyte.media/sashakoshka/goparse v0.2.0 h1:uQmKvOCV2AOlCHEDjg9uclZCXQZzq2PxaXfZ1aIMiQI=
git.tebibyte.media/sashakoshka/goparse v0.2.0/go.mod h1:tSQwfuD+EujRoKr6Y1oaRy74ZynatzkRLxjE3sbpCmk=
git.tebibyte.media/sashakoshka/xgbkb v1.0.0/go.mod h1:pNcE6TRO93vHd6q42SdwLSTTj25L0Yzggz7yLe0JV6Q= git.tebibyte.media/sashakoshka/xgbkb v1.0.0/go.mod h1:pNcE6TRO93vHd6q42SdwLSTTj25L0Yzggz7yLe0JV6Q=
git.tebibyte.media/tomo/backend v0.5.0 h1:Byrz1yjhYKnMXljIHgEdwW5/41gWtRm3xHYZ18lBUbg= git.tebibyte.media/tomo/backend v0.7.0 h1:12A+IsbwIKCmg4jKjD9xCDz+o7R3X6Yp8cZup+wOGIM=
git.tebibyte.media/tomo/backend v0.5.0/go.mod h1:1Ne0h4nsRYy8GudBia7um+qaqzME0wDrOAjpHvySqRA= git.tebibyte.media/tomo/backend v0.7.0/go.mod h1:G3Kh6N2MuiAwsnuPe3h9CwWL65vmmsaqgapA38MPyhk=
git.tebibyte.media/tomo/objects v0.20.0 h1:HK6aWxBfRe0sgqvfIZnvfW0c8jSYP7F5Od/rWvi5DAM= git.tebibyte.media/tomo/objects v0.22.0 h1:2t21W32HW2xvPBICqmArVMVWxg9ohhTJw6ChZ0DcdYY=
git.tebibyte.media/tomo/objects v0.20.0/go.mod h1:DNTKSU9u126XF4usZG0ax9b20So4UpBZ+wAwRiA7XOQ= git.tebibyte.media/tomo/objects v0.22.0/go.mod h1:f5J5tAhO+eN5glVbCJLPSopIeTylXqLgKLVAIg8iAPQ=
git.tebibyte.media/tomo/tomo v0.41.0 h1:Z+7FHhbGiKjs+kQNvuJOfz47xIct5qxvSJqyDuoNIOs= git.tebibyte.media/tomo/tomo v0.46.1 h1:/8fT6I9l4TK529zokrThbNDHGRvUsNgif1Zs++0PBSQ=
git.tebibyte.media/tomo/tomo v0.41.0/go.mod h1:C9EzepS9wjkTJjnZaPBh22YvVPyA4hbBAJVU20Rdmps= git.tebibyte.media/tomo/tomo v0.46.1/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs=
git.tebibyte.media/tomo/typeset v0.7.1 h1:aZrsHwCG5ZB4f5CruRFsxLv5ezJUCFUFsQJJso2sXQ8= git.tebibyte.media/tomo/typeset v0.7.1 h1:aZrsHwCG5ZB4f5CruRFsxLv5ezJUCFUFsQJJso2sXQ8=
git.tebibyte.media/tomo/typeset v0.7.1/go.mod h1:PwDpSdBF3l/EzoIsa2ME7QffVVajnTHZN6l3MHEGe1g= git.tebibyte.media/tomo/typeset v0.7.1/go.mod h1:PwDpSdBF3l/EzoIsa2ME7QffVVajnTHZN6l3MHEGe1g=
git.tebibyte.media/tomo/xdg v0.1.0 h1:6G2WYPPiM2IXleCpKKHuJA34BxumwNWuLsUoX3yu5zA= git.tebibyte.media/tomo/xdg v0.1.0 h1:6G2WYPPiM2IXleCpKKHuJA34BxumwNWuLsUoX3yu5zA=
@@ -15,6 +17,8 @@ github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJ
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
@@ -40,6 +44,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -0,0 +1,35 @@
package fallbackFaces
import "golang.org/x/image/font"
import "git.tebibyte.media/tomo/tomo"
import "golang.org/x/image/font/basicfont"
import "git.tebibyte.media/tomo/backend/style"
type faceSet struct {
regular font.Face
bold font.Face
italic font.Face
boldItalic font.Face
}
// New creates a new fallback face set.
func New () style.FaceSet {
// TODO maybe pre-generate different variations of this face
return &faceSet {
regular: basicfont.Face7x13,
bold: basicfont.Face7x13,
italic: basicfont.Face7x13,
boldItalic: basicfont.Face7x13,
}
}
func (this *faceSet) Face (face tomo.Face) font.Face {
bold := face.Weight >= 500
italic := face.Italic >= 0.1 || face.Slant >= 0.1
switch {
case bold && italic: return this.boldItalic
case bold: return this.bold
case italic: return this.italic
default: return this.regular
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -6,14 +6,37 @@ import _ "embed"
import _ "image/png" import _ "image/png"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/data" 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/tomo/canvas"
import "git.tebibyte.media/tomo/backend/style"
import "git.tebibyte.media/tomo/nasin/internal/util"
//go:embed assets/icons-small.png //go:embed assets/icons-small.png
var atlasSmallBytes []byte var atlasSmallBytes []byte
//go:embed assets/icons-large.png //go:embed assets/icons-large.png
var atlasLargeBytes []byte var atlasLargeBytes []byte
func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture { const (
iconApplicationXGeneric = tomo.Icon("application/x-generic")
iconApplicationXExecutable = tomo.Icon("application/x-executable")
iconAudioXGeneric = tomo.Icon("audio/x-generic")
iconFontXGeneric = tomo.Icon("font/x-generic")
iconImageXGeneric = tomo.Icon("image/x-generic")
iconModelXGeneric = tomo.Icon("model/x-generic")
iconPackageXGeneric = tomo.Icon("package/x-generic")
iconTextXGeneric = tomo.Icon("text/x-generic")
iconTextHtml = tomo.Icon("text/html")
iconTextXGenericTemplate = tomo.Icon("text/x-generic-template")
iconTextXScript = tomo.Icon("text/x-script")
iconVideoXGeneric = tomo.Icon("video/x-generic")
iconXOfficeAddressBook = tomo.Icon("x-office-address-book")
iconXOfficeCalendar = tomo.Icon("x-office-calendar")
iconXOfficeDocument = tomo.Icon("x-office-document")
iconXOfficePresentation = tomo.Icon("x-office-presentation")
iconXOfficeSpreadsheet = tomo.Icon("x-office-spreadsheet")
)
func generateSource (data []byte, width int) (canvas.TextureCloser, map[tomo.Icon] canvas.Texture) {
atlasImage, _, err := image.Decode(bytes.NewReader(data)) atlasImage, _, err := image.Decode(bytes.NewReader(data))
if err != nil { panic(err) } if err != nil { panic(err) }
atlasTexture := tomo.NewTexture(atlasImage) atlasTexture := tomo.NewTexture(atlasImage)
@@ -36,7 +59,23 @@ func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
} }
col(tomo.IconUnknown) col(tomo.IconUnknown)
col(tomo.Icon("File")) col(iconApplicationXGeneric)
col(iconApplicationXExecutable)
col(iconAudioXGeneric)
col(iconFontXGeneric)
col(iconImageXGeneric)
col(iconModelXGeneric)
col(iconPackageXGeneric)
col(iconTextXGeneric)
col(iconTextHtml)
col(iconTextXGenericTemplate)
col(iconTextXScript)
col(iconVideoXGeneric)
col(iconXOfficeAddressBook)
col(iconXOfficeCalendar)
col(iconXOfficeDocument)
col(iconXOfficePresentation)
col(iconXOfficeSpreadsheet)
row() row()
// actions // actions
@@ -110,9 +149,14 @@ func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
col(tomo.IconInsertLink) col(tomo.IconInsertLink)
col(tomo.IconInsertObject) col(tomo.IconInsertObject)
col(tomo.IconInsertText) col(tomo.IconInsertText)
row()
// actions: list // actions: list
col(tomo.IconListAdd) col(tomo.IconListAdd)
col(tomo.IconListRemove) col(tomo.IconListRemove)
col(tomo.IconListChoose)
col(tomo.IconListExpand)
col(tomo.IconListContract)
row() row()
// actions: mail // actions: mail
@@ -310,9 +354,6 @@ func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
col(tomo.IconPlacePreferences) col(tomo.IconPlacePreferences)
row() row()
// status: checkbox
col(tomo.IconCheckboxChecked)
col(tomo.IconCheckboxUnchecked)
// status: appointments // status: appointments
col(tomo.IconAppointmentMissed) col(tomo.IconAppointmentMissed)
col(tomo.IconAppointmentSoon) col(tomo.IconAppointmentSoon)
@@ -408,26 +449,29 @@ func generateSource (data []byte, width int) map[tomo.Icon] canvas.Texture {
col(tomo.IconWeatherSnow) col(tomo.IconWeatherSnow)
col(tomo.IconWeatherStorm) col(tomo.IconWeatherStorm)
return source return atlasTexture, source
} }
type iconTheme struct { type iconSet struct {
atlasSmall canvas.TextureCloser
atlasLarge canvas.TextureCloser
texturesSmall map[tomo.Icon] canvas.Texture texturesSmall map[tomo.Icon] canvas.Texture
texturesLarge map[tomo.Icon] canvas.Texture texturesLarge map[tomo.Icon] canvas.Texture
} }
// New creates a new fallback icon theme. // New creates a new fallback icon set.
func New () tomo.IconSet { func New () (style.IconSet, event.Cookie) {
return new(iconTheme) iconSet := new(iconSet)
return iconSet, iconSet
} }
func (this *iconTheme) ensure () { func (this *iconSet) ensure () {
if this.texturesSmall != nil { return } if this.texturesSmall != nil { return }
this.texturesSmall = generateSource(atlasSmallBytes, 16) this.atlasSmall, this.texturesSmall = generateSource(atlasSmallBytes, 16)
this.texturesLarge = generateSource(atlasLargeBytes, 32) this.atlasLarge, this.texturesLarge = generateSource(atlasLargeBytes, 32)
} }
func (this *iconTheme) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.Texture { func (this *iconSet) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.Texture {
if size == tomo.IconSizeSmall { if size == tomo.IconSizeSmall {
return this.texturesSmall return this.texturesSmall
} else { } else {
@@ -435,7 +479,7 @@ func (this *iconTheme) selectSource (size tomo.IconSize) map[tomo.Icon] canvas.T
} }
} }
func (this *iconTheme) Icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture { func (this *iconSet) Icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture {
this.ensure() this.ensure()
source := this.selectSource(size) source := this.selectSource(size)
if texture, ok := source[icon]; ok { if texture, ok := source[icon]; ok {
@@ -444,12 +488,28 @@ func (this *iconTheme) Icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture
return nil return nil
} }
func (this *iconTheme) MimeIcon (mime data.Mime, size tomo.IconSize) canvas.Texture { func (this *iconSet) MimeIcon (mime data.Mime, size tomo.IconSize) canvas.Texture {
this.ensure() this.ensure()
source := this.selectSource(size) source := this.selectSource(size)
if icon, ok := source[tomo.Icon(mime.String())]; ok {
return icon
}
if mime == data.M("inode", "directory") { if mime == data.M("inode", "directory") {
return source[tomo.IconPlaceDirectory] return source[tomo.IconPlaceDirectory]
} else if icon, ok := source[tomo.Icon(util.GeneralizeXDGIconMimeType(mime).String())]; ok {
return icon
} else { } else {
return source[tomo.Icon("File")] 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

@@ -8,34 +8,37 @@ import "regexp"
import "strings" import "strings"
import _ "image/png" import _ "image/png"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import xdgIconTheme "git.tebibyte.media/tomo/xdg/icon-theme"
import "git.tebibyte.media/tomo/tomo/data" 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/tomo/canvas"
import "git.tebibyte.media/tomo/backend/style"
import "git.tebibyte.media/tomo/nasin/internal/util"
import xdgIconTheme "git.tebibyte.media/tomo/xdg/icon-theme"
type iconTheme struct { type iconTheme struct {
xdg xdgIconTheme.Theme xdg xdgIconTheme.Theme
fallback tomo.IconSet fallback style.IconSet
texturesSmall map[tomo.Icon] canvas.Texture texturesSmall map[tomo.Icon] canvas.TextureCloser
texturesMedium map[tomo.Icon] canvas.Texture texturesMedium map[tomo.Icon] canvas.TextureCloser
texturesLarge map[tomo.Icon] canvas.Texture texturesLarge map[tomo.Icon] canvas.TextureCloser
} }
func FindThemeWarn (name string, fallback tomo.IconSet, path ...string) (tomo.IconSet, error) { func FindThemeWarn (name string, fallback style.IconSet, path ...string) (style.IconSet, event.Cookie, error) {
this := &iconTheme { this := &iconTheme {
fallback: fallback, fallback: fallback,
texturesLarge: make(map[tomo.Icon] canvas.Texture), texturesLarge: make(map[tomo.Icon] canvas.TextureCloser),
texturesMedium: make(map[tomo.Icon] canvas.Texture), texturesMedium: make(map[tomo.Icon] canvas.TextureCloser),
texturesSmall: make(map[tomo.Icon] canvas.Texture), texturesSmall: make(map[tomo.Icon] canvas.TextureCloser),
} }
xdg, err := xdgIconTheme.FindThemeWarn(name, path...) xdg, err := xdgIconTheme.FindThemeWarn(name, path...)
if err != nil { return nil, err } if err != nil { return nil, nil, err }
this.xdg = xdg 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 { switch size {
case tomo.IconSizeMedium: return this.texturesMedium case tomo.IconSizeMedium: return this.texturesMedium
case tomo.IconSizeLarge: return this.texturesLarge case tomo.IconSizeLarge: return this.texturesLarge
@@ -43,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 // TODO use scaling factor instead of 1
// find icon file // find icon file
icon, err := this.xdg.FindIcon(name, iconSizePixels(size), 1, xdgIconTheme.PNG) icon, err := this.xdg.FindIcon(name, iconSizePixels(size), 1, xdgIconTheme.PNG)
@@ -98,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 { if texture, ok := this.xdgIcon(XdgIconName(icon), size); ok {
return texture return texture
} }
@@ -108,11 +124,11 @@ func (this *iconTheme) icon (icon tomo.Icon, size tomo.IconSize) canvas.Texture
return nil 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 { if texture, ok := this.xdgIcon(xdgFormatMime(mime), size); ok {
return texture return texture
} }
if texture, ok := this.xdgIcon(xdgFormatMime(generalizeMimeType(mime)), size); ok { if texture, ok := this.xdgIcon(xdgFormatMime(util.GeneralizeXDGIconMimeType(mime)), size); ok {
return texture return texture
} }
if texture, ok := this.xdgIcon(xdgFormatMime(data.M("text", "x-generic")), size); ok { if texture, ok := this.xdgIcon(xdgFormatMime(data.M("text", "x-generic")), size); ok {
@@ -165,13 +181,6 @@ func xdgFormatMime (mime data.Mime) string {
return fmt.Sprintf("%s-%s", mime.Type, mime.Subtype) return fmt.Sprintf("%s-%s", mime.Type, mime.Subtype)
} }
func generalizeMimeType (mime data.Mime) data.Mime {
// FIXME make this more accurate
// https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html
mime.Subtype = "x-generic"
return mime
}
func iconSizePixels (size tomo.IconSize) int { func iconSizePixels (size tomo.IconSize) int {
// TODO: once Tomo has scaling support, take that into account here // TODO: once Tomo has scaling support, take that into account here
switch size { switch size {

View File

@@ -1,43 +1,82 @@
//go:build unix && (!darwin) //go:build unix && (!darwin)
package registrar package registrar
import "os"
import "log" import "log"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/backend/x" 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/icons/xdg"
import "git.tebibyte.media/tomo/nasin/internal/styles/tss"
import "git.tebibyte.media/tomo/nasin/internal/icons/fallback" import "git.tebibyte.media/tomo/nasin/internal/icons/fallback"
import "git.tebibyte.media/tomo/nasin/internal/style/fallback" import "git.tebibyte.media/tomo/nasin/internal/styles/fallback"
// import "git.tebibyte.media/tomo/nasin/internal/style/aluminum" import "git.tebibyte.media/tomo/nasin/internal/faces/fallback"
func RegisterBackend () error { type Registrar struct {
tomo.Register(1, x.New) backend *x.Backend
return nil iconSetCookie event.Cookie
styleCookie event.Cookie
} }
func SetTheme () error { func (this *Registrar) SetBackend () (tomo.Backend, error) {
var styl *tomo.Style backend, err := x.New()
// TODO eventually get rid of this when we make a file format for if err != nil { return nil, err }
// storing visual styles this.backend = backend.(*x.Backend)
// TODO migrate aluminum tomo.SetBackend(backend)
// if os.Getenv("TOMO_USE_ALUMINUM_STYLE") != "" { return backend, nil
// styl = aluminumStyle.New() }
// } else {
styl = fallbackStyle.New() func (this *Registrar) SetStyle (name string) error {
// } if this.styleCookie != nil {
icons := fallbackIcons.New() this.styleCookie.Close()
this.styleCookie = nil
iconThemeName := os.Getenv("TOMO_XDG_ICON_THEME") }
if iconThemeName != "" {
xdgIconTheme, err := xdgIcons.FindThemeWarn(iconThemeName, icons) if name != "" {
styl, cookie, err := tss.LoadFile(name)
if err == nil { if err == nil {
icons = xdgIconTheme this.backend.SetStyle(styl)
this.styleCookie = cookie
return nil
} else { } else {
log.Printf("nasin: could not load icon theme '%s': %v", iconThemeName, err) log.Printf (
"nasin: could not load style sheet '%s'\n%v",
name, parse.Format(err))
} }
} }
tomo.SetStyle(styl) styl, cookie := fallbackStyle.New()
tomo.SetIconSet(icons) this.styleCookie = cookie
return nil this.backend.SetStyle(styl)
return nil
}
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", name, err)
}
}
this.backend.SetIconSet(iconSet)
this.iconSetCookie = cookie
return nil
}
func (this *Registrar) SetFaceSet () error {
// TODO replace this with something that uses findfont, and caches and
// refcounts the faces
faceSet := fallbackFaces.New()
this.backend.SetFaceSet(faceSet)
return nil
} }

View File

@@ -1,18 +0,0 @@
package aluminumStyle
import "image/color"
import "git.tebibyte.media/tomo/tomo"
// New returns Aluminum, a futuristic, bluish-white style.
func New () *style.Style {
return &style.Style {
Colors: map[tomo.Color] color.Color {
tomo.ColorBackground: colorBackground,
tomo.ColorForeground: colorForeground,
tomo.ColorRaised: colorRaised,
tomo.ColorSunken: colorSunken,
tomo.ColorAccent: colorFocus,
},
Rules: rules,
}
}

View File

@@ -1,391 +0,0 @@
package aluminumStyle
import "image/color"
import "git.tebibyte.media/tomo/tomo"
import "golang.org/x/image/font/basicfont"
func hex (color uint32) (c color.RGBA) {
c.A = uint8(color)
c.B = uint8(color >> 8)
c.G = uint8(color >> 16)
c.R = uint8(color >> 24)
return
}
func border (top, right, bottom, left uint32, width ...int) tomo.Border {
return tomo.Border {
Width: tomo.I(width...),
Color: [4]color.Color {
hex(top), hex(right),
hex(bottom), hex(left),
},
}
}
var colorDot = hex(0x7391c080)
var colorFocus = hex(0x5f8bc4FF)
var colorHighlight = hex(0x5f8bc4FF)
var colorBackground = hex(0xd4d4d4FF)
var colorForeground = color.Black
var colorOutline = color.Black
var colorGutter = hex(0xbfc6d1FF)
var colorGutterHovered = hex(0xc5cbd6FF)
var colorRaised = hex(0xe9eaeaFF)
var colorRaisedPressed = hex(0xccd4ddFF)
var colorRaisedFocused = hex(0xcfd6ddFF)
var colorRaisedHovered = hex(0xf1f3f5FF)
var colorSunken = hex(0xe9eaeaFF)
var colorSunkenFocused = hex(0xe0e6eeFF)
var colorSunkenPressed = hex(0xe0e6eeFF)
var colorCalendarWeekdayHeader = hex(0xd3cac2FF)
var colorCalendarWeekend = hex(0xc2d3c4FF)
var colorCalendarDay = hex(0xd6dae2FF)
var outline = tomo.Border {
Width: tomo.I(1),
Color: [4]color.Color {
colorOutline,
colorOutline,
colorOutline,
colorOutline,
},
}
var borderEngraved = border(0xc3c3c5FF, 0xe3e3e3FF, 0xe9e9e9ff, 0xc2c2c2ff, 1)
var borderGap = border(0x697c7cFF, 0x566767FF, 0x566767ff, 0x697c7cff, 1)
var borderLifted = border(0xf9fafcFF, 0xc2c8d3FF, 0xa4afc0ff, 0xf5f6f8ff, 1)
var borderLiftedFocused = border(0xf0f4f9FF, 0xb1bacaFF, 0x9aa6b7ff, 0xe4e9eeff, 1)
var borderFocused = border(0x5f8bc4FF, 0x5f8bc4FF, 0x5f8bc4ff, 0x5f8bc4ff, 1)
var borderTear = borderEngraved
var borderTearFocused = border(0x7f94b5FF, 0xced7e4FF, 0xced7e4FF, 0x7f94b5FF, 1)
var borderTearPad = border(0x00000000, 0x00000000, 0x00000000, 0x00000000, 3)
var borderTearPadFocused = border(0x7391c080, 0x7391c080, 0x7391c080, 0x7391c080, 3)
var borderInnerShadow = border(0xa4afc0FF, 0xa4afc0FF, 0xa4afc0ff, 0xa4afc0ff, 1, 0, 0, 1)
var borderOuterShadow = border(0xa4afc0FF, 0xa4afc0FF, 0xa4afc0ff, 0xa4afc0ff, 0, 1, 1, 0)
var rules = []style.Rule {
// *.*[*]
style.Rule {
Default: style.AS (
style.AttrFace { Face: basicfont.Face7x13 },
style.AttrTextColor { Color: tomo.ColorForeground },
style.AttrDotColor { Color: colorDot },
style.AttrGap { X: 8, Y: 8 },
),
},
// *.Button[*]
style.Rule {
Role: tomo.R("", "Button", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderLifted,
},
style.AttrPadding(tomo.I(4, 8)),
style.AttrColor { Color: tomo.ColorRaised },
),
Pressed: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrPadding(tomo.I(5, 8, 4, 9)),
style.AttrColor { Color: colorRaisedPressed },
),
Focused: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderLiftedFocused,
},
style.AttrPadding(tomo.I(4, 8)),
style.AttrColor { Color: colorRaisedFocused },
),
Hovered: style.AS (
style.AttrColor { Color: colorRaisedHovered },
),
},
// *.TextInput[*]
style.Rule {
Role: tomo.R("", "TextInput", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrColor { Color: tomo.ColorSunken },
style.AttrPadding(tomo.I(5, 4, 4, 5)),
),
Focused: style.AS (
style.AttrBorder {
borderEngraved,
borderFocused,
borderInnerShadow,
},
),
},
// *.TextView[*]
style.Rule {
Role: tomo.R("", "TextView", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrColor { Color: tomo.ColorSunken },
style.AttrPadding(tomo.I(8)),
),
},
// *.NumberInput[*]
style.Rule {
Role: tomo.R("", "NumberInput", ""),
Default: style.AS (
style.AttrGap { },
),
},
// *.Container[sunken]
style.Rule {
Role: tomo.R("", "Container", "sunken"),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrColor { Color: tomo.ColorSunken },
style.AttrPadding(tomo.I(8)),
),
},
// *.Container[outer]
style.Rule {
Role: tomo.R("", "Container", "outer"),
Default: style.AS (
style.AttrColor { Color: tomo.ColorBackground },
style.AttrPadding(tomo.I(8)),
),
},
// *.Container[menu]
style.Rule {
Role: tomo.R("", "Container", "menu"),
Default: style.AS (
style.AttrBorder {
borderGap,
borderLifted,
},
style.AttrColor { Color: tomo.ColorBackground },
style.AttrGap { },
),
},
// *.Heading[*]
style.Rule {
Role: tomo.R("", "Heading", ""),
Default: style.AS (
style.AttrAlign { X: tomo.AlignMiddle, Y: tomo.AlignMiddle },
),
},
// *.Separator[*]
style.Rule {
Role: tomo.R("", "Separator", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
},
),
},
// *.Slider[*]
style.Rule {
Role: tomo.R("", "Slider", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrColor { Color: colorGutter },
),
Focused: style.AS (
style.AttrBorder {
borderEngraved,
borderFocused,
borderInnerShadow,
},
),
Hovered: style.AS (
style.AttrColor { Color: colorGutterHovered },
),
},
// *.Slider[horizontal]
style.Rule {
Role: tomo.R("", "Slider", "horizontal"),
Default: style.AS(style.AttrMinimumSize { X: 48 }),
},
// *.Slider[vertical]
style.Rule {
Role: tomo.R("", "Slider", "vertical"),
Default: style.AS(style.AttrMinimumSize { Y: 48 }),
},
// *.SliderHandle[*]
style.Rule {
Role: tomo.R("", "SliderHandle", ""),
Default: style.AS (
style.AttrBorder {
borderOuterShadow,
borderGap,
borderLifted,
},
style.AttrColor { Color: tomo.ColorRaised },
style.AttrMinimumSize { X: 12, Y: 12, },
),
},
// *.Checkbox[*]
style.Rule {
Role: tomo.R("", "Checkbox", ""),
Default: style.AS (
style.AttrBorder {
borderEngraved,
borderGap,
borderInnerShadow,
},
style.AttrColor { Color: tomo.ColorSunken },
style.AttrPadding(tomo.I(0, 1, 1, 0)),
style.AttrMinimumSize { X: 19, Y: 19 },
),
Focused: style.AS (
style.AttrBorder {
borderEngraved,
borderFocused,
borderInnerShadow,
},
style.AttrPadding(tomo.I(0)),
style.AttrColor { Color: colorSunkenFocused },
),
},
// *.LabelCheckbox[*]
style.Rule {
Role: tomo.R("", "LabelCheckbox", ""),
Default: style.AS (
style.AttrGap { X: 8, Y: 8 },
),
},
// *.MenuItem[*]
style.Rule {
Role: tomo.R("", "MenuItem", ""),
Default: style.AS (
style.AttrPadding(tomo.I(4)),
style.AttrGap { X: 4, Y: 4 },
style.AttrColor { Color: color.Transparent },
),
Hovered: style.AS (
style.AttrColor { Color: colorDot },
),
Focused: style.AS (
style.AttrColor { Color: colorDot },
),
},
// *.File[*]
style.Rule {
Role: tomo.R("", "File", ""),
Default: style.AS (
style.AttrColor { Color: color.Transparent },
),
Focused: style.AS (
style.AttrColor { Color: colorDot },
),
},
// *.TearLine[*]
style.Rule {
Role: tomo.R("", "TearLine", ""),
Default: style.AS (
style.AttrBorder {
borderTearPad,
borderTear,
},
),
Hovered: style.AS (
style.AttrBorder {
borderTearPadFocused,
borderTearFocused,
},
),
Focused: style.AS (
style.AttrBorder {
borderTearPadFocused,
borderTearFocused,
},
),
},
// *.Calendar[*]
style.Rule {
Role: tomo.R("", "Calendar", ""),
Default: style.AS (
style.AttrBorder {
borderOuterShadow,
borderGap,
},
style.AttrColor { Color: tomo.ColorRaised },
style.AttrPadding(tomo.I(2)),
style.AttrGap { X: 2, Y: 2 },
),
},
// *.CalendarGrid[*]
style.Rule {
Role: tomo.R("", "CalendarGrid", ""),
Default: style.AS (
style.AttrGap { X: 2, Y: 2 },
),
},
// *.CalendarWeekdayHeader[*]
style.Rule {
Role: tomo.R("", "CalendarWeekdayHeader", ""),
Default: style.AS (
style.AttrPadding(tomo.I(2)),
style.AttrColor { Color: colorCalendarWeekdayHeader },
),
},
// *.CalendarDay[weekday]
style.Rule {
Role: tomo.R("", "CalendarDay", "weekday"),
Default: style.AS (
style.AttrPadding(tomo.I(2)),
style.AttrMinimumSize { X: 32, Y: 32 },
style.AttrColor { Color: colorCalendarDay },
),
},
// *.CalendarDay[weekend]
style.Rule {
Role: tomo.R("", "CalendarDay", "weekend"),
Default: style.AS (
style.AttrPadding(tomo.I(2)),
style.AttrMinimumSize { X: 32, Y: 32 },
style.AttrColor { Color: colorCalendarWeekend },
),
},
}

View File

@@ -1,19 +0,0 @@
package fallbackStyle
import "image/color"
import "git.tebibyte.media/tomo/tomo"
// New returns Wintergreen, the default Tomo style. It is neutral-gray with
// green and turquoise accents.
func New () *tomo.Style {
return &tomo.Style {
Rules: rules,
Colors: map[tomo.Color] color.Color {
tomo.ColorBackground: colorBackground,
tomo.ColorForeground: colorForeground,
tomo.ColorRaised: colorCarved,
tomo.ColorSunken: colorCarved,
tomo.ColorAccent: colorFocus,
},
}
}

View File

@@ -1,21 +0,0 @@
$colorBlack = #000000FF
$borderOutline = $black / 1
*.Slider {
Border: $borderOutline, $borderColorFocused / 1
Color: $colorGutter
Padding: 0 1 1 0
}
*.Slider[focused] {
Border: $borderOutline
Padding: 0
}
*.Slider[horizontal] {
MinimumSize: 48 0
}
*.Slider[vertical] {
MinimumSize: 0 48
}

View File

@@ -0,0 +1,319 @@
// Colors
$ColorDot = #7391c080;
$ColorAccent = #5f8bc4;
$ColorHighlight = #5f8bc4;
$ColorBackground = #d4d4d4;
$ColorForeground = #000;
$ColorOutline = $ColorForeground;
$ColorGutter = #bfc6d1;
$ColorGutterHovered = #c5cbd6;
$ColorRaised = #e9eaea;
$ColorRaisedPressed = #ccd4dd;
$ColorRaisedFocused = #cfd6dd;
$ColorRaisedHovered = #f1f3f5;
$ColorSunken = #e9eaea;
$ColorSunkenFocused = #e0e6ee;
$ColorSunkenPressed = #e0e6ee;
$ColorCalendarWeekdayHeader = #d3cac2;
$ColorCalendarWeekend = #c2d3c4;
$ColorCalendarDay = #d6dae2;
// Borders
$BorderOutline = $ColorOutline ;
$BorderEngraved = #c3c3c5 #e3e3e3 #e9e9e9 #c2c2c2;
$BorderGap = #697c7c #566767 #566767 #697c7c;
$BorderLifted = #f9fafc #c2c8d3 #a4afc0 #f5f6f8;
$BorderLiftedFocused = #f0f4f9 #b1baca #9aa6b7 #e4e9ee;
$BorderFocused = #5f8bc4 #5f8bc4 #5f8bc4 #5f8bc4;
$BorderTear = $BorderEngraved ;
$BorderTearFocused = #7f94b5 #ced7e4 #ced7e4 #7f94b5;
$BorderTearPad = #0000 ;
$BorderTearPadFocused = #7391c080 ;
$BorderInnerShadow = #a4afc0 ;
$BorderOuterShadow = #a4afc0 ;
*.* {
TextColor: $ColorForeground;
DotColor: $ColorDot;
Gap: 8;
}
*.Button {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderLifted / 1;
Padding: 4 8;
Color: $ColorRaised;
}
*.Button[focused] {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderLiftedFocused / 1;
Padding: 4 8;
Color: $ColorRaisedFocused;
}
*.Button[hovered] {
Color: $ColorRaisedHovered;
}
*.Button[pressed] {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Padding: 5 8 4 9;
Color: $ColorRaisedPressed;
}
*.TextInput {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Padding: 5 4 4 5;
Color: $ColorSunken;
}
*.TextInput[focused] {
Border: $BorderEngraved / 1, $BorderFocused / 1, $BorderInnerShadow / 1 0 0 1;
}
*.TextView {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Padding: 5 4 4 5;
Color: $ColorSunken;
}
*.NumberInput {
Gap: 0;
}
*.Container[sunken] {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Padding: 5 4 4 5;
Color: $ColorSunken;
}
*.Container[outer] {
Color: $ColorBackground;
Padding: 8;
}
*.Container[menu] {
Border: $BorderGap / 1, $BorderLifted / 1;
Color: $ColorBackground;
Gap: 0;
}
*.Container[menu, torn] {
Border: ;
Color: $ColorBackground;
Gap: 0;
}
*.Heading {
Align: middle middle;
}
*.Heading[menu] {
Align: middle middle;
Padding: 4 8;
}
*.Separator {
Border: $BorderEngraved / 1;
}
*.Slider {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Color: $ColorGutter;
}
*.Slider[focused] {
Border: $BorderEngraved / 1, $BorderFocused / 1, $BorderInnerShadow / 1 0 0 1;
}
*.Slider[hovered] {
Color: $ColorGutterHovered;
}
*.Slider[horizontal] {
MinimumSize: 48 0;
}
*.Slider[vertical] {
MinimumSize: 0 48;
}
*.SliderHandle {
Border: $BorderOuterShadow / 0 1 1 0, $BorderGap / 1, $BorderLifted / 1;
Color: $ColorRaised;
MinimumSize: 12;
}
*.Scrollbar {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Color: $ColorGutter;
}
*.Scrollbar[focused] {
Border: $BorderEngraved / 1, $BorderFocused / 1, $BorderInnerShadow / 1 0 0 1;
}
*.Scrollbar[hovered] {
Color: $ColorGutterHovered;
}
*.Scrollbar[horizontal] {
MinimumSize: 48 0;
}
*.Scrollbar[vertical] {
MinimumSize: 0 48;
}
*.ScrollbarHandle {
Border: $BorderOuterShadow / 0 1 1 0, $BorderGap / 1, $BorderLifted / 1;
Color: $ColorRaised;
MinimumSize: 12;
}
*.ScrollContainer {
Gap: 0;
}
*.Checkbox {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Color: $ColorSunken;
Padding: 0 1 1 0;
MinimumSize: 19;
}
*.Checkbox[focused] {
Border: $BorderEngraved / 1, $BorderFocused / 1, $BorderInnerShadow / 1 0 0 1;
Color: $ColorSunkenFocused;
Padding: 0;
}
*.Checkbox[checked] {
Texture: "assets/checkbox-checked.png";
TextureMode: center;
}
*.MenuItem {
Padding: 4;
Gap: 4;
Color: #0000;
}
*.MenuItem[hovered] {
Color: $ColorDot;
}
*.MenuItem[focused] {
Color: $ColorDot;
}
*.File {
Color: #0000;
}
*.File[focused] {
Color: $ColorDot;
}
*.TearLine {
Border: $BorderTearPad / 3, $BorderTear / 1;
}
*.TearLine[hovered] {
Border: $BorderTearPadFocused / 3, $BorderTearFocused / 1;
}
*.TearLine[focused] {
Border: $BorderTearPadFocused / 3, $BorderTearFocused / 1;
}
*.Calendar {
Border: $BorderOuterShadow / 0 1 1 0, $BorderGap / 1;
Color: $ColorRaised;
Padding: 2;
Gap: 2;
}
*.CalendarGrid {
Gap: 2 2;
}
*.CalendarWeekdayHeader {
Color: $ColorCalendarWeekdayHeader;
Padding: 2;
}
*.CalendarDay {
Color: $ColorCalendarDay;
Padding: 2;
MinimumSize: 32;
}
*.CalendarDay[weekend] {
Color: $ColorCalendarWeekend;
}
*.TabbedContainer {
Gap: 0;
}
*.TabRow {
Border: $BorderEngraved / 1 1 0 1, $BorderGap / 1 1 0 1, $BorderInnerShadow / 1 0 0 1;
Color: $ColorSunken;
Gap: 0;
Padding: 1 0 0 0;
}
*.Tab {
Border: #0000 / 1 0 0 0, $BorderOuterShadow / 0 1 0 0, $BorderGap / 1 1 0 1, $BorderLifted / 1;
Color: $ColorSunken;
Padding: 4 8 2 8;
}
*.Tab[active] {
Border: $BorderOuterShadow / 0 1 0 0, $BorderGap / 1 1 0 1, $BorderLifted / 1 1 0 1;
Color: $ColorBackground;
Padding: 4 8;
}
*.TabSpacer {
Border: $BorderLifted / 0 0 1 0;
MinimumSize: 1 0;
}
*.Swatch {
Border: $BorderEngraved / 1, $BorderGap / 1;
Color: #FFF;
MinimumSize: 19;
}
*.Swatch[focused] {
Border: $BorderEngraved / 1, $BorderFocused / 1;
}
*.ColorPickerMap {
Border: $BorderEngraved / 1, $BorderGap / 1;
Color: $ColorSunken;
MinimumSize: 128;
}
*.Dropdown {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderLifted / 1;
Padding: 4 8;
Color: $ColorRaised;
}
*.Dropdown[focused] {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderLiftedFocused / 1;
Padding: 4 8;
Color: $ColorRaisedFocused;
}
*.Dropdown[hovered] {
Color: $ColorRaisedHovered;
}
*.Dropdown[pressed] {
Border: $BorderEngraved / 1, $BorderGap / 1, $BorderInnerShadow / 1 0 0 1;
Padding: 5 8 4 9;
Color: $ColorRaisedPressed;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

View File

@@ -1,8 +1,14 @@
package fallbackStyle package fallbackStyle
import "io"
import "bytes"
import "image"
import _ "embed"
import _ "image/png"
import "image/color" import "image/color"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "golang.org/x/image/font/basicfont" import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/backend/style"
var colorFocus = color.RGBA { R: 61, G: 128, B: 143, A: 255 } var colorFocus = color.RGBA { R: 61, G: 128, B: 143, A: 255 }
var colorInput = color.RGBA { R: 208, G: 203, B: 150, A: 255 } var colorInput = color.RGBA { R: 208, G: 203, B: 150, A: 255 }
@@ -38,17 +44,40 @@ var borderColorInput = [4]color.Color { colorInputShadow, colorInput, col
var borderColorFocused = [4]color.Color { colorFocus, colorFocus, colorFocus, colorFocus } var borderColorFocused = [4]color.Color { colorFocus, colorFocus, colorFocus, colorFocus }
var borderColorShade = [4]color.Color { colorShade, colorShade, colorShade, colorShade } var borderColorShade = [4]color.Color { colorShade, colorShade, colorShade, colorShade }
var rules = []tomo.Rule { //go:embed assets/atlas.png
var atlasBytes []byte
type closerCookie struct { io.Closer }
func (cookie closerCookie) Close () { cookie.Closer.Close() }
func newCloserCookie (closer io.Closer) event.Cookie {
return closerCookie { Closer: closer }
}
// New returns Wintergreen, the default Tomo style. It is neutral-gray with
// green and turquoise accents.
func New () (*style.Style, event.Cookie) {
atlasImage, _, err := image.Decode(bytes.NewReader(atlasBytes))
if err != nil { panic(err) }
atlasTexture := tomo.NewTexture(atlasImage)
textureCheckboxChecked := atlasTexture.SubTexture(image.Rect( 0, 0, 12, 11))
// textureCorkboard := atlasTexture.SubTexture(image.Rect(16, 0, 28, 12))
textureTearLine := atlasTexture.SubTexture(image.Rect(16, 12, 18, 13))
textureHandleVertical := atlasTexture.SubTexture(image.Rect(28, 0, 29, 2))
textureHandleHorizontal := atlasTexture.SubTexture(image.Rect(28, 0, 30, 1))
cookie := event.MultiCookie(newCloserCookie(atlasTexture))
rules := []style.Rule {
// *.* // *.*
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AFace (basicfont.Face7x13 ),
tomo.ATextColor (tomo.ColorForeground), tomo.ATextColor (tomo.ColorForeground),
tomo.ADotColor (tomo.ColorAccent ), tomo.ADotColor (tomo.ColorAccent ),
tomo.AGap (8, 8 ), tomo.AGap (8, 8 ),
), tomo.R("", "")), ), tomo.R("", "")),
// *.Button // *.Button
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -61,7 +90,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Button")), ), tomo.R("", "Button")),
// *.Button[focused] // *.Button[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -69,11 +98,10 @@ var rules = []tomo.Rule {
Color: borderColorFocused, Color: borderColorFocused,
}, },
}, },
tomo.AttrPadding(tomo.I(4, 8)),
), tomo.R("", "Button"), "focused"), ), tomo.R("", "Button"), "focused"),
// *.Button[pressed] // *.Button[pressed]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -86,7 +114,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Button"), "pressed"), ), tomo.R("", "Button"), "pressed"),
// *.TextInput // *.TextInput
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ABorder ( tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
@@ -98,7 +126,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TextInput")), ), tomo.R("", "TextInput")),
// *.TextInput[focused] // *.TextInput[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ABorder ( tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
@@ -108,7 +136,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TextInput"), "focused"), ), tomo.R("", "TextInput"), "focused"),
// *.TextView // *.TextView
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ABorder ( tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
@@ -120,31 +148,31 @@ var rules = []tomo.Rule {
), tomo.R("", "TextView")), ), tomo.R("", "TextView")),
// *.NumberInput // *.NumberInput
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AGap(0, 0), tomo.AGap(0, 0),
), tomo.R("", "NumberInput")), ), tomo.R("", "NumberInput")),
// *.Container[sunken] // *.Container[sunken]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
Width: tomo.I(1, 0, 0, 1), Width: tomo.I(1, 0, 0, 1),
Color: borderColorEngraved, Color: borderColorEngraved,
}, }),
}, tomo.AColor(tomo.ColorSunken),
tomo.AttrColor { Color: tomo.ColorSunken }, // tomo.ATexture(textureCorkboard),
tomo.AttrPadding(tomo.I(8)), tomo.APadding(8),
), tomo.R("", "Container"), "sunken"), ), tomo.R("", "Container"), "sunken"),
// *.Container[outer] // *.Container[outer]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrColor { Color: tomo.ColorBackground }, tomo.AttrColor { Color: tomo.ColorBackground },
tomo.AttrPadding(tomo.I(8)), tomo.AttrPadding(tomo.I(8)),
), tomo.R("", "Container"), "outer"), ), tomo.R("", "Container"), "outer"),
// *.Container[menu] // *.Container[menu]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -157,12 +185,17 @@ var rules = []tomo.Rule {
), tomo.R("", "Container"), "menu"), ), tomo.R("", "Container"), "menu"),
// *.Heading // *.Heading
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AAlign(tomo.AlignMiddle, tomo.AlignMiddle), tomo.AAlign(tomo.AlignMiddle, tomo.AlignMiddle),
), tomo.R("", "Heading")), ), tomo.R("", "Heading")),
// *.Heading
style.Ru(style.AS (
tomo.APadding(4, 8),
), tomo.R("", "Heading"), "menu"),
// *.Separator // *.Separator
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(1), Width: tomo.I(1),
@@ -172,7 +205,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Separator")), ), tomo.R("", "Separator")),
// *.Slider // *.Slider
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -185,42 +218,117 @@ var rules = []tomo.Rule {
), tomo.R("", "Slider")), ), tomo.R("", "Slider")),
// *.Slider[focused] // *.Slider[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
Width: tomo.I(1), Width: tomo.I(1),
Color: borderColorFocused, Color: borderColorFocused,
}, }),
}, tomo.APadding(0),
tomo.AttrPadding(tomo.I(0)),
), tomo.R("", "Slider"), "focused"), ), tomo.R("", "Slider"), "focused"),
// *.Slider[horizontal] // *.Slider[horizontal]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AMinimumSize(48, 0), tomo.AMinimumSize(48, 0),
), tomo.R("", "Slider"), "horizontal"), ), tomo.R("", "Slider"), "horizontal"),
// *.Slider[vertical] // *.Slider[vertical]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AMinimumSize(0, 48), tomo.AMinimumSize(0, 48),
), tomo.R("", "Slider"), "vertical"), ), tomo.R("", "Slider"), "vertical"),
// *.SliderHandle // *.SliderHandle
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
Width: tomo.I(1), Width: tomo.I(1),
Color: borderColorLifted, Color: borderColorLifted,
}, },
}, tomo.Border {
tomo.AttrColor { Color: tomo.ColorRaised }, Width: tomo.I(1),
tomo.AttrMinimumSize { X: 12, Y: 12, }, Color: [4]color.Color {
tomo.ColorRaised, tomo.ColorRaised,
tomo.ColorRaised, tomo.ColorRaised,
},
}),
tomo.AColor(nil),
tomo.ATexture(textureHandleVertical),
tomo.AMinimumSize(12, 12),
), tomo.R("", "SliderHandle")), ), tomo.R("", "SliderHandle")),
// *.SliderHandle[horizontal]
style.Ru(style.AS (
tomo.ATexture(textureHandleHorizontal),
), tomo.R("", "SliderHandle"), "horizontal"),
// *.Scrollbar
style.Ru(style.AS (
tomo.AttrBorder {
outline,
tomo.Border {
Width: tomo.I(1, 0, 0, 1),
Color: borderColorEngraved,
},
},
tomo.AttrColor { Color: colorGutter },
tomo.AttrPadding(tomo.I(0, 1, 1, 0)),
), tomo.R("", "Scrollbar")),
// *.Scrollbar[focused]
style.Ru(style.AS (
tomo.ABorder (
outline,
tomo.Border {
Width: tomo.I(1),
Color: borderColorFocused,
}),
tomo.APadding(0),
), tomo.R("", "Scrollbar"), "focused"),
// *.Scrollbar[horizontal]
style.Ru(style.AS (
tomo.AMinimumSize(48, 0),
), tomo.R("", "Scrollbar"), "horizontal"),
// *.Scrollbar[vertical]
style.Ru(style.AS (
tomo.AMinimumSize(0, 48),
), tomo.R("", "Scrollbar"), "vertical"),
// *.ScrollbarHandle
style.Ru(style.AS (
tomo.ABorder (
outline,
tomo.Border {
Width: tomo.I(1),
Color: borderColorLifted,
},
tomo.Border {
Width: tomo.I(1),
Color: [4]color.Color {
tomo.ColorRaised, tomo.ColorRaised,
tomo.ColorRaised, tomo.ColorRaised,
},
}),
tomo.AColor(nil),
tomo.ATexture(textureHandleVertical),
tomo.AMinimumSize(12, 12),
), tomo.R("", "ScrollbarHandle")),
// *.ScrollbarHandle[horizontal]
style.Ru(style.AS (
tomo.ATexture(textureHandleHorizontal),
), tomo.R("", "ScrollbarHandle"), "horizontal"),
// *.ScrollContainer
style.Ru(style.AS (
tomo.AGap(0, 0),
), tomo.R("", "ScrollContainer")),
// *.Checkbox // *.Checkbox
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ABorder ( tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
@@ -235,7 +343,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Checkbox")), ), tomo.R("", "Checkbox")),
// *.Checkbox[focused] // *.Checkbox[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ABorder ( tomo.ABorder (
outline, outline,
tomo.Border { tomo.Border {
@@ -246,40 +354,40 @@ var rules = []tomo.Rule {
), tomo.R("", "Checkbox"), "focused"), ), tomo.R("", "Checkbox"), "focused"),
// *.Checkbox[checked] // *.Checkbox[checked]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.ATexture(nil), // TODO: checked texture tomo.ATexture(textureCheckboxChecked),
), tomo.R("", "Checkbox"), "checked"), ), tomo.R("", "Checkbox"), "checked"),
// *.MenuItem // *.MenuItem
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrPadding(tomo.I(4)), tomo.AttrPadding(tomo.I(4)),
tomo.AttrGap { X: 4, Y: 4 }, tomo.AttrGap { X: 4, Y: 4 },
tomo.AttrColor { Color: color.Transparent }, tomo.AttrColor { Color: color.Transparent },
), tomo.R("", "MenuItem")), ), tomo.R("", "MenuItem")),
// *MenuItem[focused] // *MenuItem[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrColor { Color: tomo.ColorAccent }, tomo.AttrColor { Color: tomo.ColorAccent },
), tomo.R("", "MenuItem"), "focused"), ), tomo.R("", "MenuItem"), "focused"),
// *.MenuItem[hovered] // *.MenuItem[hovered]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrColor { Color: tomo.ColorAccent }, tomo.AttrColor { Color: tomo.ColorAccent },
), tomo.R("", "MenuItem"), "hovered"), ), tomo.R("", "MenuItem"), "hovered"),
// *.File // *.File
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrColor { Color: color.Transparent }, tomo.AttrColor { Color: color.Transparent },
), tomo.R("", "File")), ), tomo.R("", "File")),
// *.File[focused] // *.File[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrColor { Color: tomo.ColorAccent }, tomo.AttrColor { Color: tomo.ColorAccent },
), tomo.R("", "File"), "focused"), ), tomo.R("", "File"), "focused"),
// *.TearLine // *.TearLine
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.ABorder (
tomo.Border { tomo.Border {
Width: tomo.I(3), Width: tomo.I(3),
Color: [4]color.Color { Color: [4]color.Color {
@@ -288,14 +396,13 @@ var rules = []tomo.Rule {
color.Transparent, color.Transparent,
color.Transparent, color.Transparent,
}, },
}, }),
}, tomo.ATexture(textureTearLine),
tomo.AttrColor { Color: tomo.ColorForeground }, tomo.APadding(1, 0, 0, 1),
tomo.AttrPadding(tomo.I(1, 0, 0, 1)),
), tomo.R("", "TearLine")), ), tomo.R("", "TearLine")),
// *.TearLine[focused] // *.TearLine[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(3), Width: tomo.I(3),
@@ -305,7 +412,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TearLine"), "focused"), ), tomo.R("", "TearLine"), "focused"),
// *.TearLine[hovered] // *.TearLine[hovered]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(3), Width: tomo.I(3),
@@ -315,7 +422,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TearLine"), "hovered"), ), tomo.R("", "TearLine"), "hovered"),
// *.Calendar // *.Calendar
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(0, 1, 1, 0), Width: tomo.I(0, 1, 1, 0),
@@ -329,37 +436,37 @@ var rules = []tomo.Rule {
), tomo.R("", "Calendar")), ), tomo.R("", "Calendar")),
// *.CalendarGrid // *.CalendarGrid
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrGap { X: 2, Y: 2 }, tomo.AttrGap { X: 2, Y: 2 },
), tomo.R("", "CalendarGrid")), ), tomo.R("", "CalendarGrid")),
// *.CalendarWeekdayHeader // *.CalendarWeekdayHeader
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrPadding(tomo.I(2)), tomo.AttrPadding(tomo.I(2)),
tomo.AttrColor { Color: colorCalendarWeekdayHeader }, tomo.AttrColor { Color: colorCalendarWeekdayHeader },
), tomo.R("", "CalendarWeekdayHeader")), ), tomo.R("", "CalendarWeekdayHeader")),
// *.CalendarDay[weekday] // *.CalendarDay[weekday]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrPadding(tomo.I(2)), tomo.AttrPadding(tomo.I(2)),
tomo.AttrMinimumSize { X: 32, Y: 32 }, tomo.AttrMinimumSize { X: 32, Y: 32 },
tomo.AttrColor { Color: colorCalendarDay }, tomo.AttrColor { Color: colorCalendarDay },
), tomo.R("", "CalendarDay"), "weekday"), ), tomo.R("", "CalendarDay"), "weekday"),
// *.CalendarDay[weekend] // *.CalendarDay[weekend]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrPadding(tomo.I(2)), tomo.AttrPadding(tomo.I(2)),
tomo.AttrMinimumSize { X: 32, Y: 32 }, tomo.AttrMinimumSize { X: 32, Y: 32 },
tomo.AttrColor { Color: colorCalendarWeekend }, tomo.AttrColor { Color: colorCalendarWeekend },
), tomo.R("", "CalendarDay"), "weekend"), ), tomo.R("", "CalendarDay"), "weekend"),
// *.TabbedContainer // *.TabbedContainer
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AGap(0, 0), tomo.AGap(0, 0),
), tomo.R("", "TabbedContainer")), ), tomo.R("", "TabbedContainer")),
// *.TabRow // *.TabRow
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(1, 1, 0, 1), Width: tomo.I(1, 1, 0, 1),
@@ -376,7 +483,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TabRow")), ), tomo.R("", "TabRow")),
// *.TabSpacer[left] // *.TabSpacer[left]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(0, 0, 1, 0), Width: tomo.I(0, 0, 1, 0),
@@ -391,7 +498,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TabSpacer")), ), tomo.R("", "TabSpacer")),
// *.TabSpacer[right] // *.TabSpacer[right]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(1, 0, 0, 0), Width: tomo.I(1, 0, 0, 0),
@@ -417,7 +524,7 @@ var rules = []tomo.Rule {
), tomo.R("", "TabSpacer"), "right"), ), tomo.R("", "TabSpacer"), "right"),
// *.Tab // *.Tab
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(1, 0, 0, 0), Width: tomo.I(1, 0, 0, 0),
@@ -444,7 +551,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Tab")), ), tomo.R("", "Tab")),
// *.Tab[active] // *.Tab[active]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
tomo.Border { tomo.Border {
Width: tomo.I(1, 0, 0, 1), Width: tomo.I(1, 0, 0, 1),
@@ -455,12 +562,12 @@ var rules = []tomo.Rule {
Color: borderColorLifted, Color: borderColorLifted,
}, },
}, },
tomo.AttrPadding(tomo.I(4, 8, 4, 8)), tomo.AttrPadding(tomo.I(4, 8, 7, 8)),
tomo.AttrColor { Color: tomo.ColorBackground }, tomo.AttrColor { Color: tomo.ColorBackground },
), tomo.R("", "Tab"), "active"), ), tomo.R("", "Tab"), "active"),
// *.Swatch // *.Swatch
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
}, },
@@ -468,7 +575,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Swatch")), ), tomo.R("", "Swatch")),
// *.Swatch[focused] // *.Swatch[focused]
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -479,7 +586,7 @@ var rules = []tomo.Rule {
), tomo.R("", "Swatch"), "focused"), ), tomo.R("", "Swatch"), "focused"),
// *.ColorPickerMap // *.ColorPickerMap
tomo.Ru(tomo.AS ( style.Ru(style.AS (
tomo.AttrBorder { tomo.AttrBorder {
outline, outline,
tomo.Border { tomo.Border {
@@ -490,4 +597,53 @@ var rules = []tomo.Rule {
tomo.AttrColor { Color: tomo.ColorSunken }, tomo.AttrColor { Color: tomo.ColorSunken },
tomo.AttrMinimumSize { X: 128, Y: 128 }, tomo.AttrMinimumSize { X: 128, Y: 128 },
), tomo.R("", "ColorPickerMap")), ), tomo.R("", "ColorPickerMap")),
// *.Dropdown
style.Ru(style.AS (
tomo.AttrBorder {
outline,
tomo.Border {
Width: tomo.I(1),
Color: borderColorLifted,
},
},
tomo.AttrPadding(tomo.I(4, 8)),
tomo.AttrColor { Color: tomo.ColorRaised },
), tomo.R("", "Dropdown")),
// *.Dropdown[focused]
style.Ru(style.AS (
tomo.AttrBorder {
outline,
tomo.Border {
Width: tomo.I(1),
Color: borderColorFocused,
},
},
), tomo.R("", "Dropdown"), "focused"),
// *.Dropdown[pressed]
style.Ru(style.AS (
tomo.AttrBorder {
outline,
tomo.Border {
Width: tomo.I(1, 0, 0, 1),
Color: borderColorEngraved,
},
},
tomo.AttrPadding(tomo.I(5, 8, 4, 9)),
tomo.AttrColor { Color: colorCarvedPressed },
), tomo.R("", "Dropdown"), "pressed"),
}
return &style.Style {
Rules: rules,
Colors: map[tomo.Color] color.Color {
tomo.ColorBackground: colorBackground,
tomo.ColorForeground: colorForeground,
tomo.ColorRaised: colorCarved,
tomo.ColorSunken: colorCarved,
tomo.ColorAccent: colorFocus,
},
}, cookie
} }

View File

@@ -0,0 +1,21 @@
$colorBlack = #000000FF;
$borderOutline = $black 1;
*.Slider {
Border: $borderOutline, $borderColorFocused 1;
Color: $colorGutter;
Padding: 0 1 1 0;
}
*.Slider[focused] {
Border: $borderOutline;
Padding: 0;
}
*.Slider[horizontal] {
MinimumSize: 48 0;
}
*.Slider[vertical] {
MinimumSize: 0 48;
}

View File

@@ -0,0 +1,14 @@
$ColorBackground = #FFF;
$ColorForeground = #000;
$ColorRaised = #AAA;
$ColorSunken = #888;
$ColorAccent = #0FF;
*.* {
Color: $ColorBackground;
Border: $ColorForeground / 1;
TextColor: $ColorForeground;
DotColor: $ColorAccent;
Padding: 2;
}

View File

@@ -0,0 +1,407 @@
package tss
import "os"
import "io"
import "fmt"
import "image"
import "errors"
import "image/color"
import _ "image/png"
import "path/filepath"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/tomo/canvas"
import "git.tebibyte.media/tomo/backend/style"
type styleBuilder struct {
sheet Sheet
workingDir string
textures map[string] canvas.TextureCloser
}
// BuildStyle builds a Tomo style from the specified sheet. Resources associated
// with it (such as textures) can be freed by closing the returned cookie.
func BuildStyle (sheet Sheet) (*style.Style, event.Cookie, error) {
builder := &styleBuilder {
sheet: sheet,
workingDir: filepath.Dir(sheet.Path),
}
return builder.build()
}
func (this *styleBuilder) build () (*style.Style, event.Cookie, error) {
// ensure the sheet is flattened (all vars are resolved)
err := this.sheet.Flatten()
if err != nil { return nil, nil, err }
getColor := func (name string) color.Color {
if list, ok := this.sheet.Variables[name]; ok {
if len(list) > 0 {
if col, ok := list[0].(ValueColor); ok {
return col
}
}
}
return color.RGBA { R: 255, B: 255, A: 255 }
}
// construct style and get colors
cookies := []event.Cookie { }
sty := &style.Style {
Rules: make([]style.Rule, len(this.sheet.Rules)),
Colors: map[tomo.Color] color.Color {
tomo.ColorBackground: getColor("ColorBackground"),
tomo.ColorForeground: getColor("ColorForeground"),
tomo.ColorRaised: getColor("ColorRaised"),
tomo.ColorSunken: getColor("ColorSunken"),
tomo.ColorAccent: getColor("ColorAccent"),
},
}
// build style rules from the sheet's rule slice
for index, rule := range this.sheet.Rules {
styleRule := style.Rule {
Role: tomo.Role {
Package: rule.Selector.Package,
Object: rule.Selector.Object,
},
Tags: rule.Selector.Tags,
Set: make(style.AttrSet),
}
for name, attr := range rule.Attrs {
styleAttr, cookie, err := this.buildAttr(name, attr)
if err != nil { return nil, nil, err }
styleRule.Set.Add(styleAttr)
if cookie != nil {
cookies = append(cookies, cookie)
}
}
sty.Rules[index] = styleRule
}
// add each texture to the cookies list
for _, texture := range this.textures {
cookies = append(cookies, closerCookie { Closer: texture })
}
return sty, event.MultiCookie(cookies...), nil
}
func (this *styleBuilder) buildAttr (name string, attr []ValueList) (tomo.Attr, event.Cookie, error) {
errWrongType := func () error {
return errors.New(fmt.Sprintf("wrong type for %s attribute", name))
}
expectSingle := func () error {
if len(attr) != 1 {
return errors.New(fmt.Sprintf (
"%s attribute requires exactly one value list",
name))
}
return nil
}
expectSingleSingle := func () error {
err := expectSingle()
if err != nil { return err }
if len(attr[0]) != 1 {
return errors.New(fmt.Sprintf (
"%s attribute requires exactly one value",
name))
}
return nil
}
expectNumbers := func (list ValueList) error {
for _, value := range list {
if _, ok := value.(ValueNumber); ok { continue }
return errWrongType()
}
return nil
}
numbers := func (list ValueList) ([]int, error) {
nums := make([]int, len(list))
for index, value := range list {
if value, ok := value.(ValueNumber); ok {
nums[index] = int(value)
continue
}
return nil, errWrongType()
}
return nums, nil
}
bools := func (list ValueList) ([]bool, error) {
bools := make([]bool, len(list))
for index, value := range list {
if value, ok := value.(ValueKeyword); ok {
switch value {
case "true":
bools[index] = true
continue
case "false":
bools[index] = false
continue
}
}
return nil, errWrongType()
}
return bools, nil
}
point := func (list ValueList) (image.Point, error) {
err := expectNumbers(list)
if err != nil { return image.Point { }, err }
vector := image.Point { }
switch len(attr[0]) {
case 1:
vector.X = int(list[0].(ValueNumber))
vector.Y = int(list[0].(ValueNumber))
case 2:
vector.X = int(list[0].(ValueNumber))
vector.Y = int(list[1].(ValueNumber))
default:
return image.Point { }, errors.New(fmt.Sprintf (
"%s attribute requires exactly one or two values",
name))
}
return vector, nil
}
switch name {
case "Color":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if col, ok := attr[0][0].(ValueColor); ok {
return tomo.AColor(col), nil, nil
}
return nil, nil, errWrongType()
case "Texture":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if name, ok := attr[0][0].(ValueString); ok {
texture, err := this.texture(string(name))
if err != nil { return nil, nil, err }
return tomo.ATexture(texture), nil, nil
}
return nil, nil, errWrongType()
case "TextureMode":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if keyword, ok := attr[0][0].(ValueKeyword); ok {
switch keyword {
case "tile": return tomo.ATextureMode(tomo.TextureModeCenter), nil, nil
case "center": return tomo.ATextureMode(tomo.TextureModeCenter), nil, nil
}
return nil, nil, errors.New(fmt.Sprintf (
"unknown texture mode: %s",
keyword))
}
return nil, nil, errWrongType()
case "Border":
attrBorder, err := buildAttrBorder(attr)
if err != nil { return nil, nil, err }
return attrBorder, nil, nil
case "MinimumSize":
err := expectSingle()
if err != nil { return nil, nil, err }
vector, err := point(attr[0])
if err != nil { return nil, nil, err }
return tomo.AttrMinimumSize(vector), nil, nil
case "Padding":
err := expectSingle()
if err != nil { return nil, nil, err }
numbers, err := numbers(attr[0])
if err != nil { return nil, nil, err }
inset := tomo.Inset { }
if !copyBorderValue(inset[:], numbers) {
return nil, nil, errors.New(fmt.Sprintf (
"%s attribute requires exactly one, two, or four values",
name))
}
return tomo.AttrPadding(inset), nil, nil
case "Gap":
err := expectSingle()
if err != nil { return nil, nil, err }
vector, err := point(attr[0])
if err != nil { return nil, nil, err }
return tomo.AttrGap(vector), nil, nil
case "TextColor":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if col, ok := attr[0][0].(ValueColor); ok {
return tomo.ATextColor(col), nil, nil
}
return nil, nil, errWrongType()
case "DotColor":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if col, ok := attr[0][0].(ValueColor); ok {
return tomo.ADotColor(col), nil, nil
}
return nil, nil, errWrongType()
case "Face":
// TODO support weight, italic, slant
err := expectSingle()
if err != nil { return nil, nil, err }
list := attr[0]
if len(list) != 2 {
return nil, nil, errors.New(fmt.Sprintf (
"%s attribute requires exactly two values",
name))
}
name, ok := list[0].(ValueString)
if !ok { return nil, nil, errWrongType() }
size, ok := list[1].(ValueNumber)
if !ok { return nil, nil, errWrongType() }
return tomo.AFace(tomo.Face {
Font: string(name),
Size: float64(size),
}), nil, nil
case "Wrap":
err := expectSingleSingle()
if err != nil { return nil, nil, err }
if value, ok := attr[0][0].(ValueKeyword); ok {
switch value {
case "true": return tomo.AWrap(true), nil, nil
case "false": return tomo.AWrap(false), nil, nil
}
}
return nil, nil, errWrongType()
case "Align":
err := expectSingle()
if err != nil { return nil, nil, err }
list := attr[0]
if len(list) != 2 {
return nil, nil, errors.New(fmt.Sprintf (
"%s attribute requires exactly two values",
name))
}
aligns := [2]tomo.Align { }
for index, value := range list {
if keyword, ok := value.(ValueKeyword); ok {
switch keyword {
case "start": aligns[index] = tomo.AlignStart; continue
case "middle": aligns[index] = tomo.AlignMiddle; continue
case "end": aligns[index] = tomo.AlignEnd; continue
case "even": aligns[index] = tomo.AlignEven; continue
default: return nil, nil, errors.New(fmt.Sprintf (
"unknown texture mode: %s",
keyword))
}
}
return nil, nil, errWrongType()
}
return tomo.AAlign(aligns[0], aligns[1]), nil, nil
case "Overflow":
err := expectSingle()
if err != nil { return nil, nil, err }
bools, err := bools(attr[0])
if err != nil { return nil, nil, err }
if len(bools) != 2 {
return nil, nil, errors.New(fmt.Sprintf (
"%s attribute requires exactly two values",
name))
}
return tomo.AOverflow(bools[0], bools[1]), nil, nil
case "Layout":
// TODO allow use of some layouts in the objects package
default: return nil, nil, errors.New(fmt.Sprintf("unknown attribute name %s", name))
}
return nil, nil, errors.New(fmt.Sprintf("unimplemented attribute name %s", name))
}
func (this *styleBuilder) texture (path string) (canvas.TextureCloser, error) {
path = filepath.Join(this.workingDir, path)
if texture, ok := this.textures[path]; ok {
return texture, nil
}
file, err := os.Open(path)
if err != nil { return nil, err }
defer file.Close()
rawImage, _, err := image.Decode(file)
if err != nil { return nil, err }
return tomo.NewTexture(rawImage), nil
}
func buildAttrBorder (attr []ValueList) (tomo.Attr, error) {
borders := make([]tomo.Border, len(attr))
for index, list := range attr {
colors := make([]color.Color, 0, len(list))
sizes := make([]int, 0, len(list))
capturingSize := false
for _, value := range list {
if capturingSize {
if value, ok := value.(ValueNumber); ok {
sizes = append(sizes, int(value))
continue
}
} else {
if _, ok := value.(ValueCut); ok {
capturingSize = true
continue
}
if value, ok := value.(ValueColor); ok {
colors = append(colors, value)
continue
}
}
return nil, errors.New("malformed Border attribute value list")
}
border := tomo.Border { }
if !copyBorderValue(border.Width[:], sizes) {
return nil, errors.New("malformed Border attribute width list")
}
if !copyBorderValue(border.Color[:], colors) {
return nil, errors.New("malformed Border attribute color list")
}
borders[index] = border
}
return tomo.ABorder(borders...), nil
}
func copyBorderValue[T any, U ~[]T] (destination, source U) bool {
if len(source) > len(destination) { return false }
switch len(source) {
case 1:
destination[0] = source[0]
destination[1] = source[0]
destination[2] = source[0]
destination[3] = source[0]
return true
case 2:
destination[0] = source[0]
destination[1] = source[1]
destination[2] = source[0]
destination[3] = source[1]
return true
case 4:
destination[0] = source[0]
destination[1] = source[1]
destination[2] = source[2]
destination[3] = source[3]
return true
default:
return false
}
}
type closerCookie struct {
io.Closer
}
func (cookie closerCookie) Close () {
cookie.Closer.Close()
}

View File

@@ -0,0 +1,17 @@
package tss
import "testing"
func TestValueColor (test *testing.T) {
testValueColorRGBA(test, 0xFB380CFF, 0xFBFB, 0x3838, 0x0C0C, 0xFFFF)
testValueColorRGBA(test, 0xFB380C00, 0x0000, 0x0000, 0x0000, 0x0000)
}
func testValueColorRGBA (test *testing.T, col ValueColor, r, g, b, a uint32) {
gr, gg, gb, ga := col.RGBA()
test.Logf("testing RGBA for color #%08X", col)
if gr != r { test.Errorf("r component inequal (%04X != %04X)", gr, r) }
if gg != g { test.Errorf("g component inequal (%04X != %04X)", gg, g) }
if gb != b { test.Errorf("b component inequal (%04X != %04X)", gb, b) }
if ga != a { test.Errorf("a component inequal (%04X != %04X)", ga, a) }
}

View File

@@ -0,0 +1,53 @@
package tss
import "fmt"
import "errors"
// Flatten evaluates all variables recursively, thereby eliminating all
// instances of ValueVariable.
func (this *Sheet) Flatten () error {
if this.flat { return nil }
this.flat = true
for name, variable := range this.Variables {
variable, err := this.eval(variable)
if err != nil { return err }
this.Variables[name] = variable
}
for index, rule := range this.Rules {
for name, attr := range rule.Attrs {
for index, list := range attr {
list, err := this.eval(list)
if err != nil { return err }
attr[index] = list
}
rule.Attrs[name] = attr
}
this.Rules[index] = rule
}
return nil
}
func (this *Sheet) eval (source ValueList) (ValueList, error) {
destination := make(ValueList, 0, len(source))
for _, value := range source {
if name, ok := value.(ValueVariable); ok {
variable, ok := this.Variables[string(name)]
if !ok {
return nil, errors.New(fmt.Sprintf(
"variable $%s does not exist",
value))
}
variable, err := this.eval(variable)
if err != nil { return nil, err }
destination = append(destination, variable...)
continue
} else {
destination = append(destination, value)
}
}
return destination, nil
}

363
internal/styles/tss/lex.go Normal file
View File

@@ -0,0 +1,363 @@
package tss
import "io"
import "bufio"
import "unicode"
import "unicode/utf8"
import "git.tebibyte.media/sashakoshka/goparse"
const (
Comment parse.TokenKind = iota
LBrace
RBrace
LBracket
RBracket
Equals
Colon
Comma
Semicolon
Star
Dot
Dollar
Slash
Color
Ident
Number
String
)
var tokenNames = map[parse.TokenKind] string {
parse.EOF: "EOF",
Comment: "Comment",
LBrace: "LBrace",
RBrace: "RBrace",
LBracket: "LBracket",
RBracket: "RBracket",
Equals: "Equals",
Colon: "Colon",
Comma: "Comma",
Semicolon: "Semicolon",
Star: "Star",
Dot: "Dot",
Dollar: "Dollar",
Slash: "Slash",
Color: "Color",
Ident: "Ident",
Number: "Number",
String: "String",
}
type lexer struct {
filename string
lineScanner *bufio.Scanner
rune rune
line string
lineFood string
offset int
row int
column int
eof bool
}
func Lex (filename string, reader io.Reader) parse.Lexer {
lex := &lexer {
filename: filename,
lineScanner: bufio.NewScanner(reader),
}
lex.nextRune()
return lex
}
func (this *lexer) Next () (parse.Token, error) {
for {
token, err := this.next()
if err == io.EOF { return token, this.errUnexpectedEOF() }
if err != nil { return token, err }
if !token.Is(Comment) {
return token, err
}
}
}
func (this *lexer) next () (token parse.Token, err error) {
err = this.skipWhitespace()
token.Position = this.pos()
if this.eof {
token.Kind = parse.EOF
err = nil
return
}
if err != nil { return }
appendRune := func () {
token.Value += string(this.rune)
err = this.nextRune()
}
skipRune := func () {
err = this.nextRune()
}
defer func () {
newPos := this.pos()
newPos.End --
token.Position = token.Position.Union(newPos)
} ()
switch {
case this.rune == '/':
token.Kind = Comment
skipRune()
if err != nil { return }
if this.rune == '/' {
for this.rune != '\n' {
skipRune()
if err != nil { return }
}
} else {
token.Kind = Slash
}
if this.eof { err = nil; return }
case this.rune == '{':
token.Kind = LBrace
appendRune()
if this.eof { err = nil; return }
case this.rune == '}':
token.Kind = RBrace
appendRune()
if this.eof { err = nil; return }
case this.rune == '[':
token.Kind = LBracket
appendRune()
if this.eof { err = nil; return }
case this.rune == ']':
token.Kind = RBracket
appendRune()
if this.eof { err = nil; return }
case this.rune == '=':
token.Kind = Equals
appendRune()
if this.eof { err = nil; return }
case this.rune == ':':
token.Kind = Colon
appendRune()
if this.eof { err = nil; return }
case this.rune == ',':
token.Kind = Comma
appendRune()
if this.eof { err = nil; return }
case this.rune == ';':
token.Kind = Semicolon
appendRune()
if this.eof { err = nil; return }
case this.rune == '*':
token.Kind = Star
appendRune()
if this.eof { err = nil; return }
case this.rune == '.':
token.Kind = Dot
appendRune()
if this.eof { err = nil; return }
case this.rune == '$':
token.Kind = Dollar
appendRune()
if this.eof { err = nil; return }
case this.rune == '#':
token.Kind = Color
skipRune()
if err != nil { return }
for isHexDigit(this.rune) {
appendRune()
if this.eof { err = nil; return }
}
if this.eof { err = nil; return }
case unicode.IsLetter(this.rune):
token.Kind = Ident
for unicode.IsLetter(this.rune) || unicode.IsNumber(this.rune) {
appendRune()
if this.eof { err = nil; return }
}
if this.eof { err = nil; return }
case this.rune == '-':
token.Kind = Number
appendRune()
for isDigit(this.rune) {
appendRune()
if this.eof { err = nil; return }
}
if this.eof { err = nil; return }
case isDigit(this.rune):
token.Kind = Number
for isDigit(this.rune) {
appendRune()
if this.eof { err = nil; return }
}
if this.eof { err = nil; return }
case this.rune == '\'', this.rune == '"':
stringDelimiter := this.rune
token.Kind = String
err = this.nextRune()
if err != nil { return }
for this.rune != stringDelimiter {
if this.rune == '\\' {
var result rune
result, err = this.escapeSequence(stringDelimiter)
if err != nil { return }
token.Value += string(result)
} else {
appendRune()
if this.eof { err = nil; return }
if err != nil { return }
}
}
err = this.nextRune()
if this.eof { err = nil; return }
if err != nil { return }
default:
err = parse.Errorf (
this.pos(), "unexpected rune %U",
this.rune)
}
return
}
func (this *lexer) nextRune () error {
if this.lineFood == "" {
ok := this.lineScanner.Scan()
if ok {
this.line = this.lineScanner.Text()
this.lineFood = this.line
this.rune = '\n'
this.column = 0
this.row ++
} else {
err := this.lineScanner.Err()
if err == nil {
this.eof = true
return io.EOF
} else {
return err
}
}
} else {
var ch rune
var size int
for ch == 0 && this.lineFood != "" {
ch, size = utf8.DecodeRuneInString(this.lineFood)
this.lineFood = this.lineFood[size:]
}
this.rune = ch
this.column ++
}
return nil
}
func (this *lexer) escapeSequence (stringDelimiter rune) (rune, error) {
err := this.nextRune()
if err != nil { return 0, err }
if isDigit(this.rune) {
var number rune
for index := 0; index < 3; index ++ {
if !isDigit(this.rune) { break }
number *= 8
number += this.rune - '0'
err = this.nextRune()
if err != nil { return 0, err }
}
return number, nil
}
defer this.nextRune()
switch this.rune {
case '\\', '\n', stringDelimiter:
return this.rune, nil
case 'a': return '\a', nil
case 'b': return '\b', nil
case 't': return '\t', nil
case 'n': return '\n', nil
case 'v': return '\v', nil
case 'f': return '\f', nil
case 'r': return '\r', nil
default: return 0, this.errBadEscapeSequence()
}
}
func (this *lexer) skipWhitespace () error {
for isWhitespace(this.rune) {
err := this.nextRune()
if err != nil { return err }
}
return nil
}
func (this *lexer) pos () parse.Position {
return parse.Position {
File: this.filename,
Line: this.lineScanner.Text(),
Row: this.row - 1,
Start: this.column - 1,
End: this.column,
}
}
func (this *lexer) errUnexpectedEOF () error {
return parse.Errorf(this.pos(), "unexpected EOF")
}
func (this *lexer) errBadEscapeSequence () error {
return parse.Errorf(this.pos(), "bad escape sequence")
}
func isWhitespace (char rune) bool {
switch char {
case ' ', '\t', '\r', '\n': return true
default: return false
}
}
func isSymbol (char rune) bool {
switch char {
case
'~', '!', '@', '#', '$', '%', '^', '&', '-', '_', '=', '+',
'\\', '|', ';', ',', '<', '>', '/', '?':
return true
default:
return false
}
}
func isDigit (char rune) bool {
return char >= '0' && char <= '9'
}
func isHexDigit (char rune) bool {
return isDigit(char) ||
char >= 'a' && char <= 'f' ||
char >= 'A' && char <= 'F'
}

View File

@@ -0,0 +1,66 @@
package tss
import "fmt"
import "strings"
import "testing"
import "git.tebibyte.media/sashakoshka/goparse"
func TestLexSimple (test *testing.T) {
testString(test,
`hello #BABE {#Beef}, 384920 #0ab3fc840`,
tok(Ident, "hello"),
tok(Color, "BABE"),
tok(LBrace, "{"),
tok(Color, "Beef"),
tok(RBrace, "}"),
tok(Comma, ","),
tok(Number, "384920"),
tok(Color, "0ab3fc840"),
tok(parse.EOF, ""),
)}
func testString (test *testing.T, input string, correct ...parse.Token) {
lexer := Lex("test.tss", strings.NewReader(input))
index := 0
for {
token, err := lexer.Next()
if err != nil { test.Fatalf("lexer returned error:\n%v", parse.Format(err)) }
if index >= len(correct) {
test.Logf("%d:\t%-16s | !", index, tokStr(token))
test.Fatalf("index %d greater than %d", index, len(correct))
}
correctToken := correct[index]
test.Logf (
"%d:\t%-16s | %s",
index,
tokStr(token),
tokStr(correctToken))
if correctToken.Kind != token.Kind || correctToken.Value != token.Value {
test.Fatalf("tokens at %d do not match up", index)
}
if token.Is(parse.EOF) { break }
index ++
}
if index < len(correct) - 1 {
test.Fatalf("index %d less than %d", index, len(correct) - 1)
}
}
func tokStr (token parse.Token) string {
name, ok := tokenNames[token.Kind]
if !ok {
name = fmt.Sprintf("Token(%d)", token.Kind)
}
if token.Value == "" {
return name
} else {
return fmt.Sprintf("%s:\"%s\"", name, token.Value)
}
}
func tok (kind parse.TokenKind, value string) parse.Token {
return parse.Token {
Kind: kind,
Value: value,
}
}

View File

@@ -0,0 +1,254 @@
package tss
import "io"
import "strconv"
import "git.tebibyte.media/sashakoshka/goparse"
type parser struct {
parse.Parser
sheet Sheet
lexer parse.Lexer
}
func newParser (lexer parse.Lexer) *parser {
return &parser {
sheet: Sheet {
Variables: make(map[string] ValueList),
},
Parser: parse.Parser {
Lexer: lexer,
TokenNames: tokenNames,
},
}
}
func Parse (lexer parse.Lexer) (Sheet, error) {
parser := newParser(lexer)
err := parser.parse()
if err == io.EOF { err = nil }
if err != nil { return Sheet { }, err }
return parser.sheet, nil
}
func (this *parser) parse () error {
err := this.Next()
if err != nil { return err }
for this.Token.Kind != parse.EOF {
err = this.parseTopLevel()
if err != nil { return err }
}
return nil
}
func (this *parser) parseTopLevel () error {
err := this.ExpectDesc("variable or rule", Dollar, Ident, Star)
if err != nil { return err }
if this.EOF() { return nil }
pos := this.Pos()
switch this.Kind() {
case Dollar:
name, variable, err := this.parseVariable()
if err != nil { return err }
if _, exists := this.sheet.Variables[name]; exists {
return parse.Errorf(pos, "variable %s already declared", name)
}
this.sheet.Variables[name] = variable
case Ident, Star:
rule, err := this.parseRule()
if err != nil { return err }
this.sheet.Rules = append(this.sheet.Rules, rule)
}
return nil
}
func (this *parser) parseVariable () (string, ValueList, error) {
err := this.Expect(Dollar)
if err != nil { return "", nil, err }
err = this.ExpectNext(Ident)
if err != nil { return "", nil, err }
name := this.Value()
err = this.ExpectNext(Equals)
if err != nil { return "", nil, err }
this.Next()
values, err := this.parseValueList()
if err != nil { return "", nil, err }
err = this.Expect(Semicolon)
if err != nil { return "", nil, err }
return name, values, this.Next()
}
func (this *parser) parseRule () (Rule, error) {
rule := Rule {
Attrs: make(map[string] []ValueList),
}
selector, err := this.parseSelector()
if err != nil { return Rule { }, err }
rule.Selector = selector
err = this.Expect(LBrace)
if err != nil { return Rule { }, err }
for {
this.Next()
if this.Is(RBrace) { break }
pos := this.Pos()
name, attr, err := this.parseAttr()
if err != nil { return Rule { }, err }
err = this.Expect(Semicolon)
if err != nil { return Rule { }, err }
if _, exists := rule.Attrs[name]; exists {
return Rule { }, parse.Errorf (
pos,
"attribute %s already declared in this rule",
name)
}
rule.Attrs[name] = attr
}
return rule, this.Next()
}
func (this *parser) parseSelector () (Selector, error) {
selector := Selector { }
// package
err := this.ExpectDesc("selector", Ident, Star)
if err != nil { return Selector { }, err }
if this.Is(Ident) {
selector.Package = this.Value()
}
err = this.ExpectNext(Dot)
if err != nil { return Selector { }, err }
// object
err = this.ExpectNext(Ident, Star)
if err != nil { return Selector { }, err }
if this.Is(Ident) {
selector.Object = this.Value()
}
// tags
err = this.ExpectNext(LBracket)
if err == nil {
for {
this.Next()
err := this.Expect(Ident, String, RBracket)
if err != nil { return Selector { }, err }
if this.Is(RBracket) { break }
if this.Is(Comma) { this.Next() }
selector.Tags = append(selector.Tags, this.Value())
err = this.ExpectNext(Comma, RBracket)
if err != nil { return Selector { }, err }
if this.Is(RBracket) { break }
}
this.Next()
}
return selector, nil
}
func (this *parser) parseAttr () (string, []ValueList, error) {
err := this.ExpectDesc("attr", Ident)
if err != nil { return "", nil, err }
name := this.Value()
err = this.ExpectNext(Colon)
if err != nil { return "", nil, err }
attr := []ValueList { }
this.Next()
for {
err := this.ExpectDesc (
"value, Comma, or Semicolon",
Number, Color, String, Ident, Dollar, Slash,
Comma, Semicolon)
if err != nil { return "", nil, err }
if this.Is(Semicolon) { break }
if this.Is(Comma) { this.Next() }
valueList, err := this.parseValueList()
if err != nil { return "", nil, err }
attr = append(attr, valueList)
err = this.Expect(Comma, Semicolon)
if err != nil { return "", nil, err }
}
return name, attr, nil
}
func (this *parser) parseValueList () (ValueList, error) {
list := ValueList { }
for {
err := this.ExpectDesc (
"value",
Number, Color, String, Ident, Dollar, Slash)
if err != nil { break }
switch this.Kind() {
case Number:
number, err := strconv.Atoi(this.Value())
if err != nil { return nil, err }
list = append(list, ValueNumber(number))
case Color:
color, ok := parseColor([]rune(this.Value()))
if !ok {
return nil, parse.Errorf (
this.Pos(),
"malformed color literal")
}
list = append(list, ValueColor(color))
case String:
list = append(list, ValueString(this.Value()))
case Ident:
list = append(list, ValueKeyword(this.Value()))
case Dollar:
err := this.ExpectNext(Ident)
if err != nil { return nil, err }
list = append(list, ValueVariable(this.Value()))
case Slash:
list = append(list, ValueCut { })
}
this.Next()
}
return list, nil
}
func parseColor (runes []rune) (uint32, bool) {
digits := make([]uint32, len(runes))
for index, run := range runes {
digit := hexDigit(run)
if digit < 0 { return 0, false }
digits[index] = uint32(digit)
}
switch len(runes) {
case 3:
return digits[0] << 28 | digits[0] << 24 |
digits[1] << 20 | digits[1] << 16 |
digits[2] << 12 | digits[2] << 8 | 0xFF, true
case 6:
return digits[0] << 28 | digits[1] << 24 |
digits[2] << 20 | digits[3] << 16 |
digits[4] << 12 | digits[5] << 8 | 0xFF, true
case 4:
return digits[0] << 28 | digits[0] << 24 |
digits[1] << 20 | digits[1] << 16 |
digits[2] << 12 | digits[2] << 8 |
digits[3] << 4 | digits[3] << 0, true
case 8:
return digits[0] << 28 | digits[1] << 24 |
digits[2] << 20 | digits[3] << 16 |
digits[4] << 12 | digits[5] << 8 |
digits[6] << 4 | digits[7] << 0, true
default: return 0, false
}
}
func hexDigit (digit rune) int {
switch {
case digit >= '0' && digit <= '9': return int(digit - '0')
case digit >= 'a' && digit <= 'f': return int(digit - 'a') + 10
case digit >= 'A' && digit <= 'F': return int(digit - 'A') + 10
default: return -1
}
}

View File

@@ -0,0 +1,81 @@
package tss
import "os"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/backend/style"
type Sheet struct {
Path string
Variables map[string] ValueList
Rules []Rule
flat bool
}
type Rule struct {
Selector Selector
Attrs map[string] []ValueList
}
type Selector struct {
Package string
Object string
Tags []string
}
type ValueList []Value
type Value interface {
value ()
}
type ValueNumber int
func (ValueNumber) value () { }
type ValueColor uint32
func (ValueColor) value () { }
func (value ValueColor) RGBA () (r, g, b, a uint32) {
// extract components
bits := uint32(value)
r = (bits & 0xFF000000) >> 24
g = (bits & 0x00FF0000) >> 16
b = (bits & 0x0000FF00) >> 8
a = (bits & 0x000000FF)
// extend to 16 bits per channel
r = r << 8 | r
g = g << 8 | g
b = b << 8 | b
a = a << 8 | a
// alpha premultiply
r = (r * a) / 0xFFFF
g = (g * a) / 0xFFFF
b = (b * a) / 0xFFFF
return
}
type ValueString string
func (ValueString) value () { }
type ValueKeyword string
func (ValueKeyword) value () { }
type ValueVariable string
func (ValueVariable) value () { }
type ValueCut struct { }
func (ValueCut) value () { }
// LoadFile loads the stylesheet from the specified file. This may return a
// parse.Error, so use parse.Format to print it.
func LoadFile (name string) (*style.Style, event.Cookie, error) {
// TODO check cache for gobbed sheet. if the cache is nonexistent or
// invalid, then open/load/cache.
file, err := os.Open(name)
if err != nil { return nil, nil, err }
defer file.Close()
sheet, err := Parse(Lex(name, file))
if err != nil { return nil, nil, err }
sheet.Path = name
return BuildStyle(sheet)
}

View File

@@ -0,0 +1,35 @@
# Happen to use Micro to edit text?
# Drop this in ~/.config/micro/syntax and get syntax highlighting for TSS files!
filetype: tss
detect:
filename: "\\.tss$"
rules:
- type: "\\b([A-Z][a-zA-Z0-9]*).*:"
- identifier.var: "\\$[a-zA-Z0-9]*\\b"
- identifier.class: "(\\*|[a-z][a-zA-Z0-9]*)\\.(\\*|[A-Z][a-zA-Z0-9]*)"
- constant: "\\b(tile|center)\\b"
- constant: "\\b(start|middle|end|even)\\b"
- constant.bool: "\\b(true|false)\\b"
- special: "(\\/|,|\\;|:|\\.)"
- symbol.operator: "(=|\\*)"
- symbol.brackets: "(\\{\\[|\\}\\])"
- comment:
start: "//"
end: "$"
rules:
- todo: "(TODO|XXX|FIXME|BUG):?"
- constant.string:
start: "\""
end: "\""
skip: "\\\\."
rules:
- constant.specialChar: "\\\\[abfnrtv'\\\"\\\\]"
- constant.specialChar: "\\\\([0-7]{3})"
- constant.number: "\\b[0-9][0-9.]*\\b"
- constant.string: "\\B#[0-9a-fA-F]*"

10
internal/util/util.go Normal file
View File

@@ -0,0 +1,10 @@
package util
import "git.tebibyte.media/tomo/tomo/data"
func GeneralizeXDGIconMimeType (mime data.Mime) data.Mime {
// FIXME make this more accurate
// https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html
mime.Subtype = "x-generic"
return mime
}

33
manager.go Normal file
View File

@@ -0,0 +1,33 @@
package nasin
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event"
var manager struct {
count int
}
type funcCookie func ()
func (cookie funcCookie) Close () {
cookie()
}
// WaitFor ensures that the application will stay running while the given window
// is open.
func WaitFor (window tomo.Window) event.Cookie {
manager.count ++
isManaged := true
handleClose := func () {
if !isManaged { return }
isManaged = false
manager.count --
if manager.count < 1 {
tomo.Stop()
}
}
return event.MultiCookie (
window.OnClose(handleClose),
funcCookie(handleClose))
}

26
path.go
View File

@@ -10,12 +10,28 @@ func ApplicationUserDataDir (app ApplicationDescription) (string, error) {
return userMkdirAll(app.ID, userDataDir) return userMkdirAll(app.ID, userDataDir)
} }
// ApplicationSystemDataDirs returns a list of directory paths where an
// 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)
}
// ApplicationUserConfigDir returns the directory path where an application can // ApplicationUserConfigDir returns the directory path where an application can
// store its user configuration files. // store its user configuration files.
func ApplicationUserConfigDir (app ApplicationDescription) (string, error) { func ApplicationUserConfigDir (app ApplicationDescription) (string, error) {
return userMkdirAll(app.ID, userConfigDir) return userMkdirAll(app.ID, userConfigDir)
} }
// ApplicationSystemDataDirs returns a list of directory paths where an
// 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)
}
// ApplicationUserCacheDir returns the directory path where an application can // ApplicationUserCacheDir returns the directory path where an application can
// store its user cache files. // store its user cache files.
func ApplicationUserCacheDir (app ApplicationDescription) (string, error) { func ApplicationUserCacheDir (app ApplicationDescription) (string, error) {
@@ -30,3 +46,13 @@ func userMkdirAll (sub string, getter func () (string, error)) (string, error) {
if err != nil { return "", err } if err != nil { return "", err }
return path, nil return path, nil
} }
func systemDirs (sub string, getter func () ([]string, error)) ([]string, error) {
paths, err := getter()
if err != nil { return nil, err }
specificPaths := make([]string, len(paths))
for index, path := range paths {
specificPaths[index] = filepath.Join(path, sub)
}
return specificPaths, nil
}

View File

@@ -7,10 +7,18 @@ func userDataDir () (string, error) {
return basedir.DataHome() return basedir.DataHome()
} }
func systemDataDirs () ([]string, error) {
return basedir.DataDirs()
}
func userConfigDir () (string, error) { func userConfigDir () (string, error) {
return basedir.ConfigHome() return basedir.ConfigHome()
} }
func systemConfigDirs () ([]string, error) {
return basedir.ConfigDirs()
}
func userCacheDir () (string, error) { func userCacheDir () (string, error) {
return basedir.CacheHome() return basedir.CacheHome()
} }