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