list.go

   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				cell = cell.Clone()
 419				cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White)
 420				scr.SetCell(x, y, cell)
 421			}
 422		}
 423
 424		if textOnly {
 425			// Make sure we add a newline after each line of selected text
 426			selectedText.WriteByte('\n')
 427		}
 428	}
 429
 430	if textOnly {
 431		return strings.TrimSpace(selectedText.String())
 432	}
 433
 434	return scr.Render()
 435}
 436
 437// View implements List.
 438func (l *list[T]) View() string {
 439	if l.height <= 0 || l.width <= 0 {
 440		return ""
 441	}
 442	t := styles.CurrentTheme()
 443	view := l.rendered
 444	lines := strings.Split(view, "\n")
 445
 446	start, end := l.viewPosition()
 447	viewStart := max(0, start)
 448	viewEnd := min(len(lines), end+1)
 449	lines = lines[viewStart:viewEnd]
 450	if l.resize {
 451		return strings.Join(lines, "\n")
 452	}
 453	view = t.S().Base.
 454		Height(l.height).
 455		Width(l.width).
 456		Render(strings.Join(lines, "\n"))
 457
 458	if !l.hasSelection() {
 459		return view
 460	}
 461
 462	return l.selectionView(view, false)
 463}
 464
 465func (l *list[T]) viewPosition() (int, int) {
 466	start, end := 0, 0
 467	renderedLines := lipgloss.Height(l.rendered) - 1
 468	if l.direction == DirectionForward {
 469		start = max(0, l.offset)
 470		end = min(l.offset+l.height-1, renderedLines)
 471	} else {
 472		start = max(0, renderedLines-l.offset-l.height+1)
 473		end = max(0, renderedLines-l.offset)
 474	}
 475	return start, end
 476}
 477
 478func (l *list[T]) recalculateItemPositions() {
 479	currentContentHeight := 0
 480	for _, item := range slices.Collect(l.items.Seq()) {
 481		rItem, ok := l.renderedItems.Get(item.ID())
 482		if !ok {
 483			continue
 484		}
 485		rItem.start = currentContentHeight
 486		rItem.end = currentContentHeight + rItem.height - 1
 487		l.renderedItems.Set(item.ID(), rItem)
 488		currentContentHeight = rItem.end + 1 + l.gap
 489	}
 490}
 491
 492func (l *list[T]) render() tea.Cmd {
 493	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
 494		return nil
 495	}
 496	l.setDefaultSelected()
 497
 498	var focusChangeCmd tea.Cmd
 499	if l.focused {
 500		focusChangeCmd = l.focusSelectedItem()
 501	} else {
 502		focusChangeCmd = l.blurSelectedItem()
 503	}
 504	// we are not rendering the first time
 505	if l.rendered != "" {
 506		// rerender everything will mostly hit cache
 507		l.renderMu.Lock()
 508		l.rendered, _ = l.renderIterator(0, false, "")
 509		l.renderMu.Unlock()
 510		if l.direction == DirectionBackward {
 511			l.recalculateItemPositions()
 512		}
 513		// in the end scroll to the selected item
 514		if l.focused {
 515			l.scrollToSelection()
 516		}
 517		return focusChangeCmd
 518	}
 519	l.renderMu.Lock()
 520	rendered, finishIndex := l.renderIterator(0, true, "")
 521	l.rendered = rendered
 522	l.renderMu.Unlock()
 523	// recalculate for the initial items
 524	if l.direction == DirectionBackward {
 525		l.recalculateItemPositions()
 526	}
 527	renderCmd := func() tea.Msg {
 528		l.offset = 0
 529		// render the rest
 530
 531		l.renderMu.Lock()
 532		l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
 533		l.renderMu.Unlock()
 534		// needed for backwards
 535		if l.direction == DirectionBackward {
 536			l.recalculateItemPositions()
 537		}
 538		// in the end scroll to the selected item
 539		if l.focused {
 540			l.scrollToSelection()
 541		}
 542		return nil
 543	}
 544	return tea.Batch(focusChangeCmd, renderCmd)
 545}
 546
 547func (l *list[T]) setDefaultSelected() {
 548	if l.selectedItem == "" {
 549		if l.direction == DirectionForward {
 550			l.selectFirstItem()
 551		} else {
 552			l.selectLastItem()
 553		}
 554	}
 555}
 556
 557func (l *list[T]) scrollToSelection() {
 558	rItem, ok := l.renderedItems.Get(l.selectedItem)
 559	if !ok {
 560		l.selectedItem = ""
 561		l.setDefaultSelected()
 562		return
 563	}
 564
 565	start, end := l.viewPosition()
 566	// item bigger or equal to the viewport do nothing
 567	if rItem.start <= start && rItem.end >= end {
 568		return
 569	}
 570	// if we are moving by item we want to move the offset so that the
 571	// whole item is visible not just portions of it
 572	if l.movingByItem {
 573		if rItem.start >= start && rItem.end <= end {
 574			return
 575		}
 576		defer func() { l.movingByItem = false }()
 577	} else {
 578		// item already in view do nothing
 579		if rItem.start >= start && rItem.start <= end {
 580			return
 581		}
 582		if rItem.end >= start && rItem.end <= end {
 583			return
 584		}
 585	}
 586
 587	if rItem.height >= l.height {
 588		if l.direction == DirectionForward {
 589			l.offset = rItem.start
 590		} else {
 591			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
 592		}
 593		return
 594	}
 595
 596	renderedLines := lipgloss.Height(l.rendered) - 1
 597
 598	// If item is above the viewport, make it the first item
 599	if rItem.start < start {
 600		if l.direction == DirectionForward {
 601			l.offset = rItem.start
 602		} else {
 603			l.offset = max(0, renderedLines-rItem.start-l.height+1)
 604		}
 605	} else if rItem.end > end {
 606		// If item is below the viewport, make it the last item
 607		if l.direction == DirectionForward {
 608			l.offset = max(0, rItem.end-l.height+1)
 609		} else {
 610			l.offset = max(0, renderedLines-rItem.end)
 611		}
 612	}
 613}
 614
 615func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 616	rItem, ok := l.renderedItems.Get(l.selectedItem)
 617	if !ok {
 618		return nil
 619	}
 620	start, end := l.viewPosition()
 621	// item bigger than the viewport do nothing
 622	if rItem.start <= start && rItem.end >= end {
 623		return nil
 624	}
 625	// item already in view do nothing
 626	if rItem.start >= start && rItem.end <= end {
 627		return nil
 628	}
 629
 630	itemMiddle := rItem.start + rItem.height/2
 631
 632	if itemMiddle < start {
 633		// select the first item in the viewport
 634		// the item is most likely an item coming after this item
 635		inx, ok := l.indexMap.Get(rItem.id)
 636		if !ok {
 637			return nil
 638		}
 639		for {
 640			inx = l.firstSelectableItemBelow(inx)
 641			if inx == ItemNotFound {
 642				return nil
 643			}
 644			item, ok := l.items.Get(inx)
 645			if !ok {
 646				continue
 647			}
 648			renderedItem, ok := l.renderedItems.Get(item.ID())
 649			if !ok {
 650				continue
 651			}
 652
 653			// If the item is bigger than the viewport, select it
 654			if renderedItem.start <= start && renderedItem.end >= end {
 655				l.selectedItem = renderedItem.id
 656				return l.render()
 657			}
 658			// item is in the view
 659			if renderedItem.start >= start && renderedItem.start <= end {
 660				l.selectedItem = renderedItem.id
 661				return l.render()
 662			}
 663		}
 664	} else if itemMiddle > end {
 665		// select the first item in the viewport
 666		// the item is most likely an item coming after this item
 667		inx, ok := l.indexMap.Get(rItem.id)
 668		if !ok {
 669			return nil
 670		}
 671		for {
 672			inx = l.firstSelectableItemAbove(inx)
 673			if inx == ItemNotFound {
 674				return nil
 675			}
 676			item, ok := l.items.Get(inx)
 677			if !ok {
 678				continue
 679			}
 680			renderedItem, ok := l.renderedItems.Get(item.ID())
 681			if !ok {
 682				continue
 683			}
 684
 685			// If the item is bigger than the viewport, select it
 686			if renderedItem.start <= start && renderedItem.end >= end {
 687				l.selectedItem = renderedItem.id
 688				return l.render()
 689			}
 690			// item is in the view
 691			if renderedItem.end >= start && renderedItem.end <= end {
 692				l.selectedItem = renderedItem.id
 693				return l.render()
 694			}
 695		}
 696	}
 697	return nil
 698}
 699
 700func (l *list[T]) selectFirstItem() {
 701	inx := l.firstSelectableItemBelow(-1)
 702	if inx != ItemNotFound {
 703		item, ok := l.items.Get(inx)
 704		if ok {
 705			l.selectedItem = item.ID()
 706		}
 707	}
 708}
 709
 710func (l *list[T]) selectLastItem() {
 711	inx := l.firstSelectableItemAbove(l.items.Len())
 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]) firstSelectableItemAbove(inx int) int {
 721	for i := inx - 1; i >= 0; i-- {
 722		item, ok := l.items.Get(i)
 723		if !ok {
 724			continue
 725		}
 726		if _, ok := any(item).(layout.Focusable); ok {
 727			return i
 728		}
 729	}
 730	if inx == 0 && l.wrap {
 731		return l.firstSelectableItemAbove(l.items.Len())
 732	}
 733	return ItemNotFound
 734}
 735
 736func (l *list[T]) firstSelectableItemBelow(inx int) int {
 737	itemsLen := l.items.Len()
 738	for i := inx + 1; i < itemsLen; i++ {
 739		item, ok := l.items.Get(i)
 740		if !ok {
 741			continue
 742		}
 743		if _, ok := any(item).(layout.Focusable); ok {
 744			return i
 745		}
 746	}
 747	if inx == itemsLen-1 && l.wrap {
 748		return l.firstSelectableItemBelow(-1)
 749	}
 750	return ItemNotFound
 751}
 752
 753func (l *list[T]) focusSelectedItem() tea.Cmd {
 754	if l.selectedItem == "" || !l.focused {
 755		return nil
 756	}
 757	var cmds []tea.Cmd
 758	for _, item := range slices.Collect(l.items.Seq()) {
 759		if f, ok := any(item).(layout.Focusable); ok {
 760			if item.ID() == l.selectedItem && !f.IsFocused() {
 761				cmds = append(cmds, f.Focus())
 762				l.renderedItems.Del(item.ID())
 763			} else if item.ID() != l.selectedItem && f.IsFocused() {
 764				cmds = append(cmds, f.Blur())
 765				l.renderedItems.Del(item.ID())
 766			}
 767		}
 768	}
 769	return tea.Batch(cmds...)
 770}
 771
 772func (l *list[T]) blurSelectedItem() tea.Cmd {
 773	if l.selectedItem == "" || l.focused {
 774		return nil
 775	}
 776	var cmds []tea.Cmd
 777	for _, item := range slices.Collect(l.items.Seq()) {
 778		if f, ok := any(item).(layout.Focusable); ok {
 779			if item.ID() == l.selectedItem && f.IsFocused() {
 780				cmds = append(cmds, f.Blur())
 781				l.renderedItems.Del(item.ID())
 782			}
 783		}
 784	}
 785	return tea.Batch(cmds...)
 786}
 787
 788// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
 789// returns the last index and the rendered content so far
 790// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
 791func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
 792	currentContentHeight := lipgloss.Height(rendered) - 1
 793	itemsLen := l.items.Len()
 794	for i := startInx; i < itemsLen; i++ {
 795		if currentContentHeight >= l.height && limitHeight {
 796			return rendered, i
 797		}
 798		// cool way to go through the list in both directions
 799		inx := i
 800
 801		if l.direction != DirectionForward {
 802			inx = (itemsLen - 1) - i
 803		}
 804
 805		item, ok := l.items.Get(inx)
 806		if !ok {
 807			continue
 808		}
 809		var rItem renderedItem
 810		if cache, ok := l.renderedItems.Get(item.ID()); ok {
 811			rItem = cache
 812		} else {
 813			rItem = l.renderItem(item)
 814			rItem.start = currentContentHeight
 815			rItem.end = currentContentHeight + rItem.height - 1
 816			l.renderedItems.Set(item.ID(), rItem)
 817		}
 818		gap := l.gap + 1
 819		if inx == itemsLen-1 {
 820			gap = 0
 821		}
 822
 823		if l.direction == DirectionForward {
 824			rendered += rItem.view + strings.Repeat("\n", gap)
 825		} else {
 826			rendered = rItem.view + strings.Repeat("\n", gap) + rendered
 827		}
 828		currentContentHeight = rItem.end + 1 + l.gap
 829	}
 830	return rendered, itemsLen
 831}
 832
 833func (l *list[T]) renderItem(item Item) renderedItem {
 834	view := item.View()
 835	return renderedItem{
 836		id:     item.ID(),
 837		view:   view,
 838		height: lipgloss.Height(view),
 839	}
 840}
 841
 842// AppendItem implements List.
 843func (l *list[T]) AppendItem(item T) tea.Cmd {
 844	var cmds []tea.Cmd
 845	cmd := item.Init()
 846	if cmd != nil {
 847		cmds = append(cmds, cmd)
 848	}
 849
 850	l.items.Append(item)
 851	l.indexMap = csync.NewMap[string, int]()
 852	for inx, item := range slices.Collect(l.items.Seq()) {
 853		l.indexMap.Set(item.ID(), inx)
 854	}
 855	if l.width > 0 && l.height > 0 {
 856		cmd = item.SetSize(l.width, l.height)
 857		if cmd != nil {
 858			cmds = append(cmds, cmd)
 859		}
 860	}
 861	cmd = l.render()
 862	if cmd != nil {
 863		cmds = append(cmds, cmd)
 864	}
 865	if l.direction == DirectionBackward {
 866		if l.offset == 0 {
 867			cmd = l.GoToBottom()
 868			if cmd != nil {
 869				cmds = append(cmds, cmd)
 870			}
 871		} else {
 872			newItem, ok := l.renderedItems.Get(item.ID())
 873			if ok {
 874				newLines := newItem.height
 875				if l.items.Len() > 1 {
 876					newLines += l.gap
 877				}
 878				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
 879			}
 880		}
 881	}
 882	return tea.Sequence(cmds...)
 883}
 884
 885// Blur implements List.
 886func (l *list[T]) Blur() tea.Cmd {
 887	l.focused = false
 888	return l.render()
 889}
 890
 891// DeleteItem implements List.
 892func (l *list[T]) DeleteItem(id string) tea.Cmd {
 893	inx, ok := l.indexMap.Get(id)
 894	if !ok {
 895		return nil
 896	}
 897	l.items.Delete(inx)
 898	l.renderedItems.Del(id)
 899	for inx, item := range slices.Collect(l.items.Seq()) {
 900		l.indexMap.Set(item.ID(), inx)
 901	}
 902
 903	if l.selectedItem == id {
 904		if inx > 0 {
 905			item, ok := l.items.Get(inx - 1)
 906			if ok {
 907				l.selectedItem = item.ID()
 908			} else {
 909				l.selectedItem = ""
 910			}
 911		} else {
 912			l.selectedItem = ""
 913		}
 914	}
 915	cmd := l.render()
 916	if l.rendered != "" {
 917		renderedHeight := lipgloss.Height(l.rendered)
 918		if renderedHeight <= l.height {
 919			l.offset = 0
 920		} else {
 921			maxOffset := renderedHeight - l.height
 922			if l.offset > maxOffset {
 923				l.offset = maxOffset
 924			}
 925		}
 926	}
 927	return cmd
 928}
 929
 930// Focus implements List.
 931func (l *list[T]) Focus() tea.Cmd {
 932	l.focused = true
 933	return l.render()
 934}
 935
 936// GetSize implements List.
 937func (l *list[T]) GetSize() (int, int) {
 938	return l.width, l.height
 939}
 940
 941// GoToBottom implements List.
 942func (l *list[T]) GoToBottom() tea.Cmd {
 943	l.offset = 0
 944	l.selectedItem = ""
 945	l.direction = DirectionBackward
 946	return l.render()
 947}
 948
 949// GoToTop implements List.
 950func (l *list[T]) GoToTop() tea.Cmd {
 951	l.offset = 0
 952	l.selectedItem = ""
 953	l.direction = DirectionForward
 954	return l.render()
 955}
 956
 957// IsFocused implements List.
 958func (l *list[T]) IsFocused() bool {
 959	return l.focused
 960}
 961
 962// Items implements List.
 963func (l *list[T]) Items() []T {
 964	return slices.Collect(l.items.Seq())
 965}
 966
 967func (l *list[T]) incrementOffset(n int) {
 968	renderedHeight := lipgloss.Height(l.rendered)
 969	// no need for offset
 970	if renderedHeight <= l.height {
 971		return
 972	}
 973	maxOffset := renderedHeight - l.height
 974	n = min(n, maxOffset-l.offset)
 975	if n <= 0 {
 976		return
 977	}
 978	l.offset += n
 979}
 980
 981func (l *list[T]) decrementOffset(n int) {
 982	n = min(n, l.offset)
 983	if n <= 0 {
 984		return
 985	}
 986	l.offset -= n
 987	if l.offset < 0 {
 988		l.offset = 0
 989	}
 990}
 991
 992// MoveDown implements List.
 993func (l *list[T]) MoveDown(n int) tea.Cmd {
 994	oldOffset := l.offset
 995	if l.direction == DirectionForward {
 996		l.incrementOffset(n)
 997	} else {
 998		l.decrementOffset(n)
 999	}
1000
1001	if oldOffset == l.offset {
1002		// no change in offset, so no need to change selection
1003		return nil
1004	}
1005	// if we are not actively selecting move the whole selection down
1006	if l.hasSelection() && !l.selectionActive {
1007		if l.selectionStartLine < l.selectionEndLine {
1008			l.selectionStartLine -= n
1009			l.selectionEndLine -= n
1010		} else {
1011			l.selectionStartLine -= n
1012			l.selectionEndLine -= n
1013		}
1014	}
1015	if l.selectionActive {
1016		if l.selectionStartLine < l.selectionEndLine {
1017			l.selectionStartLine -= n
1018		} else {
1019			l.selectionEndLine -= n
1020		}
1021	}
1022	return l.changeSelectionWhenScrolling()
1023}
1024
1025// MoveUp implements List.
1026func (l *list[T]) MoveUp(n int) tea.Cmd {
1027	oldOffset := l.offset
1028	if l.direction == DirectionForward {
1029		l.decrementOffset(n)
1030	} else {
1031		l.incrementOffset(n)
1032	}
1033
1034	if oldOffset == l.offset {
1035		// no change in offset, so no need to change selection
1036		return nil
1037	}
1038
1039	if l.hasSelection() && !l.selectionActive {
1040		if l.selectionStartLine > l.selectionEndLine {
1041			l.selectionStartLine += n
1042			l.selectionEndLine += n
1043		} else {
1044			l.selectionStartLine += n
1045			l.selectionEndLine += n
1046		}
1047	}
1048	if l.selectionActive {
1049		if l.selectionStartLine > l.selectionEndLine {
1050			l.selectionStartLine += n
1051		} else {
1052			l.selectionEndLine += n
1053		}
1054	}
1055	return l.changeSelectionWhenScrolling()
1056}
1057
1058// PrependItem implements List.
1059func (l *list[T]) PrependItem(item T) tea.Cmd {
1060	cmds := []tea.Cmd{
1061		item.Init(),
1062	}
1063	l.items.Prepend(item)
1064	l.indexMap = csync.NewMap[string, int]()
1065	for inx, item := range slices.Collect(l.items.Seq()) {
1066		l.indexMap.Set(item.ID(), inx)
1067	}
1068	if l.width > 0 && l.height > 0 {
1069		cmds = append(cmds, item.SetSize(l.width, l.height))
1070	}
1071	cmds = append(cmds, l.render())
1072	if l.direction == DirectionForward {
1073		if l.offset == 0 {
1074			cmd := l.GoToTop()
1075			if cmd != nil {
1076				cmds = append(cmds, cmd)
1077			}
1078		} else {
1079			newItem, ok := l.renderedItems.Get(item.ID())
1080			if ok {
1081				newLines := newItem.height
1082				if l.items.Len() > 1 {
1083					newLines += l.gap
1084				}
1085				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1086			}
1087		}
1088	}
1089	return tea.Batch(cmds...)
1090}
1091
1092// SelectItemAbove implements List.
1093func (l *list[T]) SelectItemAbove() tea.Cmd {
1094	inx, ok := l.indexMap.Get(l.selectedItem)
1095	if !ok {
1096		return nil
1097	}
1098
1099	newIndex := l.firstSelectableItemAbove(inx)
1100	if newIndex == ItemNotFound {
1101		// no item above
1102		return nil
1103	}
1104	var cmds []tea.Cmd
1105	if newIndex == 1 {
1106		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1107		if peakAboveIndex == ItemNotFound {
1108			// this means there is a section above move to the top
1109			cmd := l.GoToTop()
1110			if cmd != nil {
1111				cmds = append(cmds, cmd)
1112			}
1113		}
1114	}
1115	item, ok := l.items.Get(newIndex)
1116	if !ok {
1117		return nil
1118	}
1119	l.selectedItem = item.ID()
1120	l.movingByItem = true
1121	renderCmd := l.render()
1122	if renderCmd != nil {
1123		cmds = append(cmds, renderCmd)
1124	}
1125	return tea.Sequence(cmds...)
1126}
1127
1128// SelectItemBelow implements List.
1129func (l *list[T]) SelectItemBelow() tea.Cmd {
1130	inx, ok := l.indexMap.Get(l.selectedItem)
1131	if !ok {
1132		return nil
1133	}
1134
1135	newIndex := l.firstSelectableItemBelow(inx)
1136	if newIndex == ItemNotFound {
1137		// no item above
1138		return nil
1139	}
1140	item, ok := l.items.Get(newIndex)
1141	if !ok {
1142		return nil
1143	}
1144	l.selectedItem = item.ID()
1145	l.movingByItem = true
1146	return l.render()
1147}
1148
1149// SelectedItem implements List.
1150func (l *list[T]) SelectedItem() *T {
1151	inx, ok := l.indexMap.Get(l.selectedItem)
1152	if !ok {
1153		return nil
1154	}
1155	if inx > l.items.Len()-1 {
1156		return nil
1157	}
1158	item, ok := l.items.Get(inx)
1159	if !ok {
1160		return nil
1161	}
1162	return &item
1163}
1164
1165// SetItems implements List.
1166func (l *list[T]) SetItems(items []T) tea.Cmd {
1167	l.items.SetSlice(items)
1168	var cmds []tea.Cmd
1169	for inx, item := range slices.Collect(l.items.Seq()) {
1170		if i, ok := any(item).(Indexable); ok {
1171			i.SetIndex(inx)
1172		}
1173		cmds = append(cmds, item.Init())
1174	}
1175	cmds = append(cmds, l.reset(""))
1176	return tea.Batch(cmds...)
1177}
1178
1179// SetSelected implements List.
1180func (l *list[T]) SetSelected(id string) tea.Cmd {
1181	l.selectedItem = id
1182	return l.render()
1183}
1184
1185func (l *list[T]) reset(selectedItem string) tea.Cmd {
1186	var cmds []tea.Cmd
1187	l.rendered = ""
1188	l.offset = 0
1189	l.selectedItem = selectedItem
1190	l.indexMap = csync.NewMap[string, int]()
1191	l.renderedItems = csync.NewMap[string, renderedItem]()
1192	for inx, item := range slices.Collect(l.items.Seq()) {
1193		l.indexMap.Set(item.ID(), inx)
1194		if l.width > 0 && l.height > 0 {
1195			cmds = append(cmds, item.SetSize(l.width, l.height))
1196		}
1197	}
1198	cmds = append(cmds, l.render())
1199	return tea.Batch(cmds...)
1200}
1201
1202// SetSize implements List.
1203func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1204	oldWidth := l.width
1205	l.width = width
1206	l.height = height
1207	if oldWidth != width {
1208		cmd := l.reset(l.selectedItem)
1209		return cmd
1210	}
1211	return nil
1212}
1213
1214// UpdateItem implements List.
1215func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1216	var cmds []tea.Cmd
1217	if inx, ok := l.indexMap.Get(id); ok {
1218		l.items.Set(inx, item)
1219		oldItem, hasOldItem := l.renderedItems.Get(id)
1220		oldPosition := l.offset
1221		if l.direction == DirectionBackward {
1222			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
1223		}
1224
1225		l.renderedItems.Del(id)
1226		cmd := l.render()
1227
1228		// need to check for nil because of sequence not handling nil
1229		if cmd != nil {
1230			cmds = append(cmds, cmd)
1231		}
1232		if hasOldItem && l.direction == DirectionBackward {
1233			// if we are the last item and there is no offset
1234			// make sure to go to the bottom
1235			if oldPosition < oldItem.end {
1236				newItem, ok := l.renderedItems.Get(item.ID())
1237				if ok {
1238					newLines := newItem.height - oldItem.height
1239					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1240				}
1241			}
1242		} else if hasOldItem && l.offset > oldItem.start {
1243			newItem, ok := l.renderedItems.Get(item.ID())
1244			if ok {
1245				newLines := newItem.height - oldItem.height
1246				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1247			}
1248		}
1249	}
1250	return tea.Sequence(cmds...)
1251}
1252
1253func (l *list[T]) hasSelection() bool {
1254	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1255}
1256
1257// StartSelection implements List.
1258func (l *list[T]) StartSelection(col, line int) {
1259	l.selectionStartCol = col
1260	l.selectionStartLine = line
1261	l.selectionEndCol = col
1262	l.selectionEndLine = line
1263	l.selectionActive = true
1264}
1265
1266// EndSelection implements List.
1267func (l *list[T]) EndSelection(col, line int) {
1268	if !l.selectionActive {
1269		return
1270	}
1271	l.selectionEndCol = col
1272	l.selectionEndLine = line
1273}
1274
1275func (l *list[T]) SelectionStop() {
1276	l.selectionActive = false
1277}
1278
1279func (l *list[T]) SelectionClear() {
1280	l.selectionStartCol = -1
1281	l.selectionStartLine = -1
1282	l.selectionEndCol = -1
1283	l.selectionEndLine = -1
1284	l.selectionActive = false
1285}
1286
1287func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1288	lines := strings.Split(l.rendered, "\n")
1289	for i, l := range lines {
1290		lines[i] = ansi.Strip(l)
1291	}
1292
1293	if l.direction == DirectionBackward {
1294		line = ((len(lines) - 1) - l.height) + line + 1
1295	}
1296
1297	if l.offset > 0 {
1298		if l.direction == DirectionBackward {
1299			line -= l.offset
1300		} else {
1301			line += l.offset
1302		}
1303	}
1304
1305	currentLine := lines[line]
1306	gr := uniseg.NewGraphemes(currentLine)
1307	startCol = -1
1308	upTo := col
1309	for gr.Next() {
1310		if gr.IsWordBoundary() && upTo > 0 {
1311			startCol = col - upTo + 1
1312		} else if gr.IsWordBoundary() && upTo < 0 {
1313			endCol = col - upTo + 1
1314			break
1315		}
1316		if upTo == 0 && gr.Str() == " " {
1317			return 0, 0
1318		}
1319		upTo -= 1
1320	}
1321	if startCol == -1 {
1322		return 0, 0
1323	}
1324	return
1325}
1326
1327func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1328	lines := strings.Split(l.rendered, "\n")
1329	for i, l := range lines {
1330		lines[i] = ansi.Strip(l)
1331		for _, icon := range styles.SelectionIgnoreIcons {
1332			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1333		}
1334	}
1335	if l.direction == DirectionBackward {
1336		line = (len(lines) - 1) - l.height + line + 1
1337	}
1338
1339	if strings.TrimSpace(lines[line]) == "" {
1340		return 0, 0, false
1341	}
1342
1343	if l.offset > 0 {
1344		if l.direction == DirectionBackward {
1345			line -= l.offset
1346		} else {
1347			line += l.offset
1348		}
1349	}
1350
1351	// Ensure line is within bounds
1352	if line < 0 || line >= len(lines) {
1353		return 0, 0, false
1354	}
1355
1356	// Find start of paragraph (search backwards for empty line or start of text)
1357	startLine = line
1358	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1359		startLine--
1360	}
1361
1362	// Find end of paragraph (search forwards for empty line or end of text)
1363	endLine = line
1364	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1365		endLine++
1366	}
1367
1368	// revert the line numbers if we are in backward direction
1369	if l.direction == DirectionBackward {
1370		startLine = startLine - (len(lines) - 1) + l.height - 1
1371		endLine = endLine - (len(lines) - 1) + l.height - 1
1372	}
1373	if l.offset > 0 {
1374		if l.direction == DirectionBackward {
1375			startLine += l.offset
1376			endLine += l.offset
1377		} else {
1378			startLine -= l.offset
1379			endLine -= l.offset
1380		}
1381	}
1382	return startLine, endLine, true
1383}
1384
1385// SelectWord selects the word at the given position.
1386func (l *list[T]) SelectWord(col, line int) {
1387	startCol, endCol := l.findWordBoundaries(col, line)
1388	l.selectionStartCol = startCol
1389	l.selectionStartLine = line
1390	l.selectionEndCol = endCol
1391	l.selectionEndLine = line
1392	l.selectionActive = false // Not actively selecting, just selected
1393}
1394
1395// SelectParagraph selects the paragraph at the given position.
1396func (l *list[T]) SelectParagraph(col, line int) {
1397	startLine, endLine, found := l.findParagraphBoundaries(line)
1398	if !found {
1399		return
1400	}
1401	l.selectionStartCol = 0
1402	l.selectionStartLine = startLine
1403	l.selectionEndCol = l.width - 1
1404	l.selectionEndLine = endLine
1405	l.selectionActive = false // Not actively selecting, just selected
1406}
1407
1408// HasSelection returns whether there is an active selection.
1409func (l *list[T]) HasSelection() bool {
1410	return l.hasSelection()
1411}
1412
1413// GetSelectedText returns the currently selected text.
1414func (l *list[T]) GetSelectedText(paddingLeft int) string {
1415	if !l.hasSelection() {
1416		return ""
1417	}
1418
1419	return l.selectionView(l.View(), true)
1420}