Add fuzzy matching for time tags

This commit is contained in:
Martin Pander
2026-02-02 15:41:53 +01:00
parent 81b9d87935
commit 938ed177f1
5 changed files with 577 additions and 32 deletions

View File

@ -0,0 +1,224 @@
package autocomplete
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Autocomplete struct {
input textinput.Model
allSuggestions []string // All available suggestions (newest first)
filteredSuggestions []string // Currently matching suggestions
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) *Autocomplete {
ti := textinput.New()
ti.Width = 50
return &Autocomplete{
input: ti,
allSuggestions: suggestions,
selectedIndex: -1,
maxVisible: 5,
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
}
// 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 := " "
style := lipgloss.NewStyle().Padding(0, 1)
if i == a.selectedIndex {
// Highlight selected suggestion
style = style.Bold(true).Foreground(lipgloss.Color("12"))
prefix = "→ "
}
suggestionViews = append(suggestionViews, style.Render(prefix+suggestion))
}
// 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)
}
// updateFilteredSuggestions filters suggestions based on current input
func (a *Autocomplete) updateFilteredSuggestions() {
value := strings.ToLower(a.input.Value())
// Only show if >= minChars
if len(value) < a.minChars {
a.showSuggestions = false
a.filteredSuggestions = nil
a.selectedIndex = -1
return
}
// Substring match (case-insensitive)
var filtered []string
for _, suggestion := range a.allSuggestions {
if strings.Contains(strings.ToLower(suggestion), value) {
filtered = append(filtered, suggestion)
if len(filtered) >= a.maxVisible {
break
}
}
}
a.filteredSuggestions = filtered
a.showSuggestions = len(filtered) > 0 && a.focused
a.selectedIndex = -1 // Reset to input
}