package tui
import (
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
type PickerItem struct {
ID string
Label string
Detail string
}
type PickerSelectMsg struct {
ID string
}
type PickerCancelMsg struct{}
type PickerModel struct {
Styles Styles
Title string
Items []PickerItem
Filtered []PickerItem
SearchInput textinput.Model
Cursor int
Width int
Height int
WithSearch bool
}
func NewPickerModel(title string, items []PickerItem, withSearch bool, initialSelection string) PickerModel {
ti := newTextInput()
ti.Placeholder = ""
ti.Focus()
ti.Prompt = ""
m := PickerModel{
Title: title,
Items: items,
WithSearch: withSearch,
SearchInput: ti,
Styles: DefaultStyles,
}
m.applyFilter()
if initialSelection != "" {
if i := slices.IndexFunc(m.Filtered, func(it PickerItem) bool { return it.ID == initialSelection }); i >= 0 {
m.Cursor = i
}
}
return m
}
// Highlighted returns the item under the cursor.
func (m PickerModel) Highlighted() (PickerItem, bool) {
if m.Cursor >= 0 && m.Cursor < len(m.Filtered) {
return m.Filtered[m.Cursor], true
}
return PickerItem{}, false
}
func (m *PickerModel) SetSize(width, height int) {
m.Width = width
m.Height = height
m.SearchInput.SetWidth(max(1, width-8))
}
func (m *PickerModel) applyFilter() {
item, ok := m.Highlighted()
old := ""
if ok {
old = item.ID
}
tokens := strings.Fields(strings.ToLower(m.SearchInput.Value()))
m.Filtered = nil
for _, item := range m.Items {
haystack := strings.ToLower(item.ID + " " + item.Label + " " + item.Detail)
match := true
for _, token := range tokens {
if !strings.Contains(haystack, token) {
match = false
break
}
}
if match {
m.Filtered = append(m.Filtered, item)
}
}
m.Cursor = max(0, slices.IndexFunc(m.Filtered, func(item PickerItem) bool { return item.ID == old }))
}
func inkWrap(text string, width int) string {
if width <= 0 {
return text
}
return ansi.Wrap(text, width, " ")
}
// selectableRows is a bottom-anchored list window with the selected row on
// the selection surface.
func selectableRows(lines []string, selected, height, limit, width int, styles Styles) string {
if height <= 0 {
height = 24
}
if limit <= 0 {
limit = height
}
available := max(1, min(limit, height-8))
first := min(max(0, selected-available+1), max(0, len(lines)-available))
if len(lines) == 0 {
return styles.Faint.Render("no matches")
}
var b strings.Builder
bar := selectBar() + " "
for i := first; i < min(len(lines), first+available); i++ {
lead := " "
if i == selected {
lead = bar
}
line := lead + lines[i]
if width > 0 {
line = ansi.Truncate(line, width, "…")
}
if i == selected {
line = selectedLine(line, width)
}
if i > first {
b.WriteByte('\n')
}
b.WriteString(line)
}
return b.String()
}
func pickerRow(item PickerItem) string {
line := item.Label
if item.Detail != "" {
line += DefaultStyles.Faint.Render(" " + item.Detail)
}
return line
}
func (m PickerModel) Init() tea.Cmd {
if m.WithSearch {
return textinput.Blink
}
return nil
}
func (m PickerModel) Update(msg tea.Msg) (PickerModel, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "esc", "ctrl+c", "ctrl+d":
return m, func() tea.Msg { return PickerCancelMsg{} }
case "enter":
if item, ok := m.Highlighted(); ok {
return m, func() tea.Msg { return PickerSelectMsg{ID: item.ID} }
}
return m, nil
case "up", "ctrl+p":
if m.Cursor > 0 {
m.Cursor--
}
return m, nil
case "down", "ctrl+n":
if m.Cursor < len(m.Filtered)-1 {
m.Cursor++
}
return m, nil
}
}
if m.WithSearch {
var tiCmd tea.Cmd
m.SearchInput, tiCmd = m.SearchInput.Update(msg)
m.applyFilter()
cmd = tea.Batch(cmd, tiCmd)
}
return m, cmd
}
func (m PickerModel) View() string {
var b strings.Builder
if m.Title != "" {
b.WriteString(m.Title)
b.WriteByte('\n')
}
lines := make([]string, len(m.Filtered))
for i, item := range m.Filtered {
lines[i] = pickerRow(item)
}
b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width, m.Styles))
b.WriteByte('\n')
if m.WithSearch {
b.WriteString(promptLead())
b.WriteString(m.SearchInput.View())
}
b.WriteByte('\n')
b.WriteString(keyHints(hint{"↑↓", "select"}, hint{"enter", "choose"}, hint{"esc", "cancel"}))
return b.String()
}