286 lines
6.8 KiB
Go
286 lines
6.8 KiB
Go
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
|
|
}
|
|
}
|
|
}
|