list.go

  1package list
  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/layout"
  9	"github.com/charmbracelet/crush/internal/tui/util"
 10	"github.com/charmbracelet/lipgloss/v2"
 11)
 12
 13type Item interface {
 14	util.Model
 15	layout.Sizeable
 16	ID() string
 17}
 18
 19type List[T Item] interface {
 20	util.Model
 21	layout.Sizeable
 22	layout.Focusable
 23	MoveUp(int) tea.Cmd
 24	MoveDown(int) tea.Cmd
 25	GoToTop() tea.Cmd
 26	GoToBottom() tea.Cmd
 27	SelectItemAbove() tea.Cmd
 28	SelectItemBelow() tea.Cmd
 29	SetItems([]T) tea.Cmd
 30	SetSelected(string) tea.Cmd
 31	SelectedItem() *T
 32}
 33
 34type direction int
 35
 36const (
 37	Forward direction = iota
 38	Backward
 39)
 40
 41const (
 42	NotFound          = -1
 43	DefaultScrollSize = 2
 44)
 45
 46type setSelectedMsg struct {
 47	selectedItemID string
 48}
 49
 50type renderedItem struct {
 51	id     string
 52	view   string
 53	height int
 54}
 55
 56type confOptions struct {
 57	width, height int
 58	gap           int
 59	// if you are at the last item and go down it will wrap to the top
 60	wrap         bool
 61	keyMap       KeyMap
 62	direction    direction
 63	selectedItem string
 64}
 65type list[T Item] struct {
 66	confOptions
 67
 68	focused       bool
 69	offset        int
 70	items         []T
 71	renderedItems []renderedItem
 72	rendered      string
 73	isReady       bool
 74}
 75
 76type listOption func(*confOptions)
 77
 78// WithSize sets the size of the list.
 79func WithSize(width, height int) listOption {
 80	return func(l *confOptions) {
 81		l.width = width
 82		l.height = height
 83	}
 84}
 85
 86// WithGap sets the gap between items in the list.
 87func WithGap(gap int) listOption {
 88	return func(l *confOptions) {
 89		l.gap = gap
 90	}
 91}
 92
 93// WithDirection sets the direction of the list.
 94func WithDirection(dir direction) listOption {
 95	return func(l *confOptions) {
 96		l.direction = dir
 97	}
 98}
 99
