charmbracelet / bubbletea

A powerful little TUI framework 🏗
MIT License
27.86k stars 804 forks source link

Swapping out current model dynamically #154

Closed markhuge closed 3 years ago

markhuge commented 3 years ago

:wave: Not sure if I'm just approaching this incorrectly. I'm building a UI with an index that has a list of items, and selecting the item drills down into the details for the item.

I created a top level model as recommended in #13, but because all my child models are dynamic, I was generating them and sending up to the parent via a channel. Here's a simplified, contrived example:

package main

import (
    "fmt"

    tea "github.com/charmbracelet/bubbletea"
)

type item struct {
    details string
}

func (i item) String() string {
    return i.details
}

/* Parent model *****************************************************/
type model struct {
    current tea.Model
    ch      chan tea.Model
}

func (m *model) Watch() {
    for {
        select {
        case mm := <-m.ch:
            m.current = mm
        }
    }
}

func (m model) Init() tea.Cmd {
    return tea.EnterAltScreen
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    return m.current.Update(msg)
}

func (m model) View() string {
    return m.current.View()
}

/* First child model ************************************************/
type listModel struct {
    cursor int
    msgs   []item
    ch     chan tea.Model
}

func (l listModel) Init() tea.Cmd {
    return tea.EnterAltScreen
}

func (l listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q":
            return l, tea.Quit
        case "k":
            if l.cursor > 0 {
                l.cursor--
            }
        case "j":
            if l.cursor < len(l.msgs)-1 {
                l.cursor++
            }
        case "enter", " ":
            l.ch <- itemModel{item: l.msgs[l.cursor], ch: l.ch}
        }
    }
    return l, nil
}

func (l listModel) View() string {
    str := "List view\n"
    for i, msg := range l.msgs {
        cursor := " "
        if l.cursor == i {
            cursor = ">"
        }
        str += fmt.Sprintf("%s %s\n", cursor, msg)
    }
    str += "\nPress q to quit\n"
    return str
}

func newListModel(ch chan tea.Model) listModel {
    return listModel{
        ch: ch,
        msgs: []item{
            {"one"},
            {"two"},
            {"three"},
        },
    }
}

/* Drilldown child model ********************************************/

type itemModel struct {
    item item
    ch   chan tea.Model
}

func (i itemModel) Init() tea.Cmd {
    return tea.EnterAltScreen
}

func (i itemModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q":
            return i, tea.Quit
        case "b":
            i.ch <- newListModel(i.ch)
        }
    }
    return i, nil
}

func (i itemModel) View() string {
    return fmt.Sprintf("Item view\n %s\n q to quit, b to go back\n", i.item)
}

/* main *************************************************************/

func main() {
    ch := make(chan tea.Model, 1)
    m := model{
        current: newListModel(ch),
        ch:      ch,
    }

    go m.Watch()

    p := tea.NewProgram(m)
    p.Start()
}

The models never seem to update. I've tried manually kicking off Init() and Update() after setting in Watch(), but no luck.

Is there some lifecycle event I'm missing? Am I just approaching this in a dumb way?

meowgorithm commented 3 years ago

Hi! You actually don’t need to be that fancy in terms of channel use. When matching on the enter keypress in Update you can just return the new model you want. So basically drop all the watching and channel stuff and…

Change this:

case "enter", " ":
    l.ch <- itemModel{item: l.msgs[l.cursor], ch: l.ch}

To this:

case "enter", " ":
    return itemModel{item: l.msgs[l.cursor]}, nil

Here’s the full file with the changes you can make, and here’s the diff.


As a side note, the reason the original code isn’t working is because the channel activity in Update introduces a data race in which Update is returning before the channel has had an opportunity to change the model. As a hard and fast rule, it's best to keep everything in Update synchronous. If you need to run something asynchronously you can return a Cmd, which will always run in a groutine under the hood.

markhuge commented 3 years ago

I totally misunderstood what was happening with the returned model in #13's example. This is much simpler and nicer to use than I realized. Thanks!