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