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