1package list
2
3import (
4 "image/color"
5
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/x/ansi"
11 "github.com/google/uuid"
12 "github.com/rivo/uniseg"
13)
14
15type CompletionItem[T any] interface {
16 FilterableItem
17 layout.Focusable
18 layout.Sizeable
19 HasMatchIndexes
20 Value() T
21}
22
23type completionItemCmp[T any] struct {
24 width int
25 id string
26 text string
27 value T
28 focus bool
29 matchIndexes []int
30 bgColor color.Color
31 shortcut string
32}
33
34type options struct {
35 id string
36 text string
37 bgColor color.Color
38 matchIndexes []int
39 shortcut string
40}
41
42type completionOption func(*options)
43
44func WithBackgroundColor(c color.Color) completionOption {
45 return func(cmp *options) {
46 cmp.bgColor = c
47 }
48}
49
50func WithMatchIndexes(indexes ...int) completionOption {
51 return func(cmp *options) {
52 cmp.matchIndexes = indexes
53 }
54}
55
56func WithShortcut(shortcut string) completionOption {
57 return func(cmp *options) {
58 cmp.shortcut = shortcut
59 }
60}
61
62func WithID(id string) completionOption {
63 return func(cmp *options) {
64 cmp.id = id
65 }
66}
67
68func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
69 c := &completionItemCmp[T]{
70 text: text,
71 value: value,
72 }
73 o := &options{}
74
75 for _, opt := range opts {
76 opt(o)
77 }
78 if o.id == "" {
79 o.id = uuid.NewString()
80 }
81 c.id = o.id
82 c.bgColor = o.bgColor
83 c.matchIndexes = o.matchIndexes
84 c.shortcut = o.shortcut
85 return c
86}
87
88// Init implements CommandItem.
89func (c *completionItemCmp[T]) Init() tea.Cmd {
90 return nil
91}
92
93// Update implements CommandItem.
94func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
95 return c, nil
96}
97
98// View implements CommandItem.
99func (c *completionItemCmp[T]) View() string {
100 t := styles.CurrentTheme()
101
102 itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
103 innerWidth := c.width - 2 // Account for padding
104
105 if c.shortcut != "" {
106 innerWidth -= lipgloss.Width(c.shortcut)
107 }
108
109 titleStyle := t.S().Text.Width(innerWidth)
110 titleMatchStyle := t.S().Text.Underline(true)
111 if c.bgColor != nil {
112 titleStyle = titleStyle.Background(c.bgColor)
113 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
114 itemStyle = itemStyle.Background(c.bgColor)
115 }
116
117 if c.focus {
118 titleStyle = t.S().TextSelected.Width(innerWidth)
119 titleMatchStyle = t.S().TextSelected.Underline(true)
120 itemStyle = itemStyle.Background(t.Primary)
121 }
122
123 var truncatedTitle string
124
125 if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
126 // Smart truncation: ensure the last matching part is visible
127 truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
128 } else {
129 // No matches, use regular truncation
130 truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
131 }
132
133 text := titleStyle.Render(truncatedTitle)
134 if len(c.matchIndexes) > 0 {
135 var ranges []lipgloss.Range
136 for _, rng := range matchedRanges(c.matchIndexes) {
137 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
138 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
139 // so we need to adjust it here:
140 start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
141 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
142 }
143 text = lipgloss.StyleRanges(text, ranges...)
144 }
145 parts := []string{text}
146 if c.shortcut != "" {
147 // Add the shortcut at the end
148 shortcutStyle := t.S().Muted
149 if c.focus {
150 shortcutStyle = t.S().TextSelected
151 }
152 parts = append(parts, shortcutStyle.Render(c.shortcut))
153 }
154 item := itemStyle.Render(
155 lipgloss.JoinHorizontal(
156 lipgloss.Left,
157 parts...,
158 ),
159 )
160 return item
161}
162
163// Blur implements CommandItem.
164func (c *completionItemCmp[T]) Blur() tea.Cmd {
165 c.focus = false
166 return nil
167}
168
169// Focus implements CommandItem.
170func (c *completionItemCmp[T]) Focus() tea.Cmd {
171 c.focus = true
172 return nil
173}
174
175// GetSize implements CommandItem.
176func (c *completionItemCmp[T]) GetSize() (int, int) {
177 return c.width, 1
178}
179
180// IsFocused implements CommandItem.
181func (c *completionItemCmp[T]) IsFocused() bool {
182 return c.focus
183}
184
185// SetSize implements CommandItem.
186func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
187 c.width = width
188 return nil
189}
190
191func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
192 c.matchIndexes = indexes
193}
194
195func (c *completionItemCmp[T]) FilterValue() string {
196 return c.text
197}
198
199func (c *completionItemCmp[T]) Value() T {
200 return c.value
201}
202
203// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
204func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
205 if width <= 0 {
206 return ""
207 }
208
209 textLen := ansi.StringWidth(text)
210 if textLen <= width {
211 return text
212 }
213
214 if len(matchIndexes) == 0 {
215 return ansi.Truncate(text, width, "…")
216 }
217
218 // Find the last match position
219 lastMatchPos := matchIndexes[len(matchIndexes)-1]
220
221 // Convert byte position to visual width position
222 lastMatchVisualPos := 0
223 bytePos := 0
224 gr := uniseg.NewGraphemes(text)
225 for bytePos < lastMatchPos && gr.Next() {
226 bytePos += len(gr.Str())
227 lastMatchVisualPos += max(1, gr.Width())
228 }
229
230 // Calculate how much space we need for the ellipsis
231 ellipsisWidth := 1 // "…" character width
232 availableWidth := width - ellipsisWidth
233
234 // If the last match is within the available width, truncate from the end
235 if lastMatchVisualPos < availableWidth {
236 return ansi.Truncate(text, width, "…")
237 }
238
239 // Calculate the start position to ensure the last match is visible
240 // We want to show some context before the last match if possible
241 startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
242
243 // Convert visual position back to byte position
244 startBytePos := 0
245 currentVisualPos := 0
246 gr = uniseg.NewGraphemes(text)
247 for currentVisualPos < startVisualPos && gr.Next() {
248 startBytePos += len(gr.Str())
249 currentVisualPos += max(1, gr.Width())
250 }
251
252 // Extract the substring starting from startBytePos
253 truncatedText := text[startBytePos:]
254
255 // Truncate to fit width with ellipsis
256 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
257 truncatedText = "…" + truncatedText
258 return truncatedText
259}
260
261func matchedRanges(in []int) [][2]int {
262 if len(in) == 0 {
263 return [][2]int{}
264 }
265 current := [2]int{in[0], in[0]}
266 if len(in) == 1 {
267 return [][2]int{current}
268 }
269 var out [][2]int
270 for i := 1; i < len(in); i++ {
271 if in[i] == current[1]+1 {
272 current[1] = in[i]
273 } else {
274 out = append(out, current)
275 current = [2]int{in[i], in[i]}
276 }
277 }
278 out = append(out, current)
279 return out
280}
281
282func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
283 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
284 pos, start, stop := 0, 0, 0
285 gr := uniseg.NewGraphemes(str)
286 for byteStart > bytePos {
287 if !gr.Next() {
288 break
289 }
290 bytePos += len(gr.Str())
291 pos += max(1, gr.Width())
292 }
293 start = pos
294 for byteStop > bytePos {
295 if !gr.Next() {
296 break
297 }
298 bytePos += len(gr.Str())
299 pos += max(1, gr.Width())
300 }
301 stop = pos
302 return start, stop
303}
304
305// ID implements CompletionItem.
306func (c *completionItemCmp[T]) ID() string {
307 return c.id
308}