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	item, ok := l.items.Get(newIndex)
851	if !ok {
852		return nil
853	}
854	l.selectedItem = item.ID()
855	l.movingByItem = true
856	renderCmd := l.render()
857	if renderCmd != nil {
858		cmds = append(cmds, renderCmd)
859	}
860	return tea.Sequence(cmds...)
861}
862
863// SelectItemBelow implements List.
864func (l *list[T]) SelectItemBelow() tea.Cmd {
865	inx, ok := l.indexMap.Get(l.selectedItem)
866	if !ok {
867		return nil
868	}
869
870	newIndex := l.firstSelectableItemBelow(inx)
871	if newIndex == ItemNotFound {
872		// no item above
873		return nil
874	}
875	item, ok := l.items.Get(newIndex)
876	if !ok {
877		return nil
878	}
879	l.selectedItem = item.ID()
880	l.movingByItem = true
881	return l.render()
882}
883
884// SelectedItem implements List.
885func (l *list[T]) SelectedItem() *T {
886	inx, ok := l.indexMap.Get(l.selectedItem)
887	if !ok {
888		return nil
889	}
890	if inx > l.items.Len()-1 {
891		return nil
892	}
893	item, ok := l.items.Get(inx)
894	if !ok {
895		return nil
896	}
897	return &item
898}
899
900// SetItems implements List.
901func (l *list[T]) SetItems(items []T) tea.Cmd {
902	l.items.SetSlice(items)
903	var cmds []tea.Cmd
904	for inx, item := range l.items.Slice() {
905		if i, ok := any(item).(Indexable); ok {
906			i.SetIndex(inx)
907		}
908		cmds = append(cmds, item.Init())
909	}
910	cmds = append(cmds, l.reset(""))
911	return tea.Batch(cmds...)
912}
913
914// SetSelected implements List.
915func (l *list[T]) SetSelected(id string) tea.Cmd {
916	l.selectedItem = id
917	return l.render()
918}
919
920func (l *list[T]) reset(selectedItem string) tea.Cmd {
921	var cmds []tea.Cmd
922	l.rendered = ""
923	l.offset = 0
924	l.selectedItem = selectedItem
925	l.indexMap = csync.NewMap[string, int]()
926	l.renderedItems = csync.NewMap[string, renderedItem]()
927	for inx, item := range l.items.Slice() {
928		l.indexMap.Set(item.ID(), inx)
929		if l.width > 0 && l.height > 0 {
930			cmds = append(cmds, item.SetSize(l.width, l.height))
931		}
932	}
933	cmds = append(cmds, l.render())
934	return tea.Batch(cmds...)
935}
936
937// SetSize implements List.
938func (l *list[T]) SetSize(width int, height int) tea.Cmd {
939	oldWidth := l.width
940	l.width = width
941	l.height = height
942	if oldWidth != width {
943		cmd := l.reset(l.selectedItem)
944		return cmd
945	}
946	return nil
947}
948
949// UpdateItem implements List.
950func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
951	var cmds []tea.Cmd
952	if inx, ok := l.indexMap.Get(id); ok {
953		l.items.Set(inx, item)
954		oldItem, hasOldItem := l.renderedItems.Get(id)
955		oldPosition := l.offset
956		if l.direction == DirectionBackward {
957			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
958		}
959
960		l.renderedItems.Del(id)
961		cmd := l.render()
962
963		// need to check for nil because of sequence not handling nil
964		if cmd != nil {
965			cmds = append(cmds, cmd)
966		}
967		if hasOldItem && l.direction == DirectionBackward {
968			// if we are the last item and there is no offset
969			// make sure to go to the bottom
970			if inx == l.items.Len()-1 && l.offset == 0 {
971				cmd = l.GoToBottom()
972				if cmd != nil {
973					cmds = append(cmds, cmd)
974				}
975				// if the item is at least partially below the viewport
976			} else if oldPosition < oldItem.end {
977				newItem, ok := l.renderedItems.Get(item.ID())
978				if ok {
979					newLines := newItem.height - oldItem.height
980					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
981				}
982			}
983		} else if hasOldItem && l.offset > oldItem.start {
984			newItem, ok := l.renderedItems.Get(item.ID())
985			if ok {
986				newLines := newItem.height - oldItem.height
987				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
988			}
989		}
990	}
991	return tea.Sequence(cmds...)
992}