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