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