RC02: Extending filtering capabilities in Bubble Tea apps

| recurse

In Week 1 of RC, I started building a todo app. It's built using Bubble Tea, a TUI framework written in Go.

The app uses the List component as its base, which comes from Bubbles, a repo containing TUI components for Bubble Tea.

One of the features it provides is filtering items. Filtering works by pressing the / key, followed by entering the text of choice. The filter value, i.e., what field to filter can be declared in the code.

In the case of the default list (what I used for this app), it can be either the Title or the Description. I went ahead with the description, where I store the status of a task.

The app takes the tasks from a plaintext file. Each task in the app can be written in one of these formats.

- Todo
* Today
/ Waiting
X Done
! Idea 
< Archive

The description stores textual versions of these symbols, and that's what used as the filter value.

Since the filter values are limited, I wanted a way to access these filters faster. Rather than having to type /today for tasks tagged Today, I wanted to press 1. Similarly, I wanted the key 2 for Todo, 3 for Waiting and so on.

I set out to look for if there was a function that did this. The first place I checked was the Issues tab on GitHub to see if others had asked for something similar.

While there is no built-in function to do this, there are pull requests that offer functions to be added to the source code. This pull request has been around for a while, but hasn't been merged to the main source code for some reason.

Attempt #1: Use a modified, local Bubbles instance #

I was impatient and really wanted to implement this feature in my app. So I cloned the entire Bubbles repository to my directory.

I specifically cloned the user's fork, so I could use the newly created function, SetFilterText(). The code was located in the branch called feat-prefilter-opt.

$ git clone https://github.com/taigrr/bubbles
$ cd bubbles && git checkout remotes/origin/feat-prefilter-opt

I implemented the core logic in my app's Update() function, which handles keypresses.

switch msg := msg.(type) {
case tea.KeyMsg:
    switch msg.String() {
    case "1":
        m.list.SetFilterText("today")
    case "2":
        m.list.SetFilterText("todo")
    case "3":
        m.list.SetFilterText("waiting")
    case "4":
        m.list.SetFilterText("done")
    case "5":
        m.list.SetFilterText("idea")
    case "6":
        m.list.SetFilterText("archive")
    }
}

Next, I needed to create keybindings for the keys above, so that they show up in the help section of the app.

I created keybindings for the keys 1 - 6 and a combined key called Section using the following code:

type listKeyMap struct {
    Section key.Binding
    Today   key.Binding
    Todo    key.Binding
    Waiting key.Binding
    Done    key.Binding
    Idea    key.Binding
    Archive key.Binding
}

func newListKeyMap() *listKeyMap {
    return &listKeyMap{
        Section: key.NewBinding(
            key.WithKeys("1-6"),
            key.WithHelp("1-6", "section"),
        ),
        Today: key.NewBinding(
            key.WithKeys("1"),
            key.WithHelp("1", "today"),
        ),
        Todo: key.NewBinding(
            key.WithKeys("2"),
            key.WithHelp("2", "todo"),
        ),
        Waiting: key.NewBinding(
            key.WithKeys("3"),
            key.WithHelp("3", "waiting"),
        ),
        Done: key.NewBinding(
            key.WithKeys("4"),
            key.WithHelp("4", "done"),
        ),
        Idea: key.NewBinding(
            key.WithKeys("5"),
            key.WithHelp("5", "idea"),
        ),
        Archive: key.NewBinding(
            key.WithKeys("6"),
            key.WithHelp("6", "archive"),
        ),
    }
}

The program has two help sections - a short help displayed below the app, and a full help that can be accessed by pressing the ? key.

The Section key is created for the short help, to avoid making the short help too long. Each of the individual section keys go in the full help. This code goes in the app's newModel() function, that builds the list.

list.AdditionalFullHelpKeys = func() []key.Binding {
    return []key.Binding{
        listKeys.Today,
        listKeys.Todo,
        listKeys.Waiting,
        listKeys.Done,
        listKeys.Idea,
        listKeys.Archive,
        listKeys.Edit,
    }
}
list.AdditionalShortHelpKeys = func() []key.Binding {
    return []key.Binding{
        listKeys.Section,
    }
}

