diff --git a/application.go b/application.go index ccec8c6..1564d6f 100644 --- a/application.go +++ b/application.go @@ -6,8 +6,10 @@ import "flag" import "image" import "strings" import "net/url" +import "path/filepath" import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/objects" +import "git.tebibyte.media/tomo/nasin/config" import "git.tebibyte.media/tomo/nasin/internal/registrar" // Application represents an application object. @@ -180,6 +182,20 @@ func NewApplicationWindow (application Application, bounds image.Rectangle) (tom return window, nil } +// ApplicationConfig opens a new config for the specified application. It must +// be closed when it is no longer needed. +func ApplicationConfig (app ApplicationDescription) (config.ConfigCloser, error) { + 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...) +} + func errorPopupf (title, format string, v ...any) func (func ()) { return func (callback func ()) { dialog, err := objects.NewDialogOk ( diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4e2391d --- /dev/null +++ b/config/config.go @@ -0,0 +1,142 @@ +// 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. + 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. + 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 +} + +// ValueString is a string value. +type ValueString string +var _ Value = ValueString("") +func (ValueString) value () { } +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) 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) String () string { + if value { + return "true" + } else { + return "false" + } +} diff --git a/config/escape.go b/config/escape.go new file mode 100644 index 0000000..c411b3b --- /dev/null +++ b/config/escape.go @@ -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 +} diff --git a/config/escape_test.go b/config/escape_test.go new file mode 100644 index 0000000..b4e7cf3 --- /dev/null +++ b/config/escape_test.go @@ -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) + } +} diff --git a/config/file.go b/config/file.go new file mode 100644 index 0000000..a99a4a1 --- /dev/null +++ b/config/file.go @@ -0,0 +1,231 @@ +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 } + +// 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. +// +// The general format of the file is as follows: +// - Encoded in UTF-8 +// - Consists of lines, separated by \n, or \r\n +// - Lines can be any of these: +// - Blank line: has only whitespace +// - Comment: begins with a '#' +// - Entry: a key/value pair separated by an '=' sign +// +// For entries, all whitespace on either side of the '=' sign, the key, or the +// value is ignored. The key may contain any letter or digit, as well as '-' +// and '.'. The value is always identified by its first rune (after the +// preliminary whitespace of course) and can be one of: +// - String: either a double-quoted string, or any string of runes not +// identifiable as any other kind of value. The quoted string is always +// unquoted when it is read. Either way, these escape sequences are +// supported, and resolved when they are read: +// - '\\': a literal backslash +// - '\a': alert, bell +// - '\b': backspace +// - '\t': horizontal tab +// - '\n': line feed +// - '\v': vertical tab +// - '\f': form feed +// - '\r': carriage return +// - '\"': double quote +// - Number: a floating point value. It can be of the form: +// - Inf +// - -Inf +// - NaN +// - [0-9]+ +// - [0-9]+\.[0-9]* +// - Bool: a boolean value. It can be one of: +// - true +// - false +// +// Be aware that some unquoted strings, within reason, are subject to being read +// as some other value in the future. For example, if there were suddenly a +// third boolean value called glorble, the unquoted string glorble would be read +// as a boolean value instead of a string. +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 + } +} + +// Get gets the keyed value. If the value is unspecified, it returns nil, +// ErrNonexistentEntry. If the key is invalid, it returns nil, ErrMalformedKey. +func (this *File) Get (key string) (Value, error) { + if !KeyValid(key) { return nil, ErrMalformedKey } + if index, ok := this.keys[key]; ok { + if lin := this.lines[index].(entry); ok { + 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 } + if index, ok := this.keys[key]; ok { + ent := this.lines[index].(entry) + ent.value = value + this.lines[index] = ent + return nil + } + this.keys[key] = len(this.lines) + this.lines = append(this.lines, value) + 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 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 +} + +// WriteTo writes the data in this file to an io.Writer. +func (file *File) WriteTo (writer io.Writer) (n int64, err error) { + for _, lin := range file.lines { + 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 +} diff --git a/config/file_test.go b/config/file_test.go new file mode 100644 index 0000000..0d081c4 --- /dev/null +++ b/config/file_test.go @@ -0,0 +1,146 @@ +package config + +import "math" +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 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() + } +} diff --git a/config/impl.go b/config/impl.go new file mode 100644 index 0000000..21529f7 --- /dev/null +++ b/config/impl.go @@ -0,0 +1,210 @@ +package config + +import "os" +import "log" +import "sync" +import "slices" +import "path/filepath" +import "github.com/fsnotify/fsnotify" +import "git.tebibyte.media/tomo/tomo/event" + +type config struct { + open bool + watcher *fsnotify.Watcher + + paths struct { + user string + system []string + watching map[string] struct { } + } + + data struct { + user *File + system []map[string] Value + } + + on struct { + lock sync.RWMutex + 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 conf.run() + return conf, nil +} + +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) run () { + for event := range this.watcher.Events { + if !(event.Has(fsnotify.Write)) { continue } + if _, ok := this.paths.watching[event.Name]; !ok { continue } + + if event.Name == this.paths.user { + this.reloadUser() + } else { + index := slices.Index(this.paths.system, event.Name) + if index > 0 { + this.reloadSystem(index) + } + } + + // TODO diff and call event handler if changed + } +} + +func (this *config) Close () error { + this.open = false + return this.watcher.Close() +} + +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 } + return nil +} + +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) GetString (key string, fallback string) (string, error) { + 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) { + 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) { + 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 { + if this.data.user == nil { this.data.user = NewFile() } + err := this.data.user.Set(key, value) + if err != nil { return err } + return this.saveUser() +} + +func (this *config) Reset (key string) error { + if this.data.user == nil { this.data.user = NewFile() } + err := this.data.user.Reset(key) + if err != nil { return err } + return this.saveUser() +} + +func (this *config) OnChange (callback func (string)) event.Cookie { + this.on.lock.Lock() + defer this.on.lock.Unlock() + return this.on.change.Connect(callback) +} diff --git a/go.mod b/go.mod index 7cee33d..927a8ee 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( git.tebibyte.media/tomo/objects v0.22.0 git.tebibyte.media/tomo/tomo v0.46.1 git.tebibyte.media/tomo/xdg v0.1.0 + github.com/fsnotify/fsnotify v1.7.0 golang.org/x/image v0.11.0 ) @@ -18,4 +19,5 @@ require ( github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect github.com/jezek/xgb v1.1.1 // indirect github.com/jezek/xgbutil v0.0.0-20231116234834-47f30c120111 // indirect + golang.org/x/sys v0.5.0 // indirect ) diff --git a/go.sum b/go.sum index 042a957..4389f4b 100644 --- a/go.sum +++ b/go.sum @@ -17,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/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/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.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= @@ -42,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-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.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=