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