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 }
94
95 if c.focus {
96 titleStyle = t.S().TextSelected.Width(innerWidth)
97 titleMatchStyle = t.S().TextSelected.Underline(true)
98 itemStyle = itemStyle.Background(t.Primary)
99 }
100
101 var truncatedTitle string
102
103 if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
104 // Smart truncation: ensure the last matching part is visible
105 truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
106 } else {
107 // No matches, use regular truncation
108 truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
109 }
110
111 text := titleStyle.Render(truncatedTitle)
112 if len(c.matchIndexes) > 0 {
113 var ranges []lipgloss.Range
114 for _, rng := range matchedRanges(c.matchIndexes) {
115 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
116 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
117 // so we need to adjust it here:
118 start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
119 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
120 }
121 text = lipgloss.StyleRanges(text, ranges...)
122 }
123 parts := []string{text}
124 if c.shortcut != "" {
125 // Add the shortcut at the end
126 shortcutStyle := t.S().Muted
127 if c.focus {
128 shortcutStyle = t.S().TextSelected
129 }
130 parts = append(parts, shortcutStyle.Render(c.shortcut))
131 }
132 item := itemStyle.Render(
133 lipgloss.JoinHorizontal(
134 lipgloss.Left,
135 parts...,
136 ),
137 )
138 return item
139}
140
141// Blur implements CommandItem.
142func (c *completionItemCmp) Blur() tea.Cmd {
143 c.focus = false
144 return nil
145}
146
147// Focus implements CommandItem.
148func (c *completionItemCmp) Focus() tea.Cmd {
149 c.focus = true
150 return nil
151}
152
153// GetSize implements CommandItem.
154func (c *completionItemCmp) GetSize() (int, int) {
155 return c.width, 1
156}
157
158// IsFocused implements CommandItem.
159func (c *completionItemCmp) IsFocused() bool {
160 return c.focus
161}
162
163// SetSize implements CommandItem.
164func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
165 c.width = width
166 return nil
167}
168
169func (c *completionItemCmp) MatchIndexes(indexes []int) {
170 c.matchIndexes = indexes
171}
172
173func (c *completionItemCmp) FilterValue() string {
174 return c.text
175}
176
177func (c *completionItemCmp) Value() any {
178 return c.value
179}
180
181// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
182func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
183 if width <= 0 {
184 return ""
185 }
186
187 textLen := ansi.StringWidth(text)
188 if textLen <= width {
189 return text
190 }
191
192 if len(matchIndexes) == 0 {
193 return ansi.Truncate(text, width, "…")
194 }
195
196 // Find the last match position
197 lastMatchPos := matchIndexes[len(matchIndexes)-1]
198
199 // Convert byte position to visual width position
200 lastMatchVisualPos := 0
201 bytePos := 0
202 gr := uniseg.NewGraphemes(text)
203 for bytePos < lastMatchPos && gr.Next() {
204 bytePos += len(gr.Str())
205 lastMatchVisualPos += max(1, gr.Width())
206 }
207
208 // Calculate how much space we need for the ellipsis
209 ellipsisWidth := 1 // "…" character width
210 availableWidth := width - ellipsisWidth
211
212 // If the last match is within the available width, truncate from the end
213 if lastMatchVisualPos < availableWidth {
214 return ansi.Truncate(text, width, "…")
215 }
216
217 // Calculate the start position to ensure the last match is visible
218 // We want to show some context before the last match if possible
219 startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
220
221 // Convert visual position back to byte position
222 startBytePos := 0
223 currentVisualPos := 0
224 gr = uniseg.NewGraphemes(text)
225 for currentVisualPos < startVisualPos && gr.Next() {
226 startBytePos += len(gr.Str())
227 currentVisualPos += max(1, gr.Width())
228 }
229
230 // Extract the substring starting from startBytePos
231 truncatedText := text[startBytePos:]
232
233 // Truncate to fit width with ellipsis
234 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
235 truncatedText = "…" + truncatedText
236 return truncatedText
237}
238
239func matchedRanges(in []int) [][2]int {
240 if len(in) == 0 {
241 return [][2]int{}
242 }
243 current := [2]int{in[0], in[0]}
244 if len(in) == 1 {
245 return [][2]int{current}
246 }
247 var out [][2]int
248 for i := 1; i < len(in); i++ {
249 if in[i] == current[1]+1 {
250 current[1] = in[i]
251 } else {
252 out = append(out, current)
253 current = [2]int{in[i], in[i]}
254 }
255 }
256 out = append(out, current)
257 return out
258}
259
260func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
261 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
262 pos, start, stop := 0, 0, 0
263 gr := uniseg.NewGraphemes(str)
264 for byteStart > bytePos {
265 if !gr.Next() {
266 break
267 }
268 bytePos += len(gr.Str())
269 pos += max(1, gr.Width())
270 }
271 start = pos
272 for byteStop > bytePos {
273 if !gr.Next() {
274 break
275 }
276 bytePos += len(gr.Str())
277 pos += max(1, gr.Width())
278 }
279 stop = pos
280 return start, stop
281}