list.go

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