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