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