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 defaultValue string 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 WithDefaultValue(value string) PickerOption { return func(p *Picker) { p.defaultValue = value } } 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) l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality // Custom key for filtering (insert mode) l.KeyMap.Filter = key.NewBinding( key.WithKeys("i"), key.WithHelp("i", "filter"), ) // Disable the quit key binding - we don't want Esc to quit the list // Esc should only cancel filtering mode l.KeyMap.Quit = key.NewBinding( key.WithKeys(), // No keys bound key.WithHelp("", ""), ) // Also disable force quit l.KeyMap.ForceQuit = key.NewBinding( key.WithKeys(), // No keys bound key.WithHelp("", ""), ) 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 a default value is provided, don't start in filter mode if p.defaultValue != "" { p.filterByDefault = false } if p.filterByDefault { // Manually trigger filter mode on the list so it doesn't require a global key press p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) } // Refresh items after entering filter mode to ensure they're visible p.Refresh() // If a default value is provided, select the corresponding item if p.defaultValue != "" { p.SelectItemByFilterValue(p.defaultValue) } return p } func (p *Picker) Refresh() tea.Cmd { p.baseItems = p.itemProvider() return p.updateListItems() } func (p *Picker) updateListItems() tea.Cmd { return p.updateListItemsWithFilter(p.list.FilterValue()) } func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd { items := make([]list.Item, 0, len(p.baseItems)+1) // First add all base items items = append(items, p.baseItems...) if p.onCreate != nil && filterVal != "" { // Add the creation item at the end (bottom of the list) newItem := creationItem{ text: "(new) " + filterVal, filter: filterVal, } items = append(items, newItem) } 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 { // Trigger list item update to ensure items are properly displayed, // especially when in filter mode with an empty filter return p.updateListItems() } func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !p.focused { return p, nil } var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // If filtering, update items with predicted filter before list processes the key if p.list.FilterState() == list.Filtering { currentFilter := p.list.FilterValue() predictedFilter := currentFilter // Predict what the filter will be after this key switch msg.Type { case tea.KeyRunes: predictedFilter = currentFilter + string(msg.Runes) case tea.KeyBackspace: if len(currentFilter) > 0 { predictedFilter = currentFilter[:len(currentFilter)-1] } } // Update items with predicted filter before list processes the message if predictedFilter != currentFilter { preCmd := p.updateListItemsWithFilter(predictedFilter) cmds = append(cmds, preCmd) } 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) } } p.list, cmd = p.list.Update(msg) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } 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 } } }