completions.go

  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}