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// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
 794// returns the last index and the rendered content so far
 795// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
 796func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
 797	currentContentHeight := lipgloss.Height(rendered) - 1
 798	itemsLen := l.items.Len()
 799	for i := startInx; i < itemsLen; i++ {
 800		if currentContentHeight >= l.height && limitHeight {
 801			return rendered, i
 802		}
 803		// cool way to go through the list in both directions
 804		inx := i
 805
 806		if l.direction != DirectionForward {
 807			inx = (itemsLen - 1) - i
 808		}
 809
 810		item, ok := l.items.Get(inx)
 811		if !ok {
 812			continue
 813		}
 814		var rItem renderedItem
 815		if cache, ok := l.renderedItems.Get(item.ID()); ok {
 816			rItem = cache
 817		} else {
 818			rItem = l.renderItem(item)
 819			rItem.start = currentContentHeight
 820			rItem.end = currentContentHeight + rItem.height - 1
 821			l.renderedItems.Set(item.ID(), rItem)
 822		}
 823		gap := l.gap + 1
 824		if inx == itemsLen-1 {
 825			gap = 0
 826		}
 827
 828		if l.direction == DirectionForward {
 829			rendered += rItem.view + strings.Repeat("\n", gap)
 830		} else {
 831			rendered = rItem.view + strings.Repeat("\n", gap) + rendered
 832		}
 833		currentContentHeight = rItem.end + 1 + l.gap
 834	}
 835	return rendered, itemsLen
 836}
 837
 838func (l *list[T]) renderItem(item Item) renderedItem {
 839	view := item.View()
 840	return renderedItem{
 841		id:     item.ID(),
 842		view:   view,
 843		height: lipgloss.Height(view),
 844	}
 845}
 846
 847// AppendItem implements List.
 848func (l *list[T]) AppendItem(item T) tea.Cmd {
 849	var cmds []tea.Cmd
 850	cmd := item.Init()
 851	if cmd != nil {
 852		cmds = append(cmds, cmd)
 853	}
 854
 855	l.items.Append(item)
 856	l.indexMap = csync.NewMap[string, int]()
 857	for inx, item := range slices.Collect(l.items.Seq()) {
 858		l.indexMap.Set(item.ID(), inx)
 859	}
 860	if l.width > 0 && l.height > 0 {
 861		cmd = item.SetSize(l.width, l.height)
 862		if cmd != nil {
 863			cmds = append(cmds, cmd)
 864		}
 865	}
 866	cmd = l.render()
 867	if cmd != nil {
 868		cmds = append(cmds, cmd)
 869	}
 870	if l.direction == DirectionBackward {
 871		if l.offset == 0 {
 872			cmd = l.GoToBottom()
 873			if cmd != nil {
 874				cmds = append(cmds, cmd)
 875			}
 876		} else {
 877			newItem, ok := l.renderedItems.Get(item.ID())
 878			if ok {
 879				newLines := newItem.height
 880				if l.items.Len() > 1 {
 881					newLines += l.gap
 882				}
 883				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
 884			}
 885		}
 886	}
 887	return tea.Sequence(cmds...)
 888}
 889
 890// Blur implements List.
 891func (l *list[T]) Blur() tea.Cmd {
 892	l.focused = false
 893	return l.render()
 894}
 895
 896// DeleteItem implements List.
 897func (l *list[T]) DeleteItem(id string) tea.Cmd {
 898	inx, ok := l.indexMap.Get(id)
 899	if !ok {
 900		return nil
 901	}
 902	l.items.Delete(inx)
 903	l.renderedItems.Del(id)
 904	for inx, item := range slices.Collect(l.items.Seq()) {
 905		l.indexMap.Set(item.ID(), inx)
 906	}
 907
 908	if l.selectedItem == id {
 909		if inx > 0 {
 910			item, ok := l.items.Get(inx - 1)
 911			if ok {
 912				l.selectedItem = item.ID()
 913			} else {
 914				l.selectedItem = ""
 915			}
 916		} else {
 917			l.selectedItem = ""
 918		}
 919	}
 920	cmd := l.render()
 921	if l.rendered != "" {
 922		renderedHeight := lipgloss.Height(l.rendered)
 923		if renderedHeight <= l.height {
 924			l.offset = 0
 925		} else {
 926			maxOffset := renderedHeight - l.height
 927			if l.offset > maxOffset {
 928				l.offset = maxOffset
 929			}
 930		}
 931	}
 932	return cmd
 933}
 934
 935// Focus implements List.
 936func (l *list[T]) Focus() tea.Cmd {
 937	l.focused = true
 938	return l.render()
 939}
 940
 941// GetSize implements List.
 942func (l *list[T]) GetSize() (int, int) {
 943	return l.width, l.height
 944}
 945
 946// GoToBottom implements List.
 947func (l *list[T]) GoToBottom() tea.Cmd {
 948	l.offset = 0
 949	l.selectedItem = ""
 950	l.direction = DirectionBackward
 951	return l.render()
 952}
 953
 954// GoToTop implements List.
 955func (l *list[T]) GoToTop() tea.Cmd {
 956	l.offset = 0
 957	l.selectedItem = ""
 958	l.direction = DirectionForward
 959	return l.render()
 960}
 961
 962// IsFocused implements List.
 963func (l *list[T]) IsFocused() bool {
 964	return l.focused
 965}
 966
 967// Items implements List.
 968func (l *list[T]) Items() []T {
 969	return slices.Collect(l.items.Seq())
 970}
 971
 972func (l *list[T]) incrementOffset(n int) {
 973	renderedHeight := lipgloss.Height(l.rendered)
 974	// no need for offset
 975	if renderedHeight <= l.height {
 976		return
 977	}
 978	maxOffset := renderedHeight - l.height
 979	n = min(n, maxOffset-l.offset)
 980	if n <= 0 {
 981		return
 982	}
 983	l.offset += n
 984}
 985
 986func (l *list[T]) decrementOffset(n int) {
 987	n = min(n, l.offset)
 988	if n <= 0 {
 989		return
 990	}
 991	l.offset -= n
 992	if l.offset < 0 {
 993		l.offset = 0
 994	}
 995}
 996
 997// MoveDown implements List.
 998func (l *list[T]) MoveDown(n int) tea.Cmd {
 999	oldOffset := l.offset
1000	if l.direction == DirectionForward {
1001		l.incrementOffset(n)
1002	} else {
1003		l.decrementOffset(n)
1004	}
1005
1006	if oldOffset == l.offset {
1007		// no change in offset, so no need to change selection
1008		return nil
1009	}
1010	// if we are not actively selecting move the whole selection down
1011	if l.hasSelection() && !l.selectionActive {
1012		if l.selectionStartLine < l.selectionEndLine {
1013			l.selectionStartLine -= n
1014			l.selectionEndLine -= n
1015		} else {
1016			l.selectionStartLine -= n
1017			l.selectionEndLine -= n
1018		}
1019	}
1020	if l.selectionActive {
1021		if l.selectionStartLine < l.selectionEndLine {
1022			l.selectionStartLine -= n
1023		} else {
1024			l.selectionEndLine -= n
1025		}
1026	}
1027	return l.changeSelectionWhenScrolling()
1028}
1029
1030// MoveUp implements List.
1031func (l *list[T]) MoveUp(n int) tea.Cmd {
1032	oldOffset := l.offset
1033	if l.direction == DirectionForward {
1034		l.decrementOffset(n)
1035	} else {
1036		l.incrementOffset(n)
1037	}
1038
1039	if oldOffset == l.offset {
1040		// no change in offset, so no need to change selection
1041		return nil
1042	}
1043
1044	if l.hasSelection() && !l.selectionActive {
1045		if l.selectionStartLine > l.selectionEndLine {
1046			l.selectionStartLine += n
1047			l.selectionEndLine += n
1048		} else {
1049			l.selectionStartLine += n
1050			l.selectionEndLine += n
1051		}
1052	}
1053	if l.selectionActive {
1054		if l.selectionStartLine > l.selectionEndLine {
1055			l.selectionStartLine += n
1056		} else {
1057			l.selectionEndLine += n
1058		}
1059	}
1060	return l.changeSelectionWhenScrolling()
1061}
1062
1063// PrependItem implements List.
1064func (l *list[T]) PrependItem(item T) tea.Cmd {
1065	cmds := []tea.Cmd{
1066		item.Init(),
1067	}
1068	l.items.Prepend(item)
1069	l.indexMap = csync.NewMap[string, int]()
1070	for inx, item := range slices.Collect(l.items.Seq()) {
1071		l.indexMap.Set(item.ID(), inx)
1072	}
1073	if l.width > 0 && l.height > 0 {
1074		cmds = append(cmds, item.SetSize(l.width, l.height))
1075	}
1076	cmds = append(cmds, l.render())
1077	if l.direction == DirectionForward {
1078		if l.offset == 0 {
1079			cmd := l.GoToTop()
1080			if cmd != nil {
1081				cmds = append(cmds, cmd)
1082			}
1083		} else {
1084			newItem, ok := l.renderedItems.Get(item.ID())
1085			if ok {
1086				newLines := newItem.height
1087				if l.items.Len() > 1 {
1088					newLines += l.gap
1089				}
1090				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1091			}
1092		}
1093	}
1094	return tea.Batch(cmds...)
1095}
1096
1097// SelectItemAbove implements List.
1098func (l *list[T]) SelectItemAbove() tea.Cmd {
1099	inx, ok := l.indexMap.Get(l.selectedItem)
1100	if !ok {
1101		return nil
1102	}
1103
1104	newIndex := l.firstSelectableItemAbove(inx)
1105	if newIndex == ItemNotFound {
1106		// no item above
1107		return nil
1108	}
1109	var cmds []tea.Cmd
1110	if newIndex == 1 {
1111		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1112		if peakAboveIndex == ItemNotFound {
1113			// this means there is a section above move to the top
1114			cmd := l.GoToTop()
1115			if cmd != nil {
1116				cmds = append(cmds, cmd)
1117			}
1118		}
1119	}
1120	item, ok := l.items.Get(newIndex)
1121	if !ok {
1122		return nil
1123	}
1124	l.selectedItem = item.ID()
1125	l.movingByItem = true
1126	renderCmd := l.render()
1127	if renderCmd != nil {
1128		cmds = append(cmds, renderCmd)
1129	}
1130	return tea.Sequence(cmds...)
1131}
1132
1133// SelectItemBelow implements List.
1134func (l *list[T]) SelectItemBelow() tea.Cmd {
1135	inx, ok := l.indexMap.Get(l.selectedItem)
1136	if !ok {
1137		return nil
1138	}
1139
1140	newIndex := l.firstSelectableItemBelow(inx)
1141	if newIndex == ItemNotFound {
1142		// no item above
1143		return nil
1144	}
1145	item, ok := l.items.Get(newIndex)
1146	if !ok {
1147		return nil
1148	}
1149	l.selectedItem = item.ID()
1150	l.movingByItem = true
1151	return l.render()
1152}
1153
1154// SelectedItem implements List.
1155func (l *list[T]) SelectedItem() *T {
1156	inx, ok := l.indexMap.Get(l.selectedItem)
1157	if !ok {
1158		return nil
1159	}
1160	if inx > l.items.Len()-1 {
1161		return nil
1162	}
1163	item, ok := l.items.Get(inx)
1164	if !ok {
1165		return nil
1166	}
1167	return &item
1168}
1169
1170// SetItems implements List.
1171func (l *list[T]) SetItems(items []T) tea.Cmd {
1172	l.items.SetSlice(items)
1173	var cmds []tea.Cmd
1174	for inx, item := range slices.Collect(l.items.Seq()) {
1175		if i, ok := any(item).(Indexable); ok {
1176			i.SetIndex(inx)
1177		}
1178		cmds = append(cmds, item.Init())
1179	}
1180	cmds = append(cmds, l.reset(""))
1181	return tea.Batch(cmds...)
1182}
1183
1184// SetSelected implements List.
1185func (l *list[T]) SetSelected(id string) tea.Cmd {
1186	l.selectedItem = id
1187	return l.render()
1188}
1189
1190func (l *list[T]) reset(selectedItem string) tea.Cmd {
1191	var cmds []tea.Cmd
1192	l.rendered = ""
1193	l.offset = 0
1194	l.selectedItem = selectedItem
1195	l.indexMap = csync.NewMap[string, int]()
1196	l.renderedItems = csync.NewMap[string, renderedItem]()
1197	for inx, item := range slices.Collect(l.items.Seq()) {
1198		l.indexMap.Set(item.ID(), inx)
1199		if l.width > 0 && l.height > 0 {
1200			cmds = append(cmds, item.SetSize(l.width, l.height))
1201		}
1202	}
1203	cmds = append(cmds, l.render())
1204	return tea.Batch(cmds...)
1205}
1206
1207// SetSize implements List.
1208func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1209	oldWidth := l.width
1210	l.width = width
1211	l.height = height
1212	if oldWidth != width {
1213		cmd := l.reset(l.selectedItem)
1214		return cmd
1215	}
1216	return nil
1217}
1218
1219// UpdateItem implements List.
1220func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1221	var cmds []tea.Cmd
1222	if inx, ok := l.indexMap.Get(id); ok {
1223		l.items.Set(inx, item)
1224		oldItem, hasOldItem := l.renderedItems.Get(id)
1225		oldPosition := l.offset
1226		if l.direction == DirectionBackward {
1227			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
1228		}
1229
1230		l.renderedItems.Del(id)
1231		cmd := l.render()
1232
1233		// need to check for nil because of sequence not handling nil
1234		if cmd != nil {
1235			cmds = append(cmds, cmd)
1236		}
1237		if hasOldItem && l.direction == DirectionBackward {
1238			// if we are the last item and there is no offset
1239			// make sure to go to the bottom
1240			if oldPosition < oldItem.end {
1241				newItem, ok := l.renderedItems.Get(item.ID())
1242				if ok {
1243					newLines := newItem.height - oldItem.height
1244					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1245				}
1246			}
1247		} else if hasOldItem && l.offset > oldItem.start {
1248			newItem, ok := l.renderedItems.Get(item.ID())
1249			if ok {
1250				newLines := newItem.height - oldItem.height
1251				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1252			}
1253		}
1254	}
1255	return tea.Sequence(cmds...)
1256}
1257
1258func (l *list[T]) hasSelection() bool {
1259	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1260}
1261
1262// StartSelection implements List.
1263func (l *list[T]) StartSelection(col, line int) {
1264	l.selectionStartCol = col
1265	l.selectionStartLine = line
1266	l.selectionEndCol = col
1267	l.selectionEndLine = line
1268	l.selectionActive = true
1269}
1270
1271// EndSelection implements List.
1272func (l *list[T]) EndSelection(col, line int) {
1273	if !l.selectionActive {
1274		return
1275	}
1276	l.selectionEndCol = col
1277	l.selectionEndLine = line
1278}
1279
1280func (l *list[T]) SelectionStop() {
1281	l.selectionActive = false
1282}
1283
1284func (l *list[T]) SelectionClear() {
1285	l.selectionStartCol = -1
1286	l.selectionStartLine = -1
1287	l.selectionEndCol = -1
1288	l.selectionEndLine = -1
1289	l.selectionActive = false
1290}
1291
1292func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1293	lines := strings.Split(l.rendered, "\n")
1294	for i, l := range lines {
1295		lines[i] = ansi.Strip(l)
1296	}
1297
1298	if l.direction == DirectionBackward && len(lines) > l.height {
1299		line = ((len(lines) - 1) - l.height) + line + 1
1300	}
1301
1302	if l.offset > 0 {
1303		if l.direction == DirectionBackward {
1304			line -= l.offset
1305		} else {
1306			line += l.offset
1307		}
1308	}
1309
1310	if line < 0 || line >= len(lines) {
1311		return 0, 0
1312	}
1313
1314	currentLine := lines[line]
1315	gr := uniseg.NewGraphemes(currentLine)
1316	startCol = -1
1317	upTo := col
1318	for gr.Next() {
1319		if gr.IsWordBoundary() && upTo > 0 {
1320			startCol = col - upTo + 1
1321		} else if gr.IsWordBoundary() && upTo < 0 {
1322			endCol = col - upTo + 1
1323			break
1324		}
1325		if upTo == 0 && gr.Str() == " " {
1326			return 0, 0
1327		}
1328		upTo -= 1
1329	}
1330	if startCol == -1 {
1331		return 0, 0
1332	}
1333	return
1334}
1335
1336func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1337	lines := strings.Split(l.rendered, "\n")
1338	for i, l := range lines {
1339		lines[i] = ansi.Strip(l)
1340		for _, icon := range styles.SelectionIgnoreIcons {
1341			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1342		}
1343	}
1344	if l.direction == DirectionBackward && len(lines) > l.height {
1345		line = (len(lines) - 1) - l.height + line + 1
1346	}
1347
1348	if l.offset > 0 {
1349		if l.direction == DirectionBackward {
1350			line -= l.offset
1351		} else {
1352			line += l.offset
1353		}
1354	}
1355
1356	// Ensure line is within bounds
1357	if line < 0 || line >= len(lines) {
1358		return 0, 0, false
1359	}
1360
1361	if strings.TrimSpace(lines[line]) == "" {
1362		return 0, 0, false
1363	}
1364
1365	// Find start of paragraph (search backwards for empty line or start of text)
1366	startLine = line
1367	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1368		startLine--
1369	}
1370
1371	// Find end of paragraph (search forwards for empty line or end of text)
1372	endLine = line
1373	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1374		endLine++
1375	}
1376
1377	// revert the line numbers if we are in backward direction
1378	if l.direction == DirectionBackward && len(lines) > l.height {
1379		startLine = startLine - (len(lines) - 1) + l.height - 1
1380		endLine = endLine - (len(lines) - 1) + l.height - 1
1381	}
1382	if l.offset > 0 {
1383		if l.direction == DirectionBackward {
1384			startLine += l.offset
1385			endLine += l.offset
1386		} else {
1387			startLine -= l.offset
1388			endLine -= l.offset
1389		}
1390	}
1391	return startLine, endLine, true
1392}
1393
1394// SelectWord selects the word at the given position.
1395func (l *list[T]) SelectWord(col, line int) {
1396	startCol, endCol := l.findWordBoundaries(col, line)
1397	l.selectionStartCol = startCol
1398	l.selectionStartLine = line
1399	l.selectionEndCol = endCol
1400	l.selectionEndLine = line
1401	l.selectionActive = false // Not actively selecting, just selected
1402}
1403
1404// SelectParagraph selects the paragraph at the given position.
1405func (l *list[T]) SelectParagraph(col, line int) {
1406	startLine, endLine, found := l.findParagraphBoundaries(line)
1407	if !found {
1408		return
1409	}
1410	l.selectionStartCol = 0
1411	l.selectionStartLine = startLine
1412	l.selectionEndCol = l.width - 1
1413	l.selectionEndLine = endLine
1414	l.selectionActive = false // Not actively selecting, just selected
1415}
1416
1417// HasSelection returns whether there is an active selection.
1418func (l *list[T]) HasSelection() bool {
1419	return l.hasSelection()
1420}
1421
1422// GetSelectedText returns the currently selected text.
1423func (l *list[T]) GetSelectedText(paddingLeft int) string {
1424	if !l.hasSelection() {
1425		return ""
1426	}
1427
1428	return l.selectionView(l.View(), true)
1429}