Skip to content

Instantly share code, notes, and snippets.

@carpeliam
Last active May 18, 2025 17:32
Show Gist options
  • Save carpeliam/933c84e54b1ee267115477ebf0d0a745 to your computer and use it in GitHub Desktop.
Save carpeliam/933c84e54b1ee267115477ebf0d0a745 to your computer and use it in GitHub Desktop.
Example of a nested multi-select on top of bubbletea and huh
// Example of a nested multi-select on top of bubbletea and huh.
//
// *Current behavior*:
// - given a two-level set of nested objects,
// - allow for any parent or child to be selected
// - selecting a parent also selects all its children
// - deselecting a parent also deselects all its children
// - parents with no children are hidden from view
// - upon making a final selection and pressing Enter, the children are returned
//
// *Nits and issues*:
// - This implementation tracks the current selection via a cursor. If our value for the cursor falls out of
// sync with huh's value for the cursor for any reason (for example, if you try to filter results),
// everything breaks. One solution is to listen for all keystrokes that can change huh's cursor and update
// ours accordingly, but this would be leaky and fragile.
// - Similarly, the model needs to keep track of a lot of related abstractions, instead of being able to refer
// to one. It needs to track the Form so that it can send updates, the MultiSelect input so it can reload the
// options, the Accessor so that it can add and remove options (changing the Options themselves works to add
// to the selection, but not to remove), and the cursor so that we can maintain an internal understanding of
// position state, as it doesn't seem as though we can interrogate the multiselect for state; form.Get("key")
// will only report a value after a value has been submitted via pressing Enter.
// - I'm also unsure how to reuse the keymap from huh to listen to events. I'd like to respond to events like
// `Next`, `Prev`, or `Submit`, instead of `KeyDown`, `KeyUp`, or `KeyEnter`. The Bubbles library might be a
// fit for this.
// - I struggled a bit with testing. My current approach has been to instantiate the Model, call Update a few
// times with various messages, and then check to see if the View contains what I want. I'm not sure why, but
// the View always returns an empty string. I thought it might be because I didn't send it a window size event,
// but that doesn't seem to be it.
package main
import (
"fmt"
"os"
"slices"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type Parent struct {
Name string
Children []Child
}
type Child struct {
Name string
}
type selection interface {
display() string
children() []selection
}
type parentSelection struct {
parent *Parent
_children []selection
}
func (ps parentSelection) display() string {
return ps.parent.Name
}
func (ps *parentSelection) children() []selection {
if ps._children == nil {
// need to memoize because it's important we refer to the same objects in memory
ps._children = make([]selection, len(ps.parent.Children))
for i, child := range ps.parent.Children {
ps._children[i] = childSelection{child: &child}
}
}
return ps._children
}
type childSelection struct {
child *Child
}
// display the name, indented
func (cs childSelection) display() string {
return fmt.Sprintf(" %s", cs.child.Name)
}
// child selections don't have children
func (cs childSelection) children() []selection {
return []selection{}
}
type Model struct {
// which item is currently selected
cursor int
// the multi-select, tracked so we can update which options are checked
input *huh.MultiSelect[selection]
form *huh.Form
// all available selectable things
selections []selection
// the accessor for the multi-select, containing which items are currently selected - changing this changes which options are displayed
acc huh.Accessor[[]selection]
// populated with the selected children after submitting the form
Children []Child
}
func (m Model) options() []huh.Option[selection] {
options := make([]huh.Option[selection], len(m.selections))
for i, sel := range m.selections {
options[i] = huh.NewOption(sel.display(), sel)
}
return options
}
func NewModel(parents []Parent) Model {
selections := []selection{}
for _, parent := range parents {
// don't bother with parents without children, as we're really just interested in child selections
if len(parent.Children) == 0 {
continue
}
sel := &parentSelection{parent: &parent}
selections = append(append(selections, sel), sel.children()...)
}
acc := &huh.EmbeddedAccessor[[]selection]{}
input := huh.NewMultiSelect[selection]().Accessor(acc).Filterable(true).Title("Select Stuff Please")
form := huh.NewForm(huh.NewGroup(input))
form.SubmitCmd = tea.Quit
form.CancelCmd = tea.Quit
model := Model{
cursor: 0,
input: input,
form: form,
selections: selections,
acc: acc,
Children: []Child{},
}
input.Options(model.options()...)
return model
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
// case tea.KeyEsc:
// return m, tea.Quit
// FIXME: Esc either means to exit the program or to exit the filter if a filter is active, but I can't seem to interrogate huh to know if it's currently filtering or not
case tea.KeyUp:
// TODO: are these values right? what about page up/down or filter, does that affect cursor?
m.cursor = max(0, m.cursor-1)
case tea.KeyDown:
m.cursor = min(m.cursor+1, len(m.selections)-1)
case tea.KeySpace:
selectedOption := m.selections[m.cursor]
selections := m.acc.Get()
isNewlySelected := !slices.Contains(selections, selectedOption)
if isNewlySelected {
for _, child := range selectedOption.children() {
if !slices.Contains(selections, child) {
selections = append(selections, child)
}
}
} else {
selections = slices.DeleteFunc(selections, func(s selection) bool {
return slices.Contains(selectedOption.children(), s)
})
}
m.acc.Set(selections)
// it seems like we have to set the options again in order to make the options update, after updating the Accessor
m.input.Options(m.options()...)
case tea.KeyEnter:
children := []Child{}
for _, selection := range m.acc.Get() {
switch s := selection.(type) {
case childSelection:
children = append(children, *s.child)
}
}
m.Children = children
case tea.KeyRunes:
switch string(msg.Runes) {
case "q":
return m, tea.Quit
}
}
}
return update(m, msg)
}
func update(m Model, msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := m.form.Update(msg)
return m, cmd
}
func (m Model) View() string {
return m.form.View()
}
func main() {
data := []Parent{
{Name: "books", Children: []Child{
{Name: "A Tale Of Two Cities"},
{Name: "Hunger Games"},
{Name: "Sapiens"},
}},
{Name: "dual income, no kids", Children: []Child{}},
{Name: "todo list", Children: []Child{
{Name: "write a blog post"},
{Name: "wash the dishes"},
{Name: "ask for feedback"},
{Name: "touch some grass"},
{Name: "buy plane ticket to California"},
}},
}
m, err := tea.NewProgram(NewModel(data)).Run()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("You selected:")
selections := m.(Model).Children
if len(selections) == 0 {
fmt.Println("NOTHING. You selected nothing.")
} else {
for _, selection := range selections {
fmt.Printf(" * %s\n", selection.Name)
}
}
fmt.Println("Have a nice day!")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment