From ade8203c21cd59e0af17a81f2f7aee28dcb8cd92 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2020 17:56:30 -0400 Subject: [PATCH] Remove entire subscription model It was a valiant effort, and the implementation was solid and dependable, but at the end of the day we can achieve the same functionality in a much simpler fashion with commands, especially because Go is not held to the same restrictions as Elm. --- boba.go | 168 ++++++++++-------------------------- examples/fullscreen/main.go | 21 ++--- examples/http/main.go | 2 +- examples/input/main.go | 17 +--- examples/pager/main.go | 6 +- examples/simple/main.go | 26 +++--- examples/spinner/main.go | 30 ++----- examples/textinputs/main.go | 58 ++++++------- examples/views/main.go | 43 ++++----- pager/pager.go | 9 -- spinner/spinner.go | 22 ++--- subscriptions.go | 32 ------- textinput/textinput.go | 24 ++---- 13 files changed, 136 insertions(+), 322 deletions(-) delete mode 100644 subscriptions.go diff --git a/boba.go b/boba.go index 9e53129..8da8b2f 100644 --- a/boba.go +++ b/boba.go @@ -4,6 +4,7 @@ import ( "io" "os" "strings" + "time" "github.com/muesli/termenv" ) @@ -29,41 +30,6 @@ func Batch(cmds ...Cmd) Cmd { } } -// Sub is an event subscription; generally a recurring IO operation. If it -// returns nil it's considered a no-op, but there's really no reason to have -// a nil subscription. -type Sub func() Msg - -// Subs is a keyed set of subscriptions. The key should be a unique -// identifier: two different subscriptions should not have the same key or -// weird behavior will occur. -type Subs map[string]Sub - -// Subscriptions returns a map of subscriptions (Subs) our application will -// subscribe to. If Subscriptions is nil it's considered a no-op. -type Subscriptions func(Model) Subs - -// subscription is an internal reference to a subscription used in subscription -// management. -type subscription struct { - done chan struct{} - sub Sub -} - -// subManager is used to manage active subscriptions, hence the pointers. -type subManager map[string]*subscription - -// endAll stops all subscriptions and remove subscription references from -// subManager. -func (m *subManager) endAll() { - if m != nil { - for key, sub := range *m { - close(sub.done) - delete(*m, key) - } - } -} - // Init is the first function that will be called. It returns your initial // model and runs an optional command type Init func() (Model, Cmd) @@ -77,10 +43,9 @@ type View func(Model) string // Program is a terminal user interface type Program struct { - init Init - update Update - view View - subscriptions Subscriptions + init Init + update Update + view View } // Quit is a command that tells the program to exit @@ -95,12 +60,11 @@ type quitMsg struct{} type batchMsg []Cmd // NewProgram creates a new Program -func NewProgram(init Init, update Update, view View, subs Subscriptions) *Program { +func NewProgram(init Init, update Update, view View) *Program { return &Program{ - init: init, - update: update, - view: view, - subscriptions: subs, + init: init, + update: update, + view: view, } } @@ -109,7 +73,6 @@ func (p *Program) Start() error { var ( model Model cmd Cmd - subs = make(subManager) cmds = make(chan Cmd) msgs = make(chan Msg) errs = make(chan error) @@ -134,9 +97,7 @@ func (p *Program) Start() error { // Render initial view linesRendered = p.render(model, linesRendered) - // Subscribe to user input. We could move this out of here and offer it - // as a subscription, but it blocks nicely and seems to be a common enough - // need that we're enabling it by default. + // Subscribe to user input go func() { for { msg, err := ReadKey(os.Stdin) @@ -147,9 +108,6 @@ func (p *Program) Start() error { } }() - // Initialize subscriptions - subs = p.processSubs(msgs, model, subs) - // Process commands go func() { for { @@ -190,7 +148,6 @@ func (p *Program) Start() error { model, cmd = p.update(msg, model) // run update cmds <- cmd // process command (if any) - subs = p.processSubs(msgs, model, subs) // check for new and outdated subscriptions linesRendered = p.render(model, linesRendered) // render to terminal } } @@ -211,78 +168,6 @@ func (p *Program) render(model Model, linesRendered int) int { return strings.Count(view, "\r\n") } -// Manage subscriptions. Here we run the program's Subscription function and -// inspect the functions it returns (a Subs map). If we notice existing -// subscriptions have disappeared from the map we stop those subscriptions -// by ending the Goroutines they run on. If we notice new subscriptions which -// aren't currently running, we run them as loops in a new Goroutine. -// -// This function should be called on initialization and after every update. -func (p *Program) processSubs(msgs chan Msg, model Model, activeSubs subManager) subManager { - - // Nothing to do. - if p.subscriptions == nil && activeSubs == nil { - return activeSubs - } - - // There are no subscriptions. Cancel active ones and return. - if p.subscriptions == nil && activeSubs != nil { - activeSubs.endAll() - return activeSubs - } - - newSubs := p.subscriptions(model) - - // newSubs is an empty map. Cancel any active subscriptions and return. - if newSubs == nil { - activeSubs.endAll() - return activeSubs - } - - // Stop subscriptions that don't exist in the new subscription map and - // stop subscriptions where the new subscription is mapped to a nil. - if len(activeSubs) > 0 { - for key, sub := range activeSubs { - _, exists := newSubs[key] - if !exists || exists && newSubs[key] == nil { - close(sub.done) - delete(activeSubs, key) - } - } - } - - // Start new subscriptions if they don't exist in the active subscription map - if len(newSubs) > 0 { - for key, sub := range newSubs { - if _, exists := activeSubs[key]; !exists { - - if sub == nil { - continue - } - - activeSubs[key] = &subscription{ - done: make(chan struct{}), - sub: sub, - } - - go func(done chan struct{}, s Sub) { - for { - select { - case <-done: - return - case msgs <- s(): - continue - } - } - }(activeSubs[key].done, activeSubs[key].sub) - - } - } - } - - return activeSubs -} - // AltScreen exits the altscreen. This is just a wrapper around the termenv // function func AltScreen() { @@ -294,3 +179,38 @@ func AltScreen() { func ExitAltScreen() { termenv.ExitAltScreen() } + +type EveryMsg time.Time + +// Every is a command that ticks in sync with the system clock. So, if you +// wanted to tick with the system clock every second, minute or hour you +// could use this. It's also handy for having different things tick in sync. +// +// Note that because we're ticking with the system clock the tick will likely +// not run for the entire specified duration. For example, if we're ticking for +// one minute and the clock is at 12:34:20 then the next tick will happen at +// 12:35:00, 40 seconds later. +func Every(duration time.Duration, fn func(time.Time) Msg) Cmd { + return func() Msg { + n := time.Now() + d := n.Truncate(duration).Add(duration).Sub(n) + t := time.NewTimer(d) + select { + case now := <-t.C: + return fn(now) + } + } +} + +// Tick is a subscription that at an interval independent of the system clock +// at the given duration. That is, the timer begins when precisely when invoked, +// and runs for its entire duration. +func Tick(d time.Duration, fn func(time.Time) Msg) Cmd { + return func() Msg { + t := time.NewTimer(d) + select { + case now := <-t.C: + return fn(now) + } + } +} diff --git a/examples/fullscreen/main.go b/examples/fullscreen/main.go index e5d678a..445db07 100644 --- a/examples/fullscreen/main.go +++ b/examples/fullscreen/main.go @@ -14,21 +14,17 @@ type model int type tickMsg time.Time -func newTickMsg(t time.Time) boba.Msg { - return tickMsg(t) -} - func main() { boba.AltScreen() defer boba.ExitAltScreen() - err := boba.NewProgram(initialize, update, view, subscriptions).Start() + err := boba.NewProgram(initialize, update, view).Start() if err != nil { log.Fatal(err) } } func initialize() (boba.Model, boba.Cmd) { - return model(5), nil + return model(5), tick() } func update(message boba.Msg, mdl boba.Model) (boba.Model, boba.Cmd) { @@ -51,19 +47,20 @@ func update(message boba.Msg, mdl boba.Model) (boba.Model, boba.Cmd) { if m <= 0 { return m, boba.Quit } + return m, tick() } return m, nil } -func subscriptions(_ boba.Model) boba.Subs { - return boba.Subs{ - "tick": boba.Every(time.Second, newTickMsg), - } -} - func view(mdl boba.Model) string { m, _ := mdl.(model) return fmt.Sprintf("\n\n Hi. This program will exit in %d seconds...", m) } + +func tick() boba.Cmd { + return boba.Tick(time.Second, func(t time.Time) boba.Msg { + return tickMsg(t) + }) +} diff --git a/examples/http/main.go b/examples/http/main.go index 67c6970..62f1dc2 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -23,7 +23,7 @@ type statusMsg int type errMsg error func main() { - p := boba.NewProgram(initialize, update, view, nil) + p := boba.NewProgram(initialize, update, view) if err := p.Start(); err != nil { log.Fatal(err) } diff --git a/examples/input/main.go b/examples/input/main.go index 755ec02..571ef47 100644 --- a/examples/input/main.go +++ b/examples/input/main.go @@ -22,7 +22,6 @@ func main() { initialize, update, view, - subscriptions, ) if err := p.Start(); err != nil { @@ -38,7 +37,7 @@ func initialize() (boba.Model, boba.Cmd) { return Model{ textInput: inputModel, err: nil, - }, nil + }, input.Blink(inputModel) } func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { @@ -74,20 +73,6 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { return m, cmd } -func subscriptions(model boba.Model) boba.Subs { - m, ok := model.(Model) - if !ok { - return nil - } - sub, err := input.MakeSub(m.textInput) - if err != nil { - return nil - } - return boba.Subs{ - "input": sub, - } -} - func view(model boba.Model) string { m, ok := model.(Model) if !ok { diff --git a/examples/pager/main.go b/examples/pager/main.go index 8d0affd..8268580 100644 --- a/examples/pager/main.go +++ b/examples/pager/main.go @@ -18,7 +18,11 @@ func main() { boba.AltScreen() defer boba.ExitAltScreen() - if err := pager.NewProgram(string(content)).Start(); err != nil { + if err := boba.NewProgram( + pager.Init(string(content)), + pager.Update, + pager.View, + ).Start(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } diff --git a/examples/simple/main.go b/examples/simple/main.go index aa36169..b09abf1 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -13,7 +13,7 @@ import ( // A model can be more or less any type of data. It holds all the data for a // program, so often it's a struct. For this simple example, however, all // we'll need is a simple integer. -type Model int +type model int // Messages are events that we respond to in our Update function. This // particular one indicates that the timer has ticked. @@ -21,22 +21,22 @@ type tickMsg time.Time func main() { // Initialize our program - p := boba.NewProgram(initialize, update, view, subscriptions) + p := boba.NewProgram(initialize, update, view) if err := p.Start(); err != nil { log.Fatal(err) } } func initialize() (boba.Model, boba.Cmd) { - return Model(5), nil + return model(5), tick } // Update is called when messages are recived. The idea is that you inspect // the message and update the model (or send back a new one) accordingly. You // can also return a commmand, which is a function that peforms I/O and // returns a message. -func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { - m, _ := model.(Model) +func update(msg boba.Msg, mdl boba.Model) (boba.Model, boba.Cmd) { + m, _ := mdl.(model) switch msg.(type) { case boba.KeyMsg: @@ -46,23 +46,19 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { if m <= 0 { return m, boba.Quit } + return m, tick } return m, nil } // Views take data from the model and return a string which will be rendered // to the terminal. -func view(model boba.Model) string { - m, _ := model.(Model) +func view(mdl boba.Model) string { + m, _ := mdl.(model) return fmt.Sprintf("Hi. This program will exit in %d seconds. To quit sooner press any key.\n", m) } -// This is a subscription which we setup in NewProgram(). It waits for one -// second, sends a tick, and then restarts. -func subscriptions(_ boba.Model) boba.Subs { - return boba.Subs{ - "tick": boba.Every(time.Second, func(t time.Time) boba.Msg { - return tickMsg(t) - }), - } +func tick() boba.Msg { + time.Sleep(time.Second) + return tickMsg{} } diff --git a/examples/spinner/main.go b/examples/spinner/main.go index fbd8719..488254b 100644 --- a/examples/spinner/main.go +++ b/examples/spinner/main.go @@ -22,7 +22,7 @@ type Model struct { type errMsg error func main() { - p := boba.NewProgram(initialize, update, view, subscriptions) + p := boba.NewProgram(initialize, update, view) if err := p.Start(); err != nil { fmt.Println(err) os.Exit(1) @@ -30,12 +30,12 @@ func main() { } func initialize() (boba.Model, boba.Cmd) { - m := spinner.NewModel() - m.Type = spinner.Dot + s := spinner.NewModel() + s.Type = spinner.Dot return Model{ - spinner: m, - }, nil + spinner: s, + }, spinner.Tick(s) } func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { @@ -64,8 +64,9 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { return m, nil default: - m.spinner, _ = spinner.Update(msg, m.spinner) - return m, nil + var cmd boba.Cmd + m.spinner, cmd = spinner.Update(msg, m.spinner) + return m, cmd } } @@ -88,18 +89,3 @@ func view(model boba.Model) string { } return str } - -func subscriptions(model boba.Model) boba.Subs { - m, ok := model.(Model) - if !ok { - return nil - } - - sub, err := spinner.MakeSub(m.spinner) - if err != nil { - return nil - } - return boba.Subs{ - "tick": sub, - } -} diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go index f99eebe..75eb85b 100644 --- a/examples/textinputs/main.go +++ b/examples/textinputs/main.go @@ -23,7 +23,6 @@ func main() { initialize, update, view, - subscriptions, ).Start(); err != nil { fmt.Printf("could not start program: %s\n", err) os.Exit(1) @@ -53,7 +52,13 @@ func initialize() (boba.Model, boba.Cmd) { email.Placeholder = "Email" email.Prompt = blurredPrompt - return Model{0, name, nickName, email, blurredSubmitButton}, nil + return Model{0, name, nickName, email, blurredSubmitButton}, + boba.Batch( + input.Blink(name), + input.Blink(nickName), + input.Blink(email), + ) + } func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { @@ -62,6 +67,8 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { panic("could not perform assertion on model") } + var cmd boba.Cmd + switch msg := msg.(type) { case boba.KeyMsg: @@ -135,44 +142,29 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { default: // Handle character input - m = updateInputs(msg, m) - return m, nil + m, cmd = updateInputs(msg, m) + return m, cmd } default: // Handle blinks - m = updateInputs(msg, m) - return m, nil + m, cmd = updateInputs(msg, m) + return m, cmd } } -func updateInputs(msg boba.Msg, m Model) Model { - m.nameInput, _ = input.Update(msg, m.nameInput) - m.nickNameInput, _ = input.Update(msg, m.nickNameInput) - m.emailInput, _ = input.Update(msg, m.emailInput) - return m -} - -func subscriptions(model boba.Model) boba.Subs { - m, ok := model.(Model) - if !ok { - return nil - } - - // It's a little hacky, but we're using the subscription from one - // input element to handle the blinking for all elements. It doesn't - // have to be this way, we're just feeling a bit lazy at the moment. - inputSub, err := input.MakeSub(m.nameInput) - if err != nil { - return nil - } - - return boba.Subs{ - // It's a little hacky, but we're using the subscription from one - // input element to handle the blinking for all elements. It doesn't - // have to be this way, we're just feeling a bit lazy at the moment. - "blink": inputSub, - } +func updateInputs(msg boba.Msg, m Model) (Model, boba.Cmd) { + var ( + cmd boba.Cmd + cmds []boba.Cmd + ) + m.nameInput, cmd = input.Update(msg, m.nameInput) + cmds = append(cmds, cmd) + m.nickNameInput, cmd = input.Update(msg, m.nickNameInput) + cmds = append(cmds, cmd) + m.emailInput, cmd = input.Update(msg, m.emailInput) + cmds = append(cmds, cmd) + return m, boba.Batch(cmds...) } func view(model boba.Model) string { diff --git a/examples/views/main.go b/examples/views/main.go index 90923bf..4afc459 100644 --- a/examples/views/main.go +++ b/examples/views/main.go @@ -1,6 +1,6 @@ package main -// TODO: This code feels messy. Clean it up. +// TODO: The views feel messy. Clean 'em up. import ( "fmt" @@ -17,7 +17,6 @@ func main() { initialize, update, view, - subscriptions, ) if err := p.Start(); err != nil { fmt.Println("could not start program:", err) @@ -26,17 +25,9 @@ func main() { // MSG -type tickMsg time.Time +type tickMsg struct{} -func newTickMsg(t time.Time) boba.Msg { - return tickMsg(t) -} - -type frameMsg time.Time - -func newFrameMsg(t time.Time) boba.Msg { - return frameMsg(t) -} +type frameMsg struct{} // MODEL @@ -53,21 +44,19 @@ type Model struct { // INIT func initialize() (boba.Model, boba.Cmd) { - return Model{0, false, 10, 0, 0, false}, nil + return Model{0, false, 10, 0, 0, false}, tick } -// SUBSCRIPTIONS +// CMDS -func subscriptions(model boba.Model) boba.Subs { - m, _ := model.(Model) - if !m.Chosen || m.Loaded { - return boba.Subs{ - "tick": boba.Every(time.Second, newTickMsg), - } - } - return boba.Subs{ - "frame": boba.Every(time.Second/60, newFrameMsg), - } +func tick() boba.Msg { + time.Sleep(time.Second) + return tickMsg{} +} + +func frame() boba.Msg { + time.Sleep(time.Second / 60) + return frameMsg{} } // UPDATE @@ -118,7 +107,7 @@ func updateChoices(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { m.Ticks -= 1 } - return m, nil + return m, tick } func updateChosen(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { @@ -137,7 +126,7 @@ func updateChosen(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { case frameMsg: if !m.Loaded { m.Frames += 1 - m.Progress = ease.OutBounce(float64(m.Frames) / float64(120)) + m.Progress = ease.OutBounce(float64(m.Frames) / float64(160)) if m.Progress >= 1 { m.Progress = 1 m.Loaded = true @@ -154,7 +143,7 @@ func updateChosen(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { } } - return m, nil + return m, frame } // VIEW diff --git a/pager/pager.go b/pager/pager.go index 3a29bfe..487558b 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -9,15 +9,6 @@ import ( "golang.org/x/crypto/ssh/terminal" ) -func NewProgram(initialContent string) *boba.Program { - return boba.NewProgram( - Init(initialContent), - Update, - View, - nil, - ) -} - // MSG type terminalSizeMsg struct { diff --git a/spinner/spinner.go b/spinner/spinner.go index 3071591..9cdf3bb 100644 --- a/spinner/spinner.go +++ b/spinner/spinner.go @@ -1,7 +1,6 @@ package spinner import ( - "errors" "time" "github.com/charmbracelet/boba" @@ -24,8 +23,6 @@ var ( Dot: {"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, } - assertionErr = errors.New("could not perform assertion on model to what the spinner expects. are you sure you passed the right value?") - color = termenv.ColorProfile().Color ) @@ -50,7 +47,7 @@ func NewModel() Model { } // TickMsg indicates that the timer has ticked and we should render a frame -type TickMsg time.Time +type TickMsg struct{} // Update is the Boba update function func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { @@ -60,7 +57,7 @@ func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { if m.frame >= len(spinners[m.Type]) { m.frame = 0 } - return m, nil + return m, Tick(m) default: return m, nil } @@ -86,15 +83,10 @@ func View(model Model) string { return str } -// GetSub creates the subscription that allows the spinner to spin. Remember -// that you need to execute this function in order to get the subscription -// you'll need. -func MakeSub(model boba.Model) (boba.Sub, error) { - m, ok := model.(Model) - if !ok { - return nil, assertionErr +// Tick is the command used to advance the spinner one frame. +func Tick(model Model) boba.Cmd { + return func() boba.Msg { + time.Sleep(time.Second / time.Duration(model.FPS)) + return TickMsg{} } - return boba.Tick(time.Second/time.Duration(m.FPS), func(t time.Time) boba.Msg { - return TickMsg(t) - }), nil } diff --git a/subscriptions.go b/subscriptions.go deleted file mode 100644 index fb51d11..0000000 --- a/subscriptions.go +++ /dev/null @@ -1,32 +0,0 @@ -package boba - -import ( - "time" -) - -// Every is a subscription that ticks with the system clock at the given -// duration, similar to cron. It's particularly useful if you have several -// subscriptions that need to run in sync. -func Every(duration time.Duration, newMsg func(time.Time) Msg) Sub { - return func() Msg { - n := time.Now() - d := n.Truncate(duration).Add(duration).Sub(n) - t := time.NewTimer(d) - select { - case now := <-t.C: - return newMsg(now) - } - } -} - -// Tick is a subscription that at an interval independent of the system clock -// at the given duration. That is, it begins precisely when invoked. -func Tick(d time.Duration, newMsg func(time.Time) Msg) Sub { - return func() Msg { - t := time.NewTimer(d) - select { - case now := <-t.C: - return newMsg(now) - } - } -} diff --git a/textinput/textinput.go b/textinput/textinput.go index 440da99..5776566 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -1,7 +1,6 @@ package textinput import ( - "errors" "time" "github.com/charmbracelet/boba" @@ -77,8 +76,8 @@ func (m *Model) colorPlaceholder(s string) string { String() } -// CursorBlinkMsg is sent when the cursor should alternate it's blinking state -type CursorBlinkMsg struct{} +// BlinkMsg is sent when the cursor should alternate it's blinking state +type BlinkMsg struct{} // NewModel creates a new model with default settings func NewModel() Model { @@ -164,9 +163,9 @@ func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { m.Err = msg return m, nil - case CursorBlinkMsg: + case BlinkMsg: m.blink = !m.blink - return m, nil + return m, Blink(m) default: return m, nil @@ -230,15 +229,10 @@ func cursorView(s string, m Model) string { String() } -// MakeSub return a subscription that lets us know when to alternate the -// blinking of the cursor. -func MakeSub(model boba.Model) (boba.Sub, error) { - m, ok := model.(Model) - if !ok { - return nil, errors.New("could not assert given model to the model we expected; make sure you're passing as input model") - } +// Blink is a command used to time the cursor blinking. +func Blink(model Model) boba.Cmd { return func() boba.Msg { - time.Sleep(m.BlinkSpeed) - return CursorBlinkMsg{} - }, nil + time.Sleep(model.BlinkSpeed) + return BlinkMsg{} + } }