289 lines
7.4 KiB
Go
289 lines
7.4 KiB
Go
package autocomplete
|
|
|
|
import (
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/sahilm/fuzzy"
|
|
)
|
|
|
|
type Autocomplete struct {
|
|
input textinput.Model
|
|
allSuggestions []string // All available suggestions (newest first)
|
|
filteredSuggestions []string // Currently matching suggestions
|
|
matchedIndexes [][]int // Matched character positions for each suggestion
|
|
selectedIndex int // -1 = input focused, 0+ = suggestion selected
|
|
showSuggestions bool // Whether to display suggestion box
|
|
maxVisible int // Max suggestions to show
|
|
minChars int // Min chars before showing suggestions
|
|
focused bool
|
|
width int
|
|
placeholder string
|
|
}
|
|
|
|
// New creates a new autocomplete component
|
|
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
|
|
ti := textinput.New()
|
|
ti.Width = 50
|
|
|
|
return &Autocomplete{
|
|
input: ti,
|
|
allSuggestions: suggestions,
|
|
selectedIndex: -1,
|
|
maxVisible: maxVisible,
|
|
minChars: minChars,
|
|
width: 50,
|
|
}
|
|
}
|
|
|
|
// SetValue sets the input value
|
|
func (a *Autocomplete) SetValue(value string) {
|
|
a.input.SetValue(value)
|
|
a.updateFilteredSuggestions()
|
|
}
|
|
|
|
// GetValue returns the current input value
|
|
func (a *Autocomplete) GetValue() string {
|
|
return a.input.Value()
|
|
}
|
|
|
|
// Focus focuses the autocomplete input
|
|
func (a *Autocomplete) Focus() {
|
|
a.focused = true
|
|
a.input.Focus()
|
|
}
|
|
|
|
// Blur blurs the autocomplete input
|
|
func (a *Autocomplete) Blur() {
|
|
a.focused = false
|
|
a.input.Blur()
|
|
a.showSuggestions = false
|
|
}
|
|
|
|
// SetPlaceholder sets the placeholder text
|
|
func (a *Autocomplete) SetPlaceholder(placeholder string) {
|
|
a.placeholder = placeholder
|
|
a.input.Placeholder = placeholder
|
|
}
|
|
|
|
// SetWidth sets the width of the autocomplete
|
|
func (a *Autocomplete) SetWidth(width int) {
|
|
a.width = width
|
|
a.input.Width = width
|
|
}
|
|
|
|
// SetMaxVisible sets the maximum number of visible suggestions
|
|
func (a *Autocomplete) SetMaxVisible(max int) {
|
|
a.maxVisible = max
|
|
}
|
|
|
|
// SetMinChars sets the minimum characters required before showing suggestions
|
|
func (a *Autocomplete) SetMinChars(min int) {
|
|
a.minChars = min
|
|
}
|
|
|
|
// SetSuggestions updates the available suggestions
|
|
func (a *Autocomplete) SetSuggestions(suggestions []string) {
|
|
a.allSuggestions = suggestions
|
|
a.updateFilteredSuggestions()
|
|
}
|
|
|
|
// Init initializes the autocomplete
|
|
func (a *Autocomplete) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
// Update handles messages for the autocomplete
|
|
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if !a.focused {
|
|
return a, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
a.selectedIndex++
|
|
if a.selectedIndex >= len(a.filteredSuggestions) {
|
|
a.selectedIndex = 0
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
a.selectedIndex--
|
|
if a.selectedIndex < 0 {
|
|
a.selectedIndex = len(a.filteredSuggestions) - 1
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
|
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
|
// Accept selected suggestion
|
|
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
|
a.showSuggestions = false
|
|
a.selectedIndex = -1
|
|
return a, nil
|
|
}
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
|
// Accept first or selected suggestion
|
|
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
|
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
|
} else {
|
|
a.input.SetValue(a.filteredSuggestions[0])
|
|
}
|
|
a.showSuggestions = false
|
|
a.selectedIndex = -1
|
|
return a, nil
|
|
}
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
|
if a.showSuggestions {
|
|
a.showSuggestions = false
|
|
a.selectedIndex = -1
|
|
return a, nil
|
|
}
|
|
|
|
default:
|
|
// Handle regular text input
|
|
prevValue := a.input.Value()
|
|
a.input, cmd = a.input.Update(msg)
|
|
|
|
// Update suggestions if value changed
|
|
if a.input.Value() != prevValue {
|
|
a.updateFilteredSuggestions()
|
|
}
|
|
|
|
return a, cmd
|
|
}
|
|
}
|
|
|
|
a.input, cmd = a.input.Update(msg)
|
|
return a, cmd
|
|
}
|
|
|
|
// View renders the autocomplete
|
|
func (a *Autocomplete) View() string {
|
|
// Input field
|
|
inputView := a.input.View()
|
|
|
|
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
|
|
return inputView
|
|
}
|
|
|
|
// Suggestion box
|
|
var suggestionViews []string
|
|
for i, suggestion := range a.filteredSuggestions {
|
|
if i >= a.maxVisible {
|
|
break
|
|
}
|
|
|
|
prefix := " "
|
|
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
|
|
|
if i == a.selectedIndex {
|
|
// Highlight selected suggestion
|
|
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
|
|
prefix = "→ "
|
|
}
|
|
|
|
// Build suggestion with highlighted matched characters
|
|
var rendered string
|
|
if i < len(a.matchedIndexes) {
|
|
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
|
|
} else {
|
|
rendered = suggestion
|
|
}
|
|
|
|
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
|
|
}
|
|
|
|
// Box style
|
|
boxStyle := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("8")).
|
|
Width(a.width)
|
|
|
|
suggestionsBox := boxStyle.Render(
|
|
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
|
|
)
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
|
|
}
|
|
|
|
// renderWithHighlights renders a suggestion with matched characters highlighted
|
|
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
|
|
if len(matchedIndexes) == 0 {
|
|
return str
|
|
}
|
|
|
|
// Create a map for quick lookup
|
|
matchedMap := make(map[int]bool)
|
|
for _, idx := range matchedIndexes {
|
|
matchedMap[idx] = true
|
|
}
|
|
|
|
// Choose highlight style based on selection state
|
|
var highlightStyle lipgloss.Style
|
|
if isSelected {
|
|
// When selected, use underline to distinguish from selection bold
|
|
highlightStyle = lipgloss.NewStyle().Underline(true)
|
|
} else {
|
|
// When not selected, use bold and accent color
|
|
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
|
}
|
|
|
|
// Build the string with highlights
|
|
var result string
|
|
runes := []rune(str)
|
|
for i, r := range runes {
|
|
if matchedMap[i] {
|
|
result += highlightStyle.Render(string(r))
|
|
} else {
|
|
result += string(r)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// updateFilteredSuggestions filters suggestions based on current input
|
|
func (a *Autocomplete) updateFilteredSuggestions() {
|
|
value := a.input.Value()
|
|
|
|
// Only show if >= minChars
|
|
if len(value) < a.minChars {
|
|
a.showSuggestions = false
|
|
a.filteredSuggestions = nil
|
|
a.matchedIndexes = nil
|
|
a.selectedIndex = -1
|
|
return
|
|
}
|
|
|
|
// Fuzzy match using sahilm/fuzzy
|
|
matches := fuzzy.Find(value, a.allSuggestions)
|
|
|
|
var filtered []string
|
|
var indexes [][]int
|
|
for _, match := range matches {
|
|
filtered = append(filtered, match.Str)
|
|
indexes = append(indexes, match.MatchedIndexes)
|
|
if len(filtered) >= a.maxVisible {
|
|
break
|
|
}
|
|
}
|
|
|
|
a.filteredSuggestions = filtered
|
|
a.matchedIndexes = indexes
|
|
a.showSuggestions = len(filtered) > 0 && a.focused
|
|
a.selectedIndex = -1 // Reset to input
|
|
}
|