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