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