item.go

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