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