Processing User Input in Bubble Tea with Menu Components

In a previous tutorial, we did a “Hello World” app, and it processed quite a bit of user input (“Press Ctrl+C to exit”).

But we didn’t really feel up to using user input to change the model’s data, and in turn what we see in the app. So in this tutorial, we’re going to create a menu component that lets us move between buttons.

defining our data

The first thing we need for any bubble tea component is the data our model is in charge of. If you remember, in our simplePage model, the data was just the text we were displaying:

type simplePage struct { text string }
enter fullscreen mode

exit fullscreen mode

In our menu, what we need to do is:

  • Show us your options
  • Show which option is selected
  • Additionally, the user must press Enter to go to another page. But we’ll add that in a later tutorial.
    • For now, we can still pass an onPress function that tells us what we do if the user presses enter.

So the data for our model would look like this; If you are following along, write this to a file named menu.go,

type menu struct {
    options       []menuItem
    selectedIndex int
}

type menuItem struct {
    text    string
    onPress func() tea.Msg
}
enter fullscreen mode

exit fullscreen mode

A menu is made up of MenuItems, and each MenuItem contains text and a function that presses Enter. In this tutorial we’ll have the app toggle between all-caps and all-lowercase, so it’s at least doing something.

it returns a tea.Msg Because we are able to change the data in response to this user input. We’ll see why in the next section, when we’re implementing it. Model interface.

Implementing Model Interface

If you remember, in order to use our model as a UI component, it needs to implement this interface:

type Model interface {
    Init() Cmd
    Update(msg Msg) (Model, Cmd)
    View() string
}
enter fullscreen mode

exit fullscreen mode

First let’s write the init function.

func (m menu) Init() tea.Cmd { return nil }
enter fullscreen mode

exit fullscreen mode

Again, we still don’t have any initial Cmd We need to run, so we can just return nil,

for View Function, let’s make an old school menu with arrows that tell us which item is currently selected.

func (m menu) View() string {
    var options []string
    for i, o := range m.options {
        if i == m.selectedIndex {
            options = append(options, fmt.Sprintf("-> %s", o.text))
        } else {
            options = append(options, fmt.Sprintf("   %s", o.text))
        }
    }
    return fmt.Sprintf(`%s

Press enter/return to select a list item, arrow keys to move, or Ctrl+C to exit.`,
    strings.Join(options, "\n"))
}
enter fullscreen mode

exit fullscreen mode

As mentioned in the previous tutorial, one of the things that makes Bubble Tea really learnable is that the display for your UI is basically one big string. so in menu.View We create a piece of strings where the selected option has an arrow and the non-selected options have leading spaces. Then we tie them all together and attach our contours to the bottom.

Finally, write our update method to handle user input.

func (m menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg.(type) {
    case tea.KeyMsg:
        switch msg.(tea.KeyMsg).String() {
        case "ctrl+c":
            return m, tea.Quit
        case "down", "right", "up", "left":
            return m.moveCursor(msg.(tea.KeyMsg)), nil
        }
    }
    return m, nil
}

func (m menu) moveCursor(msg tea.KeyMsg) menu {
    switch msg.String() {
    case "up", "left":
        m.selectedIndex--
    case "down", "right":
        m.selectedIndex++
    default:
        // do nothing
    }

    optCount := len(m.options)
    m.selectedIndex = (m.selectedIndex + optCount) % optCount
    return m
}
enter fullscreen mode

exit fullscreen mode

Update The method is the most complicated part of this app, so let’s break it down.

case "ctrl+c":
    return m, tea.Quit
enter fullscreen mode

exit fullscreen mode

As before, we’re handling KeyMsg Type , and we are the Ctrl+C keypress to quit the app by returning Quit cmd.

case "down", "right", "up", "left":
    return m.moveCursor(msg.(tea.KeyMsg)), nil
enter fullscreen mode

exit fullscreen mode

For the arrow keys, however, we use a helper function, moveCursorwhich returns an updated model.

func (m menu) moveCursor(msg tea.KeyMsg) menu {
    switch msg.String() {
    case "up", "left":
        m.selectedIndex--
    case "down", "right":
        m.selectedIndex++
    default:
        // do nothing
    }

    optCount := len(m.options)
    m.selectedIndex = (m.selectedIndex + optCount) % optCount
    return m
}
enter fullscreen mode

exit fullscreen mode

The Up and Left KeyMsg strings serve as our “Navigate Up” keys, and the Down and Right keys navigate us down, decreasing and increasing m.selected,

Then, we use the mod operator to ensure that m.selected One of our options indices.

Finally, with the updated model, moveCursor Returns the model that is returned Updateand the new model eventually gets processed by us View way.

