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