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