package utils import ( "context" "fmt" "io" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/go-errors/errors" ) var ( titleStyle = lipgloss.NewStyle().MarginLeft(2) itemStyle = lipgloss.NewStyle().PaddingLeft(4) selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) ) // PromptItem is exposed as prompt input, empty summary + details will be excluded. type PromptItem struct { Summary string Details string Index int } func (i PromptItem) Title() string { return i.Summary } func (i PromptItem) Description() string { return i.Details } func (i PromptItem) FilterValue() string { return i.Summary + " " + i.Details } // Item delegate is used to finetune the list item renderer. type itemDelegate struct{} func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(PromptItem) if !ok { return } str := fmt.Sprintf("%d. %s", index+1, i.Summary) if i.Details != "" { str += fmt.Sprintf(" [%s]", i.Details) } fn := itemStyle.Render if index == m.Index() { fn = func(s ...string) string { items := append([]string{"> "}, s...) return selectedItemStyle.Render(items...) } } fmt.Fprint(w, fn(str)) } // Model is used to store state of user choices. type model struct { cancel context.CancelFunc list list.Model choice PromptItem } 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.WindowSizeMsg: m.list.SetWidth(msg.Width) return m, nil case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: m.cancel() return m, tea.Quit case tea.KeyEnter: if choice, ok := m.list.SelectedItem().(PromptItem); ok { m.choice = choice } return m, tea.Quit } } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m model) View() string { if m.choice.Summary != "" { return "" } return "\n" + m.list.View() } // Prompt user to choose from a list of items, returns the chosen index. func PromptChoice(ctx context.Context, title string, items []PromptItem, opts ...tea.ProgramOption) (PromptItem, error) { // Create list items var listItems []list.Item for _, v := range items { if strings.TrimSpace(v.FilterValue()) == "" { continue } listItems = append(listItems, v) } // Create list model height := len(listItems) * 4 if height > 14 { height = 14 } l := list.New(listItems, itemDelegate{}, 0, height) l.Title = title l.SetShowStatusBar(false) l.Styles.Title = titleStyle l.Styles.PaginationStyle = paginationStyle l.Styles.HelpStyle = helpStyle // Create our model ctx, cancel := context.WithCancel(ctx) initial := model{cancel: cancel, list: l} prog := tea.NewProgram(initial, opts...) state, err := prog.Run() if err != nil { return initial.choice, errors.Errorf("failed to prompt choice: %w", err) } if ctx.Err() != nil { return initial.choice, ctx.Err() } if m, ok := state.(model); ok { if m.choice == initial.choice { return initial.choice, errors.New("user aborted") } return m.choice, nil } return initial.choice, err }