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