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