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