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