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