Before we start processing the Enter key, we should see our app running. So let’s put our new menu components in one main Work and run it.

func main() {
    m := menu{
        options: []menuItem{
            menuItem{
                text: "new check-in",
                onPress: func() tea.Msg { return struct{}{} },
            },
            menuItem{
                text: "view check-ins",
                onPress: func() tea.Msg { return struct{}{} },
            },
        },
    }

    p := tea.NewProgram(m)
    if err := p.Start(); err != nil {
        panic(err)
    }
}
enter fullscreen mode

exit fullscreen mode

For now, onPress is just a no-op that returns an empty structure. Now, let’s run our app.

go build
./check-ins
enter fullscreen mode

exit fullscreen mode

You should see something like this:

cold! Now the menu can toggle what is selected! Now let’s handle that user input.

Handling the Enter key and seeing what the tea is up to. cmd type actually does

So far, we haven’t really taken a closer look at this tea.Cmd type. It is one of two return values ​​for Update method, but we have so far only used it to exit the app. Let’s take a closer look at its type signature.

type Cmd func() tea.Msg
enter fullscreen mode

exit fullscreen mode

a Cmd is some kind of function that does some stuff, and then gives us back a tea.Msg, That task could be time elapsed, it could be I/O like retrieving some data, anything really goes through! tea.Msg Instead we use Update Tasks to update our model and finally our view.

So handling the user pressing the Enter key, and then running an arbitrary onPress function, using CMD is one such way. So let’s start with an enter button handler.

  case tea.KeyMsg:
      switch msg.(tea.KeyMsg).String() {
      case "q":
          return m, tea.Quit
      case "down", "right", "up", "left":
          return m.moveCursor(msg.(tea.KeyMsg)), nil
+     case "enter", "return":
+         return m, m.options[m.selectedIndex].onPress
      }
enter fullscreen mode

exit fullscreen mode

Note that when the user presses enter, we unmodify the model, but we Too return selected item onPress Celebration. If you remember when we defined menuItem type, its type onPress the area was func() tea.Msg, In other words, it matches exactly Cmd Type Nickname!

there’s one more thing we need to do in Update Although method. Right now, we’re only handling tea.KeyMsg type. The type we are returning to toggle the capitalization of the selected item will be a brand new type tea.Msg, so we need to define it, and then add a case for it to our update method. First, let’s define the structure.

type toggleCasingMsg struct{}
enter fullscreen mode

exit fullscreen mode

We don’t need to pass any data, so our message is just an empty structure; If you remember tea.Msg type is just an empty interface, so we can have as much or as little data in a Msg as we need.

Now back to the update method, let’s add a case for toggleCasingMsg,

add method first toggleSelectedItemCase

func (m menu) toggleSelectedItemCase() tea.Model {
    selectedText := m.options[m.selectedIndex].text
    if selectedText == strings.ToUpper(selectedText) {
        m.options[m.selectedIndex].text = strings.ToLower(selectedText)
    } else {
        m.options[m.selectedIndex].text = strings.ToUpper(selectedText)
    }
    return m
}
enter fullscreen mode

exit fullscreen mode

then add it to Update way.

  func (m menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
      switch msg.(type) {
+     case toggleCasingMsg:
+         return m.toggleSelectedItemCase(), nil
      case tea.KeyMsg:
          // our KeyMsg handlers here
enter fullscreen mode

exit fullscreen mode

On toggleCasingMSG, we update the wrapper of the selected menu item, and then return the updated model.

Finally, in app.go, let’s use our togglecasing message

  menuItem{
      text:    "new check-in",
-     onPress: func() tea.Msg { return struct{}{} },
+     onPress: func() tea.Msg { return toggleCasingMsg{} },
  },
  menuItem{
      text:    "view check-ins",
-     onPress: func() tea.Msg { return struct{}{} },
+     onPress: func() tea.Msg { return toggleCasingMsg{} },
  },
enter fullscreen mode

exit fullscreen mode

Let’s try our app now!

go build
./check-ins
enter fullscreen mode

exit fullscreen mode

The app should now look like this:

List of options you can select, with an arrow pointing to the selected one that is now in all caps because the user pressed enter, and the instructions below

Note, by the way, at this level of the app, this isn’t the only way we can process Enter; We could have processed all the toggling entirely in the update function itself, instead of processing it with CMD. The reasons I chose to use Cmd were:

  • To show a simple use case for a non-skip cmd in Bubble T
  • Using a CMD, we can pass arbitrary event handler functions to our components, a similar pattern if you’ve coded in React.

Next, we have a menu, but it just isn’t very appealing. In the next tutorial, we’ll see how to use bubble tea to make our app look cool; First by hand, then with Bubble Tea’s CSS-like lip gloss package!

Leave a Comment