item.go

  1package completions
  2
  3import (
  4	"charm.land/lipgloss/v2"
  5	"github.com/charmbracelet/crush/internal/ui/list"
  6	"github.com/charmbracelet/x/ansi"
  7	"github.com/rivo/uniseg"
  8	"github.com/sahilm/fuzzy"
  9)
 10
 11// FileCompletionValue represents a file path completion value.
 12type FileCompletionValue struct {
 13	Path string
 14}
 15
 16// CompletionItem represents an item in the completions list.
 17type CompletionItem struct {
 18	text    string
 19	value   any
 20	match   fuzzy.Match
 21	focused bool
 22	cache   map[int]string
 23
 24	// Styles
 25	normalStyle  lipgloss.Style
 26	focusedStyle lipgloss.Style
 27	matchStyle   lipgloss.Style
 28}
 29
 30// NewCompletionItem creates a new completion item.
 31func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
 32	return &CompletionItem{
 33		text:         text,
 34		value:        value,
 35		normalStyle:  normalStyle,
 36		focusedStyle: focusedStyle,
 37		matchStyle:   matchStyle,
 38	}
 39}
 40
 41// Text returns the display text of the item.
 42func (c *CompletionItem) Text() string {
 43	return c.text
 44}
 45
 46// Value returns the value of the item.
 47func (c *CompletionItem) Value() any {
 48	return c.value
 49}
 50
 51// Filter implements [list.FilterableItem].
 52func (c *CompletionItem) Filter() string {
 53	return c.text
 54}
 55
 56// SetMatch implements [list.MatchSettable].
 57func (c *CompletionItem) SetMatch(m fuzzy.Match) {
 58	c.cache = nil
 59	c.match = m
 60}
 61
 62// SetFocused implements [list.Focusable].
 63func (c *CompletionItem) SetFocused(focused bool) {
 64	if c.focused != focused {
 65		c.cache = nil
 66	}
 67	c.focused = focused
 68}
 69
 70// Render implements [list.Item].
 71func (c *CompletionItem) Render(width int) string {
 72	return renderItem(
 73		c.normalStyle,
 74		c.focusedStyle,
 75		c.matchStyle,
 76		c.text,
 77		c.focused,
 78		width,
 79		c.cache,
 80		&c.match,
 81	)
 82}
 83
 84func renderItem(
 85	normalStyle, focusedStyle, matchStyle lipgloss.Style,
 86	text string,
 87	focused bool,
 88	width int,
 89	cache map[int]string,
 90	match *fuzzy.Match,
 91) string {
 92	if cache == nil {
 93		cache = make(map[int]string)
 94	}
 95
 96	cached, ok := cache[width]
 97	if ok {
 98		return cached
 99	}
100
101	innerWidth := width - 2 // Account for padding
102	// Truncate if needed.
103	if ansi.StringWidth(text) > innerWidth {
104		text = ansi.Truncate(text, innerWidth, "…")
105	}
106
107	// Select base style.
108	style := normalStyle
109	matchStyle = matchStyle.Background(style.GetBackground())
110	if focused {
111		style = focusedStyle
112		matchStyle = matchStyle.Background(style.GetBackground())
113	}
114
115	// Render full-width text with background.
116	content := style.Padding(0, 1).Width(width).Render(text)
117
118	// Apply match highlighting using StyleRanges.
119	if len(match.MatchedIndexes) > 0 {
120		var ranges []lipgloss.Range
121		for _, rng := range matchedRanges(match.MatchedIndexes) {
122			start, stop := bytePosToVisibleCharPos(text, rng)
123			// Offset by 1 for the padding space.
124			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
125		}
126		content = lipgloss.StyleRanges(content, ranges...)
127	}
128
129	cache[width] = content
130	return content
131}
132
133// matchedRanges converts a list of match indexes into contiguous ranges.
134func matchedRanges(in []int) [][2]int {
135	if len(in) == 0 {
136		return [][2]int{}
137	}
138	current := [2]int{in[0], in[0]}
139	if len(in) == 1 {
140		return [][2]int{current}
141	}
142	var out [][2]int
143	for i := 1; i < len(in); i++ {
144		if in[i] == current[1]+1 {
145			current[1] = in[i]
146		} else {
147			out = append(out, current)
148			current = [2]int{in[i], in[i]}
149		}
150	}
151	out = append(out, current)
152	return out
153}
154
155// bytePosToVisibleCharPos converts byte positions to visible character positions.
156func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
157	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
158	pos, start, stop := 0, 0, 0
159	gr := uniseg.NewGraphemes(str)
160	for byteStart > bytePos {
161		if !gr.Next() {
162			break
163		}
164		bytePos += len(gr.Str())
165		pos += max(1, gr.Width())
166	}
167	start = pos
168	for byteStop > bytePos {
169		if !gr.Next() {
170			break
171		}
172		bytePos += len(gr.Str())
173		pos += max(1, gr.Width())
174	}
175	stop = pos
176	return start, stop
177}
178
179// Ensure CompletionItem implements the required interfaces.
180var (
181	_ list.Item           = (*CompletionItem)(nil)
182	_ list.FilterableItem = (*CompletionItem)(nil)
183	_ list.MatchSettable  = (*CompletionItem)(nil)
184	_ list.Focusable      = (*CompletionItem)(nil)
185)