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)