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