100// WithSelectedItem sets the initially selected item in the list.
101func WithSelectedItem(id string) listOption {
102	return func(l *confOptions) {
103		l.selectedItem = id
104	}
105}
106
107func WithKeyMap(keyMap KeyMap) listOption {
108	return func(l *confOptions) {
109		l.keyMap = keyMap
110	}
111}
112
113func WithWrapNavigation() listOption {
114	return func(l *confOptions) {
115		l.wrap = true
116	}
117}
118
119func New[T Item](items []T, opts ...listOption) List[T] {
120	list := &list[T]{
121		confOptions: confOptions{
122			direction: Forward,
123			keyMap:    DefaultKeyMap(),
124		},
125		items: items,
126	}
127	for _, opt := range opts {
128		opt(&list.confOptions)
129	}
130	return list
131}
132
133// Init implements List.
134func (l *list[T]) Init() tea.Cmd {
135	var cmds []tea.Cmd
136	for _, item := range l.items {
137		cmd := item.Init()
138		cmds = append(cmds, cmd)
139	}
140	cmds = append(cmds, l.renderItems())
141	return tea.Batch(cmds...)
142}
143
144// Update implements List.
145func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146	switch msg := msg.(type) {
147	case setSelectedMsg:
148		return l, l.SetSelected(msg.selectedItemID)
149	case tea.KeyPressMsg:
150		if l.focused {
151			switch {
152			case key.Matches(msg, l.keyMap.Down):
153				return l, l.MoveDown(DefaultScrollSize)
154			case key.Matches(msg, l.keyMap.Up):
155				return l, l.MoveUp(DefaultScrollSize)
156			case key.Matches(msg, l.keyMap.DownOneItem):
157				return l, l.SelectItemBelow()
158			case key.Matches(msg, l.keyMap.UpOneItem):
159				return l, l.SelectItemAbove()
160			case key.Matches(msg, l.keyMap.HalfPageDown):
161				return l, l.MoveDown(l.listHeight() / 2)
162			case key.Matches(msg, l.keyMap.HalfPageUp):
163				return l, l.MoveUp(l.listHeight() / 2)
164			case key.Matches(msg, l.keyMap.PageDown):
165				return l, l.MoveDown(l.listHeight())
166			case key.Matches(msg, l.keyMap.PageUp):
167				return l, l.MoveUp(l.listHeight())
168			case key.Matches(msg, l.keyMap.End):
169				return l, l.GoToBottom()
170			case key.Matches(msg, l.keyMap.Home):
171				return l, l.GoToTop()
172			}
173		}
174	}
175	return l, nil
176}
177
178// View implements List.
179func (l *list[T]) View() string {
180	if l.height <= 0 || l.width <= 0 {
181		return ""
182	}
183	view := l.rendered
184	lines := strings.Split(view, "\n")
185
186	start, end := l.viewPosition()
187	lines = lines[start : end+1]
188	return strings.Join(lines, "\n")
189}
190
191func (l *list[T]) viewPosition() (int, int) {
192	start, end := 0, 0
193	renderedLines := lipgloss.Height(l.rendered) - 1
194	if l.direction == Forward {
195		start = max(0, l.offset)
196		end = min(l.offset+l.listHeight()-1, renderedLines)
197	} else {
198		start = max(0, renderedLines-l.offset-l.listHeight()+1)
199		end = max(0, renderedLines-l.offset)
200	}
201	return start, end
202}
203
204func (l *list[T]) renderItem(item Item) renderedItem {
205	view := item.View()
206	return renderedItem{
207		id:     item.ID(),
208		view:   view,
209		height: lipgloss.Height(view),
210	}
211}
212
213func (l *list[T]) renderView() {
214	var sb strings.Builder
215	for i, rendered := range l.renderedItems {
216		sb.WriteString(rendered.view)
217		if i < len(l.renderedItems)-1 {
218			sb.WriteString(strings.Repeat("\n", l.gap+1))
219		}
220	}
221	l.rendered = sb.String()
222}
223
224func (l *list[T]) incrementOffset(n int) {
225	if !l.isReady {
226		return
227	}
228	renderedHeight := lipgloss.Height(l.rendered)
229	// no need for offset
230	if renderedHeight <= l.listHeight() {
231		return
232	}
233	maxOffset := renderedHeight - l.listHeight()
234	n = min(n, maxOffset-l.offset)
235	if n <= 0 {
236		return
237	}
238	l.offset += n
239}
240
241func (l *list[T]) decrementOffset(n int) {
242	if !l.isReady {
243		return
244	}
245	n = min(n, l.offset)
246	if n <= 0 {
247		return
248	}
249	l.offset -= n
250	if l.offset < 0 {
251		l.offset = 0
252	}
253}
254
255// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
256func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
257	var cmds []tea.Cmd
258	start, end := l.viewPosition()
259	currentPosition := 0
260	itemWithinView := NotFound
261	needsMove := false
262
263	for i, item := range l.items {
264		rendered := l.renderedItems[i]
265		itemStart := currentPosition
266		// we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
267		itemEnd := itemStart + rendered.height - 1
268		if itemStart >= start && itemEnd <= end {
269			itemWithinView = i
270		}
271		if item.ID() == l.selectedItem {
272			// item is completely above the viewport
273			if itemStart < start && itemEnd < start {
274				needsMove = true
275			}
276			// item is completely below the viewport
277			if itemStart > end && itemEnd > end {
278				needsMove = true
279			}
280			if needsMove {
281				if focusable, ok := any(item).(layout.Focusable); ok {
282					cmds = append(cmds, focusable.Blur())
283				}
284				l.renderedItems[i] = l.renderItem(item)
285			} else {
286				return nil
287			}
288		}
289		if itemWithinView != NotFound && needsMove {
290			newSelection := l.items[itemWithinView]
291			l.selectedItem = newSelection.ID()
292			if focusable, ok := any(newSelection).(layout.Focusable); ok {
293				cmds = append(cmds, focusable.Focus())
294			}
295			l.renderedItems[itemWithinView] = l.renderItem(newSelection)
296			break
297		}
298		currentPosition += rendered.height + l.gap
299	}
300	l.renderView()
301	return tea.Batch(cmds...)
302}
303
304func (l *list[T]) MoveUp(n int) tea.Cmd {
305	if l.direction == Forward {
306		l.decrementOffset(n)
307	} else {
308		l.incrementOffset(n)
309	}
310	return l.changeSelectedWhenNotVisible()
311}
312
313func (l *list[T]) MoveDown(n int) tea.Cmd {
314	if l.direction == Forward {
315		l.incrementOffset(n)
316	} else {
317		l.decrementOffset(n)
318	}
319	return l.changeSelectedWhenNotVisible()
320}
321
322func (l *list[T]) firstSelectableItemBefore(inx int) int {
323	for i := inx - 1; i >= 0; i-- {
324		if _, ok := any(l.items[i]).(layout.Focusable); ok {
325			return i
326		}
327	}
328	if inx == 0 && l.wrap {
329		return l.firstSelectableItemBefore(len(l.items))
330	}
331	return NotFound
332}
333
334func (l *list[T]) firstSelectableItemAfter(inx int) int {
335	for i := inx + 1; i < len(l.items); i++ {
336		if _, ok := any(l.items[i]).(layout.Focusable); ok {
337			return i
338		}
339	}
340	if inx == len(l.items)-1 && l.wrap {
341		return l.firstSelectableItemAfter(-1)
342	}
343	return NotFound
344}
345
346func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347	var cmds []tea.Cmd
348	if l.selectedItem == "" || !l.isReady {
349		return nil
350	}
351	currentPosition := 0
352	start, end := l.viewPosition()
353	for _, item := range l.renderedItems {
354		if item.id == l.selectedItem {
355			itemStart := currentPosition
356			itemEnd := currentPosition + item.height - 1
357
358			if start <= itemStart && itemEnd <= end {
359				return nil
360			}
361
362			if center {
363				viewportCenter := l.listHeight() / 2
364				itemCenter := itemStart + item.height/2
365				targetOffset := itemCenter - viewportCenter
366				if l.direction == Forward {
367					if targetOffset > l.offset {
368						cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
369					} else if targetOffset < l.offset {
370						cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
371					}
372				} else {
373					renderedHeight := lipgloss.Height(l.rendered)
374					backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
375					if backwardTargetOffset > l.offset {
376						cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
377					} else if backwardTargetOffset < l.offset {
378						cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
379					}
380				}
381			} else {
382				if currentPosition < start {
383					cmds = append(cmds, l.MoveUp(start-currentPosition))
384				}
385				if currentPosition > end {
386					cmds = append(cmds, l.MoveDown(currentPosition-end))
387				}
388			}
389		}
390		currentPosition += item.height + l.gap
391	}
392	return tea.Batch(cmds...)
393}
394
395func (l *list[T]) SelectItemAbove() tea.Cmd {
396	if !l.isReady {
397		return nil
398	}
399	var cmds []tea.Cmd
400	for i, item := range l.items {
401		if l.selectedItem == item.ID() {
402			inx := l.firstSelectableItemBefore(i)
403			if inx == NotFound {
404				// no item above
405				return nil
406			}
407			// blur the current item
408			if focusable, ok := any(item).(layout.Focusable); ok {
409				cmds = append(cmds, focusable.Blur())
410			}
411			// rerender the item
412			l.renderedItems[i] = l.renderItem(item)
413			// focus the item above
414			above := l.items[inx]
415			if focusable, ok := any(above).(layout.Focusable); ok {
416				cmds = append(cmds, focusable.Focus())
417			}
418			// rerender the item
419			l.renderedItems[inx] = l.renderItem(above)
420			l.selectedItem = above.ID()
421			break
422		}
423	}
424	l.moveToSelected(false)
425	l.renderView()
426	return tea.Batch(cmds...)
427}
428
429func (l *list[T]) SelectItemBelow() tea.Cmd {
430	if !l.isReady {
431		return nil
432	}
433	var cmds []tea.Cmd
434	for i, item := range l.items {
435		if l.selectedItem == item.ID() {
436			inx := l.firstSelectableItemAfter(i)
437			if inx == NotFound {
438				// no item below
439				return nil
440			}
441			// blur the current item
442			if focusable, ok := any(item).(layout.Focusable); ok {
443				cmds = append(cmds, focusable.Blur())
444			}
445			// rerender the item
446			l.renderedItems[i] = l.renderItem(item)
447
448			// focus the item below
449			below := l.items[inx]
450			if focusable, ok := any(below).(layout.Focusable); ok {
451				cmds = append(cmds, focusable.Focus())
452			}
453			// rerender the item
454			l.renderedItems[inx] = l.renderItem(below)
455			l.selectedItem = below.ID()
456			break
457		}
458	}
459
460	l.moveToSelected(false)
461	l.renderView()
462	return tea.Batch(cmds...)
463}
464
465func (l *list[T]) GoToTop() tea.Cmd {
466	if !l.isReady {
467		return nil
468	}
469	l.offset = 0
470	l.direction = Forward
471	return tea.Batch(l.selectFirstItem(), l.renderForward())
472}
473
474func (l *list[T]) GoToBottom() tea.Cmd {
475	if !l.isReady {
476		return nil
477	}
478	l.offset = 0
479	l.direction = Backward
480
481	return tea.Batch(l.selectLastItem(), l.renderBackward())
482}
483
484func (l *list[T]) renderForward() tea.Cmd {
485	// TODO: figure out a way to preserve items that did not change
486	l.renderedItems = make([]renderedItem, 0)
487	currentHeight := 0
488	currentIndex := 0
489	for i, item := range l.items {
490		currentIndex = i
491		if currentHeight-1 > l.listHeight() {
492			break
493		}
494		rendered := l.renderItem(item)
495		l.renderedItems = append(l.renderedItems, rendered)
496		currentHeight += rendered.height + l.gap
497	}
498
499	// initial render
500	l.renderView()
501
502	if currentIndex == len(l.items)-1 {
503		l.isReady = true
504		return nil
505	}
506	// render the rest
507	return func() tea.Msg {
508		for i := currentIndex; i < len(l.items); i++ {
509			rendered := l.renderItem(l.items[i])
510			l.renderedItems = append(l.renderedItems, rendered)
511		}
512		l.renderView()
513		l.isReady = true
514		return nil
515	}
516}
517
518func (l *list[T]) renderBackward() tea.Cmd {
519	// TODO: figure out a way to preserve items that did not change
520	l.renderedItems = make([]renderedItem, 0)
521	currentHeight := 0
522	currentIndex := 0
523	for i := len(l.items) - 1; i >= 0; i-- {
524		currentIndex = i
525		if currentHeight > l.listHeight() {
526			break
527		}
528		rendered := l.renderItem(l.items[i])
529		l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
530		currentHeight += rendered.height + l.gap
531	}
532	// initial render
533	l.renderView()
534	if currentIndex == 0 {
535		l.isReady = true
536		return nil
537	}
538	return func() tea.Msg {
539		for i := currentIndex; i >= 0; i-- {
540			rendered := l.renderItem(l.items[i])
541			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
542		}
543		l.renderView()
544		l.isReady = true
545		return nil
546	}
547}
548
549func (l *list[T]) selectFirstItem() tea.Cmd {
550	var cmd tea.Cmd
551	inx := l.firstSelectableItemAfter(-1)
552	if inx != NotFound {
553		l.selectedItem = l.items[inx].ID()
554		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
555			cmd = focusable.Focus()
556		}
557	}
558	return cmd
559}
560
561func (l *list[T]) selectLastItem() tea.Cmd {
562	var cmd tea.Cmd
563	inx := l.firstSelectableItemBefore(len(l.items))
564	if inx != NotFound {
565		l.selectedItem = l.items[inx].ID()
566		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
567			cmd = focusable.Focus()
568		}
569	}
570	return cmd
571}
572
573func (l *list[T]) renderItems() tea.Cmd {
574	if l.height <= 0 || l.width <= 0 {
575		return nil
576	}
577	if len(l.items) == 0 {
578		return nil
579	}
580
581	if l.selectedItem == "" {
582		if l.direction == Forward {
583			l.selectFirstItem()
584		} else {
585			l.selectLastItem()
586		}
587	}
588	if l.direction == Forward {
589		return l.renderForward()
590	}
591	return l.renderBackward()
592}
593
594func (l *list[T]) listHeight() int {
595	// for the moment its the same
596	return l.height
597}
598
599func (l *list[T]) SetItems(items []T) tea.Cmd {
600	l.items = items
601	var cmds []tea.Cmd
602	for _, item := range l.items {
603		cmds = append(cmds, item.Init())
604		// Set height to 0 to let the item calculate its own height
605		cmds = append(cmds, item.SetSize(l.width, 0))
606	}
607
608	if l.selectedItem != "" {
609		cmds = append(cmds, l.moveToSelected(true))
610	}
611	cmds = append(cmds, l.renderItems())
612	return tea.Batch(cmds...)
613}
614
615// GetSize implements List.
616func (l *list[T]) GetSize() (int, int) {
617	return l.width, l.height
618}
619
620// SetSize implements List.
621func (l *list[T]) SetSize(width int, height int) tea.Cmd {
622	l.width = width
623	l.height = height
624	var cmds []tea.Cmd
625	for _, item := range l.items {
626		cmds = append(cmds, item.SetSize(width, height))
627	}
628
629	cmds = append(cmds, l.renderItems())
630	return tea.Batch(cmds...)
631}
632
633// Blur implements List.
634func (l *list[T]) Blur() tea.Cmd {
635	var cmd tea.Cmd
636	l.focused = false
637	for i, item := range l.items {
638		if item.ID() != l.selectedItem {
639			continue
640		}
641		if focusable, ok := any(item).(layout.Focusable); ok {
642			cmd = focusable.Blur()
643		}
644		l.renderedItems[i] = l.renderItem(item)
645	}
646	l.renderView()
647	return cmd
648}
649
650// Focus implements List.
651func (l *list[T]) Focus() tea.Cmd {
652	var cmd tea.Cmd
653	l.focused = true
654	if l.selectedItem != "" {
655		for i, item := range l.items {
656			if item.ID() != l.selectedItem {
657				continue
658			}
659			if focusable, ok := any(item).(layout.Focusable); ok {
660				cmd = focusable.Focus()
661			}
662			if len(l.renderedItems) > i {
663				l.renderedItems[i] = l.renderItem(item)
664			}
665		}
666		l.renderView()
667	}
668	return cmd
669}
670
671func (l *list[T]) SetSelected(id string) tea.Cmd {
672	if l.selectedItem == id {
673		return nil
674	}
675	var cmds []tea.Cmd
676	for i, item := range l.items {
677		if item.ID() == l.selectedItem {
678			if focusable, ok := any(item).(layout.Focusable); ok {
679				cmds = append(cmds, focusable.Blur())
680			}
681			if len(l.renderedItems) > i {
682				l.renderedItems[i] = l.renderItem(item)
683			}
684		} else if item.ID() == id {
685			if focusable, ok := any(item).(layout.Focusable); ok {
686				cmds = append(cmds, focusable.Focus())
687			}
688			if len(l.renderedItems) > i {
689				l.renderedItems[i] = l.renderItem(item)
690			}
691		}
692	}
693	l.selectedItem = id
694	cmds = append(cmds, l.moveToSelected(true))
695	l.renderView()
696	return tea.Batch(cmds...)
697}
698
699func (l *list[T]) SelectedItem() *T {
700	for _, item := range l.items {
701		if item.ID() == l.selectedItem {
702			return &item
703		}
704	}
705	return nil
706}
707
708// IsFocused implements List.
709func (l *list[T]) IsFocused() bool {
710	return l.focused
711}