completions.go

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