list.go

  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/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 map[string]int
 88	items    []T
 89
 90	renderedItems 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:         items,
167		indexMap:      make(map[string]int),
168		renderedItems: map[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[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 {
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 {
270		rItem, ok := l.renderedItems[item.ID()]
271		if !ok {
272			continue
273		}
274		rItem.start = currentContentHeight
275		rItem.end = currentContentHeight + rItem.height - 1
276		l.renderedItems[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 || len(l.items) == 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[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[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 := l.indexMap[rItem.id]
418		for {
419			inx = l.firstSelectableItemBelow(inx)
420			if inx == ItemNotFound {
421				return nil
422			}
423			item, ok := l.renderedItems[l.items[inx].ID()]
424			if !ok {
425				continue
426			}
427
428			// If the item is bigger than the viewport, select it
429			if item.start <= start && item.end >= end {
430				l.selectedItem = item.id
431				return l.render()
432			}
433			// item is in the view
434			if item.start >= start && item.start <= end {
435				l.selectedItem = item.id
436				return l.render()
437			}
438		}
439	} else if itemMiddle > end {
440		// select the first item in the viewport
441		// the item is most likely an item coming after this item
442		inx := l.indexMap[rItem.id]
443		for {
444			inx = l.firstSelectableItemAbove(inx)
445			if inx == ItemNotFound {
446				return nil
447			}
448			item, ok := l.renderedItems[l.items[inx].ID()]
449			if !ok {
450				continue
451			}
452
453			// If the item is bigger than the viewport, select it
454			if item.start <= start && item.end >= end {
455				l.selectedItem = item.id
456				return l.render()
457			}
458			// item is in the view
459			if item.end >= start && item.end <= end {
460				l.selectedItem = item.id
461				return l.render()
462			}
463		}
464	}
465	return nil
466}
467
468func (l *list[T]) selectFirstItem() {
469	inx := l.firstSelectableItemBelow(-1)
470	if inx != ItemNotFound {
471		l.selectedItem = l.items[inx].ID()
472	}
473}
474
475func (l *list[T]) selectLastItem() {
476	inx := l.firstSelectableItemAbove(len(l.items))
477	if inx != ItemNotFound {
478		l.selectedItem = l.items[inx].ID()
479	}
480}
481
482func (l *list[T]) firstSelectableItemAbove(inx int) int {
483	for i := inx - 1; i >= 0; i-- {
484		if _, ok := any(l.items[i]).(layout.Focusable); ok {
485			return i
486		}
487	}
488	if inx == 0 && l.wrap {
489		return l.firstSelectableItemAbove(len(l.items))
490	}
491	return ItemNotFound
492}
493
494func (l *list[T]) firstSelectableItemBelow(inx int) int {
495	for i := inx + 1; i < len(l.items); i++ {
496		if _, ok := any(l.items[i]).(layout.Focusable); ok {
497			return i
498		}
499	}
500	if inx == len(l.items)-1 && l.wrap {
501		return l.firstSelectableItemBelow(-1)
502	}
503	return ItemNotFound
504}
505
506func (l *list[T]) focusSelectedItem() tea.Cmd {
507	if l.selectedItem == "" || !l.focused {
508		return nil
509	}
510	var cmds []tea.Cmd
511	for _, item := range l.items {
512		if f, ok := any(item).(layout.Focusable); ok {
513			if item.ID() == l.selectedItem && !f.IsFocused() {
514				cmds = append(cmds, f.Focus())
515				delete(l.renderedItems, item.ID())
516			} else if item.ID() != l.selectedItem && f.IsFocused() {
517				cmds = append(cmds, f.Blur())
518				delete(l.renderedItems, item.ID())
519			}
520		}
521	}
522	return tea.Batch(cmds...)
523}
524
525func (l *list[T]) blurSelectedItem() tea.Cmd {
526	if l.selectedItem == "" || l.focused {
527		return nil
528	}
529	var cmds []tea.Cmd
530	for _, item := range l.items {
531		if f, ok := any(item).(layout.Focusable); ok {
532			if item.ID() == l.selectedItem && f.IsFocused() {
533				cmds = append(cmds, f.Blur())
534				delete(l.renderedItems, item.ID())
535			}
536		}
537	}
538	return tea.Batch(cmds...)
539}
540
541// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
542// returns the last index
543func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
544	currentContentHeight := lipgloss.Height(l.rendered) - 1
545	for i := startInx; i < len(l.items); i++ {
546		if currentContentHeight >= l.height && limitHeight {
547			return i
548		}
549		// cool way to go through the list in both directions
550		inx := i
551
552		if l.direction != DirectionForward {
553			inx = (len(l.items) - 1) - i
554		}
555
556		item := l.items[inx]
557		var rItem renderedItem
558		if cache, ok := l.renderedItems[item.ID()]; ok {
559			rItem = cache
560		} else {
561			rItem = l.renderItem(item)
562			rItem.start = currentContentHeight
563			rItem.end = currentContentHeight + rItem.height - 1
564			l.renderedItems[item.ID()] = rItem
565		}
566		gap := l.gap + 1
567		if inx == len(l.items)-1 {
568			gap = 0
569		}
570
571		if l.direction == DirectionForward {
572			l.rendered += rItem.view + strings.Repeat("\n", gap)
573		} else {
574			l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
575		}
576		currentContentHeight = rItem.end + 1 + l.gap
577	}
578	return len(l.items)
579}
580
581func (l *list[T]) renderItem(item Item) renderedItem {
582	view := item.View()
583	return renderedItem{
584		id:     item.ID(),
585		view:   view,
586		height: lipgloss.Height(view),
587	}
588}
589
590// AppendItem implements List.
591func (l *list[T]) AppendItem(item T) tea.Cmd {
592	var cmds []tea.Cmd
593	cmd := item.Init()
594	if cmd != nil {
595		cmds = append(cmds, cmd)
596	}
597
598	l.items = append(l.items, item)
599	l.indexMap = make(map[string]int)
600	for inx, item := range l.items {
601		l.indexMap[item.ID()] = inx
602	}
603	if l.width > 0 && l.height > 0 {
604		cmd = item.SetSize(l.width, l.height)
605		if cmd != nil {
606			cmds = append(cmds, cmd)
607		}
608	}
609	cmd = l.render()
610	if cmd != nil {
611		cmds = append(cmds, cmd)
612	}
613	if l.direction == DirectionBackward {
614		if l.offset == 0 {
615			cmd = l.GoToBottom()
616			if cmd != nil {
617				cmds = append(cmds, cmd)
618			}
619		} else {
620			newItem := l.renderedItems[item.ID()]
621			newLines := newItem.height
622			if len(l.items) > 1 {
623				newLines += l.gap
624			}
625			l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
626		}
627	}
628	return tea.Sequence(cmds...)
629}
630
631// Blur implements List.
632func (l *list[T]) Blur() tea.Cmd {
633	l.focused = false
634	return l.render()
635}
636
637// DeleteItem implements List.
638func (l *list[T]) DeleteItem(id string) tea.Cmd {
639	inx := l.indexMap[id]
640	l.items = slices.Delete(l.items, inx, inx+1)
641	delete(l.renderedItems, id)
642	for inx, item := range l.items {
643		l.indexMap[item.ID()] = inx
644	}
645
646	if l.selectedItem == id {
647		if inx > 0 {
648			l.selectedItem = l.items[inx-1].ID()
649		} else {
650			l.selectedItem = ""
651		}
652	}
653	cmd := l.render()
654	if l.rendered != "" {
655		renderedHeight := lipgloss.Height(l.rendered)
656		if renderedHeight <= l.height {
657			l.offset = 0
658		} else {
659			maxOffset := renderedHeight - l.height
660			if l.offset > maxOffset {
661				l.offset = maxOffset
662			}
663		}
664	}
665	return cmd
666}
667
668// Focus implements List.
669func (l *list[T]) Focus() tea.Cmd {
670	l.focused = true
671	return l.render()
672}
673
674// GetSize implements List.
675func (l *list[T]) GetSize() (int, int) {
676	return l.width, l.height
677}
678
679// GoToBottom implements List.
680func (l *list[T]) GoToBottom() tea.Cmd {
681	l.offset = 0
682	l.direction = DirectionBackward
683	l.selectedItem = ""
684	return l.render()
685}
686
687// GoToTop implements List.
688func (l *list[T]) GoToTop() tea.Cmd {
689	l.offset = 0
690	l.direction = DirectionForward
691	l.selectedItem = ""
692	return l.render()
693}
694
695// IsFocused implements List.
696func (l *list[T]) IsFocused() bool {
697	return l.focused
698}
699
700// Items implements List.
701func (l *list[T]) Items() []T {
702	return l.items
703}
704
705func (l *list[T]) incrementOffset(n int) {
706	renderedHeight := lipgloss.Height(l.rendered)
707	// no need for offset
708	if renderedHeight <= l.height {
709		return
710	}
711	maxOffset := renderedHeight - l.height
712	n = min(n, maxOffset-l.offset)
713	if n <= 0 {
714		return
715	}
716	l.offset += n
717}
718
719func (l *list[T]) decrementOffset(n int) {
720	n = min(n, l.offset)
721	if n <= 0 {
722		return
723	}
724	l.offset -= n
725	if l.offset < 0 {
726		l.offset = 0
727	}
728}
729
730// MoveDown implements List.
731func (l *list[T]) MoveDown(n int) tea.Cmd {
732	if l.direction == DirectionForward {
733		l.incrementOffset(n)
734	} else {
735		l.decrementOffset(n)
736	}
737	return l.changeSelectionWhenScrolling()
738}
739
740// MoveUp implements List.
741func (l *list[T]) MoveUp(n int) tea.Cmd {
742	if l.direction == DirectionForward {
743		l.decrementOffset(n)
744	} else {
745		l.incrementOffset(n)
746	}
747	return l.changeSelectionWhenScrolling()
748}
749
750// PrependItem implements List.
751func (l *list[T]) PrependItem(item T) tea.Cmd {
752	cmds := []tea.Cmd{
753		item.Init(),
754	}
755	l.items = append([]T{item}, l.items...)
756	l.indexMap = make(map[string]int)
757	for inx, item := range l.items {
758		l.indexMap[item.ID()] = inx
759	}
760	if l.width > 0 && l.height > 0 {
761		cmds = append(cmds, item.SetSize(l.width, l.height))
762	}
763	cmds = append(cmds, l.render())
764	if l.direction == DirectionForward {
765		if l.offset == 0 {
766			cmd := l.GoToTop()
767			if cmd != nil {
768				cmds = append(cmds, cmd)
769			}
770		} else {
771			newItem := l.renderedItems[item.ID()]
772			newLines := newItem.height
773			if len(l.items) > 1 {
774				newLines += l.gap
775			}
776			l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
777		}
778	}
779	return tea.Batch(cmds...)
780}
781
782// SelectItemAbove implements List.
783func (l *list[T]) SelectItemAbove() tea.Cmd {
784	inx, ok := l.indexMap[l.selectedItem]
785	if !ok {
786		return nil
787	}
788
789	newIndex := l.firstSelectableItemAbove(inx)
790	if newIndex == ItemNotFound {
791		// no item above
792		return nil
793	}
794	var cmds []tea.Cmd
795	if newIndex == 1 {
796		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
797		if peakAboveIndex == ItemNotFound {
798			// this means there is a section above move to the top
799			cmd := l.GoToTop()
800			if cmd != nil {
801				cmds = append(cmds, cmd)
802			}
803		}
804
805	}
806	item := l.items[newIndex]
807	l.selectedItem = item.ID()
808	l.movingByItem = true
809	renderCmd := l.render()
810	if renderCmd != nil {
811		cmds = append(cmds, renderCmd)
812	}
813	return tea.Sequence(cmds...)
814}
815
816// SelectItemBelow implements List.
817func (l *list[T]) SelectItemBelow() tea.Cmd {
818	inx, ok := l.indexMap[l.selectedItem]
819	if !ok {
820		return nil
821	}
822
823	newIndex := l.firstSelectableItemBelow(inx)
824	if newIndex == ItemNotFound {
825		// no item above
826		return nil
827	}
828	item := l.items[newIndex]
829	l.selectedItem = item.ID()
830	l.movingByItem = true
831	return l.render()
832}
833
834// SelectedItem implements List.
835func (l *list[T]) SelectedItem() *T {
836	inx, ok := l.indexMap[l.selectedItem]
837	if !ok {
838		return nil
839	}
840	if inx > len(l.items)-1 {
841		return nil
842	}
843	item := l.items[inx]
844	return &item
845}
846
847// SetItems implements List.
848func (l *list[T]) SetItems(items []T) tea.Cmd {
849	l.items = items
850	var cmds []tea.Cmd
851	for inx, item := range l.items {
852		if i, ok := any(item).(Indexable); ok {
853			i.SetIndex(inx)
854		}
855		cmds = append(cmds, item.Init())
856	}
857	cmds = append(cmds, l.reset(""))
858	return tea.Batch(cmds...)
859}
860
861// SetSelected implements List.
862func (l *list[T]) SetSelected(id string) tea.Cmd {
863	l.selectedItem = id
864	return l.render()
865}
866
867func (l *list[T]) reset(selectedItem string) tea.Cmd {
868	var cmds []tea.Cmd
869	l.rendered = ""
870	l.offset = 0
871	l.selectedItem = selectedItem
872	l.indexMap = make(map[string]int)
873	l.renderedItems = make(map[string]renderedItem)
874	for inx, item := range l.items {
875		l.indexMap[item.ID()] = inx
876		if l.width > 0 && l.height > 0 {
877			cmds = append(cmds, item.SetSize(l.width, l.height))
878		}
879	}
880	cmds = append(cmds, l.render())
881	return tea.Batch(cmds...)
882}
883
884// SetSize implements List.
885func (l *list[T]) SetSize(width int, height int) tea.Cmd {
886	oldWidth := l.width
887	l.width = width
888	l.height = height
889	if oldWidth != width {
890		cmd := l.reset(l.selectedItem)
891		return cmd
892	}
893	return nil
894}
895
896// UpdateItem implements List.
897func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
898	var cmds []tea.Cmd
899	if inx, ok := l.indexMap[id]; ok {
900		l.items[inx] = item
901		oldItem := l.renderedItems[id]
902		oldPosition := l.offset
903		if l.direction == DirectionBackward {
904			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
905		}
906
907		delete(l.renderedItems, id)
908		cmd := l.render()
909
910		// need to check for nil because of sequence not handling nil
911		if cmd != nil {
912			cmds = append(cmds, cmd)
913		}
914		if l.direction == DirectionBackward {
915			// if we are the last item and there is no offset
916			// make sure to go to the bottom
917			if inx == len(l.items)-1 && l.offset == 0 {
918				cmd = l.GoToBottom()
919				if cmd != nil {
920					cmds = append(cmds, cmd)
921				}
922				// if the item is at least partially below the viewport
923			} else if oldPosition < oldItem.end {
924				newItem := l.renderedItems[item.ID()]
925				newLines := newItem.height - oldItem.height
926				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
927			}
928		} else if l.offset > oldItem.start {
929			newItem := l.renderedItems[item.ID()]
930			newLines := newItem.height - oldItem.height
931			l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
932		}
933	}
934	return tea.Sequence(cmds...)
935}