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