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