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