Last active
May 18, 2025 17:32
-
-
Save carpeliam/933c84e54b1ee267115477ebf0d0a745 to your computer and use it in GitHub Desktop.
Example of a nested multi-select on top of bubbletea and huh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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