package picker import ( "tasksquire/common" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type Item struct { text string } func NewItem(text string) Item { return Item{text: text} } func (i Item) Title() string { return i.text } func (i Item) Description() string { return "" } func (i Item) FilterValue() string { return i.text } // creationItem is a special item for creating new entries type creationItem struct { text string filter string } func (i creationItem) Title() string { return i.text } func (i creationItem) Description() string { return "" } func (i creationItem) FilterValue() string { return i.filter } type Picker struct { common *common.Common list list.Model itemProvider func() []list.Item onSelect func(list.Item) tea.Cmd onCreate func(string) tea.Cmd title string filterByDefault bool baseItems []list.Item focused bool } type PickerOption func(*Picker) func WithFilterByDefault(enabled bool) PickerOption { return func(p *Picker) { p.filterByDefault = enabled } } func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption { return func(p *Picker) { p.onCreate = onCreate } } func (p *Picker) Focus() tea.Cmd { p.focused = true return nil } func (p *Picker) Blur() tea.Cmd { p.focused = false return nil } func (p *Picker) GetValue() string { item := p.list.SelectedItem() if item == nil { return "" } return item.FilterValue() } func New( c *common.Common, title string, itemProvider func() []list.Item, onSelect func(list.Item) tea.Cmd, opts ...PickerOption, ) *Picker { delegate := list.NewDefaultDelegate() delegate.ShowDescription = false delegate.SetSpacing(0) l := list.New([]list.Item{}, delegate, 0, 0) l.SetShowTitle(false) l.SetShowHelp(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(true) // Custom key for filtering (insert mode) l.KeyMap.Filter = key.NewBinding( key.WithKeys("i"), key.WithHelp("i", "filter"), ) p := &Picker{ common: c, list: l, itemProvider: itemProvider, onSelect: onSelect, title: title, focused: true, } if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { p.filterByDefault = true } for _, opt := range opts { opt(p) } if p.filterByDefault { // Manually trigger filter mode on the list so it doesn't require a global key press var cmd tea.Cmd p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) // We can ignore the command here as it's likely just for blinking, which will happen on Init anyway _ = cmd } p.Refresh() return p } func (p *Picker) Refresh() tea.Cmd { p.baseItems = p.itemProvider() return p.updateListItems() } func (p *Picker) updateListItems() tea.Cmd { items := p.baseItems filterVal := p.list.FilterValue() if p.onCreate != nil && filterVal != "" { newItem := creationItem{ text: "(new) " + filterVal, filter: filterVal, } newItems := make([]list.Item, len(items)+1) copy(newItems, items) newItems[len(items)] = newItem items = newItems } return p.list.SetItems(items) } func (p *Picker) SetSize(width, height int) { // We do NOT set common.SetSize here, as we are a sub-component. // Set list size. The parent is responsible for providing a reasonable size. // If this component is intended to fill a page, width/height will be large. // If it's a small embedded box, they will be small. // We apply a small margin for the title if needed, but for now we just pass through // minus a header gap if we render a title. headerHeight := 2 // Title + gap p.list.SetSize(width, height-headerHeight) } func (p *Picker) Init() tea.Cmd { return nil } func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !p.focused { return p, nil } var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // If filtering, let the list handle keys (including Enter to stop filtering) if p.list.FilterState() == list.Filtering { // if key.Matches(msg, p.common.Keymap.Ok) { // items := p.list.VisibleItems() // if len(items) == 1 { // return p, p.handleSelect(items[0]) // } // } break // Pass to list.Update } switch { case key.Matches(msg, p.common.Keymap.Ok): selectedItem := p.list.SelectedItem() if selectedItem == nil { return p, nil } return p, p.handleSelect(selectedItem) } } prevFilter := p.list.FilterValue() p.list, cmd = p.list.Update(msg) if p.list.FilterValue() != prevFilter { updateCmd := p.updateListItems() return p, tea.Batch(cmd, updateCmd) } return p, cmd } func (p *Picker) handleSelect(item list.Item) tea.Cmd { if cItem, ok := item.(creationItem); ok { if p.onCreate != nil { return p.onCreate(cItem.filter) } } return p.onSelect(item) } func (p *Picker) View() string { var title string if p.focused { title = p.common.Styles.Form.Focused.Title.Render(p.title) } else { title = p.common.Styles.Form.Blurred.Title.Render(p.title) } return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View()) } func (p *Picker) IsFiltering() bool { return p.list.FilterState() == list.Filtering } // SelectItemByFilterValue selects the item with the given filter value func (p *Picker) SelectItemByFilterValue(filterValue string) { items := p.list.Items() for i, item := range items { if item.FilterValue() == filterValue { p.list.Select(i) break } } }