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