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	id    string
331	info  string
332}
333
334// ID implements ItemSection.
335func (m *itemSectionModel) ID() string {
336	return m.id
337}
338
339func NewItemSection(title string) ItemSection {
340	return &itemSectionModel{
341		title: title,
342		inx:   -1,
343		id:    uuid.NewString(),
344	}
345}
346
347func (m *itemSectionModel) Init() tea.Cmd {
348	return nil
349}
350
351func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
352	return m, nil
353}
354
355func (m *itemSectionModel) View() string {
356	t := styles.CurrentTheme()
357	title := ansi.Truncate(m.title, m.width-2, "…")
358	style := t.S().Base.Padding(1, 1, 0, 1)
359	if m.inx == 0 {
360		style = style.Padding(0, 1, 0, 1)
361	}
362	title = t.S().Muted.Render(title)
363	section := ""
364	if m.info != "" {
365		section = core.SectionWithInfo(title, m.width-2, m.info)
366	} else {
367		section = core.Section(title, m.width-2)
368	}
369
370	return style.Render(section)
371}
372
373func (m *itemSectionModel) GetSize() (int, int) {
374	return m.width, 1
375}
376
377func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
378	m.width = width
379	return nil
380}
381
382func (m *itemSectionModel) IsSectionHeader() bool {
383	return true
384}
385
386func (m *itemSectionModel) SetInfo(info string) {
387	m.info = info
388}
389
390func (m *itemSectionModel) SetIndex(inx int) {
391	m.inx = inx
392}