completions.go

  1package completions
  2
  3import (
  4	"strings"
  5
  6	"github.com/charmbracelet/bubbles/v2/key"
  7	tea "github.com/charmbracelet/bubbletea/v2"
  8	"github.com/charmbracelet/crush/internal/tui/components/core/list"
  9	"github.com/charmbracelet/crush/internal/tui/styles"
 10	"github.com/charmbracelet/crush/internal/tui/util"
 11	"github.com/charmbracelet/lipgloss/v2"
 12)
 13
 14const maxCompletionsHeight = 10
 15
 16type Completion struct {
 17	Title string // The title of the completion item
 18	Value any    // The value of the completion item
 19}
 20
 21type OpenCompletionsMsg struct {
 22	Completions []Completion
 23	X           int // X position for the completions popup
 24	Y           int // Y position for the completions popup
 25}
 26
 27type FilterCompletionsMsg struct {
 28	Query  string // The query to filter completions
 29	Reopen bool
 30	X      int // X position for the completions popup
 31	Y      int // Y position for the completions popup
 32}
 33
 34type RepositionCompletionsMsg struct {
 35	X, Y int
 36}
 37
 38type CompletionsClosedMsg struct{}
 39
 40type CompletionsOpenedMsg struct{}
 41
 42type CloseCompletionsMsg struct{}
 43
 44type SelectCompletionMsg struct {
 45	Value  any // The value of the selected completion item
 46	Insert bool
 47}
 48
 49type Completions interface {
 50	util.Model
 51	Open() bool
 52	Query() string // Returns the current filter query
 53	KeyMap() KeyMap
 54	Position() (int, int) // Returns the X and Y position of the completions popup
 55	Width() int
 56	Height() int
 57}
 58
 59type completionsCmp struct {
 60	wWidth    int // The window width
 61	wHeight   int // The window height
 62	width     int
 63	lastWidth int
 64	height    int  // Height of the completions component`
 65	x, xorig  int  // X position for the completions popup
 66	y         int  // Y position for the completions popup
 67	open      bool // Indicates if the completions are open
 68	keyMap    KeyMap
 69
 70	list  list.ListModel
 71	query string // The current filter query
 72}
 73
 74const (
 75	maxCompletionsWidth = 80 // Maximum width for the completions popup
 76	minCompletionsWidth = 20 // Minimum width for the completions popup
 77)
 78
 79func New() Completions {
 80	completionsKeyMap := DefaultKeyMap()
 81	keyMap := list.DefaultKeyMap()
 82	keyMap.Up.SetEnabled(false)
 83	keyMap.Down.SetEnabled(false)
 84	keyMap.HalfPageDown.SetEnabled(false)
 85	keyMap.HalfPageUp.SetEnabled(false)
 86	keyMap.Home.SetEnabled(false)
 87	keyMap.End.SetEnabled(false)
 88	keyMap.UpOneItem = completionsKeyMap.Up
 89	keyMap.DownOneItem = completionsKeyMap.Down
 90
 91	l := list.New(
 92		list.WithReverse(true),
 93		list.WithKeyMap(keyMap),
 94		list.WithHideFilterInput(true),
 95	)
 96	return &completionsCmp{
 97		width:  0,
 98		height: maxCompletionsHeight,
 99		list:   l,
100		query:  "",
101		keyMap: completionsKeyMap,
102	}
103}
104
105// Init implements Completions.
106func (c *completionsCmp) Init() tea.Cmd {
107	return tea.Sequence(
108		c.list.Init(),
109		c.list.SetSize(c.width, c.height),
110	)
111}
112
113// Update implements Completions.
114func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115	switch msg := msg.(type) {
116	case tea.WindowSizeMsg:
117		c.wWidth, c.wHeight = msg.Width, msg.Height
118		return c, nil
119	case tea.KeyPressMsg:
120		switch {
121		case key.Matches(msg, c.keyMap.Up):
122			u, cmd := c.list.Update(msg)
123			c.list = u.(list.ListModel)
124			return c, cmd
125
126		case key.Matches(msg, c.keyMap.Down):
127			d, cmd := c.list.Update(msg)
128			c.list = d.(list.ListModel)
129			return c, cmd
130		case key.Matches(msg, c.keyMap.UpInsert):
131			selectedItemInx := c.list.SelectedIndex() - 1
132			items := c.list.Items()
133			if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
134				return c, nil // No item selected, do nothing
135			}
136			selectedItem := items[selectedItemInx].(CompletionItem).Value()
137			c.list.SetSelected(selectedItemInx)
138			return c, util.CmdHandler(SelectCompletionMsg{
139				Value:  selectedItem,
140				Insert: true,
141			})
142		case key.Matches(msg, c.keyMap.DownInsert):
143			selectedItemInx := c.list.SelectedIndex() + 1
144			items := c.list.Items()
145			if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
146				return c, nil // No item selected, do nothing
147			}
148			selectedItem := items[selectedItemInx].(CompletionItem).Value()
149			c.list.SetSelected(selectedItemInx)
150			return c, util.CmdHandler(SelectCompletionMsg{
151				Value:  selectedItem,
152				Insert: true,
153			})
154		case key.Matches(msg, c.keyMap.Select):
155			selectedItemInx := c.list.SelectedIndex()
156			if selectedItemInx == list.NoSelection {
157				return c, nil // No item selected, do nothing
158			}
159			items := c.list.Items()
160			selectedItem := items[selectedItemInx].(CompletionItem).Value()
161			c.open = false // Close completions after selection
162			return c, util.CmdHandler(SelectCompletionMsg{
163				Value: selectedItem,
164			})
165		case key.Matches(msg, c.keyMap.Cancel):
166			return c, util.CmdHandler(CloseCompletionsMsg{})
167		}
168	case RepositionCompletionsMsg:
169		c.x, c.y = msg.X, msg.Y
170		c.adjustPosition()
171	case CloseCompletionsMsg:
172		c.open = false
173		return c, util.CmdHandler(CompletionsClosedMsg{})
174	case OpenCompletionsMsg:
175		c.open = true
176		c.query = ""
177		c.x, c.xorig = msg.X, msg.X
178		c.y = msg.Y
179		items := []util.Model{}
180		t := styles.CurrentTheme()
181		for _, completion := range msg.Completions {
182			item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
183			items = append(items, item)
184		}
185		width := listWidth(items)
186		if len(items) == 0 {
187			width = listWidth(c.list.Items())
188		}
189		if c.x+width >= c.wWidth {
190			c.x = c.wWidth - width - 1
191		}
192		c.width = width
193		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
194		return c, tea.Batch(
195			c.list.SetItems(items),
196			c.list.SetSize(c.width, c.height),
197			util.CmdHandler(CompletionsOpenedMsg{}),
198		)
199	case FilterCompletionsMsg:
200		if !c.open && !msg.Reopen {
201			return c, nil
202		}
203		if msg.Query == c.query {
204			// PERF: if same query, don't need to filter again
205			return c, nil
206		}
207		if len(c.list.Items()) == 0 &&
208			len(msg.Query) > len(c.query) &&
209			strings.HasPrefix(msg.Query, c.query) {
210			// PERF: if c.query didn't match anything,
211			// AND msg.Query is longer than c.query,
212			// AND msg.Query is prefixed with c.query - which means
213			//		that the user typed more chars after a 0 match,
214			// it won't match anything, so return earlier.
215			return c, nil
216		}
217		c.query = msg.Query
218		var cmds []tea.Cmd
219		cmds = append(cmds, c.list.Filter(msg.Query))
220		items := c.list.Items()
221		itemsLen := len(items)
222		c.xorig = msg.X
223		c.x, c.y = msg.X, msg.Y
224		c.adjustPosition()
225		cmds = append(cmds, c.list.SetSize(c.width, c.height))
226		if itemsLen == 0 {
227			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
228		} else if msg.Reopen {
229			c.open = true
230			cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
231		}
232		return c, tea.Batch(cmds...)
233	}
234	return c, nil
235}
236
237func (c *completionsCmp) adjustPosition() {
238	items := c.list.Items()
239	itemsLen := len(items)
240	width := listWidth(items)
241	c.lastWidth = c.width
242	if c.x < 0 || width < c.lastWidth {
243		c.x = c.xorig
244	} else if c.x+width >= c.wWidth {
245		c.x = c.wWidth - width - 1
246	}
247	c.width = width
248	c.height = max(min(maxCompletionsHeight, itemsLen), 1)
249}
250
251// View implements Completions.
252func (c *completionsCmp) View() string {
253	if !c.open || len(c.list.Items()) == 0 {
254		return ""
255	}
256
257	t := styles.CurrentTheme()
258	style := t.S().Base.
259		Width(c.width).
260		Height(c.height).
261		Background(t.BgSubtle)
262
263	return style.Render(c.list.View())
264}
265
266// listWidth returns the width of the last 10 items in the list, which is used
267// to determine the width of the completions popup.
268// Note this only works for [completionItemCmp] items.
269func listWidth[T any](items []T) int {
270	var width int
271	if len(items) == 0 {
272		return width
273	}
274
275	for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
276		item, ok := any(items[i]).(*completionItemCmp)
277		if !ok {
278			continue
279		}
280		itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
281		width = max(width, itemWidth)
282	}
283
284	return width
285}
286
287func (c *completionsCmp) Open() bool {
288	return c.open
289}
290
291func (c *completionsCmp) Query() string {
292	return c.query
293}
294
295func (c *completionsCmp) KeyMap() KeyMap {
296	return c.keyMap
297}
298
299func (c *completionsCmp) Position() (int, int) {
300	return c.x, c.y - c.height
301}
302
303func (c *completionsCmp) Width() int {
304	return c.width
305}
306
307func (c *completionsCmp) Height() int {
308	return c.height
309}