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}
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 listModel = list.FilterableList[list.CompletionItem[any]]
54
55type completionsCmp struct {
56 width int
57 height int // Height of the completions component`
58 x 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 listModel
64 query string // The current filter query
65}
66
67const maxCompletionsWidth = 80 // Maximum width for the completions popup
68
69func New() Completions {
70 completionsKeyMap := DefaultKeyMap()
71 keyMap := list.DefaultKeyMap()
72 keyMap.Up.SetEnabled(false)
73 keyMap.Down.SetEnabled(false)
74 keyMap.HalfPageDown.SetEnabled(false)
75 keyMap.HalfPageUp.SetEnabled(false)
76 keyMap.Home.SetEnabled(false)
77 keyMap.End.SetEnabled(false)
78 keyMap.UpOneItem = completionsKeyMap.Up
79 keyMap.DownOneItem = completionsKeyMap.Down
80
81 l := list.NewFilterableList(
82 []list.CompletionItem[any]{},
83 list.WithFilterInputHidden(),
84 list.WithFilterListOptions(
85 list.WithDirectionBackward(),
86 list.WithKeyMap(keyMap),
87 ),
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.width = min(msg.Width-c.x, maxCompletionsWidth)
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.(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.(listModel)
123 return c, cmd
124 case key.Matches(msg, c.keyMap.UpInsert):
125 s := c.list.SelectedItem()
126 if s == nil {
127 return c, nil
128 }
129 selectedItem := *s
130 c.list.SetSelected(selectedItem.ID())
131 return c, util.CmdHandler(SelectCompletionMsg{
132 Value: selectedItem,
133 Insert: true,
134 })
135 case key.Matches(msg, c.keyMap.DownInsert):
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,
144 Insert: true,
145 })
146 case key.Matches(msg, c.keyMap.Select):
147 s := c.list.SelectedItem()
148 if s == nil {
149 return c, nil
150 }
151 selectedItem := *s
152 c.open = false // Close completions after selection
153 return c, util.CmdHandler(SelectCompletionMsg{
154 Value: selectedItem,
155 })
156 case key.Matches(msg, c.keyMap.Cancel):
157 return c, util.CmdHandler(CloseCompletionsMsg{})
158 }
159 case CloseCompletionsMsg:
160 c.open = false
161 return c, util.CmdHandler(CompletionsClosedMsg{})
162 case OpenCompletionsMsg:
163 c.open = true
164 c.query = ""
165 c.x = msg.X
166 c.y = msg.Y
167 items := []list.CompletionItem[any]{}
168 t := styles.CurrentTheme()
169 for _, completion := range msg.Completions {
170 item := list.NewCompletionItem(
171 completion.Title,
172 completion.Value,
173 list.WithCompletionBackgroundColor(t.BgSubtle),
174 )
175 items = append(items, item)
176 }
177 c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
178 return c, tea.Batch(
179 c.list.SetSize(c.width, c.height),
180 c.list.SetItems(items),
181 util.CmdHandler(CompletionsOpenedMsg{}),
182 )
183 case FilterCompletionsMsg:
184 if !c.open && !msg.Reopen {
185 return c, nil
186 }
187 if msg.Query == c.query {
188 // PERF: if same query, don't need to filter again
189 return c, nil
190 }
191 if len(c.list.Items()) == 0 &&
192 len(msg.Query) > len(c.query) &&
193 strings.HasPrefix(msg.Query, c.query) {
194 // PERF: if c.query didn't match anything,
195 // AND msg.Query is longer than c.query,
196 // AND msg.Query is prefixed with c.query - which means
197 // that the user typed more chars after a 0 match,
198 // it won't match anything, so return earlier.
199 return c, nil
200 }
201 c.query = msg.Query
202 var cmds []tea.Cmd
203 cmds = append(cmds, c.list.Filter(msg.Query))
204 itemsLen := len(c.list.Items())
205 c.height = max(min(maxCompletionsHeight, itemsLen), 1)
206 cmds = append(cmds, c.list.SetSize(c.width, c.height))
207 if itemsLen == 0 {
208 cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
209 } else if msg.Reopen {
210 c.open = true
211 cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
212 }
213 return c, tea.Batch(cmds...)
214 }
215 return c, nil
216}
217
218// View implements Completions.
219func (c *completionsCmp) View() string {
220 if !c.open || len(c.list.Items()) == 0 {
221 return ""
222 }
223
224 return c.style().Render(c.list.View())
225}
226
227func (c *completionsCmp) style() lipgloss.Style {
228 t := styles.CurrentTheme()
229 return t.S().Base.
230 Width(c.width).
231 Height(c.height).
232 Background(t.BgSubtle)
233}
234
235func (c *completionsCmp) Open() bool {
236 return c.open
237}
238
239func (c *completionsCmp) Query() string {
240 return c.query
241}
242
243func (c *completionsCmp) KeyMap() KeyMap {
244 return c.keyMap
245}
246
247func (c *completionsCmp) Position() (int, int) {
248 return c.x, c.y - c.height
249}
250
251func (c *completionsCmp) Width() int {
252 return c.width
253}
254
255func (c *completionsCmp) Height() int {
256 return c.height
257}