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