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