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 c.updateSize()
132}
133
134// Close closes the completions popup.
135func (c *Completions) Close() {
136 c.open = false
137}
138
139// Filter filters the completions with the given query.
140func (c *Completions) Filter(query string) {
141 if !c.open {
142 return
143 }
144
145 if query == c.query {
146 return
147 }
148
149 c.query = query
150 c.list.SetFilter(query)
151
152 c.updateSize()
153}
154
155func (c *Completions) updateSize() {
156 items := c.list.FilteredItems()
157 start, end := c.list.VisibleItemIndices()
158 width := 0
159 for i := start; i <= end; i++ {
160 item := c.list.ItemAt(i)
161 s := item.(interface{ Text() string }).Text()
162 width = max(width, ansi.StringWidth(s))
163 }
164 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
165 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
166 c.list.SetSize(c.width, c.height)
167 c.list.SelectFirst()
168 c.list.ScrollToSelected()
169}
170
171// HasItems returns whether there are visible items.
172func (c *Completions) HasItems() bool {
173 return len(c.list.FilteredItems()) > 0
174}
175
176// Update handles key events for the completions.
177func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
178 if !c.open {
179 return nil, false
180 }
181
182 switch {
183 case key.Matches(msg, c.keyMap.Up):
184 c.selectPrev()
185 return nil, true
186
187 case key.Matches(msg, c.keyMap.Down):
188 c.selectNext()
189 return nil, true
190
191 case key.Matches(msg, c.keyMap.UpInsert):
192 c.selectPrev()
193 return c.selectCurrent(true), true
194
195 case key.Matches(msg, c.keyMap.DownInsert):
196 c.selectNext()
197 return c.selectCurrent(true), true
198
199 case key.Matches(msg, c.keyMap.Select):
200 return c.selectCurrent(false), true
201
202 case key.Matches(msg, c.keyMap.Cancel):
203 c.Close()
204 return ClosedMsg{}, true
205 }
206
207 return nil, false
208}
209
210// selectPrev selects the previous item with circular navigation.
211func (c *Completions) selectPrev() {
212 items := c.list.FilteredItems()
213 if len(items) == 0 {
214 return
215 }
216 if !c.list.SelectPrev() {
217 c.list.WrapToEnd()
218 }
219 c.list.ScrollToSelected()
220}
221
222// selectNext selects the next item with circular navigation.
223func (c *Completions) selectNext() {
224 items := c.list.FilteredItems()
225 if len(items) == 0 {
226 return
227 }
228 if !c.list.SelectNext() {
229 c.list.WrapToStart()
230 }
231 c.list.ScrollToSelected()
232}
233
234// selectCurrent returns a command with the currently selected item.
235func (c *Completions) selectCurrent(insert bool) tea.Msg {
236 items := c.list.FilteredItems()
237 if len(items) == 0 {
238 return nil
239 }
240
241 selected := c.list.Selected()
242 if selected < 0 || selected >= len(items) {
243 return nil
244 }
245
246 item, ok := items[selected].(*CompletionItem)
247 if !ok {
248 return nil
249 }
250
251 if !insert {
252 c.open = false
253 }
254
255 return SelectionMsg{
256 Value: item.Value(),
257 Insert: insert,
258 }
259}
260
261// Render renders the completions popup.
262func (c *Completions) Render() string {
263 if !c.open {
264 return ""
265 }
266
267 items := c.list.FilteredItems()
268 if len(items) == 0 {
269 return ""
270 }
271
272 return c.list.Render()
273}