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