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	lastWidth int
 57	height    int  // Height of the completions component`
 58	x, xorig  int  // X position for the completions popup
 59	y         int  // Y position for the completions popup
 60	open      bool // Indicates if the completions are open
 61	keyMap    KeyMap
 62
 63	list  list.ListModel
 64	query string // The current filter query
 65}
 66
 67const (
 68	maxCompletionsWidth = 80 // Maximum width for the completions popup
 69	minCompletionsWidth = 20 // Minimum width for the completions popup
 70)
 71
 72func New() Completions {
 73	completionsKeyMap := DefaultKeyMap()
 74	keyMap := list.DefaultKeyMap()
 75	keyMap.Up.SetEnabled(false)
 76	keyMap.Down.SetEnabled(false)
 77	keyMap.HalfPageDown.SetEnabled(false)
 78	keyMap.HalfPageUp.SetEnabled(false)
 79	keyMap.Home.SetEnabled(false)
 80	keyMap.End.SetEnabled(false)
 81	keyMap.UpOneItem = completionsKeyMap.Up
 82	keyMap.DownOneItem = completionsKeyMap.Down
 83
 84	l := list.New(
 85		list.WithReverse(true),
 86		list.WithKeyMap(keyMap),
 87		list.WithHideFilterInput(true),
 88	)
 89	return &completionsCmp{
 90		width:  0,
 91		height: 0,
 92		list:   l,
 93		query:  "",
 94		keyMap: completionsKeyMap,
 95	}
 96}
 97
 98// Init implements Completions.
 99func (c *completionsCmp) Init() tea.Cmd {
100	return tea.Sequence(
101		c.list.Init(),
102		c.list.SetSize(c.width, c.height),
103	)
104}
105
106// Update implements Completions.
107func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108	switch msg := msg.(type) {
109	case tea.WindowSizeMsg:
110		c.wWidth = msg.Width
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 = width
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		c.lastWidth = c.width
215		if c.x < 0 || width < c.lastWidth {
216			c.x = c.xorig
217		} else if c.x+width >= c.wWidth {
218			c.x = c.wWidth - width - 1
219		}
220		c.width = width
221		c.height = max(min(maxCompletionsHeight, itemsLen), 1)
222		cmds = append(cmds, c.list.SetSize(c.width, c.height))
223		if itemsLen == 0 {
224			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
225		} else if msg.Reopen {
226			c.open = true
227			cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
228		}
229		return c, tea.Batch(cmds...)
230	}
231	return c, nil
232}
233
234// View implements Completions.
235func (c *completionsCmp) View() string {
236	if !c.open || len(c.list.Items()) == 0 {
237		return ""
238	}
239
240	t := styles.CurrentTheme()
241	style := t.S().Base.
242		Width(c.width).
243		Height(c.height).
244		Background(t.BgSubtle)
245
246	return style.Render(c.list.View())
247}
248
249// listWidth returns the width of the last 10 items in the list, which is used
250// to determine the width of the completions popup.
251// Note this only works for [completionItemCmp] items.
252func listWidth[T any](items []T) int {
253	var width int
254	if len(items) == 0 {
255		return width
256	}
257
258	for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
259		item, ok := any(items[i]).(*completionItemCmp)
260		if !ok {
261			continue
262		}
263		itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
264		width = max(width, itemWidth)
265	}
266
267	return width
268}
269
270func (c *completionsCmp) Open() bool {
271	return c.open
272}
273
274func (c *completionsCmp) Query() string {
275	return c.query
276}
277
278func (c *completionsCmp) KeyMap() KeyMap {
279	return c.keyMap
280}
281
282func (c *completionsCmp) Position() (int, int) {
283	return c.x, c.y - c.height
284}
285
286func (c *completionsCmp) Width() int {
287	return c.width
288}
289
290func (c *completionsCmp) Height() int {
291	return c.height
292}