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