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