Add proper fuzzy matching for time tags
This commit is contained in:
@ -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)
|
||||||
if len(filtered) >= a.maxVisible {
|
indexes = append(indexes, match.MatchedIndexes)
|
||||||
break
|
if len(filtered) >= a.maxVisible {
|
||||||
}
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user