list.go

  1package list
  2
  3import (
  4	"strings"
  5
  6	tea "github.com/charmbracelet/bubbletea/v2"
  7	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  8	"github.com/charmbracelet/crush/internal/tui/util"
  9	"github.com/charmbracelet/lipgloss/v2"
 10)
 11
 12type Item interface {
 13	util.Model
 14	layout.Sizeable
 15	ID() string
 16}
 17
 18type List interface {
 19	util.Model
 20	layout.Sizeable
 21	layout.Focusable
 22	SetItems(items []Item) tea.Cmd
 23}
 24
 25type direction int
 26
 27const (
 28	Forward direction = iota
 29	Backward
 30)
 31
 32const (
 33	NotFound = -1
 34)
 35
 36type renderedItem struct {
 37	id     string
 38	view   string
 39	height int
 40}
 41
 42type list struct {
 43	width, height int
 44	offset        int
 45	gap           int
 46	direction     direction
 47	selectedItem  string
 48	focused       bool
 49
 50	items         []Item
 51	renderedItems []renderedItem
 52	rendered      string
 53	isReady       bool
 54}
 55
 56type listOption func(*list)
 57
 58// WithItems sets the initial items for the list.
 59func WithItems(items ...Item) listOption {
 60	return func(l *list) {
 61		l.items = items
 62	}
 63}
 64
 65// WithSize sets the size of the list.
 66func WithSize(width, height int) listOption {
 67	return func(l *list) {
 68		l.width = width
 69		l.height = height
 70	}
 71}
 72
 73// WithGap sets the gap between items in the list.
 74func WithGap(gap int) listOption {
 75	return func(l *list) {
 76		l.gap = gap
 77	}
 78}
 79
 80// WithDirection sets the direction of the list.
 81func WithDirection(dir direction) listOption {
 82	return func(l *list) {
 83		l.direction = dir
 84	}
 85}
 86
 87// WithSelectedItem sets the initially selected item in the list.
 88func WithSelectedItem(id string) listOption {
 89	return func(l *list) {
 90		l.selectedItem = id
 91	}
 92}
 93
 94func New(opts ...listOption) List {
 95	list := &list{
 96		items:     make([]Item, 0),
 97		direction: Forward,
 98	}
 99	for _, opt := range opts {
100		opt(list)
101	}
102	return list
103}
104
105// Init implements List.
106func (l *list) Init() tea.Cmd {
107	if l.height <= 0 || l.width <= 0 {
108		return nil
109	}
110	if len(l.items) == 0 {
111		return nil
112	}
113	var cmds []tea.Cmd
114	for _, item := range l.items {
115		cmd := item.Init()
116		cmds = append(cmds, cmd)
117	}
118	cmds = append(cmds, l.renderItems())
119	return tea.Batch(cmds...)
120}
121
122// Update implements List.
123func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
124	return l, nil
125}
126
127// View implements List.
128func (l *list) View() string {
129	if l.height <= 0 || l.width <= 0 {
130		return ""
131	}
132	view := l.rendered
133	lines := strings.Split(view, "\n")
134
135	start, end := l.viewPosition(len(lines))
136	lines = lines[start:end]
137	return strings.Join(lines, "\n")
138}
139
140func (l *list) viewPosition(total int) (int, int) {
141	start, end := 0, 0
142	if l.direction == Forward {
143		start = max(0, l.offset)
144		end = min(l.offset+l.listHeight(), total)
145	} else {
146		start = max(0, total-l.offset-l.listHeight())
147		end = max(0, total-l.offset)
148	}
149	return start, end
150}
151
152func (l *list) renderItem(item Item) renderedItem {
153	view := item.View()
154	return renderedItem{
155		id:     item.ID(),
156		view:   view,
157		height: lipgloss.Height(view),
158	}
159}
160
161func (l *list) renderView() {
162	var sb strings.Builder
163	for i, rendered := range l.renderedItems {
164		sb.WriteString(rendered.view)
165		if i < len(l.renderedItems)-1 {
166			sb.WriteString(strings.Repeat("\n", l.gap+1))
167		}
168	}
169	l.rendered = sb.String()
170}
171
172func (l *list) incrementOffset(n int) {
173	if !l.isReady {
174		return
175	}
176	renderedHeight := lipgloss.Height(l.rendered)
177	// no need for offset
178	if renderedHeight <= l.listHeight() {
179		return
180	}
181	maxOffset := renderedHeight - l.listHeight()
182	n = min(n, maxOffset-l.offset)
183	if n <= 0 {
184		return
185	}
186	l.offset += n
187}
188
189func (l *list) decrementOffset(n int) {
190	if !l.isReady {
191		return
192	}
193	n = min(n, l.offset)
194	if n <= 0 {
195		return
196	}
197	l.offset -= n
198	if l.offset < 0 {
199		l.offset = 0
200	}
201}
202
203func (l *list) MoveUp(n int) {
204	if l.direction == Forward {
205		l.decrementOffset(n)
206	} else {
207		l.incrementOffset(n)
208	}
209}
210
211func (l *list) MoveDown(n int) {
212	if l.direction == Forward {
213		l.incrementOffset(n)
214	} else {
215		l.decrementOffset(n)
216	}
217}
218
219func (l *list) firstSelectableItemBefore(inx int) int {
220	for i := inx - 1; i >= 0; i-- {
221		if _, ok := l.items[i].(layout.Focusable); ok {
222			return i
223		}
224	}
225	return NotFound
226}
227
228func (l *list) firstSelectableItemAfter(inx int) int {
229	for i := inx + 1; i < len(l.items); i++ {
230		if _, ok := l.items[i].(layout.Focusable); ok {
231			return i
232		}
233	}
234	return NotFound
235}
236
237func (l *list) moveToSelected() {
238	if l.selectedItem == "" || !l.isReady {
239		return
240	}
241	currentPosition := 0
242	start, end := l.viewPosition(lipgloss.Height(l.rendered))
243	for _, item := range l.renderedItems {
244		if item.id == l.selectedItem {
245			if start <= currentPosition && currentPosition <= end {
246				return
247			}
248			// we need to go up
249			if currentPosition < start {
250				l.MoveUp(start - currentPosition)
251			}
252			// we need to go down
253			if currentPosition > end {
254				l.MoveDown(currentPosition - end)
255			}
256		}
257		currentPosition += item.height + l.gap
258	}
259}
260
261func (l *list) SelectItemAbove() tea.Cmd {
262	if !l.isReady {
263		return nil
264	}
265	var cmds []tea.Cmd
266	for i, item := range l.items {
267		if l.selectedItem == item.ID() {
268			inx := l.firstSelectableItemBefore(i)
269			if inx == NotFound {
270				// no item above
271				return nil
272			}
273			// blur the current item
274			if focusable, ok := item.(layout.Focusable); ok {
275				cmds = append(cmds, focusable.Blur())
276			}
277			// rerender the item
278			l.renderedItems[i] = l.renderItem(item)
279			// focus the item above
280			above := l.items[inx]
281			if focusable, ok := above.(layout.Focusable); ok {
282				cmds = append(cmds, focusable.Focus())
283			}
284			// rerender the item
285			l.renderedItems[inx] = l.renderItem(above)
286			l.selectedItem = above.ID()
287			break
288		}
289	}
290	l.renderView()
291	l.moveToSelected()
292	return tea.Batch(cmds...)
293}
294
295func (l *list) SelectItemBelow() tea.Cmd {
296	if !l.isReady {
297		return nil
298	}
299	var cmds []tea.Cmd
300	for i, item := range l.items {
301		if l.selectedItem == item.ID() {
302			inx := l.firstSelectableItemAfter(i)
303			if inx == NotFound {
304				// no item below
305				return nil
306			}
307			// blur the current item
308			if focusable, ok := item.(layout.Focusable); ok {
309				cmds = append(cmds, focusable.Blur())
310			}
311			// rerender the item
312			l.renderedItems[i] = l.renderItem(item)
313
314			// focus the item below
315			below := l.items[inx]
316			if focusable, ok := below.(layout.Focusable); ok {
317				cmds = append(cmds, focusable.Focus())
318			}
319			// rerender the item
320			l.renderedItems[inx] = l.renderItem(below)
321			l.selectedItem = below.ID()
322			break
323		}
324	}
325
326	l.renderView()
327	l.moveToSelected()
328	return tea.Batch(cmds...)
329}
330
331func (l *list) GoToTop() tea.Cmd {
332	if !l.isReady {
333		return nil
334	}
335	l.offset = 0
336	l.direction = Forward
337	return tea.Batch(l.selectFirstItem(), l.renderForward())
338}
339
340func (l *list) GoToBottom() tea.Cmd {
341	if !l.isReady {
342		return nil
343	}
344	l.offset = 0
345	l.direction = Backward
346
347	return tea.Batch(l.selectLastItem(), l.renderBackward())
348}
349
350func (l *list) renderForward() tea.Cmd {
351	// TODO: figure out a way to preserve items that did not change
352	l.renderedItems = make([]renderedItem, 0)
353	currentHeight := 0
354	currentIndex := 0
355	for i, item := range l.items {
356		currentIndex = i
357		if currentHeight > l.listHeight() {
358			break
359		}
360		rendered := l.renderItem(item)
361		l.renderedItems = append(l.renderedItems, rendered)
362		currentHeight += rendered.height + l.gap
363	}
364
365	// initial render
366	l.renderView()
367
368	if currentIndex == len(l.items)-1 {
369		l.isReady = true
370		return nil
371	}
372	// render the rest
373	return func() tea.Msg {
374		for i := currentIndex; i < len(l.items); i++ {
375			rendered := l.renderItem(l.items[i])
376			l.renderedItems = append(l.renderedItems, rendered)
377		}
378		l.renderView()
379		l.isReady = true
380		return nil
381	}
382}
383
384func (l *list) renderBackward() tea.Cmd {
385	// TODO: figure out a way to preserve items that did not change
386	l.renderedItems = make([]renderedItem, 0)
387	currentHeight := 0
388	currentIndex := 0
389	for i := len(l.items) - 1; i >= 0; i-- {
390		currentIndex = i
391		if currentHeight > l.listHeight() {
392			break
393		}
394		rendered := l.renderItem(l.items[i])
395		l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
396		currentHeight += rendered.height + l.gap
397	}
398	// initial render
399	l.renderView()
400	if currentIndex == len(l.items)-1 {
401		l.isReady = true
402		return nil
403	}
404	return func() tea.Msg {
405		for i := currentIndex; i >= 0; i-- {
406			rendered := l.renderItem(l.items[i])
407			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
408		}
409		l.renderView()
410		l.isReady = true
411		return nil
412	}
413}
414
415func (l *list) selectFirstItem() tea.Cmd {
416	var cmd tea.Cmd
417	inx := l.firstSelectableItemAfter(-1)
418	if inx != NotFound {
419		l.selectedItem = l.items[inx].ID()
420		if focusable, ok := l.items[inx].(layout.Focusable); ok {
421			cmd = focusable.Focus()
422		}
423	}
424	return cmd
425}
426
427func (l *list) selectLastItem() tea.Cmd {
428	var cmd tea.Cmd
429	inx := l.firstSelectableItemBefore(len(l.items))
430	if inx != NotFound {
431		l.selectedItem = l.items[inx].ID()
432		if focusable, ok := l.items[inx].(layout.Focusable); ok {
433			cmd = focusable.Focus()
434		}
435	}
436	return cmd
437}
438
439func (l *list) renderItems() tea.Cmd {
440	if l.height <= 0 || l.width <= 0 {
441		return nil
442	}
443	if len(l.items) == 0 {
444		return nil
445	}
446
447	if l.selectedItem == "" {
448		if l.direction == Forward {
449			l.selectFirstItem()
450		} else {
451			l.selectLastItem()
452		}
453	}
454	return l.renderBackward()
455}
456
457func (l *list) listHeight() int {
458	// for the moment its the same
459	return l.height
460}
461
462func (l *list) SetItems(items []Item) tea.Cmd {
463	l.items = items
464	var cmds []tea.Cmd
465	for _, item := range l.items {
466		cmds = append(cmds, item.Init())
467		// Set height to 0 to let the item calculate its own height
468		cmds = append(cmds, item.SetSize(l.width, 0))
469	}
470	cmds = append(cmds, l.renderItems())
471	return tea.Batch(cmds...)
472}
473
474// GetSize implements List.
475func (l *list) GetSize() (int, int) {
476	return l.width, l.height
477}
478
479// SetSize implements List.
480func (l *list) SetSize(width int, height int) tea.Cmd {
481	l.width = width
482	l.height = height
483	var cmds []tea.Cmd
484	for _, item := range l.items {
485		cmds = append(cmds, item.SetSize(width, height))
486	}
487	cmds = append(cmds, l.renderItems())
488	return tea.Batch(cmds...)
489}
490
491// Blur implements List.
492func (l *list) Blur() tea.Cmd {
493	var cmd tea.Cmd
494	l.focused = false
495	for i, item := range l.items {
496		if item.ID() != l.selectedItem {
497			continue
498		}
499		if focusable, ok := item.(layout.Focusable); ok {
500			cmd = focusable.Blur()
501		}
502		l.renderedItems[i] = l.renderItem(item)
503	}
504	l.renderView()
505	return cmd
506}
507
508// Focus implements List.
509func (l *list) Focus() tea.Cmd {
510	var cmd tea.Cmd
511	l.focused = true
512	for i, item := range l.items {
513		if item.ID() != l.selectedItem {
514			continue
515		}
516		if focusable, ok := item.(layout.Focusable); ok {
517			cmd = focusable.Focus()
518		}
519		l.renderedItems[i] = l.renderItem(item)
520	}
521	l.renderView()
522	return cmd
523}
524
525// IsFocused implements List.
526func (l *list) IsFocused() bool {
527	return l.focused
528}