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