return model{
    list: list,
    keys: listKeys,
}

One last change I had to make was to add the following line to the go.mod file to use the local import.

replace github.com/charmbracelet/bubbles => ./bubbles

And...it worked!

Now I could have stopped here. But having the entire Bubbles package as part of my repo made it unnecessarily bulky. There had to be another way, a way which didn't require a local copy.

I tried adding SetFilterText() to my main.go file, but it accessed private variables and functions of the List component, which I couldn't access.

An undocumented workaround #

The same pull request also contains the following text:

A workaround of using Program.Send works, but is limiting.

I got curious as to what this hack was. From an example provided by Bubble Tea, Program.Send() can be used to send messages from outside of the program.

The term "workaround" suggests that there could be a way to send keypresses from within the program through this function.

The pull request had no sample code to show how it works though, so I was left to figure it out on my own.

I read through the documentation and tried to put something together, but thanks to the combination of working with a new language (Go) and a new code structure, I couldn't get it to work.

Recursers to the rescue #

Around the same time, Manuel, a Recurse alum, shared progress on his project, that also used Bubble Tea! 1 I reached out to him for help, and we decided to figure this out together in a pairing session.

A few days later, we got on call, and actually got it to work. He did most of the figuring out, so here's my attempt of explaining the concept by showing how I implemented it in the app.

Proof of concept #

Let's start with the documentation for Program.Send, which contains the function declaration:

func (p *Program) Send(msg Msg)

It takes an input of type Msg, which is the message to be sent. I need a way to send keys to the program. For this, there is a type of Msg called KeyMsg.

type KeyMsg Key

type Key struct {
    Type  KeyType
    Runes []rune
    Alt   bool
}

KeyMsg is of type Key which requires one or two parameters:

Putting together the learnings from the pairing session and the docs, here's the syntax for sending a key press via Program.Send():

p.Send(tea.KeyMsg(tea.Key{Type: tea.CtrlC}))
p.Send(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune("q")}))

Both examples cause the program to quit. The first example sends the key Ctrl+C, while the second sends the Q key using the rune syntax.

Having this figured out, I made my own function, called SetFilter()

Attempt #2: SetFilter() #

1
2
3
4
5
6
7
8
9
10
11
12/* Custom function to switch between the different categories. */
func (m model) SetFilter(s string) {
    go func() {
        if m.list.IsFiltered() {
            p.Send(tea.KeyMsg(tea.Key{Type: tea.KeyEsc}))
        }
        p.Send(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'/'}}))
        p.Send(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(s)}))
        time.Sleep(2 * time.Millisecond)
        p.Send(tea.KeyMsg(tea.Key{Type: tea.KeyEnter}))
    }()
}

Lines 7-10 are the core of this function:

Lines 4-6 check if a filter is currently set, in which case the Esc key is sent to reset the filter before switching to another one.

Lastly, all the statements are wrapped in a goroutine. This is to ensure that neither of these commands can block the entire program.

With the function complete, I changed the Update() function.

case "1":
    m.SetFilter("today")
case "2":
    m.SetFilter("todo")
case "3":
    m.SetFilter("waiting")
case "4":
    m.SetFilter("done")
case "5":
    m.SetFilter("idea")
case "6":
    m.SetFilter("archive")

Lastly, I declared the Program p as a global variable, so that SetFilter() could access it.

Limitations #

  1. The app can flicker sometimes when switching filters, due to the 2 millisecond delay in the function.

  2. Attempt #1 displayed a "0 items found" screen when there were no items for a given filter. This happens due to the function setting a state called FilterApplied. The actual behavior for this case is different though (exits filter mode and displays all items) which is how Attempt #2 works. I prefer the first approach, but haven't found a way to implement it yet.

Footnotes

  1. I came across his blog posts from Bearblog's discovery feed, and I had no idea he was an RC alum before joining. Never thought I'd actually get to meet him, so pairing with him was totally unexpected and a really cool experience!