list.go

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