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