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	// Virtual scrolling fields
 109	virtualHeight int                     // Total height of all items
 110	itemHeights   *csync.Map[string, int] // Cache of item heights
 111
 112	movingByItem       bool
 113	selectionStartCol  int
 114	selectionStartLine int
 115	selectionEndCol    int
 116	selectionEndLine   int
 117
 118	selectionActive bool
 119}
 120
 121type ListOption func(*confOptions)
 122
 123// WithSize sets the size of the list.
 124func WithSize(width, height int) ListOption {
 125	return func(l *confOptions) {
 126		l.width = width
 127		l.height = height
 128	}
 129}
 130
 131// WithGap sets the gap between items in the list.
 132func WithGap(gap int) ListOption {
 133	return func(l *confOptions) {
 134		l.gap = gap
 135	}
 136}
 137
 138// WithDirectionForward sets the direction to forward
 139func WithDirectionForward() ListOption {
 140	return func(l *confOptions) {
 141		l.direction = DirectionForward
 142	}
 143}
 144
 145// WithDirectionBackward sets the direction to forward
 146func WithDirectionBackward() ListOption {
 147	return func(l *confOptions) {
 148		l.direction = DirectionBackward
 149	}
 150}
 151
 152// WithSelectedItem sets the initially selected item in the list.
 153func WithSelectedItem(id string) ListOption {
 154	return func(l *confOptions) {
 155		l.selectedItem = id
 156	}
 157}
 158
 159func WithKeyMap(keyMap KeyMap) ListOption {
 160	return func(l *confOptions) {
 161		l.keyMap = keyMap
 162	}
 163}
 164
 165func WithWrapNavigation() ListOption {
 166	return func(l *confOptions) {
 167		l.wrap = true
 168	}
 169}
 170
 171func WithFocus(focus bool) ListOption {
 172	return func(l *confOptions) {
 173		l.focused = focus
 174	}
 175}
 176
 177func WithResizeByList() ListOption {
 178	return func(l *confOptions) {
 179		l.resize = true
 180	}
 181}
 182
 183func WithEnableMouse() ListOption {
 184	return func(l *confOptions) {
 185		l.enableMouse = true
 186	}
 187}
 188
 189func New[T Item](items []T, opts ...ListOption) List[T] {
 190	list := &list[T]{
 191		confOptions: &confOptions{
 192			direction: DirectionForward,
 193			keyMap:    DefaultKeyMap(),
 194			focused:   true,
 195		},
 196		items:              csync.NewSliceFrom(items),
 197		indexMap:           csync.NewMap[string, int](),
 198		renderedItems:      csync.NewMap[string, renderedItem](),
 199		itemHeights:        csync.NewMap[string, int](),
 200		selectionStartCol:  -1,
 201		selectionStartLine: -1,
 202		selectionEndLine:   -1,
 203		selectionEndCol:    -1,
 204	}
 205	for _, opt := range opts {
 206		opt(list.confOptions)
 207	}
 208
 209	for inx, item := range items {
 210		if i, ok := any(item).(Indexable); ok {
 211			i.SetIndex(inx)
 212		}
 213		list.indexMap.Set(item.ID(), inx)
 214	}
 215	return list
 216}
 217
 218// Init implements List.
 219func (l *list[T]) Init() tea.Cmd {
 220	return l.render()
 221}
 222
 223// Update implements List.
 224func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 225	switch msg := msg.(type) {
 226	case tea.MouseWheelMsg:
 227		if l.enableMouse {
 228			return l.handleMouseWheel(msg)
 229		}
 230		return l, nil
 231	case anim.StepMsg:
 232		var cmds []tea.Cmd
 233		for _, item := range slices.Collect(l.items.Seq()) {
 234			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
 235				updated, cmd := i.Update(msg)
 236				cmds = append(cmds, cmd)
 237				if u, ok := updated.(T); ok {
 238					cmds = append(cmds, l.UpdateItem(u.ID(), u))
 239				}
 240			}
 241		}
 242		return l, tea.Batch(cmds...)
 243	case tea.KeyPressMsg:
 244		if l.focused {
 245			switch {
 246			case key.Matches(msg, l.keyMap.Down):
 247				return l, l.MoveDown(ViewportDefaultScrollSize)
 248			case key.Matches(msg, l.keyMap.Up):
 249				return l, l.MoveUp(ViewportDefaultScrollSize)
 250			case key.Matches(msg, l.keyMap.DownOneItem):
 251				return l, l.SelectItemBelow()
 252			case key.Matches(msg, l.keyMap.UpOneItem):
 253				return l, l.SelectItemAbove()
 254			case key.Matches(msg, l.keyMap.HalfPageDown):
 255				return l, l.MoveDown(l.height / 2)
 256			case key.Matches(msg, l.keyMap.HalfPageUp):
 257				return l, l.MoveUp(l.height / 2)
 258			case key.Matches(msg, l.keyMap.PageDown):
 259				return l, l.MoveDown(l.height)
 260			case key.Matches(msg, l.keyMap.PageUp):
 261				return l, l.MoveUp(l.height)
 262			case key.Matches(msg, l.keyMap.End):
 263				return l, l.GoToBottom()
 264			case key.Matches(msg, l.keyMap.Home):
 265				return l, l.GoToTop()
 266			}
 267			s := l.SelectedItem()
 268			if s == nil {
 269				return l, nil
 270			}
 271			item := *s
 272			var cmds []tea.Cmd
 273			updated, cmd := item.Update(msg)
 274			cmds = append(cmds, cmd)
 275			if u, ok := updated.(T); ok {
 276				cmds = append(cmds, l.UpdateItem(u.ID(), u))
 277			}
 278			return l, tea.Batch(cmds...)
 279		}
 280	}
 281	return l, nil
 282}
 283
 284func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
 285	var cmd tea.Cmd
 286	switch msg.Button {
 287	case tea.MouseWheelDown:
 288		cmd = l.MoveDown(ViewportDefaultScrollSize)
 289	case tea.MouseWheelUp:
 290		cmd = l.MoveUp(ViewportDefaultScrollSize)
 291	}
 292	return l, cmd
 293}
 294
 295// selectionView renders the highlighted selection in the view and returns it
 296// as a string. If textOnly is true, it won't render any styles.
 297func (l *list[T]) selectionView(view string, textOnly bool) string {
 298	t := styles.CurrentTheme()
 299	area := uv.Rect(0, 0, l.width, l.height)
 300	scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
 301	uv.NewStyledString(view).Draw(scr, area)
 302
 303	selArea := uv.Rectangle{
 304		Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
 305		Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
 306	}
 307	selArea = selArea.Canon()
 308
 309	specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
 310	for _, icon := range styles.SelectionIgnoreIcons {
 311		specialChars[icon] = true
 312	}
 313
 314	isNonWhitespace := func(r rune) bool {
 315		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
 316	}
 317
 318	type selectionBounds struct {
 319		startX, endX int
 320		inSelection  bool
 321	}
 322	lineSelections := make([]selectionBounds, scr.Height())
 323
 324	for y := range scr.Height() {
 325		bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
 326
 327		if y >= selArea.Min.Y && y <= selArea.Max.Y {
 328			bounds.inSelection = true
 329			if selArea.Min.Y == selArea.Max.Y {
 330				// Single line selection
 331				bounds.startX = selArea.Min.X
 332				bounds.endX = selArea.Max.X
 333			} else if y == selArea.Min.Y {
 334				// First line of multi-line selection
 335				bounds.startX = selArea.Min.X
 336				bounds.endX = scr.Width()
 337			} else if y == selArea.Max.Y {
 338				// Last line of multi-line selection
 339				bounds.startX = 0
 340				bounds.endX = selArea.Max.X
 341			} else {
 342				// Middle lines
 343				bounds.startX = 0
 344				bounds.endX = scr.Width()
 345			}
 346		}
 347		lineSelections[y] = bounds
 348	}
 349
 350	type lineBounds struct {
 351		start, end int
 352	}
 353	lineTextBounds := make([]lineBounds, scr.Height())
 354
 355	// First pass: find text bounds for lines that have selections
 356	for y := range scr.Height() {
 357		bounds := lineBounds{start: -1, end: -1}
 358
 359		// Only process lines that might have selections
 360		if lineSelections[y].inSelection {
 361			for x := range scr.Width() {
 362				cell := scr.CellAt(x, y)
 363				if cell == nil {
 364					continue
 365				}
 366
 367				cellStr := cell.String()
 368				if len(cellStr) == 0 {
 369					continue
 370				}
 371
 372				char := rune(cellStr[0])
 373				isSpecial := specialChars[cellStr]
 374
 375				if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
 376					if bounds.start == -1 {
 377						bounds.start = x
 378					}
 379					bounds.end = x + 1 // Position after last character
 380				}
 381			}
 382		}
 383		lineTextBounds[y] = bounds
 384	}
 385
 386	var selectedText strings.Builder
 387
 388	// Second pass: apply selection highlighting
 389	for y := range scr.Height() {
 390		selBounds := lineSelections[y]
 391		if !selBounds.inSelection {
 392			continue
 393		}
 394
 395		textBounds := lineTextBounds[y]
 396		if textBounds.start < 0 {
 397			if textOnly {
 398				// We don't want to get rid of all empty lines in text-only mode
 399				selectedText.WriteByte('\n')
 400			}
 401
 402			continue // No text on this line
 403		}
 404
 405		// Only scan within the intersection of text bounds and selection bounds
 406		scanStart := max(textBounds.start, selBounds.startX)
 407		scanEnd := min(textBounds.end, selBounds.endX)
 408
 409		for x := scanStart; x < scanEnd; x++ {
 410			cell := scr.CellAt(x, y)
 411			if cell == nil {
 412				continue
 413			}
 414
 415			cellStr := cell.String()
 416			if len(cellStr) > 0 && !specialChars[cellStr] {
 417				if textOnly {
 418					// Collect selected text without styles
 419					selectedText.WriteString(cell.String())
 420					continue
 421				}
 422
 423				// Text selection styling, which is a Lip Gloss style. We must
 424				// extract the values to use in a UV style, below.
 425				ts := t.TextSelection
 426
 427				cell = cell.Clone()
 428				cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
 429				scr.SetCell(x, y, cell)
 430			}
 431		}
 432
 433		if textOnly {
 434			// Make sure we add a newline after each line of selected text
 435			selectedText.WriteByte('\n')
 436		}
 437	}
 438
 439	if textOnly {
 440		return strings.TrimSpace(selectedText.String())
 441	}
 442
 443	return scr.Render()
 444}
 445
 446// View implements List.
 447func (l *list[T]) View() string {
 448	if l.height <= 0 || l.width <= 0 {
 449		return ""
 450	}
 451	t := styles.CurrentTheme()
 452	
 453	// With virtual scrolling, rendered already contains only visible content
 454	view := l.rendered
 455	
 456	if l.resize {
 457		return view
 458	}
 459	
 460	view = t.S().Base.
 461		Height(l.height).
 462		Width(l.width).
 463		Render(view)
 464
 465	if !l.hasSelection() {
 466		return view
 467	}
 468
 469	return l.selectionView(view, false)
 470}
 471
 472func (l *list[T]) viewPosition() (int, int) {
 473	// View position in the virtual space
 474	start, end := 0, 0
 475	if l.direction == DirectionForward {
 476		start = l.offset
 477		if l.virtualHeight > 0 {
 478			end = min(l.offset+l.height-1, l.virtualHeight-1)
 479		} else {
 480			end = l.offset + l.height - 1
 481		}
 482	} else {
 483		// For backward direction
 484		if l.virtualHeight > 0 {
 485			end = l.virtualHeight - l.offset - 1
 486			start = max(0, end - l.height + 1)
 487		} else {
 488			end = 0
 489			start = 0
 490		}
 491	}
 492	return start, end
 493}
 494
 495func (l *list[T]) render() tea.Cmd {
 496	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
 497		return nil
 498	}
 499	l.setDefaultSelected()
 500
 501	var focusChangeCmd tea.Cmd
 502	if l.focused {
 503		focusChangeCmd = l.focusSelectedItem()
 504	} else {
 505		focusChangeCmd = l.blurSelectedItem()
 506	}
 507
 508	// Calculate all item positions and total height
 509	l.calculateItemPositions()
 510
 511	// Render only visible items
 512	l.renderMu.Lock()
 513	l.rendered = l.renderVirtualScrolling()
 514	l.renderMu.Unlock()
 515
 516	// Scroll to selected item if focused
 517	if l.focused {
 518		l.scrollToSelection()
 519	}
 520
 521	return focusChangeCmd
 522}
 523
 524func (l *list[T]) setDefaultSelected() {
 525	if l.selectedItem == "" {
 526		if l.direction == DirectionForward {
 527			l.selectFirstItem()
 528		} else {
 529			l.selectLastItem()
 530		}
 531	}
 532}
 533
 534func (l *list[T]) scrollToSelection() {
 535	rItem, ok := l.renderedItems.Get(l.selectedItem)
 536	if !ok {
 537		l.selectedItem = ""
 538		l.setDefaultSelected()
 539		return
 540	}
 541
 542	start, end := l.viewPosition()
 543	
 544	// item bigger or equal to the viewport - show from start
 545	if rItem.height >= l.height {
 546		if l.direction == DirectionForward {
 547			l.offset = rItem.start
 548		} else {
 549			if l.virtualHeight > 0 {
 550			l.offset = l.virtualHeight - rItem.end
 551		} else {
 552			l.offset = 0
 553		}
 554		}
 555		return
 556	}
 557	
 558	// if we are moving by item we want to move the offset so that the
 559	// whole item is visible not just portions of it
 560	if l.movingByItem {
 561		if rItem.start >= start && rItem.end <= end {
 562			// Item is fully visible, no need to scroll
 563			return
 564		}
 565		defer func() { l.movingByItem = false }()
 566	} else {
 567		// item already in view do nothing
 568		if rItem.start >= start && rItem.start <= end {
 569			return
 570		}
 571		if rItem.end >= start && rItem.end <= end {
 572			return
 573		}
 574	}
 575
 576	// If item is above the viewport, make it the first item
 577	if rItem.start < start {
 578		if l.direction == DirectionForward {
 579			l.offset = rItem.start
 580		} else {
 581			if l.virtualHeight > 0 {
 582			l.offset = l.virtualHeight - rItem.end
 583		} else {
 584			l.offset = 0
 585		}
 586		}
 587	} else if rItem.end > end {
 588		// If item is below the viewport, make it the last item
 589		if l.direction == DirectionForward {
 590			l.offset = max(0, rItem.end - l.height + 1)
 591		} else {
 592			if l.virtualHeight > 0 {
 593			l.offset = max(0, l.virtualHeight - rItem.start - l.height + 1)
 594		} else {
 595			l.offset = 0
 596		}
 597		}
 598	}
 599}
 600
 601func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 602	rItem, ok := l.renderedItems.Get(l.selectedItem)
 603	if !ok {
 604		return nil
 605	}
 606	start, end := l.viewPosition()
 607	// item bigger than the viewport do nothing
 608	if rItem.start <= start && rItem.end >= end {
 609		return nil
 610	}
 611	// item already in view do nothing
 612	if rItem.start >= start && rItem.end <= end {
 613		return nil
 614	}
 615
 616	itemMiddle := rItem.start + rItem.height/2
 617
 618	if itemMiddle < start {
 619		// select the first item in the viewport
 620		// the item is most likely an item coming after this item
 621		inx, ok := l.indexMap.Get(rItem.id)
 622		if !ok {
 623			return nil
 624		}
 625		for {
 626			inx = l.firstSelectableItemBelow(inx)
 627			if inx == ItemNotFound {
 628				return nil
 629			}
 630			item, ok := l.items.Get(inx)
 631			if !ok {
 632				continue
 633			}
 634			renderedItem, ok := l.renderedItems.Get(item.ID())
 635			if !ok {
 636				continue
 637			}
 638
 639			// If the item is bigger than the viewport, select it
 640			if renderedItem.start <= start && renderedItem.end >= end {
 641				l.selectedItem = renderedItem.id
 642				return l.render()
 643			}
 644			// item is in the view
 645			if renderedItem.start >= start && renderedItem.start <= end {
 646				l.selectedItem = renderedItem.id
 647				return l.render()
 648			}
 649		}
 650	} else if itemMiddle > end {
 651		// select the first item in the viewport
 652		// the item is most likely an item coming after this item
 653		inx, ok := l.indexMap.Get(rItem.id)
 654		if !ok {
 655			return nil
 656		}
 657		for {
 658			inx = l.firstSelectableItemAbove(inx)
 659			if inx == ItemNotFound {
 660				return nil
 661			}
 662			item, ok := l.items.Get(inx)
 663			if !ok {
 664				continue
 665			}
 666			renderedItem, ok := l.renderedItems.Get(item.ID())
 667			if !ok {
 668				continue
 669			}
 670
 671			// If the item is bigger than the viewport, select it
 672			if renderedItem.start <= start && renderedItem.end >= end {
 673				l.selectedItem = renderedItem.id
 674				return l.render()
 675			}
 676			// item is in the view
 677			if renderedItem.end >= start && renderedItem.end <= end {
 678				l.selectedItem = renderedItem.id
 679				return l.render()
 680			}
 681		}
 682	}
 683	return nil
 684}
 685
 686func (l *list[T]) selectFirstItem() {
 687	inx := l.firstSelectableItemBelow(-1)
 688	if inx != ItemNotFound {
 689		item, ok := l.items.Get(inx)
 690		if ok {
 691			l.selectedItem = item.ID()
 692		}
 693	}
 694}
 695
 696func (l *list[T]) selectLastItem() {
 697	inx := l.firstSelectableItemAbove(l.items.Len())
 698	if inx != ItemNotFound {
 699		item, ok := l.items.Get(inx)
 700		if ok {
 701			l.selectedItem = item.ID()
 702		}
 703	}
 704}
 705
 706func (l *list[T]) firstSelectableItemAbove(inx int) int {
 707	for i := inx - 1; i >= 0; i-- {
 708		item, ok := l.items.Get(i)
 709		if !ok {
 710			continue
 711		}
 712		if _, ok := any(item).(layout.Focusable); ok {
 713			return i
 714		}
 715	}
 716	if inx == 0 && l.wrap {
 717		return l.firstSelectableItemAbove(l.items.Len())
 718	}
 719	return ItemNotFound
 720}
 721
 722func (l *list[T]) firstSelectableItemBelow(inx int) int {
 723	itemsLen := l.items.Len()
 724	for i := inx + 1; i < itemsLen; i++ {
 725		item, ok := l.items.Get(i)
 726		if !ok {
 727			continue
 728		}
 729		if _, ok := any(item).(layout.Focusable); ok {
 730			return i
 731		}
 732	}
 733	if inx == itemsLen-1 && l.wrap {
 734		return l.firstSelectableItemBelow(-1)
 735	}
 736	return ItemNotFound
 737}
 738
 739func (l *list[T]) focusSelectedItem() tea.Cmd {
 740	if l.selectedItem == "" || !l.focused {
 741		return nil
 742	}
 743	var cmds []tea.Cmd
 744	for _, item := range slices.Collect(l.items.Seq()) {
 745		if f, ok := any(item).(layout.Focusable); ok {
 746			if item.ID() == l.selectedItem && !f.IsFocused() {
 747				cmds = append(cmds, f.Focus())
 748				l.renderedItems.Del(item.ID())
 749			} else if item.ID() != l.selectedItem && f.IsFocused() {
 750				cmds = append(cmds, f.Blur())
 751				l.renderedItems.Del(item.ID())
 752			}
 753		}
 754	}
 755	return tea.Batch(cmds...)
 756}
 757
 758func (l *list[T]) blurSelectedItem() 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.Blur())
 767				l.renderedItems.Del(item.ID())
 768			}
 769		}
 770	}
 771	return tea.Batch(cmds...)
 772}
 773
 774
 775
 776// calculateItemPositions calculates and caches the position and height of all items.
 777func (l *list[T]) calculateItemPositions() {
 778	currentHeight := 0
 779	itemsLen := l.items.Len()
 780
 781	// Always calculate positions in forward order (logical positions)
 782	for i := 0; i < itemsLen; i++ {
 783		item, ok := l.items.Get(i)
 784		if !ok {
 785			continue
 786		}
 787
 788		// Get or calculate item height
 789		var height int
 790		if cachedHeight, ok := l.itemHeights.Get(item.ID()); ok {
 791			height = cachedHeight
 792		} else {
 793			// Calculate and cache the height
 794			view := item.View()
 795			height = lipgloss.Height(view)
 796			l.itemHeights.Set(item.ID(), height)
 797		}
 798
 799		// Update or create rendered item with position info
 800		var rItem renderedItem
 801		if cached, ok := l.renderedItems.Get(item.ID()); ok {
 802			rItem = cached
 803			rItem.height = height
 804		} else {
 805			rItem = renderedItem{
 806				id:     item.ID(),
 807				height: height,
 808			}
 809		}
 810		
 811		rItem.start = currentHeight
 812		rItem.end = currentHeight + rItem.height - 1
 813		l.renderedItems.Set(item.ID(), rItem)
 814
 815		currentHeight = rItem.end + 1
 816		if i < itemsLen-1 {
 817			currentHeight += l.gap
 818		}
 819	}
 820
 821	l.virtualHeight = currentHeight
 822}
 823
 824// renderVirtualScrolling renders only the visible portion of the list.
 825func (l *list[T]) renderVirtualScrolling() string {
 826	if l.items.Len() == 0 {
 827		return ""
 828	}
 829
 830	// Calculate viewport bounds
 831	viewStart, viewEnd := l.viewPosition()
 832	
 833	// Find which items are visible
 834	var visibleItems []struct {
 835		item   T
 836		rItem  renderedItem
 837		index  int
 838	}
 839	
 840	itemsLen := l.items.Len()
 841	for i := 0; i < itemsLen; i++ {
 842		item, ok := l.items.Get(i)
 843		if !ok {
 844			continue
 845		}
 846		
 847		rItem, ok := l.renderedItems.Get(item.ID())
 848		if !ok {
 849			continue
 850		}
 851		
 852		// Check if item is visible (overlaps with viewport)
 853		if rItem.end >= viewStart && rItem.start <= viewEnd {
 854			visibleItems = append(visibleItems, struct {
 855				item  T
 856				rItem renderedItem
 857				index int
 858			}{item, rItem, i})
 859		}
 860		
 861		// Early exit if we've passed the viewport
 862		if rItem.start > viewEnd {
 863			break
 864		}
 865	}
 866	
 867	if len(visibleItems) == 0 {
 868		// Return empty lines to maintain height
 869		var lines []string
 870		for i := 0; i < l.height; i++ {
 871			lines = append(lines, "")
 872		}
 873		return strings.Join(lines, "\n")
 874	}
 875	
 876	// Render visible items
 877	var b strings.Builder
 878	currentLine := viewStart
 879	
 880	// Handle first visible item
 881	firstVisible := visibleItems[0]
 882	if firstVisible.rItem.start < viewStart {
 883		// We're starting mid-item, render partial
 884		if cached, ok := l.renderedItems.Get(firstVisible.item.ID()); ok && cached.view != "" {
 885			lines := strings.Split(cached.view, "\n")
 886			skipLines := viewStart - firstVisible.rItem.start
 887			if skipLines >= 0 && skipLines < len(lines) {
 888				for i := skipLines; i < len(lines); i++ {
 889					if currentLine > viewEnd {
 890						break
 891					}
 892					b.WriteString(lines[i])
 893					b.WriteByte('\n')
 894					currentLine++
 895				}
 896			}
 897		}
 898	} else if firstVisible.rItem.start > viewStart {
 899		// Add empty lines before first item
 900		for currentLine < firstVisible.rItem.start && currentLine <= viewEnd {
 901			if b.Len() > 0 {
 902				b.WriteByte('\n')
 903			}
 904			currentLine++
 905		}
 906	}
 907	
 908	// Render fully visible items
 909	for i, vis := range visibleItems {
 910		if currentLine > viewEnd {
 911			break
 912		}
 913		
 914		// Skip first item if we already rendered it partially
 915		if i == 0 && firstVisible.rItem.start < viewStart {
 916			continue
 917		}
 918		
 919		// Add gap before item (except for first)
 920		if i > 0 && currentLine <= viewEnd {
 921			for j := 0; j < l.gap && currentLine <= viewEnd; j++ {
 922				b.WriteByte('\n')
 923				currentLine++
 924			}
 925		}
 926		
 927		// Render item or use cache
 928		var view string
 929		if cached, ok := l.renderedItems.Get(vis.item.ID()); ok && cached.view != "" {
 930			view = cached.view
 931		} else {
 932			view = vis.item.View()
 933			// Update cache
 934			rItem := vis.rItem
 935			rItem.view = view
 936			l.renderedItems.Set(vis.item.ID(), rItem)
 937		}
 938		
 939		// Handle partial rendering if item extends beyond viewport
 940		lines := strings.Split(view, "\n")
 941		for _, line := range lines {
 942			if currentLine > viewEnd {
 943				break
 944			}
 945			if b.Len() > 0 {
 946				b.WriteByte('\n')
 947			}
 948			b.WriteString(line)
 949			currentLine++
 950		}
 951	}
 952	
 953	// Fill remaining viewport with empty lines if needed
 954	for currentLine <= viewEnd {
 955		if b.Len() > 0 {
 956			b.WriteByte('\n')
 957		}
 958		currentLine++
 959	}
 960	
 961	return b.String()
 962}
 963
 964
 965
 966// AppendItem implements List.
 967func (l *list[T]) AppendItem(item T) tea.Cmd {
 968	var cmds []tea.Cmd
 969	cmd := item.Init()
 970	if cmd != nil {
 971		cmds = append(cmds, cmd)
 972	}
 973
 974	l.items.Append(item)
 975	l.indexMap = csync.NewMap[string, int]()
 976	for inx, item := range slices.Collect(l.items.Seq()) {
 977		l.indexMap.Set(item.ID(), inx)
 978	}
 979	if l.width > 0 && l.height > 0 {
 980		cmd = item.SetSize(l.width, l.height)
 981		if cmd != nil {
 982			cmds = append(cmds, cmd)
 983		}
 984	}
 985	cmd = l.render()
 986	if cmd != nil {
 987		cmds = append(cmds, cmd)
 988	}
 989	if l.direction == DirectionBackward {
 990		if l.offset == 0 {
 991			cmd = l.GoToBottom()
 992			if cmd != nil {
 993				cmds = append(cmds, cmd)
 994			}
 995		} else {
 996			newItem, ok := l.renderedItems.Get(item.ID())
 997			if ok {
 998				newLines := newItem.height
 999				if l.items.Len() > 1 {
1000					newLines += l.gap
1001				}
1002				if l.virtualHeight > 0 {
1003					l.offset = min(l.virtualHeight-1, l.offset+newLines)
1004				}
1005			}
1006		}
1007	}
1008	return tea.Sequence(cmds...)
1009}
1010
1011// Blur implements List.
1012func (l *list[T]) Blur() tea.Cmd {
1013	l.focused = false
1014	return l.render()
1015}
1016
1017// DeleteItem implements List.
1018func (l *list[T]) DeleteItem(id string) tea.Cmd {
1019	inx, ok := l.indexMap.Get(id)
1020	if !ok {
1021		return nil
1022	}
1023	l.items.Delete(inx)
1024	l.renderedItems.Del(id)
1025	for inx, item := range slices.Collect(l.items.Seq()) {
1026		l.indexMap.Set(item.ID(), inx)
1027	}
1028
1029	if l.selectedItem == id {
1030		if inx > 0 {
1031			item, ok := l.items.Get(inx - 1)
1032			if ok {
1033				l.selectedItem = item.ID()
1034			} else {
1035				l.selectedItem = ""
1036			}
1037		} else {
1038			l.selectedItem = ""
1039		}
1040	}
1041	cmd := l.render()
1042	if l.rendered != "" {
1043		renderedHeight := l.virtualHeight
1044		if renderedHeight <= l.height {
1045			l.offset = 0
1046		} else {
1047			maxOffset := renderedHeight - l.height
1048			if l.offset > maxOffset {
1049				l.offset = maxOffset
1050			}
1051		}
1052	}
1053	return cmd
1054}
1055
1056// Focus implements List.
1057func (l *list[T]) Focus() tea.Cmd {
1058	l.focused = true
1059	return l.render()
1060}
1061
1062// GetSize implements List.
1063func (l *list[T]) GetSize() (int, int) {
1064	return l.width, l.height
1065}
1066
1067// GoToBottom implements List.
1068func (l *list[T]) GoToBottom() tea.Cmd {
1069	l.offset = 0
1070	l.selectedItem = ""
1071	l.direction = DirectionBackward
1072	return l.render()
1073}
1074
1075// GoToTop implements List.
1076func (l *list[T]) GoToTop() tea.Cmd {
1077	l.offset = 0
1078	l.selectedItem = ""
1079	l.direction = DirectionForward
1080	return l.render()
1081}
1082
1083// IsFocused implements List.
1084func (l *list[T]) IsFocused() bool {
1085	return l.focused
1086}
1087
1088// Items implements List.
1089func (l *list[T]) Items() []T {
1090	return slices.Collect(l.items.Seq())
1091}
1092
1093func (l *list[T]) incrementOffset(n int) {
1094	renderedHeight := l.virtualHeight
1095	// no need for offset
1096	if renderedHeight <= l.height {
1097		return
1098	}
1099	maxOffset := renderedHeight - l.height
1100	n = min(n, maxOffset-l.offset)
1101	if n <= 0 {
1102		return
1103	}
1104	l.offset += n
1105}
1106
1107func (l *list[T]) decrementOffset(n int) {
1108	n = min(n, l.offset)
1109	if n <= 0 {
1110		return
1111	}
1112	l.offset -= n
1113	if l.offset < 0 {
1114		l.offset = 0
1115	}
1116}
1117
1118// MoveDown implements List.
1119func (l *list[T]) MoveDown(n int) tea.Cmd {
1120	oldOffset := l.offset
1121	if l.direction == DirectionForward {
1122		l.incrementOffset(n)
1123	} else {
1124		l.decrementOffset(n)
1125	}
1126
1127	if oldOffset == l.offset {
1128		// no change in offset, so no need to change selection
1129		return nil
1130	}
1131	// if we are not actively selecting move the whole selection down
1132	if l.hasSelection() && !l.selectionActive {
1133		if l.selectionStartLine < l.selectionEndLine {
1134			l.selectionStartLine -= n
1135			l.selectionEndLine -= n
1136		} else {
1137			l.selectionStartLine -= n
1138			l.selectionEndLine -= n
1139		}
1140	}
1141	if l.selectionActive {
1142		if l.selectionStartLine < l.selectionEndLine {
1143			l.selectionStartLine -= n
1144		} else {
1145			l.selectionEndLine -= n
1146		}
1147	}
1148	return l.changeSelectionWhenScrolling()
1149}
1150
1151// MoveUp implements List.
1152func (l *list[T]) MoveUp(n int) tea.Cmd {
1153	oldOffset := l.offset
1154	if l.direction == DirectionForward {
1155		l.decrementOffset(n)
1156	} else {
1157		l.incrementOffset(n)
1158	}
1159
1160	if oldOffset == l.offset {
1161		// no change in offset, so no need to change selection
1162		return nil
1163	}
1164
1165	if l.hasSelection() && !l.selectionActive {
1166		if l.selectionStartLine > l.selectionEndLine {
1167			l.selectionStartLine += n
1168			l.selectionEndLine += n
1169		} else {
1170			l.selectionStartLine += n
1171			l.selectionEndLine += n
1172		}
1173	}
1174	if l.selectionActive {
1175		if l.selectionStartLine > l.selectionEndLine {
1176			l.selectionStartLine += n
1177		} else {
1178			l.selectionEndLine += n
1179		}
1180	}
1181	return l.changeSelectionWhenScrolling()
1182}
1183
1184// PrependItem implements List.
1185func (l *list[T]) PrependItem(item T) tea.Cmd {
1186	cmds := []tea.Cmd{
1187		item.Init(),
1188	}
1189	l.items.Prepend(item)
1190	l.indexMap = csync.NewMap[string, int]()
1191	for inx, item := range slices.Collect(l.items.Seq()) {
1192		l.indexMap.Set(item.ID(), inx)
1193	}
1194	if l.width > 0 && l.height > 0 {
1195		cmds = append(cmds, item.SetSize(l.width, l.height))
1196	}
1197	cmds = append(cmds, l.render())
1198	if l.direction == DirectionForward {
1199		if l.offset == 0 {
1200			cmd := l.GoToTop()
1201			if cmd != nil {
1202				cmds = append(cmds, cmd)
1203			}
1204		} else {
1205			newItem, ok := l.renderedItems.Get(item.ID())
1206			if ok {
1207				newLines := newItem.height
1208				if l.items.Len() > 1 {
1209					newLines += l.gap
1210				}
1211				if l.virtualHeight > 0 {
1212					l.offset = min(l.virtualHeight-1, l.offset+newLines)
1213				}
1214			}
1215		}
1216	}
1217	return tea.Batch(cmds...)
1218}
1219
1220// SelectItemAbove implements List.
1221func (l *list[T]) SelectItemAbove() tea.Cmd {
1222	inx, ok := l.indexMap.Get(l.selectedItem)
1223	if !ok {
1224		return nil
1225	}
1226
1227	newIndex := l.firstSelectableItemAbove(inx)
1228	if newIndex == ItemNotFound {
1229		// no item above
1230		return nil
1231	}
1232	var cmds []tea.Cmd
1233	if newIndex == 1 {
1234		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1235		if peakAboveIndex == ItemNotFound {
1236			// this means there is a section above move to the top
1237			cmd := l.GoToTop()
1238			if cmd != nil {
1239				cmds = append(cmds, cmd)
1240			}
1241		}
1242	}
1243	item, ok := l.items.Get(newIndex)
1244	if !ok {
1245		return nil
1246	}
1247	l.selectedItem = item.ID()
1248	l.movingByItem = true
1249	renderCmd := l.render()
1250	if renderCmd != nil {
1251		cmds = append(cmds, renderCmd)
1252	}
1253	return tea.Sequence(cmds...)
1254}
1255
1256// SelectItemBelow implements List.
1257func (l *list[T]) SelectItemBelow() tea.Cmd {
1258	inx, ok := l.indexMap.Get(l.selectedItem)
1259	if !ok {
1260		return nil
1261	}
1262
1263	newIndex := l.firstSelectableItemBelow(inx)
1264	if newIndex == ItemNotFound {
1265		// no item above
1266		return nil
1267	}
1268	item, ok := l.items.Get(newIndex)
1269	if !ok {
1270		return nil
1271	}
1272	l.selectedItem = item.ID()
1273	l.movingByItem = true
1274	return l.render()
1275}
1276
1277// SelectedItem implements List.
1278func (l *list[T]) SelectedItem() *T {
1279	inx, ok := l.indexMap.Get(l.selectedItem)
1280	if !ok {
1281		return nil
1282	}
1283	if inx > l.items.Len()-1 {
1284		return nil
1285	}
1286	item, ok := l.items.Get(inx)
1287	if !ok {
1288		return nil
1289	}
1290	return &item
1291}
1292
1293// SetItems implements List.
1294func (l *list[T]) SetItems(items []T) tea.Cmd {
1295	l.items.SetSlice(items)
1296	var cmds []tea.Cmd
1297	for inx, item := range slices.Collect(l.items.Seq()) {
1298		if i, ok := any(item).(Indexable); ok {
1299			i.SetIndex(inx)
1300		}
1301		cmds = append(cmds, item.Init())
1302	}
1303	cmds = append(cmds, l.reset(""))
1304	return tea.Batch(cmds...)
1305}
1306
1307// SetSelected implements List.
1308func (l *list[T]) SetSelected(id string) tea.Cmd {
1309	l.selectedItem = id
1310	return l.render()
1311}
1312
1313func (l *list[T]) reset(selectedItem string) tea.Cmd {
1314	var cmds []tea.Cmd
1315	l.rendered = ""
1316	l.offset = 0
1317	l.selectedItem = selectedItem
1318	l.indexMap = csync.NewMap[string, int]()
1319	l.renderedItems = csync.NewMap[string, renderedItem]()
1320	l.itemHeights = csync.NewMap[string, int]()
1321	l.virtualHeight = 0
1322	for inx, item := range slices.Collect(l.items.Seq()) {
1323		l.indexMap.Set(item.ID(), inx)
1324		if l.width > 0 && l.height > 0 {
1325			cmds = append(cmds, item.SetSize(l.width, l.height))
1326		}
1327	}
1328	cmds = append(cmds, l.render())
1329	return tea.Batch(cmds...)
1330}
1331
1332// SetSize implements List.
1333func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1334	oldWidth := l.width
1335	l.width = width
1336	l.height = height
1337	if oldWidth != width {
1338		cmd := l.reset(l.selectedItem)
1339		return cmd
1340	}
1341	return nil
1342}
1343
1344// UpdateItem implements List.
1345func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1346	var cmds []tea.Cmd
1347	if inx, ok := l.indexMap.Get(id); ok {
1348		l.items.Set(inx, item)
1349		oldItem, hasOldItem := l.renderedItems.Get(id)
1350		oldPosition := l.offset
1351		if l.direction == DirectionBackward {
1352			if l.virtualHeight > 0 {
1353			oldPosition = (l.virtualHeight - 1) - l.offset
1354		} else {
1355			oldPosition = 0
1356		}
1357		}
1358
1359		// Clear caches for this item
1360		l.renderedItems.Del(id)
1361		l.itemHeights.Del(id)
1362		
1363		cmd := l.render()
1364
1365		// need to check for nil because of sequence not handling nil
1366		if cmd != nil {
1367			cmds = append(cmds, cmd)
1368		}
1369		if hasOldItem && l.direction == DirectionBackward {
1370			// if we are the last item and there is no offset
1371			// make sure to go to the bottom
1372			if oldPosition < oldItem.end {
1373				newItem, ok := l.renderedItems.Get(item.ID())
1374				if ok {
1375					newLines := newItem.height - oldItem.height
1376					if l.virtualHeight > 0 {
1377					l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
1378				} else {
1379					l.offset = 0
1380				}
1381				}
1382			}
1383		} else if hasOldItem && l.offset > oldItem.start {
1384			newItem, ok := l.renderedItems.Get(item.ID())
1385			if ok {
1386				newLines := newItem.height - oldItem.height
1387				if l.virtualHeight > 0 {
1388					l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
1389				} else {
1390					l.offset = 0
1391				}
1392			}
1393		}
1394	}
1395	return tea.Sequence(cmds...)
1396}
1397
1398func (l *list[T]) hasSelection() bool {
1399	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1400}
1401
1402// StartSelection implements List.
1403func (l *list[T]) StartSelection(col, line int) {
1404	l.selectionStartCol = col
1405	l.selectionStartLine = line
1406	l.selectionEndCol = col
1407	l.selectionEndLine = line
1408	l.selectionActive = true
1409}
1410
1411// EndSelection implements List.
1412func (l *list[T]) EndSelection(col, line int) {
1413	if !l.selectionActive {
1414		return
1415	}
1416	l.selectionEndCol = col
1417	l.selectionEndLine = line
1418}
1419
1420func (l *list[T]) SelectionStop() {
1421	l.selectionActive = false
1422}
1423
1424func (l *list[T]) SelectionClear() {
1425	l.selectionStartCol = -1
1426	l.selectionStartLine = -1
1427	l.selectionEndCol = -1
1428	l.selectionEndLine = -1
1429	l.selectionActive = false
1430}
1431
1432func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1433	lines := strings.Split(l.rendered, "\n")
1434	for i, l := range lines {
1435		lines[i] = ansi.Strip(l)
1436	}
1437
1438	if l.direction == DirectionBackward && len(lines) > l.height {
1439		line = ((len(lines) - 1) - l.height) + line + 1
1440	}
1441
1442	if l.offset > 0 {
1443		if l.direction == DirectionBackward {
1444			line -= l.offset
1445		} else {
1446			line += l.offset
1447		}
1448	}
1449
1450	if line < 0 || line >= len(lines) {
1451		return 0, 0
1452	}
1453
1454	currentLine := lines[line]
1455	gr := uniseg.NewGraphemes(currentLine)
1456	startCol = -1
1457	upTo := col
1458	for gr.Next() {
1459		if gr.IsWordBoundary() && upTo > 0 {
1460			startCol = col - upTo + 1
1461		} else if gr.IsWordBoundary() && upTo < 0 {
1462			endCol = col - upTo + 1
1463			break
1464		}
1465		if upTo == 0 && gr.Str() == " " {
1466			return 0, 0
1467		}
1468		upTo -= 1
1469	}
1470	if startCol == -1 {
1471		return 0, 0
1472	}
1473	return
1474}
1475
1476func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1477	lines := strings.Split(l.rendered, "\n")
1478	for i, l := range lines {
1479		lines[i] = ansi.Strip(l)
1480		for _, icon := range styles.SelectionIgnoreIcons {
1481			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1482		}
1483	}
1484	if l.direction == DirectionBackward && len(lines) > l.height {
1485		line = (len(lines) - 1) - l.height + line + 1
1486	}
1487
1488	if l.offset > 0 {
1489		if l.direction == DirectionBackward {
1490			line -= l.offset
1491		} else {
1492			line += l.offset
1493		}
1494	}
1495
1496	// Ensure line is within bounds
1497	if line < 0 || line >= len(lines) {
1498		return 0, 0, false
1499	}
1500
1501	if strings.TrimSpace(lines[line]) == "" {
1502		return 0, 0, false
1503	}
1504
1505	// Find start of paragraph (search backwards for empty line or start of text)
1506	startLine = line
1507	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1508		startLine--
1509	}
1510
1511	// Find end of paragraph (search forwards for empty line or end of text)
1512	endLine = line
1513	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1514		endLine++
1515	}
1516
1517	// revert the line numbers if we are in backward direction
1518	if l.direction == DirectionBackward && len(lines) > l.height {
1519		startLine = startLine - (len(lines) - 1) + l.height - 1
1520		endLine = endLine - (len(lines) - 1) + l.height - 1
1521	}
1522	if l.offset > 0 {
1523		if l.direction == DirectionBackward {
1524			startLine += l.offset
1525			endLine += l.offset
1526		} else {
1527			startLine -= l.offset
1528			endLine -= l.offset
1529		}
1530	}
1531	return startLine, endLine, true
1532}
1533
1534// SelectWord selects the word at the given position.
1535func (l *list[T]) SelectWord(col, line int) {
1536	startCol, endCol := l.findWordBoundaries(col, line)
1537	l.selectionStartCol = startCol
1538	l.selectionStartLine = line
1539	l.selectionEndCol = endCol
1540	l.selectionEndLine = line
1541	l.selectionActive = false // Not actively selecting, just selected
1542}
1543
1544// SelectParagraph selects the paragraph at the given position.
1545func (l *list[T]) SelectParagraph(col, line int) {
1546	startLine, endLine, found := l.findParagraphBoundaries(line)
1547	if !found {
1548		return
1549	}
1550	l.selectionStartCol = 0
1551	l.selectionStartLine = startLine
1552	l.selectionEndCol = l.width - 1
1553	l.selectionEndLine = endLine
1554	l.selectionActive = false // Not actively selecting, just selected
1555}
1556
1557// HasSelection returns whether there is an active selection.
1558func (l *list[T]) HasSelection() bool {
1559	return l.hasSelection()
1560}
1561
1562// GetSelectedText returns the currently selected text.
1563func (l *list[T]) GetSelectedText(paddingLeft int) string {
1564	if !l.hasSelection() {
1565		return ""
1566	}
1567
1568	return l.selectionView(l.View(), true)
1569}