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