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