445 lines
11 KiB
Go
445 lines
11 KiB
Go
package prompter
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/spinner"
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/huh/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
// multiSelectSearchField is a custom huh Field that combines a text input
|
|
// for searching with a multi-select list. Unlike huh's built-in OptionsFunc,
|
|
// search results are loaded synchronously when the user presses Enter in
|
|
// the search input, avoiding goroutine races with selection state.
|
|
type multiSelectSearchField struct {
|
|
// configuration
|
|
title string
|
|
searchTitle string
|
|
searchFunc func(string) MultiSelectSearchResult
|
|
|
|
// state
|
|
mode msMode // which sub-component has focus
|
|
search textinput.Model
|
|
cursor int
|
|
loading bool
|
|
spinner spinner.Model
|
|
|
|
// options and selections
|
|
options []msOption
|
|
selected map[string]bool // key → selected (source of truth)
|
|
optionLabels map[string]string // key → display label
|
|
lastQuery string
|
|
defaultValues []string
|
|
persistent []string
|
|
|
|
// field metadata
|
|
key string
|
|
err error
|
|
focused bool
|
|
width int
|
|
height int
|
|
theme huh.Theme
|
|
hasDarkBg bool
|
|
position huh.FieldPosition
|
|
}
|
|
|
|
type msMode int
|
|
|
|
const (
|
|
msModeSearch msMode = iota
|
|
msModeSelect
|
|
)
|
|
|
|
type msOption struct {
|
|
label string
|
|
value string
|
|
}
|
|
|
|
// msSearchResultMsg carries search results back from the background goroutine.
|
|
type msSearchResultMsg struct {
|
|
query string
|
|
result MultiSelectSearchResult
|
|
}
|
|
|
|
func newMultiSelectSearchField(
|
|
title, searchTitle string,
|
|
defaults, persistent []string,
|
|
searchFunc func(string) MultiSelectSearchResult,
|
|
) *multiSelectSearchField {
|
|
ti := textinput.New()
|
|
ti.Prompt = "> "
|
|
ti.Placeholder = "Type to search"
|
|
ti.Focus()
|
|
|
|
selected := make(map[string]bool)
|
|
for _, k := range defaults {
|
|
selected[k] = true
|
|
}
|
|
|
|
m := &multiSelectSearchField{
|
|
title: title,
|
|
searchTitle: searchTitle,
|
|
searchFunc: searchFunc,
|
|
mode: msModeSearch,
|
|
search: ti,
|
|
selected: selected,
|
|
optionLabels: make(map[string]string),
|
|
defaultValues: defaults,
|
|
persistent: persistent,
|
|
height: 10,
|
|
spinner: spinner.New(spinner.WithSpinner(spinner.Line)),
|
|
}
|
|
|
|
// Load initial results synchronously (form hasn't started yet).
|
|
m.applySearchResult("", m.searchFunc(""))
|
|
|
|
return m
|
|
}
|
|
|
|
// startSearch launches an async search and returns a tea.Cmd that will
|
|
// deliver the result via msSearchResultMsg.
|
|
func (m *multiSelectSearchField) startSearch(query string) tea.Cmd {
|
|
m.loading = true
|
|
searchFunc := m.searchFunc
|
|
return tea.Batch(
|
|
func() tea.Msg {
|
|
return msSearchResultMsg{query: query, result: searchFunc(query)}
|
|
},
|
|
m.spinner.Tick,
|
|
)
|
|
}
|
|
|
|
// applySearchResult processes a completed search and rebuilds the option list.
|
|
func (m *multiSelectSearchField) applySearchResult(query string, result MultiSelectSearchResult) {
|
|
m.loading = false
|
|
m.lastQuery = query
|
|
if result.Err != nil {
|
|
m.err = result.Err
|
|
return
|
|
}
|
|
if len(result.Keys) != len(result.Labels) {
|
|
m.err = fmt.Errorf("search returned mismatched keys and labels: %d keys, %d labels", len(result.Keys), len(result.Labels))
|
|
return
|
|
}
|
|
|
|
for i, k := range result.Keys {
|
|
m.optionLabels[k] = result.Labels[i]
|
|
}
|
|
|
|
// Build option list: selected items first, then results, then persistent.
|
|
var options []msOption
|
|
seen := make(map[string]bool)
|
|
|
|
// 1. Currently selected items.
|
|
for _, k := range m.selectedKeys() {
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
options = append(options, msOption{label: m.label(k), value: k})
|
|
}
|
|
|
|
// 2. Search results.
|
|
for i, k := range result.Keys {
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
l := result.Labels[i]
|
|
if l == "" {
|
|
l = k
|
|
}
|
|
options = append(options, msOption{label: l, value: k})
|
|
}
|
|
|
|
// 3. Persistent options.
|
|
for _, k := range m.persistent {
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
options = append(options, msOption{label: m.label(k), value: k})
|
|
}
|
|
|
|
m.options = options
|
|
m.cursor = 0
|
|
m.err = nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) selectedKeys() []string {
|
|
keys := make([]string, 0)
|
|
// Maintain order: defaults first, then any added during this session.
|
|
seen := make(map[string]bool)
|
|
for _, k := range m.defaultValues {
|
|
if m.selected[k] && !seen[k] {
|
|
keys = append(keys, k)
|
|
seen[k] = true
|
|
}
|
|
}
|
|
for _, o := range m.options {
|
|
if m.selected[o.value] && !seen[o.value] {
|
|
keys = append(keys, o.value)
|
|
seen[o.value] = true
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (m *multiSelectSearchField) label(key string) string {
|
|
if l, ok := m.optionLabels[key]; ok && l != "" {
|
|
return l
|
|
}
|
|
return key
|
|
}
|
|
|
|
// --- huh.Field interface ---
|
|
|
|
func (m *multiSelectSearchField) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) Update(msg tea.Msg) (huh.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.BackgroundColorMsg:
|
|
m.hasDarkBg = msg.IsDark()
|
|
|
|
case msSearchResultMsg:
|
|
m.applySearchResult(msg.query, msg.result)
|
|
m.mode = msModeSelect
|
|
m.search.Blur()
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if !m.loading {
|
|
break
|
|
}
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
|
|
case tea.KeyPressMsg:
|
|
if m.loading {
|
|
return m, nil // ignore keys while loading
|
|
}
|
|
switch m.mode {
|
|
case msModeSearch:
|
|
return m.updateSearch(msg)
|
|
case msModeSelect:
|
|
return m.updateSelect(msg)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) updateSearch(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "tab"))):
|
|
query := m.search.Value()
|
|
if query == m.lastQuery {
|
|
// Query unchanged — just switch to select mode.
|
|
m.mode = msModeSelect
|
|
m.search.Blur()
|
|
return m, nil
|
|
}
|
|
// New query — clear input and search in background with spinner.
|
|
m.search.SetValue("")
|
|
return m, m.startSearch(query)
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
|
return m, huh.PrevField
|
|
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.search, cmd = m.search.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
func (m *multiSelectSearchField) updateSelect(msg tea.KeyPressMsg) (huh.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
|
|
// Back to search mode.
|
|
m.mode = msModeSearch
|
|
m.search.Focus()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
|
return m, huh.NextField
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
|
if m.cursor < len(m.options)-1 {
|
|
m.cursor++
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("space", "x"))):
|
|
if len(m.options) > 0 {
|
|
k := m.options[m.cursor].value
|
|
m.selected[k] = !m.selected[k]
|
|
if !m.selected[k] {
|
|
delete(m.selected, k)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) View() string {
|
|
styles := m.activeStyles()
|
|
var sb strings.Builder
|
|
|
|
// Title.
|
|
if m.title != "" {
|
|
sb.WriteString(styles.Title.Render(m.title))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Search input.
|
|
if m.searchTitle != "" {
|
|
sb.WriteString(styles.Description.Render(m.searchTitle))
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString(m.search.View())
|
|
sb.WriteString("\n")
|
|
|
|
// Options list.
|
|
if m.loading {
|
|
m.spinner.Style = styles.MultiSelectSelector.UnsetString()
|
|
sb.WriteString(m.spinner.View() + " Loading...")
|
|
sb.WriteString("\n")
|
|
} else if len(m.options) == 0 {
|
|
sb.WriteString(styles.UnselectedOption.Render(" No results"))
|
|
sb.WriteString("\n")
|
|
} else {
|
|
for i, o := range m.options {
|
|
cursor := m.mode == msModeSelect && i == m.cursor
|
|
isSelected := m.selected[o.value]
|
|
sb.WriteString(m.renderOption(o, cursor, isSelected))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return styles.Base.Width(m.width).Height(m.height).Render(sb.String())
|
|
}
|
|
|
|
func (m *multiSelectSearchField) renderOption(o msOption, cursor, selected bool) string {
|
|
styles := m.activeStyles()
|
|
|
|
var parts []string
|
|
if cursor {
|
|
parts = append(parts, styles.MultiSelectSelector.String())
|
|
} else {
|
|
parts = append(parts, strings.Repeat(" ", lipgloss.Width(styles.MultiSelectSelector.String())))
|
|
}
|
|
if selected {
|
|
parts = append(parts, styles.SelectedPrefix.String())
|
|
parts = append(parts, styles.SelectedOption.Render(o.label))
|
|
} else {
|
|
parts = append(parts, styles.UnselectedPrefix.String())
|
|
parts = append(parts, styles.UnselectedOption.Render(o.label))
|
|
}
|
|
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
|
|
}
|
|
|
|
func (m *multiSelectSearchField) activeStyles() *huh.FieldStyles {
|
|
theme := m.theme
|
|
if theme == nil {
|
|
theme = huh.ThemeFunc(huh.ThemeCharm)
|
|
}
|
|
if m.focused {
|
|
return &theme.Theme(m.hasDarkBg).Focused
|
|
}
|
|
return &theme.Theme(m.hasDarkBg).Blurred
|
|
}
|
|
|
|
func (m *multiSelectSearchField) Focus() tea.Cmd {
|
|
m.focused = true
|
|
if m.mode == msModeSearch {
|
|
return m.search.Focus()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) Blur() tea.Cmd {
|
|
m.focused = false
|
|
m.search.Blur()
|
|
return nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) Error() error { return m.err }
|
|
func (*multiSelectSearchField) Skip() bool { return false }
|
|
func (*multiSelectSearchField) Zoom() bool { return false }
|
|
func (m *multiSelectSearchField) GetKey() string { return m.key }
|
|
func (m *multiSelectSearchField) GetValue() any { return m.selectedKeys() }
|
|
func (m *multiSelectSearchField) Run() error { return huh.Run(m) }
|
|
func (m *multiSelectSearchField) RunAccessible(w io.Writer, r io.Reader) error {
|
|
_, _ = fmt.Fprintln(w, "MultiSelectWithSearch accessible mode not implemented")
|
|
return nil
|
|
}
|
|
|
|
func (m *multiSelectSearchField) KeyBinds() []key.Binding {
|
|
if m.mode == msModeSearch {
|
|
return []key.Binding{
|
|
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "search")),
|
|
key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")),
|
|
}
|
|
}
|
|
return []key.Binding{
|
|
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "toggle")),
|
|
key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
|
|
key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
|
|
key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "search")),
|
|
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
|
|
}
|
|
}
|
|
|
|
func (m *multiSelectSearchField) WithTheme(theme huh.Theme) huh.Field {
|
|
if m.theme != nil {
|
|
return m
|
|
}
|
|
m.theme = theme
|
|
|
|
styles := theme.Theme(m.hasDarkBg)
|
|
st := m.search.Styles()
|
|
st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground()
|
|
st.Focused.Prompt = styles.Focused.TextInput.Prompt
|
|
st.Focused.Text = styles.Focused.TextInput.Text
|
|
st.Focused.Placeholder = styles.Focused.TextInput.Placeholder
|
|
m.search.SetStyles(st)
|
|
|
|
return m
|
|
}
|
|
|
|
func (m *multiSelectSearchField) WithKeyMap(k *huh.KeyMap) huh.Field {
|
|
return m
|
|
}
|
|
|
|
func (m *multiSelectSearchField) WithWidth(width int) huh.Field {
|
|
m.width = width
|
|
m.search.SetWidth(width)
|
|
return m
|
|
}
|
|
|
|
func (m *multiSelectSearchField) WithHeight(height int) huh.Field {
|
|
m.height = height
|
|
return m
|
|
}
|
|
|
|
func (m *multiSelectSearchField) WithPosition(p huh.FieldPosition) huh.Field {
|
|
m.position = p
|
|
return m
|
|
}
|