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