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	l.offset = 0
 750	l.direction = DirectionBackward
 751	l.selectedItem = ""
 752	return l.render()
 753}
 754
 755// GoToTop implements List.
 756func (l *list[T]) GoToTop() tea.Cmd {
 757	l.offset = 0
 758	l.direction = DirectionForward
 759	l.selectedItem = ""
 760	return l.render()
 761}
 762
 763// IsFocused implements List.
 764func (l *list[T]) IsFocused() bool {
 765	return l.focused
 766}
 767
 768// Items implements List.
 769func (l *list[T]) Items() []T {
 770	return l.items.Slice()
 771}
 772
 773func (l *list[T]) incrementOffset(n int) {
 774	renderedHeight := lipgloss.Height(l.rendered)
 775	// no need for offset
 776	if renderedHeight <= l.height {
 777		return
 778	}
 779	maxOffset := renderedHeight - l.height
 780	n = min(n, maxOffset-l.offset)
 781	if n <= 0 {
 782		return
 783	}
 784	l.offset += n
 785}
 786
 787func (l *list[T]) decrementOffset(n int) {
 788	n = min(n, l.offset)
 789	if n <= 0 {
 790		return
 791	}
 792	l.offset -= n
 793	if l.offset < 0 {
 794		l.offset = 0
 795	}
 796}
 797
 798// MoveDown implements List.
 799func (l *list[T]) MoveDown(n int) tea.Cmd {
 800	if l.direction == DirectionForward {
 801		l.incrementOffset(n)
 802	} else {
 803		l.decrementOffset(n)
 804	}
 805	return l.changeSelectionWhenScrolling()
 806}
 807
 808// MoveUp implements List.
 809func (l *list[T]) MoveUp(n int) tea.Cmd {
 810	if l.direction == DirectionForward {
 811		l.decrementOffset(n)
 812	} else {
 813		l.incrementOffset(n)
 814	}
 815	return l.changeSelectionWhenScrolling()
 816}
 817
 818// PrependItem implements List.
 819func (l *list[T]) PrependItem(item T) tea.Cmd {
 820	cmds := []tea.Cmd{
 821		item.Init(),
 822	}
 823	l.items.Prepend(item)
 824	l.indexMap = csync.NewMap[string, int]()
 825	for inx, item := range l.items.Slice() {
 826		l.indexMap.Set(item.ID(), inx)
 827	}
 828	if l.width > 0 && l.height > 0 {
 829		cmds = append(cmds, item.SetSize(l.width, l.height))
 830	}
 831	cmds = append(cmds, l.render())
 832	if l.direction == DirectionForward {
 833		if l.offset == 0 {
 834			cmd := l.GoToTop()
 835			if cmd != nil {
 836				cmds = append(cmds, cmd)
 837			}
 838		} else {
 839			newItem, ok := l.renderedItems.Get(item.ID())
 840			if ok {
 841				newLines := newItem.height
 842				if l.items.Len() > 1 {
 843					newLines += l.gap
 844				}
 845				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
 846			}
 847		}
 848	}
 849	return tea.Batch(cmds...)
 850}
 851
 852// SelectItemAbove implements List.
 853func (l *list[T]) SelectItemAbove() tea.Cmd {
 854	inx, ok := l.indexMap.Get(l.selectedItem)
 855	if !ok {
 856		return nil
 857	}
 858
 859	newIndex := l.firstSelectableItemAbove(inx)
 860	if newIndex == ItemNotFound {
 861		// no item above
 862		return nil
 863	}
 864	var cmds []tea.Cmd
 865	if newIndex == 1 {
 866		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
 867		if peakAboveIndex == ItemNotFound {
 868			// this means there is a section above move to the top
 869			cmd := l.GoToTop()
 870			if cmd != nil {
 871				cmds = append(cmds, cmd)
 872			}
 873		}
 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	renderCmd := l.render()
 882	if renderCmd != nil {
 883		cmds = append(cmds, renderCmd)
 884	}
 885	return tea.Sequence(cmds...)
 886}
 887
 888// SelectItemBelow implements List.
 889func (l *list[T]) SelectItemBelow() tea.Cmd {
 890	inx, ok := l.indexMap.Get(l.selectedItem)
 891	if !ok {
 892		return nil
 893	}
 894
 895	newIndex := l.firstSelectableItemBelow(inx)
 896	if newIndex == ItemNotFound {
 897		// no item above
 898		return nil
 899	}
 900	item, ok := l.items.Get(newIndex)
 901	if !ok {
 902		return nil
 903	}
 904	l.selectedItem = item.ID()
 905	l.movingByItem = true
 906	return l.render()
 907}
 908
 909// SelectedItem implements List.
 910func (l *list[T]) SelectedItem() *T {
 911	inx, ok := l.indexMap.Get(l.selectedItem)
 912	if !ok {
 913		return nil
 914	}
 915	if inx > l.items.Len()-1 {
 916		return nil
 917	}
 918	item, ok := l.items.Get(inx)
 919	if !ok {
 920		return nil
 921	}
 922	return &item
 923}
 924
 925// SetItems implements List.
 926func (l *list[T]) SetItems(items []T) tea.Cmd {
 927	l.items.SetSlice(items)
 928	var cmds []tea.Cmd
 929	for inx, item := range l.items.Slice() {
 930		if i, ok := any(item).(Indexable); ok {
 931			i.SetIndex(inx)
 932		}
 933		cmds = append(cmds, item.Init())
 934	}
 935	cmds = append(cmds, l.reset(""))
 936	return tea.Batch(cmds...)
 937}
 938
 939// SetSelected implements List.
 940func (l *list[T]) SetSelected(id string) tea.Cmd {
 941	l.selectedItem = id
 942	return l.render()
 943}
 944
 945func (l *list[T]) reset(selectedItem string) tea.Cmd {
 946	var cmds []tea.Cmd
 947	l.rendered = ""
 948	l.offset = 0
 949	l.selectedItem = selectedItem
 950	l.indexMap = csync.NewMap[string, int]()
 951	l.renderedItems = csync.NewMap[string, renderedItem]()
 952	for inx, item := range l.items.Slice() {
 953		l.indexMap.Set(item.ID(), inx)
 954		if l.width > 0 && l.height > 0 {
 955			cmds = append(cmds, item.SetSize(l.width, l.height))
 956		}
 957	}
 958	cmds = append(cmds, l.render())
 959	return tea.Batch(cmds...)
 960}
 961
 962// SetSize implements List.
 963func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 964	oldWidth := l.width
 965	l.width = width
 966	l.height = height
 967	if oldWidth != width {
 968		cmd := l.reset(l.selectedItem)
 969		return cmd
 970	}
 971	return nil
 972}
 973
 974// UpdateItem implements List.
 975func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
 976	var cmds []tea.Cmd
 977	if inx, ok := l.indexMap.Get(id); ok {
 978		l.items.Set(inx, item)
 979		oldItem, hasOldItem := l.renderedItems.Get(id)
 980		oldPosition := l.offset
 981		if l.direction == DirectionBackward {
 982			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
 983		}
 984
 985		l.renderedItems.Del(id)
 986		cmd := l.render()
 987
 988		// need to check for nil because of sequence not handling nil
 989		if cmd != nil {
 990			cmds = append(cmds, cmd)
 991		}
 992		if hasOldItem && l.direction == DirectionBackward {
 993			// if we are the last item and there is no offset
 994			// make sure to go to the bottom
 995			if inx == l.items.Len()-1 && l.offset == 0 {
 996				cmd = l.GoToBottom()
 997				if cmd != nil {
 998					cmds = append(cmds, cmd)
 999				}
1000				// if the item is at least partially below the viewport
1001			} else if oldPosition < oldItem.end {
1002				newItem, ok := l.renderedItems.Get(item.ID())
1003				if ok {
1004					newLines := newItem.height - oldItem.height
1005					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1006				}
1007			}
1008		} else if hasOldItem && l.offset > oldItem.start {
1009			newItem, ok := l.renderedItems.Get(item.ID())
1010			if ok {
1011				newLines := newItem.height - oldItem.height
1012				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1013			}
1014		}
1015	}
1016	return tea.Sequence(cmds...)
1017}