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// 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}