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