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.height = min(msg.Height-c.y, 15)
111		return c, nil
112	case tea.KeyPressMsg:
113		switch {
114		case key.Matches(msg, c.keyMap.Up):
115			u, cmd := c.list.Update(msg)
116			c.list = u.(list.ListModel)
117			return c, cmd
118
119		case key.Matches(msg, c.keyMap.Down):
120			d, cmd := c.list.Update(msg)
121			c.list = d.(list.ListModel)
122			return c, cmd
123		case key.Matches(msg, c.keyMap.UpInsert):
124			selectedItemInx := c.list.SelectedIndex() - 1
125			items := c.list.Items()
126			if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
127				return c, nil // No item selected, do nothing
128			}
129			selectedItem := items[selectedItemInx].(CompletionItem).Value()
130			c.list.SetSelected(selectedItemInx)
131			return c, util.CmdHandler(SelectCompletionMsg{
132				Value:  selectedItem,
133				Insert: true,
134			})
135		case key.Matches(msg, c.keyMap.DownInsert):
136			selectedItemInx := c.list.SelectedIndex() + 1
137			items := c.list.Items()
138			if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
139				return c, nil // No item selected, do nothing
140			}
141			selectedItem := items[selectedItemInx].(CompletionItem).Value()
142			c.list.SetSelected(selectedItemInx)
143			return c, util.CmdHandler(SelectCompletionMsg{
144				Value:  selectedItem,
145				Insert: true,
146			})
147		case key.Matches(msg, c.keyMap.Select):
148			selectedItemInx := c.list.SelectedIndex()
149			if selectedItemInx == list.NoSelection {
150				return c, nil // No item selected, do nothing
151			}
152			items := c.list.Items()
153			selectedItem := items[selectedItemInx].(CompletionItem).Value()
154			c.open = false // Close completions after selection
155			return c, util.CmdHandler(SelectCompletionMsg{
156				Value: selectedItem,
157			})
158		case key.Matches(msg, c.keyMap.Cancel):
159			return c, util.CmdHandler(CloseCompletionsMsg{})
160		}
161	case CloseCompletionsMsg:
162		c.open = false
163		return c, util.CmdHandler(CompletionsClosedMsg{})
164	case OpenCompletionsMsg:
165		c.open = true
166		c.query = ""
167		c.x, c.xorig = msg.X, msg.X
168		c.y = msg.Y
169		items := []util.Model{}
170		t := styles.CurrentTheme()
171		for _, completion := range msg.Completions {
172			item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
173			items = append(items, item)
174		}
175		width := listWidth(items)
176		if len(items) == 0 {
177			width = listWidth(c.list.Items())
178		}
179		if c.x+width >= c.wWidth {
180			c.x = c.wWidth - width - 1
181		}
182		c.width = width
183		c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
184		return c, tea.Batch(
185			c.list.SetItems(items),
186			c.list.SetSize(c.width, c.height),
187			util.CmdHandler(CompletionsOpenedMsg{}),
188		)
189	case FilterCompletionsMsg:
190		if !c.open && !msg.Reopen {
191			return c, nil
192		}
193		if msg.Query == c.query {
194			// PERF: if same query, don't need to filter again
195			return c, nil
196		}
197		if len(c.list.Items()) == 0 &&
198			len(msg.Query) > len(c.query) &&
199			strings.HasPrefix(msg.Query, c.query) {
200			// PERF: if c.query didn't match anything,
201			// AND msg.Query is longer than c.query,
202			// AND msg.Query is prefixed with c.query - which means
203			//		that the user typed more chars after a 0 match,
204			// it won't match anything, so return earlier.
205			return c, nil
206		}
207		c.query = msg.Query
208		var cmds []tea.Cmd
209		cmds = append(cmds, c.list.Filter(msg.Query))
210		items := c.list.Items()
211		itemsLen := len(items)
212		width := listWidth(items)
213		if c.x < 0 {
214			c.x = c.xorig
215		} else if c.x+width >= c.wWidth {
216			c.x = c.wWidth - width - 1
217		}
218		c.width = width
219		c.height = max(min(maxCompletionsHeight, itemsLen), 1)
220		cmds = append(cmds, c.list.SetSize(c.width, c.height))
221		if itemsLen == 0 {
222			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
223		} else if msg.Reopen {
224			c.open = true
225			cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
226		}
227		return c, tea.Batch(cmds...)
228	}
229	return c, nil
230}
231
232// View implements Completions.
233func (c *completionsCmp) View() string {
234	if !c.open || len(c.list.Items()) == 0 {
235		return ""
236	}
237
238	t := styles.CurrentTheme()
239	style := t.S().Base.
240		Width(c.width).
241		Height(c.height).
242		Background(t.BgSubtle)
243
244	return style.Render(c.list.View())
245}
246
247// listWidth returns the width of the last 10 items in the list, which is used
248// to determine the width of the completions popup.
249// Note this only works for [completionItemCmp] items.
250func listWidth[T any](items []T) int {
251	var width int
252	if len(items) == 0 {
253		return width
254	}
255
256	for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
257		item, ok := any(items[i]).(*completionItemCmp)
258		if !ok {
259			continue
260		}
261		itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
262		width = max(width, itemWidth)
263	}
264
265	return width
266}
267
268func (c *completionsCmp) Open() bool {
269	return c.open
270}
271
272func (c *completionsCmp) Query() string {
273	return c.query
274}
275
276func (c *completionsCmp) KeyMap() KeyMap {
277	return c.keyMap
278}
279
280func (c *completionsCmp) Position() (int, int) {
281	return c.x, c.y - c.height
282}
283
284func (c *completionsCmp) Width() int {
285	return c.width
286}
287
288func (c *completionsCmp) Height() int {
289	return c.height
290}