1package completions
2
3import (
4 "charm.land/lipgloss/v2"
5 "git.secluded.site/crush/internal/ui/list"
6 "github.com/charmbracelet/x/ansi"
7 "github.com/rivo/uniseg"
8 "github.com/sahilm/fuzzy"
9)
10
11// FileCompletionValue represents a file path completion value.
12type FileCompletionValue struct {
13 Path string
14}
15
16// ResourceCompletionValue represents a MCP resource completion value.
17type ResourceCompletionValue struct {
18 MCPName string
19 URI string
20 Title string
21 MIMEType string
22}
23
24// CompletionItem represents an item in the completions list.
25type CompletionItem struct {
26 text string
27 value any
28 match fuzzy.Match
29 focused bool
30 cache map[int]string
31
32 // Styles
33 normalStyle lipgloss.Style
34 focusedStyle lipgloss.Style
35 matchStyle lipgloss.Style
36}
37
38// NewCompletionItem creates a new completion item.
39func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
40 return &CompletionItem{
41 text: text,
42 value: value,
43 normalStyle: normalStyle,
44 focusedStyle: focusedStyle,
45 matchStyle: matchStyle,
46 }
47}
48
49// Text returns the display text of the item.
50func (c *CompletionItem) Text() string {
51 return c.text
52}
53
54// Value returns the value of the item.
55func (c *CompletionItem) Value() any {
56 return c.value
57}
58
59// Filter implements [list.FilterableItem].
60func (c *CompletionItem) Filter() string {
61 return c.text
62}
63
64// SetMatch implements [list.MatchSettable].
65func (c *CompletionItem) SetMatch(m fuzzy.Match) {
66 c.cache = nil
67 c.match = m
68}
69
70// SetFocused implements [list.Focusable].
71func (c *CompletionItem) SetFocused(focused bool) {
72 if c.focused != focused {
73 c.cache = nil
74 }
75 c.focused = focused
76}
77
78// Render implements [list.Item].
79func (c *CompletionItem) Render(width int) string {
80 return renderItem(
81 c.normalStyle,
82 c.focusedStyle,
83 c.matchStyle,
84 c.text,
85 c.focused,
86 width,
87 c.cache,
88 &c.match,
89 )
90}
91
92func renderItem(
93 normalStyle, focusedStyle, matchStyle lipgloss.Style,
94 text string,
95 focused bool,
96 width int,
97 cache map[int]string,
98 match *fuzzy.Match,
99) string {
100 if cache == nil {
101 cache = make(map[int]string)
102 }
103
104 cached, ok := cache[width]
105 if ok {
106 return cached
107 }
108
109 innerWidth := width - 2 // Account for padding
110 // Truncate if needed.
111 if ansi.StringWidth(text) > innerWidth {
112 text = ansi.Truncate(text, innerWidth, "…")
113 }
114
115 // Select base style.
116 style := normalStyle
117 matchStyle = matchStyle.Background(style.GetBackground())
118 if focused {
119 style = focusedStyle
120 matchStyle = matchStyle.Background(style.GetBackground())
121 }
122
123 // Render full-width text with background.
124 content := style.Padding(0, 1).Width(width).Render(text)
125
126 // Apply match highlighting using StyleRanges.
127 if len(match.MatchedIndexes) > 0 {
128 var ranges []lipgloss.Range
129 for _, rng := range matchedRanges(match.MatchedIndexes) {
130 start, stop := bytePosToVisibleCharPos(text, rng)
131 // Offset by 1 for the padding space.
132 ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
133 }
134 content = lipgloss.StyleRanges(content, ranges...)
135 }
136
137 cache[width] = content
138 return content
139}
140
141// matchedRanges converts a list of match indexes into contiguous ranges.
142func matchedRanges(in []int) [][2]int {
143 if len(in) == 0 {
144 return [][2]int{}
145 }
146 current := [2]int{in[0], in[0]}
147 if len(in) == 1 {
148 return [][2]int{current}
149 }
150 var out [][2]int
151 for i := 1; i < len(in); i++ {
152 if in[i] == current[1]+1 {
153 current[1] = in[i]
154 } else {
155 out = append(out, current)
156 current = [2]int{in[i], in[i]}
157 }
158 }
159 out = append(out, current)
160 return out
161}
162
163// bytePosToVisibleCharPos converts byte positions to visible character positions.
164func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
165 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
166 pos, start, stop := 0, 0, 0
167 gr := uniseg.NewGraphemes(str)
168 for byteStart > bytePos {
169 if !gr.Next() {
170 break
171 }
172 bytePos += len(gr.Str())
173 pos += max(1, gr.Width())
174 }
175 start = pos
176 for byteStop > bytePos {
177 if !gr.Next() {
178 break
179 }
180 bytePos += len(gr.Str())
181 pos += max(1, gr.Width())
182 }
183 stop = pos
184 return start, stop
185}
186
187// Ensure CompletionItem implements the required interfaces.
188var (
189 _ list.Item = (*CompletionItem)(nil)
190 _ list.FilterableItem = (*CompletionItem)(nil)
191 _ list.MatchSettable = (*CompletionItem)(nil)
192 _ list.Focusable = (*CompletionItem)(nil)
193)