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