diff --git a/components/autocomplete/autocomplete.go b/components/autocomplete/autocomplete.go index 2e98307..f459b2b 100644 --- a/components/autocomplete/autocomplete.go +++ b/components/autocomplete/autocomplete.go @@ -1,18 +1,18 @@ package autocomplete import ( - "strings" - "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 @@ -23,7 +23,7 @@ type Autocomplete struct { } // 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.Width = 50 @@ -31,7 +31,7 @@ func New(suggestions []string, minChars int) *Autocomplete { input: ti, allSuggestions: suggestions, selectedIndex: -1, - maxVisible: 5, + maxVisible: maxVisible, minChars: minChars, width: 50, } @@ -73,6 +73,16 @@ func (a *Autocomplete) SetWidth(width int) { 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 func (a *Autocomplete) Init() tea.Cmd { return textinput.Blink @@ -171,15 +181,23 @@ func (a *Autocomplete) View() string { } prefix := " " - style := lipgloss.NewStyle().Padding(0, 1) + baseStyle := lipgloss.NewStyle().Padding(0, 1) if i == a.selectedIndex { // Highlight selected suggestion - style = style.Bold(true).Foreground(lipgloss.Color("12")) + baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12")) 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 @@ -195,30 +213,70 @@ func (a *Autocomplete) View() string { 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 := strings.ToLower(a.input.Value()) + 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 } - // Substring match (case-insensitive) + // Fuzzy match using sahilm/fuzzy + matches := fuzzy.Find(value, a.allSuggestions) + 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 - } + 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 } diff --git a/pages/timeEditor.go b/pages/timeEditor.go index e88ed68..c573f6b 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -41,7 +41,7 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time // Create tags autocomplete with combinations from past intervals 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.SetValue(formatTags(interval.Tags)) tagsInput.SetWidth(50)