Add proper fuzzy matching for time tags

This commit is contained in:
Martin Pander
2026-02-02 15:54:39 +01:00
parent 938ed177f1
commit f5d297e6ab
2 changed files with 74 additions and 16 deletions

View File

@ -1,18 +1,18 @@
package autocomplete package autocomplete
import ( import (
"strings"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
) )
type Autocomplete struct { type Autocomplete struct {
input textinput.Model input textinput.Model
allSuggestions []string // All available suggestions (newest first) allSuggestions []string // All available suggestions (newest first)
filteredSuggestions []string // Currently matching suggestions filteredSuggestions []string // Currently matching suggestions
matchedIndexes [][]int // Matched character positions for each suggestion
selectedIndex int // -1 = input focused, 0+ = suggestion selected selectedIndex int // -1 = input focused, 0+ = suggestion selected
showSuggestions bool // Whether to display suggestion box showSuggestions bool // Whether to display suggestion box
maxVisible int // Max suggestions to show maxVisible int // Max suggestions to show
@ -23,7 +23,7 @@ type Autocomplete struct {
} }
// New creates a new autocomplete component // New creates a new autocomplete component
func New(suggestions []string, minChars int) *Autocomplete { func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
ti := textinput.New() ti := textinput.New()
ti.Width = 50 ti.Width = 50
@ -31,7 +31,7 @@ func New(suggestions []string, minChars int) *Autocomplete {
input: ti, input: ti,
allSuggestions: suggestions, allSuggestions: suggestions,
selectedIndex: -1, selectedIndex: -1,
maxVisible: 5, maxVisible: maxVisible,
minChars: minChars, minChars: minChars,
width: 50, width: 50,
} }
@ -73,6 +73,16 @@ func (a *Autocomplete) SetWidth(width int) {
a.input.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
}
// Init initializes the autocomplete // Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd { func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink return textinput.Blink
@ -171,15 +181,23 @@ func (a *Autocomplete) View() string {
} }
prefix := " " prefix := " "
style := lipgloss.NewStyle().Padding(0, 1) baseStyle := lipgloss.NewStyle().Padding(0, 1)
if i == a.selectedIndex { if i == a.selectedIndex {
// Highlight selected suggestion // Highlight selected suggestion
style = style.Bold(true).Foreground(lipgloss.Color("12")) baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
prefix = "→ " prefix = "→ "
} }
suggestionViews = append(suggestionViews, style.Render(prefix+suggestion)) // 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 // Box style
@ -195,30 +213,70 @@ func (a *Autocomplete) View() string {
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox) 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 // updateFilteredSuggestions filters suggestions based on current input
func (a *Autocomplete) updateFilteredSuggestions() { func (a *Autocomplete) updateFilteredSuggestions() {
value := strings.ToLower(a.input.Value()) value := a.input.Value()
// Only show if >= minChars // Only show if >= minChars
if len(value) < a.minChars { if len(value) < a.minChars {
a.showSuggestions = false a.showSuggestions = false
a.filteredSuggestions = nil a.filteredSuggestions = nil
a.matchedIndexes = nil
a.selectedIndex = -1 a.selectedIndex = -1
return return
} }
// Substring match (case-insensitive) // Fuzzy match using sahilm/fuzzy
matches := fuzzy.Find(value, a.allSuggestions)
var filtered []string var filtered []string
for _, suggestion := range a.allSuggestions { var indexes [][]int
if strings.Contains(strings.ToLower(suggestion), value) { for _, match := range matches {
filtered = append(filtered, suggestion) filtered = append(filtered, match.Str)
indexes = append(indexes, match.MatchedIndexes)
if len(filtered) >= a.maxVisible { if len(filtered) >= a.maxVisible {
break break
} }
} }
}
a.filteredSuggestions = filtered a.filteredSuggestions = filtered
a.matchedIndexes = indexes
a.showSuggestions = len(filtered) > 0 && a.focused a.showSuggestions = len(filtered) > 0 && a.focused
a.selectedIndex = -1 // Reset to input a.selectedIndex = -1 // Reset to input
} }

View File

@ -41,7 +41,7 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
// Create tags autocomplete with combinations from past intervals // Create tags autocomplete with combinations from past intervals
tagCombinations := com.TimeW.GetTagCombinations() tagCombinations := com.TimeW.GetTagCombinations()
tagsInput := autocomplete.New(tagCombinations, 3) tagsInput := autocomplete.New(tagCombinations, 3, 10)
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
tagsInput.SetValue(formatTags(interval.Tags)) tagsInput.SetValue(formatTags(interval.Tags))
tagsInput.SetWidth(50) tagsInput.SetWidth(50)