items.go

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