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