diff --git a/README.md b/README.md index b2dba47..1d64a4d 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,8 @@ My custom status for i3bar. -There's no configuration files, everything is decided at compile time. - -I don't even use this anymore, but I also don't want to archive it just -yet. - -## Compile-time requirements - -* Go compiler. See the `go.mod' file to know which version I was using - last time I updated this repository. - -## Runtime requirements - -* A running instance of MPD. Take a look at the `cmd/dsts' package to - know which port number is expected. - -## What is being displayed - -* Current date and time in the same format as Skyrim. -* Song currently playing in MPD. - -## How it works - -Each `component' is a function receiving a channel as its only argument. -Each time a status is sent to that channel, a refresh is triggered. - -The main program coordinates all those updates, and sends new messages -when necessary. - -This allows components to each have their own `refresh rate', instead of -using a single global refresh rate for everything. +There's runtime no configuration files. The whole point of this, is to +have a single executable that just works. ## License diff --git a/cmd/dsts/main.go b/cmd/dsts/main.go index 47c2e86..ceea3c7 100644 --- a/cmd/dsts/main.go +++ b/cmd/dsts/main.go @@ -4,78 +4,83 @@ package main import ( + "context" "encoding/json" + "errors" "os" - "sync" + "sync/atomic" "github.com/c032/dsts" - "github.com/c032/dsts/mpd" - "github.com/c032/dsts/tamrieltime" + dststime "github.com/c032/dsts/time" ) -const ( - // mpdAddr is the address where MPD is listening in. - mpdAddr = ":6600" +func main() { + ctx := context.Background() - // mpdStatusMaxWidth is the maximum width, in characters, allowed for the - // MPD status. - mpdStatusMaxWidth = 80 -) + timeSource := &dststime.Source{} + + sources := []dsts.Source{ + timeSource, + } -func main() { // Active providers, in the same order that they will be displayed in the // i3bar. // // Providers with a lower index will be displayed somewhere to the left of // providers with a higher index. - providers := []dsts.Provider{ - dsts.Wrap( - mpd.Dial(mpdAddr), - mpdStatusMaxWidth, - ), - tamrieltime.TamrielTime, + mutableStatusItems := []*atomic.Pointer[dsts.I3Status]{ + &timeSource.StatusUnix, + &timeSource.StatusDateTime, } - enc := json.NewEncoder(os.Stdout) - - os.Stdout.Write([]byte(`{"version":1}[[]`)) - // tick is only used to notify when the status line must be updated. tick := make(chan struct{}) - // mu prevents multiple goroutines from modifying `i3sts` at the same time. - mu := sync.RWMutex{} + for _, source := range sources { + _ = source.OnUpdate(func() { + tick <- struct{}{} + }) + } - // i3sts is what will be serialized as JSON and printed to standard output. - i3sts := make([]dsts.I3Status, len(providers)) + enc := json.NewEncoder(os.Stdout) - // Run each provider on their own goroutine, coordinate updates to the - // i3status line, and trigger a refresh when necessary. - for i, p := range providers { - ch := make(chan dsts.I3Status) + os.Stdout.Write([]byte(`{"version":1}[[]`)) - // Run the provider, usually forever. - go p(ch) + // visibleStatusItems is what will be serialized as JSON and printed to + // standard output. + visibleStatusItems := make([]dsts.I3Status, len(mutableStatusItems)) - // For each update from the provider, update the current status and - // trigger a refresh. - go func(i int, p dsts.Provider) { - for status := range ch { - mu.Lock() - i3sts[i] = status - mu.Unlock() + // Wait for a refresh to trigger, and update the status line. + for range tick { + for i, itemPtr := range mutableStatusItems { + if itemPtr == nil { + continue + } - tick <- struct{}{} + v := itemPtr.Load() + if v == nil { + continue } - }(i, p) - } - // Wait for a refresh to trigger, and update the status line. - for range tick { + visibleStatusItems[i] = *v + } + os.Stdout.Write([]byte{','}) - mu.RLock() - _ = enc.Encode(i3sts) - mu.RUnlock() + _ = enc.Encode(visibleStatusItems) + } + + <-ctx.Done() + + err := ctx.Err() + if err != nil { + if !errors.Is(err, context.Canceled) { + os.Exit(1) + } + + cause := context.Cause(ctx) + if cause != nil && cause != err { + os.Exit(1) + } } } diff --git a/color.go b/color.go new file mode 100644 index 0000000..e05931d --- /dev/null +++ b/color.go @@ -0,0 +1,36 @@ +package dsts + +import ( + "errors" +) + +var ErrInvalidColor = errors.New("invalid color") + +const ( + DefaultStatusColor = "#999999" + DefaultStatusColorError = "#e20024" +) + +func isNumber(c rune) bool { + return c >= '0' && c <= '9' +} + +func IsValidColor(color string) bool { + if len(color) != len("#000") && len(color) != len("#000000") { + return false + } + if color[0] != '#' { + return false + } + + for i, c := range color { + if i == 0 { + continue + } + if !isNumber(c) { + return false + } + } + + return true +} diff --git a/mpd/mpd.go b/mpd/mpd.go deleted file mode 100644 index 8412acd..0000000 --- a/mpd/mpd.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package mpd implements a dsts.Provider for displaying the currently playing -// song from MPD. -// -// See . -package mpd - -import ( - "bufio" - "errors" - "fmt" - "net" - "strings" - "time" - - "github.com/c032/dsts" -) - -const ( - colorError = "#e20024" - colorNormal = "#ffffff" - - reconnectInterval = 1 * time.Second - refreshInterval = 300 * time.Millisecond -) - -// makeError creates a status from an error. -func makeError(err error) dsts.I3Status { - return dsts.I3Status{ - FullText: err.Error(), - Color: colorError, - } -} - -// Dial returns a `dsts.Provider` for an MPD listening on `addr`. -func Dial(addr string) dsts.Provider { - return func(ch chan<- dsts.I3Status) { - // Infinite loop so we always try to reconnect when some error occurs. - for ; ; time.Sleep(reconnectInterval) { - // Wrap iterations in a function just so that we can `defer` - // without problem. - // - // Returning from this function triggers a reconnect. - func() { - conn, err := net.Dial("tcp", addr) - if err != nil { - ch <- makeError(err) - - return - } - defer conn.Close() - - sc := bufio.NewScanner(conn) - if !sc.Scan() { - ch <- makeError(errors.New("unexpected eof")) - - return - } - - greeting := sc.Text() - if !strings.HasPrefix(greeting, "OK MPD ") { - ch <- makeError(errors.New("unexpected mpd response")) - - return - } - - // - // We're ready to interact with MPD. - // - - for ; ; time.Sleep(refreshInterval) { - isPlaying := false - - // Check whether MPD is currently playing something. - fmt.Fprintf(conn, "status\n") - for sc.Scan() { - line := sc.Text() - if line == "OK" { - break - } - - components := strings.Split(line, ":") - if len(components) == 2 { - key := strings.ToLower(strings.TrimSpace(components[0])) - value := strings.ToLower(strings.TrimSpace(components[1])) - - if key == "state" && value == "play" { - isPlaying = true - } - } - } - - err = sc.Err() - if err != nil { - ch <- makeError(err) - - return - } - - // Hide the component while nothing is being played. - if !isPlaying { - ch <- dsts.I3Status{ - FullText: "", - Color: colorNormal, - } - - continue - } - - // - // Retrieve song information. - // - // We need to read lines until we get a line containing - // only "OK". - // - - var ( - artist string - album string - title string - ) - - fmt.Fprintf(conn, "currentsong\n") - for sc.Scan() { - line := sc.Text() - if line == "OK" { - break - } - - components := strings.SplitN(line, ":", 2) - if len(components) == 2 { - key := strings.ToLower(strings.TrimSpace(components[0])) - value := strings.TrimSpace(components[1]) - - switch key { - case "artist": - artist = value - case "album": - album = value - case "title": - title = value - } - } - } - - if title == "" { - continue - } - - // - // Format the output message and send it. - // - - var currentlyPlaying string - if album != "" { - currentlyPlaying = "(" + album + ")" - } - if artist != "" { - currentlyPlaying = strings.TrimSpace(artist+" "+currentlyPlaying) + " -" - } - - currentlyPlaying = strings.TrimSpace(currentlyPlaying + " " + title) - - ch <- dsts.I3Status{ - FullText: currentlyPlaying, - Color: colorNormal, - } - } - }() - } - } -} diff --git a/provider.go b/provider.go deleted file mode 100644 index ed67ebf..0000000 --- a/provider.go +++ /dev/null @@ -1,8 +0,0 @@ -package dsts - -import "context" - -// Provider is a function that pushes updates to `ch`. -// -// A provider is expected to block until `ctx` is cancelled. -type Provider func(ctx context.Context, ch chan<- I3Status) error diff --git a/source.go b/source.go new file mode 100644 index 0000000..3d069f9 --- /dev/null +++ b/source.go @@ -0,0 +1,15 @@ +package dsts + +type Source interface { + OnUpdate(callback OnUpdateCallbackFunc) RemoveOnUpdateCallbackFunc +} + +type OnUpdateCallbackFunc func() + +type RemoveOnUpdateCallbackFunc func() + +type sourceOnUpdateFunc func(callback OnUpdateCallbackFunc) RemoveOnUpdateCallbackFunc + +func (fn sourceOnUpdateFunc) OnUpdate(callback OnUpdateCallbackFunc) RemoveOnUpdateCallbackFunc { + return fn(callback) +} diff --git a/statusprovider.go b/statusprovider.go new file mode 100644 index 0000000..d523121 --- /dev/null +++ b/statusprovider.go @@ -0,0 +1,47 @@ +package dsts + +import ( + "context" + "sync/atomic" +) + +type StatusProviderFunc func(ctx context.Context, ch chan<- I3Status) error + +func StatusProvider(ctx context.Context, p StatusProviderFunc) (Source, *atomic.Pointer[I3Status]) { + statusProvider := &atomic.Pointer[I3Status]{} + + source := sourceOnUpdateFunc(func(callback OnUpdateCallbackFunc) RemoveOnUpdateCallbackFunc { + ch := make(chan I3Status) + + ctxProvider, cancel := context.WithCancelCause(ctx) + + go func(ctxProvider context.Context, ch chan<- I3Status) { + err := p(ctxProvider, ch) + if err != nil { + cancel(err) + } + + cancel(nil) + }(ctxProvider, ch) + + go func() { + for { + select { + case <-ctx.Done(): + return + case status := <-ch: + statusProvider.Store(&status) + callback() + } + } + }() + + remove := func() { + cancel(nil) + } + + return remove + }) + + return source, statusProvider +} diff --git a/tamrieltime/tamrieltime.go b/tamrieltime/tamrieltime.go index 0c47634..75584aa 100644 --- a/tamrieltime/tamrieltime.go +++ b/tamrieltime/tamrieltime.go @@ -5,6 +5,7 @@ package tamrieltime import ( + "context" "fmt" "time" @@ -61,15 +62,29 @@ func Format(t time.Time) string { return fmt.Sprintf("%s, %s, %d%s of %s", weekdays[t.Weekday()], timeStr, day, daySuffix, months[t.Month()]) } -var _ dsts.Provider = TamrielTime +var _ dsts.StatusProviderFunc = TamrielTime // TamrielTime is a `dsts.Provider` for displaying the current date and time in // the format used by Skyrim. -func TamrielTime(ch chan<- dsts.I3Status) { - for ; ; time.Sleep(500 * time.Millisecond) { - ch <- dsts.I3Status{ - FullText: Format(time.Now()), - Color: "#999999", +func TamrielTime(ctx context.Context, ch chan<- dsts.I3Status) error { + firstTick := make(chan struct{}) + go func() { + firstTick <- struct{}{} + }() + for { + select { + case <-ctx.Done(): + return nil + case <-firstTick: + ch <- dsts.I3Status{ + FullText: Format(time.Now()), + Color: "#999999", + } + case <-time.After(500 * time.Millisecond): + ch <- dsts.I3Status{ + FullText: Format(time.Now()), + Color: "#999999", + } } } } diff --git a/time/time.go b/time/time.go new file mode 100644 index 0000000..9a174b8 --- /dev/null +++ b/time/time.go @@ -0,0 +1,108 @@ +package time + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/c032/dsts" +) + +type Source struct { + sync.RWMutex + + Context context.Context + + ticker *time.Ticker + callbacks sync.Map + nextKey int64 + + StatusUnix atomic.Pointer[dsts.I3Status] + StatusDateTime atomic.Pointer[dsts.I3Status] +} + +func (t *Source) runCallbacks() { + t.callbacks.Range(func(key any, value any) bool { + const shouldContinue = true + + // We want this to panic if the conversion can't be done, because that + // would mean there's a bug somewhere. + callback := value.(dsts.OnUpdateCallbackFunc) + + callback() + + return shouldContinue + }) +} + +func (t *Source) update(now time.Time) { + const ( + suffix = " ยท " + color = dsts.DefaultStatusColor + ) + + t.StatusUnix.Store(&dsts.I3Status{ + FullText: fmt.Sprintf("@%d", now.Unix()), + Color: color, + }) + + t.StatusDateTime.Store(&dsts.I3Status{ + FullText: now.Format("2006-01-02 15:04:05") + suffix, + Color: color, + }) +} + +func (t *Source) init() { + if t.Context == nil { + t.Context = context.Background() + } + + ctx := t.Context + + if t.ticker == nil { + t.ticker = time.NewTicker(200 * time.Millisecond) + + go func(ctx context.Context, tick <-chan time.Time) { + firstTick := make(chan struct{}) + go func() { + firstTick <- struct{}{} + }() + + onTick := func() { + now := time.Now() + t.update(now) + t.runCallbacks() + } + + for { + select { + case <-ctx.Done(): + return + case <-firstTick: + onTick() + case <-tick: + onTick() + } + } + }(ctx, t.ticker.C) + } +} + +func (t *Source) OnUpdate(callback dsts.OnUpdateCallbackFunc) dsts.RemoveOnUpdateCallbackFunc { + t.Lock() + defer t.Unlock() + + t.init() + + key := t.nextKey + t.nextKey++ + t.callbacks.Store(key, callback) + + removeCallback := func() { + t.callbacks.Delete(key) + } + + return removeCallback +} diff --git a/wrap.go b/wrap.go index 4181a90..a3a901d 100644 --- a/wrap.go +++ b/wrap.go @@ -1,6 +1,7 @@ package dsts import ( + "context" "time" ) @@ -20,12 +21,15 @@ const ( // in characters, the wrapper will output at most `width` characters, and will // automatically "scroll" back and forth, so the full status can be eventually // read. -func Wrap(p Provider, width int) Provider { - return func(chOut chan<- I3Status) { +func Wrap(p StatusProviderFunc, width int) StatusProviderFunc { + return func(ctx context.Context, chOut chan<- I3Status) error { + chError := make(chan error) chIn := make(chan I3Status) // Start the inner provider. - go p(chIn) + go func(chError chan<- error) { + chError <- p(ctx, chIn) + }(chError) var ( sts I3Status @@ -41,9 +45,14 @@ func Wrap(p Provider, width int) Provider { offset := 0 direction := 1 + var innerError error + Scroll: for { select { + case innerError = <-chError: + return innerError + // The underlying provider sent a new message. case sts = <-chIn: // If the current message is the same as the previous one,