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/csync"
  9	"github.com/charmbracelet/crush/internal/tui/components/anim"
 10	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 11	"github.com/charmbracelet/crush/internal/tui/styles"
 12	"github.com/charmbracelet/crush/internal/tui/util"
 13	"github.com/charmbracelet/lipgloss/v2"
 14)
 15
 16type Item interface {
 17	util.Model
 18	layout.Sizeable
 19	ID() string
 20}
 21
 22type HasAnim interface {
 23	Item
 24	Spinning() bool
 25}
 26type renderedMsg struct{}
 27
 28type List[T Item] interface {
 29	util.Model
 30	layout.Sizeable
 31	layout.Focusable
 32
 33	// Just change state
 34	MoveUp(int) tea.Cmd
 35	MoveDown(int) tea.Cmd
 36	GoToTop() tea.Cmd
 37	GoToBottom() tea.Cmd
 38	SelectItemAbove() tea.Cmd
 39	SelectItemBelow() tea.Cmd
 40	SetItems([]T) tea.Cmd
 41	SetSelected(string) tea.Cmd
 42	SelectedItem() *T
 43	Items() []T
 44	UpdateItem(string, T) tea.Cmd
 45	DeleteItem(string) tea.Cmd
 46	PrependItem(T) tea.Cmd
 47	AppendItem(T) tea.Cmd
 48}
 49
 50type direction int
 51
 52const (
 53	DirectionForward direction = iota
 54	DirectionBackward
 55)
 56
 57const (
 58	ItemNotFound              = -1
 59	ViewportDefaultScrollSize = 2
 60)
 61
 62type renderedItem struct {
 63	id     string
 64	view   string
 65	height int
 66	start  int
 67	end    int
 68}
 69
 70type confOptions struct {
 71	width, height int
 72	gap           int
 73	// if you are at the last item and go down it will wrap to the top
 74	wrap         bool
 75	keyMap       KeyMap
 76	direction    direction
 77	selectedItem string
 78	focused      bool
 79	resize       bool
 80}
 81
 82type list[T Item] struct {
 83	*confOptions
 84
 85	offset int
 86
 87	indexMap *csync.Map[string, int]
 88	items    *csync.Slice[T]
 89
 90	renderedItems *csync.Map[string, renderedItem]
 91
 92	rendered string
 93
 94	movingByItem bool
 95}
 96
 97type ListOption func(*confOptions)
 98
 99// WithSize sets the size of the list.
