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