Files
tasksquire/components/picker/picker.go
Martin Pander 44ddbc0f47 Add syncing
2026-02-03 16:04:47 +01:00

273 lines
6.5 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"),
)
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
}
}
}