item.go

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