item.go

  1package completions
  2
  3import (
  4	"slices"
  5
  6	"charm.land/lipgloss/v2"
  7	"git.secluded.site/crush/internal/ui/list"
  8	"github.com/charmbracelet/x/ansi"
  9	"github.com/rivo/uniseg"
 10	"github.com/sahilm/fuzzy"
 11)
 12
 13// FileCompletionValue represents a file path completion value.
 14type FileCompletionValue struct {
 15	Path string
 16}
 17
 18// ResourceCompletionValue represents a MCP resource completion value.
 19type ResourceCompletionValue struct {
 20	MCPName  string
 21	URI      string
 22	Title    string
 23	MIMEType string
 24}
 25
 26// CompletionItem represents an item in the completions list.
 27type CompletionItem struct {
 28	*list.Versioned
 29
 30	text    string
 31	value   any
 32	match   fuzzy.Match
 33	focused bool
 34	cache   map[int]string
 35
 36	// Styles
 37	normalStyle  lipgloss.Style
 38	focusedStyle lipgloss.Style
 39	matchStyle   lipgloss.Style
 40}
 41
 42// NewCompletionItem creates a new completion item.
 43func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
 44	return &CompletionItem{
 45		Versioned:    list.NewVersioned(),
 46		text:         text,
 47		value:        value,
 48		normalStyle:  normalStyle,
 49		focusedStyle: focusedStyle,
 50		matchStyle:   matchStyle,
 51	}
 52}
 53
 54// Finished implements list.Item. Completion items render purely from
 55// (text, match, focus); any mutation (SetMatch / SetFocused) bumps
 56// Version() so the frozen cache entry invalidates on the next
 57// render. Marking them finished lets the F6 list memo skip the
 58// per-line work for the steady completions popup.
 59func (c *CompletionItem) Finished() bool {
 60	return true
 61}
 62
 63// Text returns the display text of the item.
 64func (c *CompletionItem) Text() string {
 65	return c.text
 66}
 67
 68// Value returns the value of the item.
 69func (c *CompletionItem) Value() any {
 70	return c.value
 71}
 72
 73// Filter implements [list.FilterableItem].
 74func (c *CompletionItem) Filter() string {
 75	return c.text
 76}
 77
 78// SetMatch implements [list.MatchSettable].
 79func (c *CompletionItem) SetMatch(m fuzzy.Match) {
 80	if sameFuzzyMatch(c.match, m) {
 81		return
 82	}
 83	c.cache = nil
 84	c.match = m
 85	c.Bump()
 86}
 87
 88// sameFuzzyMatch reports whether two fuzzy.Match values are
 89// observably equal. Because Match contains a slice (MatchedIndexes)
 90// it is not directly comparable with ==; we compare the scalar
 91// fields and then walk the indexes. SetMatch uses this to skip
 92// gratuitous version bumps when the same match is reapplied.
 93func sameFuzzyMatch(a, b fuzzy.Match) bool {
 94	return a.Str == b.Str &&
 95		a.Index == b.Index &&
 96		a.Score == b.Score &&
 97		slices.Equal(a.MatchedIndexes, b.MatchedIndexes)
 98}
 99
100// SetFocused implements [list.Focusable].
101func (c *CompletionItem) SetFocused(focused bool) {
102	if c.focused == focused {
103		return
104	}
105	c.cache = nil
106	c.focused = focused
107	c.Bump()
108}
109
110// Render implements [list.Item].
111func (c *CompletionItem) Render(width int) string {
112	return renderItem(
113		c.normalStyle,
114		c.focusedStyle,
115		c.matchStyle,
116		c.text,
117		c.focused,
118		width,
119		c.cache,
120		&c.match,
121	)
122}
123
124func renderItem(
125	normalStyle, focusedStyle, matchStyle lipgloss.Style,
126	text string,
127	focused bool,
128	width int,
129	cache map[int]string,
130	match *fuzzy.Match,
131) string {
132	if cache == nil {
133		cache = make(map[int]string)
134	}
135
136	cached, ok := cache[width]
137	if ok {
138		return cached
139	}
140
141	innerWidth := width - 2 // Account for padding
142	// Truncate if needed.
143	if ansi.StringWidth(text) > innerWidth {
144		text = ansi.Truncate(text, innerWidth, "…")
145	}
146
147	// Select base style.
148	style := normalStyle
149	matchStyle = matchStyle.Background(style.GetBackground())
150	if focused {
151		style = focusedStyle
152		matchStyle = matchStyle.Background(style.GetBackground())
153	}
154
155	// Render full-width text with background.
156	content := style.Padding(0, 1).Width(width).Render(text)
157
158	// Apply match highlighting using StyleRanges.
159	if len(match.MatchedIndexes) > 0 {
160		var ranges []lipgloss.Range
161		for _, rng := range matchedRanges(match.MatchedIndexes) {
162			start, stop := bytePosToVisibleCharPos(text, rng)
163			// Offset by 1 for the padding space.
164			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
165		}
166		content = lipgloss.StyleRanges(content, ranges...)
167	}
168
169	cache[width] = content
170	return content
171}
172
173// matchedRanges converts a list of match indexes into contiguous ranges.
174func matchedRanges(in []int) [][2]int {
175	if len(in) == 0 {
176		return [][2]int{}
177	}
178	current := [2]int{in[0], in[0]}
179	if len(in) == 1 {
180		return [][2]int{current}
181	}
182	var out [][2]int
183	for i := 1; i < len(in); i++ {
184		if in[i] == current[1]+1 {
185			current[1] = in[i]
186		} else {
187			out = append(out, current)
188			current = [2]int{in[i], in[i]}
189		}
190	}
191	out = append(out, current)
192	return out
193}
194
195// bytePosToVisibleCharPos converts byte positions to visible character positions.
196func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
197	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
198	pos, start, stop := 0, 0, 0
199	gr := uniseg.NewGraphemes(str)
200	for byteStart > bytePos {
201		if !gr.Next() {
202			break
203		}
204		bytePos += len(gr.Str())
205		pos += max(1, gr.Width())
206	}
207	start = pos
208	for byteStop > bytePos {
209		if !gr.Next() {
210			break
211		}
212		bytePos += len(gr.Str())
213		pos += max(1, gr.Width())
214	}
215	stop = pos
216	return start, stop
217}
218
219// Ensure CompletionItem implements the required interfaces.
220var (
221	_ list.Item           = (*CompletionItem)(nil)
222	_ list.FilterableItem = (*CompletionItem)(nil)
223	_ list.MatchSettable  = (*CompletionItem)(nil)
224	_ list.Focusable      = (*CompletionItem)(nil)
225)