item.go

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