1package completions
2
3import (
4 "strings"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
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)
13
14const maxCompletionsHeight = 10
15
16type Completion struct {
17 Title string // The title of the completion item
18 Value any // The value of the completion item
19}
20
21type OpenCompletionsMsg struct {
22 Completions []Completion
23 X int // X position for the completions popup
24 Y int // Y position for the completions popup
25}
26
27type FilterCompletionsMsg struct {
28 Query string // The query to filter completions
29 Reopen bool
30 X int // X position for the completions popup
31 Y int // Y position for the completions popup
32}
33
34type RepositionCompletionsMsg struct {
35 X, Y int
36}
37
38type CompletionsClosedMsg struct{}
39
40type CompletionsOpenedMsg struct{}
41
42type CloseCompletionsMsg struct{}
43
44type SelectCompletionMsg struct {
45 Value any // The value of the selected completion item
46 Insert bool
47}
48
49type Completions interface {
50 util.Model
51 Open() bool
52 Query() string // Returns the current filter query
53 KeyMap() KeyMap
54 Position() (int, int) // Returns the X and Y position of the completions popup
55 Width() int
56 Height() int
57}
58
59type completionsCmp struct {
60 wWidth int // The window width
61 wHeight int // The window height
62 width int
63 lastWidth int
64 height int // Height of the completions component`
65 x, xorig int // X position for the completions popup
66 y int // Y position for the completions popup
67 open bool // Indicates if the completions are open
68 keyMap KeyMap
69
70 list list.ListModel
71 query string // The current filter query
72}
73
74func New() Completions {
75 completionsKeyMap := DefaultKeyMap()
76 keyMap := list.DefaultKeyMap()
77 keyMap.Up.SetEnabled(false)
78 keyMap.Down.SetEnabled(false)
79 keyMap.HalfPageDown.SetEnabled(false)
80 keyMap.HalfPageUp.SetEnabled(false)
81 keyMap.Home.SetEnabled(false)
82 keyMap.End.SetEnabled(false)
83 keyMap.UpOneItem = completionsKeyMap.Up
84 keyMap.DownOneItem = completionsKeyMap.Down
85
86 l := list.New(
87 list.WithReverse(true),
88 list.WithKeyMap(keyMap),
89 list.WithHideFilterInput(true),
90 )
91 return &completionsCmp{
92 width: 0,
93 height: maxCompletionsHeight,
94 list: l,
95 query: "",
96 keyMap: completionsKeyMap,
97 }
98}
99
100// Init implements Completions.
101func (c *completionsCmp) Init() tea.Cmd {
102 return tea.Sequence(
103 c.list.Init(),
104 c.list.SetSize(c.width, c.height),
105 )
106}
107
108// Update implements Completions.
109func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110 switch msg := msg.(type) {
111 case tea.WindowSizeMsg:
112 c.wWidth, c.wHeight = msg.Width, msg.Height
113 return c, nil
114 case tea.KeyPressMsg:
115 switch {
116 case key.Matches(msg, c.keyMap.Up):
117 u, cmd := c.list.Update(msg)
118 c.list = u.(list.ListModel)
119 return c, cmd
120
121 case key.Matches(msg, c.keyMap.Down):
122 d, cmd := c.list.Update(msg)
123 c.list = d.(list.ListModel)
124 return c, cmd
125 case key.Matches(msg, c.keyMap.UpInsert):
126 selectedItemInx := c.list.SelectedIndex() - 1
127 items := c.list.Items()
128 if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
129 return c, nil // No item selected, do nothing
130 }
131 selectedItem := items[selectedItemInx].(CompletionItem).Value()
132 c.list.SetSelected(selectedItemInx)
133 return c, util.CmdHandler(SelectCompletionMsg{
134 Value: selectedItem,
135 Insert: true,
136 })
137 case key.Matches(msg, c.keyMap.DownInsert):
138 selectedItemInx := c.list.SelectedIndex() + 1
139 items := c.list.Items()
140 if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
141 return c, nil // No item selected, do nothing
142 }
143 selectedItem := items[selectedItemInx].(CompletionItem).Value()
144 c.list.SetSelected(selectedItemInx)
145 return c, util.CmdHandler(SelectCompletionMsg{
146 Value: selectedItem,
147 Insert: true,
148 })
149 case key.Matches(msg, c.keyMap.Select):
150 selectedItemInx := c.list.SelectedIndex()
151 if selectedItemInx == list.NoSelection {
152 return c, nil // No item selected, do nothing
153 }
154 items := c.list.Items()
155 selectedItem := items[selectedItemInx].(CompletionItem).Value()
156 c.open = false // Close completions after selection
157 return c, util.CmdHandler(SelectCompletionMsg{
158 Value: selectedItem,
159 })
160 case key.Matches(msg, c.keyMap.Cancel):
161 return c, util.CmdHandler(CloseCompletionsMsg{})
162 }
163 case RepositionCompletionsMsg:
164 c.x, c.y = msg.X, msg.Y
165 c.adjustPosition()
166 case CloseCompletionsMsg:
167 c.open = false
168 return c, util.CmdHandler(CompletionsClosedMsg{})
169 case OpenCompletionsMsg:
170 c.open = true
171 c.query = ""
172 c.x, c.xorig = msg.X, msg.X
173 c.y = msg.Y
174 items := []util.Model{}
175 t := styles.CurrentTheme()
176 for _, completion := range msg.Completions {
177 item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
178 items = append(items, item)
179 }
180 width := listWidth(items)
181 if len(items) == 0 {
182 width = listWidth(c.list.Items())
183 }
184 if c.x+width >= c.wWidth {
185 c.x = c.wWidth - width - 1
186 }
187 c.width = width
188 c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
189 return c, tea.Batch(
190 c.list.SetItems(items),
191 c.list.SetSize(c.width, c.height),
192 util.CmdHandler(CompletionsOpenedMsg{}),
193 )
194 case FilterCompletionsMsg:
195 if !c.open && !msg.Reopen {
196 return c, nil
197 }
198 if msg.Query == c.query {
199 // PERF: if same query, don't need to filter again
200 return c, nil
201 }
202 if len(c.list.Items()) == 0 &&
203 len(msg.Query) > len(c.query) &&
204 strings.HasPrefix(msg.Query, c.query) {
205 // PERF: if c.query didn't match anything,
206 // AND msg.Query is longer than c.query,
207 // AND msg.Query is prefixed with c.query - which means
208 // that the user typed more chars after a 0 match,
209 // it won't match anything, so return earlier.
210 return c, nil
211 }
212 c.query = msg.Query
213 var cmds []tea.Cmd
214 cmds = append(cmds, c.list.Filter(msg.Query))
215 items := c.list.Items()
216 itemsLen := len(items)
217 c.xorig = msg.X
218 c.x, c.y = msg.X, msg.Y
219 c.adjustPosition()
220 cmds = append(cmds, c.list.SetSize(c.width, c.height))
221 if itemsLen == 0 {
222 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
223 } else if msg.Reopen {
224 c.open = true
225 cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
226 }
227 return c, tea.Batch(cmds...)
228 }
229 return c, nil
230}
231
232func (c *completionsCmp) adjustPosition() {
233 items := c.list.Items()
234 itemsLen := len(items)
235 width := listWidth(items)
236 c.lastWidth = c.width
237 if c.x < 0 || width < c.lastWidth {
238 c.x = c.xorig
239 } else if c.x+width >= c.wWidth {
240 c.x = c.wWidth - width - 1
241 }
242 c.width = width
243 c.height = max(min(maxCompletionsHeight, itemsLen), 1)
244}
245
246// View implements Completions.
247func (c *completionsCmp) View() string {
248 if !c.open || len(c.list.Items()) == 0 {
249 return ""
250 }
251
252 t := styles.CurrentTheme()
253 style := t.S().Base.
254 Width(c.width).
255 Height(c.height).
256 Background(t.BgSubtle)
257
258 return style.Render(c.list.View())
259}
260
261// listWidth returns the width of the last 10 items in the list, which is used
262// to determine the width of the completions popup.
263// Note this only works for [completionItemCmp] items.
264func listWidth[T any](items []T) int {
265 var width int
266 if len(items) == 0 {
267 return width
268 }
269
270 for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
271 item, ok := any(items[i]).(*completionItemCmp)
272 if !ok {
273 continue
274 }
275 itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
276 width = max(width, itemWidth)
277 }
278
279 return width
280}
281
282func (c *completionsCmp) Open() bool {
283 return c.open
284}
285
286func (c *completionsCmp) Query() string {
287 return c.query
288}
289
290func (c *completionsCmp) KeyMap() KeyMap {
291 return c.keyMap
292}
293
294func (c *completionsCmp) Position() (int, int) {
295 return c.x, c.y - c.height
296}
297
298func (c *completionsCmp) Width() int {
299 return c.width
300}
301
302func (c *completionsCmp) Height() int {
303 return c.height
304}