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.
This commit is contained in:
Christian Rocha
2020-05-12 17:56:30 -04:00
parent 82ddbb8e12
commit ade8203c21
13 changed files with 136 additions and 322 deletions

168
boba.go
View File

@@ -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)
}
}
}

View File

@@ -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)
})
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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{}
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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{}
}
}