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