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