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}