100func WithSize(width, height int) ListOption {
101	return func(l *confOptions) {
102		l.width = width
103		l.height = height
104	}
105}
106
107// WithGap sets the gap between items in the list.
108func WithGap(gap int) ListOption {
109	return func(l *confOptions) {
110		l.gap = gap
111	}
112}
113
114// WithDirectionForward sets the direction to forward
115func WithDirectionForward() ListOption {
116	return func(l *confOptions) {
117		l.direction = DirectionForward
118	}
119}
120
121// WithDirectionBackward sets the direction to forward
122func WithDirectionBackward() ListOption {
123	return func(l *confOptions) {
124		l.direction = DirectionBackward
125	}
126}
127
128// WithSelectedItem sets the initially selected item in the list.
129func WithSelectedItem(id string) ListOption {
130	return func(l *confOptions) {
131		l.selectedItem = id
132	}
133}
134
135func WithKeyMap(keyMap KeyMap) ListOption {
136	return func(l *confOptions) {
137		l.keyMap = keyMap
138	}
139}
140
141func WithWrapNavigation() ListOption {
142	return func(l *confOptions) {
143		l.wrap = true
144	}
145}
146
147func WithFocus(focus bool) ListOption {
148	return func(l *confOptions) {
149		l.focused = focus
150	}
151}
152
153func WithResizeByList() ListOption {
154	return func(l *confOptions) {
155		l.resize = true
156	}
157}
158
159func New[T Item](items []T, opts ...ListOption) List[T] {
160	list := &list[T]{
161		confOptions: &confOptions{
162			direction: DirectionForward,
163			keyMap:    DefaultKeyMap(),
164			focused:   true,
165		},
166		items:         csync.NewSliceFrom(items),
167		indexMap:      csync.NewMap[string, int](),
168		renderedItems: csync.NewMap[string, renderedItem](),
169	}
170	for _, opt := range opts {
171		opt(list.confOptions)
172	}
173
174	for inx, item := range items {
175		if i, ok := any(item).(Indexable); ok {
176			i.SetIndex(inx)
177		}
178		list.indexMap.Set(item.ID(), inx)
179	}
180	return list
181}
182
183// Init implements List.
184func (l *list[T]) Init() tea.Cmd {
185	return l.render()
186}
187
188// Update implements List.
189func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190	switch msg := msg.(type) {
191	case anim.StepMsg:
192		var cmds []tea.Cmd
193		for _, item := range l.items.Slice() {
194			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
195				updated, cmd := i.Update(msg)
196				cmds = append(cmds, cmd)
197				if u, ok := updated.(T); ok {
198					cmds = append(cmds, l.UpdateItem(u.ID(), u))
199				}
200			}
201		}
202		return l, tea.Batch(cmds...)
203	case tea.KeyPressMsg:
204		if l.focused {
205			switch {
206			case key.Matches(msg, l.keyMap.Down):
207				return l, l.MoveDown(ViewportDefaultScrollSize)
208			case key.Matches(msg, l.keyMap.Up):
209				return l, l.MoveUp(ViewportDefaultScrollSize)
210			case key.Matches(msg, l.keyMap.DownOneItem):
211				return l, l.SelectItemBelow()
212			case key.Matches(msg, l.keyMap.UpOneItem):
213				return l, l.SelectItemAbove()
214			case key.Matches(msg, l.keyMap.HalfPageDown):
215				return l, l.MoveDown(l.height / 2)
216			case key.Matches(msg, l.keyMap.HalfPageUp):
217				return l, l.MoveUp(l.height / 2)
218			case key.Matches(msg, l.keyMap.PageDown):
219				return l, l.MoveDown(l.height)
220			case key.Matches(msg, l.keyMap.PageUp):
221				return l, l.MoveUp(l.height)
222			case key.Matches(msg, l.keyMap.End):
223				return l, l.GoToBottom()
224			case key.Matches(msg, l.keyMap.Home):
225				return l, l.GoToTop()
226			}
227		}
228	}
229	return l, nil
230}
231
232// View implements List.
233func (l *list[T]) View() string {
234	if l.height <= 0 || l.width <= 0 {
235		return ""
236	}
237	t := styles.CurrentTheme()
238	view := l.rendered
239	lines := strings.Split(view, "\n")
240
241	start, end := l.viewPosition()
242	viewStart := max(0, start)
243	viewEnd := min(len(lines), end+1)
244	lines = lines[viewStart:viewEnd]
245	if l.resize {
246		return strings.Join(lines, "\n")
247	}
248	return t.S().Base.
249		Height(l.height).
250		Width(l.width).
251		Render(strings.Join(lines, "\n"))
252}
253
254func (l *list[T]) viewPosition() (int, int) {
255	start, end := 0, 0
256	renderedLines := lipgloss.Height(l.rendered) - 1
257	if l.direction == DirectionForward {
258		start = max(0, l.offset)
259		end = min(l.offset+l.height-1, renderedLines)
260	} else {
261		start = max(0, renderedLines-l.offset-l.height+1)
262		end = max(0, renderedLines-l.offset)
263	}
264	return start, end
265}
266
267func (l *list[T]) recalculateItemPositions() {
268	currentContentHeight := 0
269	for _, item := range l.items.Slice() {
270		rItem, ok := l.renderedItems.Get(item.ID())
271		if !ok {
272			continue
273		}
274		rItem.start = currentContentHeight
275		rItem.end = currentContentHeight + rItem.height - 1
276		l.renderedItems.Set(item.ID(), rItem)
277		currentContentHeight = rItem.end + 1 + l.gap
278	}
279}
280
281func (l *list[T]) render() tea.Cmd {
282	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
283		return nil
284	}
285	l.setDefaultSelected()
286
287	var focusChangeCmd tea.Cmd
288	if l.focused {
289		focusChangeCmd = l.focusSelectedItem()
290	} else {
291		focusChangeCmd = l.blurSelectedItem()
292	}
293	// we are not rendering the first time
294	if l.rendered != "" {
295		l.rendered = ""
296		// rerender everything will mostly hit cache
297		_ = l.renderIterator(0, false)
298		if l.direction == DirectionBackward {
299			l.recalculateItemPositions()
300		}
301		// in the end scroll to the selected item
302		if l.focused {
303			l.scrollToSelection()
304		}
305		return focusChangeCmd
306	}
307	finishIndex := l.renderIterator(0, true)
308	// recalculate for the initial items
309	if l.direction == DirectionBackward {
310		l.recalculateItemPositions()
311	}
312	renderCmd := func() tea.Msg {
313		// render the rest
314		_ = l.renderIterator(finishIndex, false)
315		// needed for backwards
316		if l.direction == DirectionBackward {
317			l.recalculateItemPositions()
318		}
319		// in the end scroll to the selected item
320		if l.focused {
321			l.scrollToSelection()
322		}
323
324		return renderedMsg{}
325	}
326	return tea.Batch(focusChangeCmd, renderCmd)
327}
328
329func (l *list[T]) setDefaultSelected() {
330	if l.selectedItem == "" {
331		if l.direction == DirectionForward {
332			l.selectFirstItem()
333		} else {
334			l.selectLastItem()
335		}
336	}
337}
338
339func (l *list[T]) scrollToSelection() {
340	rItem, ok := l.renderedItems.Get(l.selectedItem)
341	if !ok {
342		l.selectedItem = ""
343		l.setDefaultSelected()
344		return
345	}
346
347	start, end := l.viewPosition()
348	// item bigger or equal to the viewport do nothing
349	if rItem.start <= start && rItem.end >= end {
350		return
351	}
352	// if we are moving by item we want to move the offset so that the
353	// whole item is visible not just portions of it
354	if l.movingByItem {
355		if rItem.start >= start && rItem.end <= end {
356			return
357		}
358		defer func() { l.movingByItem = false }()
359	} else {
360		// item already in view do nothing
361		if rItem.start >= start && rItem.start <= end {
362			return
363		}
364		if rItem.end >= start && rItem.end <= end {
365			return
366		}
367	}
368
369	if rItem.height >= l.height {
370		if l.direction == DirectionForward {
371			l.offset = rItem.start
372		} else {
373			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
374		}
375		return
376	}
377
378	renderedLines := lipgloss.Height(l.rendered) - 1
379
380	// If item is above the viewport, make it the first item
381	if rItem.start < start {
382		if l.direction == DirectionForward {
383			l.offset = rItem.start
384		} else {
385			l.offset = max(0, renderedLines-rItem.start-l.height+1)
386		}
387	} else if rItem.end > end {
388		// If item is below the viewport, make it the last item
389		if l.direction == DirectionForward {
390			l.offset = max(0, rItem.end-l.height+1)
391		} else {
392			l.offset = max(0, renderedLines-rItem.end)
393		}
394	}
395}
396
397func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
398	rItem, ok := l.renderedItems.Get(l.selectedItem)
399	if !ok {
400		return nil
401	}
402	start, end := l.viewPosition()
403	// item bigger than the viewport do nothing
404	if rItem.start <= start && rItem.end >= end {
405		return nil
406	}
407	// item already in view do nothing
408	if rItem.start >= start && rItem.end <= end {
409		return nil
410	}
411
412	itemMiddle := rItem.start + rItem.height/2
413
414	if itemMiddle < start {
415		// select the first item in the viewport
416		// the item is most likely an item coming after this item
417		inx, ok := l.indexMap.Get(rItem.id)
418		if !ok {
419			return nil
420		}
421		for {
422			inx = l.firstSelectableItemBelow(inx)
423			if inx == ItemNotFound {
424				return nil
425			}
426			item, ok := l.items.Get(inx)
427			if !ok {
428				continue
429			}
430			renderedItem, ok := l.renderedItems.Get(item.ID())
431			if !ok {
432				continue
433			}
434
435			// If the item is bigger than the viewport, select it
436			if renderedItem.start <= start && renderedItem.end >= end {
437				l.selectedItem = renderedItem.id
438				return l.render()
439			}
440			// item is in the view
441			if renderedItem.start >= start && renderedItem.start <= end {
442				l.selectedItem = renderedItem.id
443				return l.render()
444			}
445		}
446	} else if itemMiddle > end {
447		// select the first item in the viewport
448		// the item is most likely an item coming after this item
449		inx, ok := l.indexMap.Get(rItem.id)
450		if !ok {
451			return nil
452		}
453		for {
454			inx = l.firstSelectableItemAbove(inx)
455			if inx == ItemNotFound {
456				return nil
457			}
458			item, ok := l.items.Get(inx)
459			if !ok {
460				continue
461			}
462			renderedItem, ok := l.renderedItems.Get(item.ID())
463			if !ok {
464				continue
465			}
466
467			// If the item is bigger than the viewport, select it
468			if renderedItem.start <= start && renderedItem.end >= end {
469				l.selectedItem = renderedItem.id
470				return l.render()
471			}
472			// item is in the view
473			if renderedItem.end >= start && renderedItem.end <= end {
474				l.selectedItem = renderedItem.id
475				return l.render()
476			}
477		}
478	}
479	return nil
480}
481
482func (l *list[T]) selectFirstItem() {
483	inx := l.firstSelectableItemBelow(-1)
484	if inx != ItemNotFound {
485		item, ok := l.items.Get(inx)
486		if ok {
487			l.selectedItem = item.ID()
488		}
489	}
490}
491
492func (l *list[T]) selectLastItem() {
493	inx := l.firstSelectableItemAbove(l.items.Len())
494	if inx != ItemNotFound {
495		item, ok := l.items.Get(inx)
496		if ok {
497			l.selectedItem = item.ID()
498		}
499	}
500}
501
502func (l *list[T]) firstSelectableItemAbove(inx int) int {
503	for i := inx - 1; i >= 0; i-- {
504		item, ok := l.items.Get(i)
505		if !ok {
506			continue
507		}
508		if _, ok := any(item).(layout.Focusable); ok {
509			return i
510		}
511	}
512	if inx == 0 && l.wrap {
513		return l.firstSelectableItemAbove(l.items.Len())
514	}
515	return ItemNotFound
516}
517
518func (l *list[T]) firstSelectableItemBelow(inx int) int {
519	itemsLen := l.items.Len()
520	for i := inx + 1; i < itemsLen; i++ {
521		item, ok := l.items.Get(i)
522		if !ok {
523			continue
524		}
525		if _, ok := any(item).(layout.Focusable); ok {
526			return i
527		}
528	}
529	if inx == itemsLen-1 && l.wrap {
530		return l.firstSelectableItemBelow(-1)
531	}
532	return ItemNotFound
533}
534
535func (l *list[T]) focusSelectedItem() tea.Cmd {
536	if l.selectedItem == "" || !l.focused {
537		return nil
538	}
539	var cmds []tea.Cmd
540	for _, item := range l.items.Slice() {
541		if f, ok := any(item).(layout.Focusable); ok {
542			if item.ID() == l.selectedItem && !f.IsFocused() {
543				cmds = append(cmds, f.Focus())
544				l.renderedItems.Del(item.ID())
545			} else if item.ID() != l.selectedItem && f.IsFocused() {
546				cmds = append(cmds, f.Blur())
547				l.renderedItems.Del(item.ID())
548			}
549		}
550	}
551	return tea.Batch(cmds...)
552}
553
554func (l *list[T]) blurSelectedItem() tea.Cmd {
555	if l.selectedItem == "" || l.focused {
556		return nil
557	}
558	var cmds []tea.Cmd
559	for _, item := range l.items.Slice() {
560		if f, ok := any(item).(layout.Focusable); ok {
561			if item.ID() == l.selectedItem && f.IsFocused() {
562				cmds = append(cmds, f.Blur())
563				l.renderedItems.Del(item.ID())
564			}
565		}
566	}
567	return tea.Batch(cmds...)
568}
569
570// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
571// returns the last index
572func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
573	currentContentHeight := lipgloss.Height(l.rendered) - 1
574	itemsLen := l.items.Len()
575	for i := startInx; i < itemsLen; i++ {
576		if currentContentHeight >= l.height && limitHeight {
577			return i
578		}
579		// cool way to go through the list in both directions
580		inx := i
581
582		if l.direction != DirectionForward {
583			inx = (itemsLen - 1) - i
584		}
585
586		item, ok := l.items.Get(inx)
587		if !ok {
588			continue
589		}
590		var rItem renderedItem
591		if cache, ok := l.renderedItems.Get(item.ID()); ok {
592			rItem = cache
593		} else {
594			rItem = l.renderItem(item)
595			rItem.start = currentContentHeight
596			rItem.end = currentContentHeight + rItem.height - 1
597			l.renderedItems.Set(item.ID(), rItem)
598		}
599		gap := l.gap + 1
600		if inx == itemsLen-1 {
601			gap = 0
602		}
603
604		if l.direction == DirectionForward {
605			l.rendered += rItem.view + strings.Repeat("\n", gap)
606		} else {
607			l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
608		}
609		currentContentHeight = rItem.end + 1 + l.gap
610	}
611	return itemsLen
612}
613
614func (l *list[T]) renderItem(item Item) renderedItem {
615	view := item.View()
616	return renderedItem{
617		id:     item.ID(),
618		view:   view,
619		height: lipgloss.Height(view),
620	}
621}
622
623// AppendItem implements List.
624func (l *list[T]) AppendItem(item T) tea.Cmd {
625	var cmds []tea.Cmd
626	cmd := item.Init()
627	if cmd != nil {
628		cmds = append(cmds, cmd)
629	}
630
631	l.items.Append(item)
632	l.indexMap = csync.NewMap[string, int]()
633	for inx, item := range l.items.Slice() {
634		l.indexMap.Set(item.ID(), inx)
635	}
636	if l.width > 0 && l.height > 0 {
637		cmd = item.SetSize(l.width, l.height)
638		if cmd != nil {
639			cmds = append(cmds, cmd)
640		}
641	}
642	cmd = l.render()
643	if cmd != nil {
644		cmds = append(cmds, cmd)
645	}
646	if l.direction == DirectionBackward {
647		if l.offset == 0 {
648			cmd = l.GoToBottom()
649			if cmd != nil {
650				cmds = append(cmds, cmd)
651			}
652		} else {
653			newItem, ok := l.renderedItems.Get(item.ID())
654			if ok {
655				newLines := newItem.height
656				if l.items.Len() > 1 {
657					newLines += l.gap
658				}
659				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
660			}
661		}
662	}
663	return tea.Sequence(cmds...)
664}
665
666// Blur implements List.
667func (l *list[T]) Blur() tea.Cmd {
668	l.focused = false
669	return l.render()
670}
671
672// DeleteItem implements List.
673func (l *list[T]) DeleteItem(id string) tea.Cmd {
674	inx, ok := l.indexMap.Get(id)
675	if !ok {
676		return nil
677	}
678	l.items.Delete(inx)
679	l.renderedItems.Del(id)
680	for inx, item := range l.items.Slice() {
681		l.indexMap.Set(item.ID(), inx)
682	}
683
684	if l.selectedItem == id {
685		if inx > 0 {
686			item, ok := l.items.Get(inx - 1)
687			if ok {
688				l.selectedItem = item.ID()
689			} else {
690				l.selectedItem = ""
691			}
692		} else {
693			l.selectedItem = ""
694		}
695	}
696	cmd := l.render()
697	if l.rendered != "" {
698		renderedHeight := lipgloss.Height(l.rendered)
699		if renderedHeight <= l.height {
700			l.offset = 0
701		} else {
702			maxOffset := renderedHeight - l.height
703			if l.offset > maxOffset {
704				l.offset = maxOffset
705			}
706		}
707	}
708	return cmd
709}
710
711// Focus implements List.
712func (l *list[T]) Focus() tea.Cmd {
713	l.focused = true
714	return l.render()
715}
716
717// GetSize implements List.
718func (l *list[T]) GetSize() (int, int) {
719	return l.width, l.height
720}
721
722// GoToBottom implements List.
723func (l *list[T]) GoToBottom() tea.Cmd {
724	l.offset = 0
725	l.direction = DirectionBackward
726	l.selectedItem = ""
727	return l.render()
728}
729
730// GoToTop implements List.
731func (l *list[T]) GoToTop() tea.Cmd {
732	l.offset = 0
733	l.direction = DirectionForward
734	l.selectedItem = ""
735	return l.render()
736}
737
738// IsFocused implements List.
739func (l *list[T]) IsFocused() bool {
740	return l.focused
741}
742
743// Items implements List.
744func (l *list[T]) Items() []T {
745	return l.items.Slice()
746}
747
748func (l *list[T]) incrementOffset(n int) {
749	renderedHeight := lipgloss.Height(l.rendered)
750	// no need for offset
751	if renderedHeight <= l.height {
752		return
753	}
754	maxOffset := renderedHeight - l.height
755	n = min(n, maxOffset-l.offset)
756	if n <= 0 {
757		return
758	}
759	l.offset += n
760}
761
762func (l *list[T]) decrementOffset(n int) {
763	n = min(n, l.offset)
764	if n <= 0 {
765		return
766	}
767	l.offset -= n
768	if l.offset < 0 {
769		l.offset = 0
770	}
771}
772
773// MoveDown implements List.
774func (l *list[T]) MoveDown(n int) tea.Cmd {
775	if l.direction == DirectionForward {
776		l.incrementOffset(n)
777	} else {
778		l.decrementOffset(n)
779	}
780	return l.changeSelectionWhenScrolling()
781}
782
783// MoveUp implements List.
784func (l *list[T]) MoveUp(n int) tea.Cmd {
785	if l.direction == DirectionForward {
786		l.decrementOffset(n)
787	} else {
788		l.incrementOffset(n)
789	}
790	return l.changeSelectionWhenScrolling()
791}
792
793// PrependItem implements List.
794func (l *list[T]) PrependItem(item T) tea.Cmd {
795	cmds := []tea.Cmd{
796		item.Init(),
797	}
798	l.items.Prepend(item)
799	l.indexMap = csync.NewMap[string, int]()
800	for inx, item := range l.items.Slice() {
801		l.indexMap.Set(item.ID(), inx)
802	}
803	if l.width > 0 && l.height > 0 {
804		cmds = append(cmds, item.SetSize(l.width, l.height))
805	}
806	cmds = append(cmds, l.render())
807	if l.direction == DirectionForward {
808		if l.offset == 0 {
809			cmd := l.GoToTop()
810			if cmd != nil {
811				cmds = append(cmds, cmd)
812			}
813		} else {
814			newItem, ok := l.renderedItems.Get(item.ID())
815			if ok {
816				newLines := newItem.height
817				if l.items.Len() > 1 {
818					newLines += l.gap
819				}
820				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
821			}
822		}
823	}
824	return tea.Batch(cmds...)
825}
826
827// SelectItemAbove implements List.
828func (l *list[T]) SelectItemAbove() tea.Cmd {
829	inx, ok := l.indexMap.Get(l.selectedItem)
830	if !ok {
831		return nil
832	}
833
834	newIndex := l.firstSelectableItemAbove(inx)
835	if newIndex == ItemNotFound {
836		// no item above
837		return nil
838	}
839	var cmds []tea.Cmd
840	if newIndex == 1 {
841		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
842		if peakAboveIndex == ItemNotFound {
843			// this means there is a section above move to the top
844			cmd := l.GoToTop()
845			if cmd != nil {
846				cmds = append(cmds, cmd)
847			}
848		}
849
850	}
851	item, ok := l.items.Get(newIndex)
852	if !ok {
853		return nil
854	}
855	l.selectedItem = item.ID()
856	l.movingByItem = true
857	renderCmd := l.render()
858	if renderCmd != nil {
859		cmds = append(cmds, renderCmd)
860	}
861	return tea.Sequence(cmds...)
862}
863
864// SelectItemBelow implements List.
865func (l *list[T]) SelectItemBelow() tea.Cmd {
866	inx, ok := l.indexMap.Get(l.selectedItem)
867	if !ok {
868		return nil
869	}
870
871	newIndex := l.firstSelectableItemBelow(inx)
872	if newIndex == ItemNotFound {
873		// no item above
874		return nil
875	}
876	item, ok := l.items.Get(newIndex)
877	if !ok {
878		return nil
879	}
880	l.selectedItem = item.ID()
881	l.movingByItem = true
882	return l.render()
883}
884
885// SelectedItem implements List.
886func (l *list[T]) SelectedItem() *T {
887	inx, ok := l.indexMap.Get(l.selectedItem)
888	if !ok {
889		return nil
890	}
891	if inx > l.items.Len()-1 {
892		return nil
893	}
894	item, ok := l.items.Get(inx)
895	if !ok {
896		return nil
897	}
898	return &item
899}
900
901// SetItems implements List.
902func (l *list[T]) SetItems(items []T) tea.Cmd {
903	l.items.SetSlice(items)
904	var cmds []tea.Cmd
905	for inx, item := range l.items.Slice() {
906		if i, ok := any(item).(Indexable); ok {
907			i.SetIndex(inx)
908		}
909		cmds = append(cmds, item.Init())
910	}
911	cmds = append(cmds, l.reset(""))
912	return tea.Batch(cmds...)
913}
914
915// SetSelected implements List.
916func (l *list[T]) SetSelected(id string) tea.Cmd {
917	l.selectedItem = id
918	return l.render()
919}
920
921func (l *list[T]) reset(selectedItem string) tea.Cmd {
922	var cmds []tea.Cmd
923	l.rendered = ""
924	l.offset = 0
925	l.selectedItem = selectedItem
926	l.indexMap = csync.NewMap[string, int]()
927	l.renderedItems = csync.NewMap[string, renderedItem]()
928	for inx, item := range l.items.Slice() {
929		l.indexMap.Set(item.ID(), inx)
930		if l.width > 0 && l.height > 0 {
931			cmds = append(cmds, item.SetSize(l.width, l.height))
932		}
933	}
934	cmds = append(cmds, l.render())
935	return tea.Batch(cmds...)
936}
937
938// SetSize implements List.
939func (l *list[T]) SetSize(width int, height int) tea.Cmd {
940	oldWidth := l.width
941	l.width = width
942	l.height = height
943	if oldWidth != width {
944		cmd := l.reset(l.selectedItem)
945		return cmd
946	}
947	return nil
948}
949
950// UpdateItem implements List.
951func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
952	var cmds []tea.Cmd
953	if inx, ok := l.indexMap.Get(id); ok {
954		l.items.Set(inx, item)
955		oldItem, hasOldItem := l.renderedItems.Get(id)
956		oldPosition := l.offset
957		if l.direction == DirectionBackward {
958			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
959		}
960
961		l.renderedItems.Del(id)
962		cmd := l.render()
963
964		// need to check for nil because of sequence not handling nil
965		if cmd != nil {
966			cmds = append(cmds, cmd)
967		}
968		if hasOldItem && l.direction == DirectionBackward {
969			// if we are the last item and there is no offset
970			// make sure to go to the bottom
971			if inx == l.items.Len()-1 && l.offset == 0 {
972				cmd = l.GoToBottom()
973				if cmd != nil {
974					cmds = append(cmds, cmd)
975				}
976				// if the item is at least partially below the viewport
977			} else if oldPosition < oldItem.end {
978				newItem, ok := l.renderedItems.Get(item.ID())
979				if ok {
980					newLines := newItem.height - oldItem.height
981					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
982				}
983			}
984		} else if hasOldItem && l.offset > oldItem.start {
985			newItem, ok := l.renderedItems.Get(item.ID())
986			if ok {
987				newLines := newItem.height - oldItem.height
988				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
989			}
990		}
991	}
992	return tea.Sequence(cmds...)
993}