1package completions
  2
  3import (
  4	"image/color"
  5
  6	tea "github.com/charmbracelet/bubbletea/v2"
  7	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  8	"github.com/charmbracelet/crush/internal/tui/components/core/list"
  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/rivo/uniseg"
 14)
 15
 16type CompletionItem interface {
 17	util.Model
 18	layout.Focusable
 19	layout.Sizeable
 20	list.HasMatchIndexes
 21	list.HasFilterValue
 22	Value() any
 23}
 24
 25type completionItemCmp struct {
 26	width        int
 27	text         string
 28	value        any
 29	focus        bool
 30	matchIndexes []int
 31	bgColor      color.Color
 32	shortcut     string
 33}
 34
 35type CompletionOption func(*completionItemCmp)
 36
 37func WithBackgroundColor(c color.Color) CompletionOption {
 38	return func(cmp *completionItemCmp) {
 39		cmp.bgColor = c
 40	}
 41}
 42
 43func WithMatchIndexes(indexes ...int) CompletionOption {
 44	return func(cmp *completionItemCmp) {
 45		cmp.matchIndexes = indexes
 46	}
 47}
 48
 49func WithShortcut(shortcut string) CompletionOption {
 50	return func(cmp *completionItemCmp) {
 51		cmp.shortcut = shortcut
 52	}
 53}
 54
 55func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
 56	c := &completionItemCmp{
 57		text:  text,
 58		value: value,
 59	}
 60
 61	for _, opt := range opts {
 62		opt(c)
 63	}
 64	return c
 65}
 66
 67// Init implements CommandItem.
 68func (c *completionItemCmp) Init() tea.Cmd {
 69	return nil
 70}
 71
 72// Update implements CommandItem.
 73func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
 74	return c, nil
 75}
 76
 77// View implements CommandItem.
 78func (c *completionItemCmp) View() string {
 79	t := styles.CurrentTheme()
 80
 81	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
 82	innerWidth := c.width - 2 // Account for padding
 83
 84	if c.shortcut != "" {
 85		innerWidth -= lipgloss.Width(c.shortcut)
 86	}
 87
 88	titleStyle := t.S().Text.Width(innerWidth)
 89	titleMatchStyle := t.S().Text.Underline(true)
 90	if c.bgColor != nil {
 91		titleStyle = titleStyle.Background(c.bgColor)
 92		titleMatchStyle = titleMatchStyle.Background(c.bgColor)
 93		itemStyle = itemStyle.Background(c.bgColor)
 94	}
 95
 96	if c.focus {
 97		titleStyle = t.S().TextSelected.Width(innerWidth)
 98		titleMatchStyle = t.S().TextSelected.Underline(true)
 99		itemStyle = itemStyle.Background(t.Primary)
100	}
101
102	var truncatedTitle string
103
104	if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
105		// Smart truncation: ensure the last matching part is visible
106		truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
107	} else {
108		// No matches, use regular truncation
109		truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
110	}
111
112	text := titleStyle.Render(truncatedTitle)
113	if len(c.matchIndexes) > 0 {
114		var ranges []lipgloss.Range
115		for _, rng := range matchedRanges(c.matchIndexes) {
116			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
117			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
118			// so we need to adjust it here:
119			start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
120			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
121		}
122		text = lipgloss.StyleRanges(text, ranges...)
123	}
124	parts := []string{text}
125	if c.shortcut != "" {
126		// Add the shortcut at the end
127		shortcutStyle := t.S().Muted
128		if c.focus {
129			shortcutStyle = t.S().TextSelected
130		}
131		parts = append(parts, shortcutStyle.Render(c.shortcut))
132	}
133	item := itemStyle.Render(
134		lipgloss.JoinHorizontal(
135			lipgloss.Left,
136			parts...,
137		),
138	)
139	return item
140}
141
142// Blur implements CommandItem.
143func (c *completionItemCmp) Blur() tea.Cmd {
144	c.focus = false
145	return nil
146}
147
148// Focus implements CommandItem.
149func (c *completionItemCmp) Focus() tea.Cmd {
150	c.focus = true
151	return nil
152}
153
154// GetSize implements CommandItem.
155func (c *completionItemCmp) GetSize() (int, int) {
156	return c.width, 1
157}
158
159// IsFocused implements CommandItem.
160func (c *completionItemCmp) IsFocused() bool {
161	return c.focus
162}
163
164// SetSize implements CommandItem.
165func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
166	c.width = width
167	return nil
168}
169
170func (c *completionItemCmp) MatchIndexes(indexes []int) {
171	c.matchIndexes = indexes
172}
173
174func (c *completionItemCmp) FilterValue() string {
175	return c.text
176}
177
178func (c *completionItemCmp) Value() any {
179	return c.value
180}
181
182// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
183func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
184	if width <= 0 {
185		return ""
186	}
187
188	textLen := ansi.StringWidth(text)
189	if textLen <= width {
190		return text
191	}
192
193	if len(matchIndexes) == 0 {
194		return ansi.Truncate(text, width, "…")
195	}
196
197	// Find the last match position
198	lastMatchPos := matchIndexes[len(matchIndexes)-1]
199
200	// Convert byte position to visual width position
201	lastMatchVisualPos := 0
202	bytePos := 0
203	gr := uniseg.NewGraphemes(text)
204	for bytePos < lastMatchPos && gr.Next() {
205		bytePos += len(gr.Str())
206		lastMatchVisualPos += max(1, gr.Width())
207	}
208
209	// Calculate how much space we need for the ellipsis
210	ellipsisWidth := 1 // "…" character width
211	availableWidth := width - ellipsisWidth
212
213	// If the last match is within the available width, truncate from the end
214	if lastMatchVisualPos < availableWidth {
215		return ansi.Truncate(text, width, "…")
216	}
217
218	// Calculate the start position to ensure the last match is visible
219	// We want to show some context before the last match if possible
220	startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
221
222	// Convert visual position back to byte position
223	startBytePos := 0
224	currentVisualPos := 0
225	gr = uniseg.NewGraphemes(text)
226	for currentVisualPos < startVisualPos && gr.Next() {
227		startBytePos += len(gr.Str())
228		currentVisualPos += max(1, gr.Width())
229	}
230
231	// Extract the substring starting from startBytePos
232	truncatedText := text[startBytePos:]
233
234	// Truncate to fit width with ellipsis
235	truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
236	truncatedText = "…" + truncatedText
237	return truncatedText
238}
239
240func matchedRanges(in []int) [][2]int {
241	if len(in) == 0 {
242		return [][2]int{}
243	}
244	current := [2]int{in[0], in[0]}
245	if len(in) == 1 {
246		return [][2]int{current}
247	}
248	var out [][2]int
249	for i := 1; i < len(in); i++ {
250		if in[i] == current[1]+1 {
251			current[1] = in[i]
252		} else {
253			out = append(out, current)
254			current = [2]int{in[i], in[i]}
255		}
256	}
257	out = append(out, current)
258	return out
259}
260
261func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
262	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
263	pos, start, stop := 0, 0, 0
264	gr := uniseg.NewGraphemes(str)
265	for byteStart > bytePos {
266		if !gr.Next() {
267			break
268		}
269		bytePos += len(gr.Str())
270		pos += max(1, gr.Width())
271	}
272	start = pos
273	for byteStop > bytePos {
274		if !gr.Next() {
275			break
276		}
277		bytePos += len(gr.Str())
278		pos += max(1, gr.Width())
279	}
280	stop = pos
281	return start, stop
282}