1package completions
2
3import (
4 "slices"
5 "strings"
6
7 "charm.land/bubbles/v2/key"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
10 "github.com/charmbracelet/crush/internal/fsext"
11 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/exp/ordered"
14)
15
16const (
17 minHeight = 1
18 maxHeight = 10
19 minWidth = 10
20 maxWidth = 100
21)
22
23// SelectionMsg is sent when a completion is selected.
24type SelectionMsg struct {
25 Value any
26 Insert bool // If true, insert without closing.
27}
28
29// ClosedMsg is sent when the completions are closed.
30type ClosedMsg struct{}
31
32// FilesLoadedMsg is sent when files have been loaded for completions.
33type FilesLoadedMsg struct {
34 Files []string
35}
36
37// Completions represents the completions popup component.
38type Completions struct {
39 // Popup dimensions
40 width int
41 height int
42
43 // State
44 open bool
45 query string
46
47 // Key bindings
48 keyMap KeyMap
49
50 // List component
51 list *list.FilterableList
52
53 // Styling
54 normalStyle lipgloss.Style
55 focusedStyle lipgloss.Style
56 matchStyle lipgloss.Style
57}
58
59// New creates a new completions component.
60func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
61 l := list.NewFilterableList()
62 l.SetGap(0)
63 l.SetReverse(true)
64
65 return &Completions{
66 keyMap: DefaultKeyMap(),
67 list: l,
68 normalStyle: normalStyle,
69 focusedStyle: focusedStyle,
70 matchStyle: matchStyle,
71 }
72}
73
74// IsOpen returns whether the completions popup is open.
75func (c *Completions) IsOpen() bool {
76 return c.open
77}
78
79// Query returns the current filter query.
80func (c *Completions) Query() string {
81 return c.query
82}
83
84// Size returns the visible size of the popup.
85func (c *Completions) Size() (width, height int) {
86 visible := len(c.list.FilteredItems())
87 return c.width, min(visible, c.height)
88}
89
90// KeyMap returns the key bindings.
91func (c *Completions) KeyMap() KeyMap {
92 return c.keyMap
93}
94
95// OpenWithFiles opens the completions with file items from the filesystem.
96func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
97 return func() tea.Msg {
98 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
99 slices.Sort(files)
100 return FilesLoadedMsg{Files: files}
101 }
102}
103
104// SetFiles sets the file items on the completions popup.
105func (c *Completions) SetFiles(files []string) {
106 items := make([]list.FilterableItem, 0, len(files))
107 for _, file := range files {
108 file = strings.TrimPrefix(file, "./")
109 item := NewCompletionItem(
110 file,
111 FileCompletionValue{Path: file},
112 c.normalStyle,
113 c.focusedStyle,
114 c.matchStyle,
115 )
116 items = append(items, item)
117 }
118
119 c.open = true
120 c.query = ""
121 c.list.SetItems(items...)
122 c.list.SetFilter("") // Clear any previous filter.
123 c.list.Focus()
124
125 c.width = maxWidth
126 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
127 c.list.SetSize(c.width, c.height)
128 c.list.SelectFirst()
129 c.list.ScrollToSelected()
130
131 // recalculate width by using just the visible items
132 start, end := c.list.VisibleItemIndices()
133 width := 0
134 if end != 0 {
135 for _, file := range files[start : end+1] {
136 width = max(width, ansi.StringWidth(file))
137 }
138 }
139 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
140 c.list.SetSize(c.width, c.height)
141}
142
143// Close closes the completions popup.
144func (c *Completions) Close() {
145 c.open = false
146}
147
148// Filter filters the completions with the given query.
149func (c *Completions) Filter(query string) {
150 if !c.open {
151 return
152 }
153
154 if query == c.query {
155 return
156 }
157
158 c.query = query
159 c.list.SetFilter(query)
160
161 // recalculate width by using just the visible items
162 items := c.list.FilteredItems()
163 start, end := c.list.VisibleItemIndices()
164 width := 0
165 if end != 0 {
166 for _, item := range items[start : end+1] {
167 width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
168 }
169 }
170 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
171 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
172 c.list.SetSize(c.width, c.height)
173 c.list.SelectFirst()
174 c.list.ScrollToSelected()
175}
176
177// HasItems returns whether there are visible items.
178func (c *Completions) HasItems() bool {
179 return len(c.list.FilteredItems()) > 0
180}
181
182// Update handles key events for the completions.
183func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
184 if !c.open {
185 return nil, false
186 }
187
188 switch {
189 case key.Matches(msg, c.keyMap.Up):
190 c.selectPrev()
191 return nil, true
192
193 case key.Matches(msg, c.keyMap.Down):
194 c.selectNext()
195 return nil, true
196
197 case key.Matches(msg, c.keyMap.UpInsert):
198 c.selectPrev()
199 return c.selectCurrent(true), true
200
201 case key.Matches(msg, c.keyMap.DownInsert):
202 c.selectNext()
203 return c.selectCurrent(true), true
204
205 case key.Matches(msg, c.keyMap.Select):
206 return c.selectCurrent(false), true
207
208 case key.Matches(msg, c.keyMap.Cancel):
209 c.Close()
210 return ClosedMsg{}, true
211 }
212
213 return nil, false
214}
215
216// selectPrev selects the previous item with circular navigation.
217func (c *Completions) selectPrev() {
218 items := c.list.FilteredItems()
219 if len(items) == 0 {
220 return
221 }
222 if !c.list.SelectPrev() {
223 c.list.WrapToEnd()
224 }
225 c.list.ScrollToSelected()
226}
227
228// selectNext selects the next item with circular navigation.
229func (c *Completions) selectNext() {
230 items := c.list.FilteredItems()
231 if len(items) == 0 {
232 return
233 }
234 if !c.list.SelectNext() {
235 c.list.WrapToStart()
236 }
237 c.list.ScrollToSelected()
238}
239
240// selectCurrent returns a command with the currently selected item.
241func (c *Completions) selectCurrent(insert bool) tea.Msg {
242 items := c.list.FilteredItems()
243 if len(items) == 0 {
244 return nil
245 }
246
247 selected := c.list.Selected()
248 if selected < 0 || selected >= len(items) {
249 return nil
250 }
251
252 item, ok := items[selected].(*CompletionItem)
253 if !ok {
254 return nil
255 }
256
257 if !insert {
258 c.open = false
259 }
260
261 return SelectionMsg{
262 Value: item.Value(),
263 Insert: insert,
264 }
265}
266
267// Render renders the completions popup.
268func (c *Completions) Render() string {
269 if !c.open {
270 return ""
271 }
272
273 items := c.list.FilteredItems()
274 if len(items) == 0 {
275 return ""
276 }
277
278 return c.list.Render()
279}