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