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