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 && i.Spinning() {
 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 := 0; i < itemsLen; i++ {
 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	if oldOffset == l.offset {
1235		// Even if offset didn't change, we might need to change selection
1236		// if we're at the edge of the scrollable area
1237		return l.changeSelectionWhenScrolling()
1238	}
1239	// if we are not actively selecting move the whole selection down
1240	if l.hasSelection() && !l.selectionActive {
1241		if l.selectionStartLine < l.selectionEndLine {
1242			l.selectionStartLine -= n
1243			l.selectionEndLine -= n
1244		} else {
1245			l.selectionStartLine -= n
1246			l.selectionEndLine -= n
1247		}
1248	}
1249	if l.selectionActive {
1250		if l.selectionStartLine < l.selectionEndLine {
1251			l.selectionStartLine -= n
1252		} else {
1253			l.selectionEndLine -= n
1254		}
1255	}
1256	return l.changeSelectionWhenScrolling()
1257}
1258
1259// MoveUp implements List.
1260func (l *list[T]) MoveUp(n int) tea.Cmd {
1261	oldOffset := l.offset
1262	if l.direction == DirectionForward {
1263		l.decrementOffset(n)
1264	} else {
1265		l.incrementOffset(n)
1266	}
1267
1268	if oldOffset == l.offset {
1269		// Even if offset didn't change, we might need to change selection
1270		// if we're at the edge of the scrollable area
1271		return l.changeSelectionWhenScrolling()
1272	}
1273
1274	if l.hasSelection() && !l.selectionActive {
1275		if l.selectionStartLine > l.selectionEndLine {
1276			l.selectionStartLine += n
1277			l.selectionEndLine += n
1278		} else {
1279			l.selectionStartLine += n
1280			l.selectionEndLine += n
1281		}
1282	}
1283	if l.selectionActive {
1284		if l.selectionStartLine > l.selectionEndLine {
1285			l.selectionStartLine += n
1286		} else {
1287			l.selectionEndLine += n
1288		}
1289	}
1290	return l.changeSelectionWhenScrolling()
1291}
1292
1293// PrependItem implements List.
1294func (l *list[T]) PrependItem(item T) tea.Cmd {
1295	cmds := []tea.Cmd{
1296		item.Init(),
1297	}
1298	l.items.Prepend(item)
1299	l.indexMap = csync.NewMap[string, int]()
1300	for inx, item := range slices.Collect(l.items.Seq()) {
1301		l.indexMap.Set(item.ID(), inx)
1302	}
1303	if l.width > 0 && l.height > 0 {
1304		cmds = append(cmds, item.SetSize(l.width, l.height))
1305	}
1306
1307	l.shouldCalculateItemPositions = true
1308
1309	if l.direction == DirectionForward {
1310		if l.offset == 0 {
1311			// If we're at the top, stay at the top
1312			cmds = append(cmds, l.render())
1313			cmd := l.GoToTop()
1314			if cmd != nil {
1315				cmds = append(cmds, cmd)
1316			}
1317		} else {
1318			// Note: We need to calculate positions to adjust offset properly
1319			// This is one case where we might need to calculate immediately
1320			l.calculateItemPositions()
1321			l.shouldCalculateItemPositions = false
1322
1323			// Adjust offset to maintain viewport position
1324			// The prepended item is at index 0
1325			if len(l.itemPositions) > 0 {
1326				newItem := l.itemPositions[0]
1327				newLines := newItem.height
1328				if l.items.Len() > 1 {
1329					newLines += l.gap
1330				}
1331				// Increase offset to keep the same content visible
1332				if l.virtualHeight > 0 {
1333					l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1334				}
1335			}
1336			cmds = append(cmds, l.renderWithScrollToSelection(false))
1337		}
1338	} else {
1339		// For backward direction, prepending doesn't affect the offset
1340		// since offset is from the bottom
1341		cmds = append(cmds, l.render())
1342	}
1343
1344	// Adjust selected index since we prepended
1345	if l.selectedIndex >= 0 {
1346		l.selectedIndex++
1347	}
1348
1349	return tea.Batch(cmds...)
1350}
1351
1352// SelectItemAbove implements List.
1353func (l *list[T]) SelectItemAbove() tea.Cmd {
1354	if l.selectedIndex < 0 {
1355		return nil
1356	}
1357
1358	newIndex := l.firstSelectableItemAbove(l.selectedIndex)
1359	if newIndex == ItemNotFound {
1360		// no item above
1361		return nil
1362	}
1363	var cmds []tea.Cmd
1364	if newIndex == 1 {
1365		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1366		if peakAboveIndex == ItemNotFound {
1367			// this means there is a section above move to the top
1368			cmd := l.GoToTop()
1369			if cmd != nil {
1370				cmds = append(cmds, cmd)
1371			}
1372		}
1373	}
1374	l.selectedIndex = newIndex
1375	l.movingByItem = true
1376	renderCmd := l.render()
1377	if renderCmd != nil {
1378		cmds = append(cmds, renderCmd)
1379	}
1380	return tea.Sequence(cmds...)
1381}
1382
1383// SelectItemBelow implements List.
1384func (l *list[T]) SelectItemBelow() tea.Cmd {
1385	if l.selectedIndex < 0 {
1386		return nil
1387	}
1388
1389	newIndex := l.firstSelectableItemBelow(l.selectedIndex)
1390	if newIndex == ItemNotFound {
1391		// no item below
1392		return nil
1393	}
1394	l.selectedIndex = newIndex
1395	l.movingByItem = true
1396	return l.render()
1397}
1398
1399// SelectedItem implements List.
1400func (l *list[T]) SelectedItem() *T {
1401	if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1402		return nil
1403	}
1404	item, ok := l.items.Get(l.selectedIndex)
1405	if !ok {
1406		return nil
1407	}
1408	return &item
1409}
1410
1411// SelectedItemID returns the ID of the currently selected item (for testing).
1412func (l *list[T]) SelectedItemID() string {
1413	if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1414		return ""
1415	}
1416	item, ok := l.items.Get(l.selectedIndex)
1417	if !ok {
1418		return ""
1419	}
1420	return item.ID()
1421}
1422
1423// SelectedItemIndex returns the index of the currently selected item.
1424// Returns -1 if no item is selected.
1425func (l *list[T]) SelectedItemIndex() int {
1426	return l.selectedIndex
1427}
1428
1429// SetItems implements List.
1430func (l *list[T]) SetItems(items []T) tea.Cmd {
1431	l.items.SetSlice(items)
1432	var cmds []tea.Cmd
1433	for inx, item := range slices.Collect(l.items.Seq()) {
1434		if i, ok := any(item).(Indexable); ok {
1435			i.SetIndex(inx)
1436		}
1437		cmds = append(cmds, item.Init())
1438	}
1439	cmds = append(cmds, l.reset(""))
1440	return tea.Batch(cmds...)
1441}
1442
1443// SetSelected implements List.
1444func (l *list[T]) SetSelected(id string) tea.Cmd {
1445	inx, ok := l.indexMap.Get(id)
1446	if ok {
1447		l.selectedIndex = inx
1448	} else {
1449		l.selectedIndex = -1
1450	}
1451	return l.render()
1452}
1453
1454func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1455	var cmds []tea.Cmd
1456	l.rendered = ""
1457	l.offset = 0
1458
1459	// Convert ID to index if provided
1460	if selectedItemID != "" {
1461		if inx, ok := l.indexMap.Get(selectedItemID); ok {
1462			l.selectedIndex = inx
1463		} else {
1464			l.selectedIndex = -1
1465		}
1466	} else {
1467		l.selectedIndex = -1
1468	}
1469
1470	l.indexMap = csync.NewMap[string, int]()
1471	l.viewCache = csync.NewMap[string, string]()
1472	l.itemPositions = nil // Will be recalculated
1473	l.virtualHeight = 0
1474	l.shouldCalculateItemPositions = true
1475	for inx, item := range slices.Collect(l.items.Seq()) {
1476		l.indexMap.Set(item.ID(), inx)
1477		if l.width > 0 && l.height > 0 {
1478			cmds = append(cmds, item.SetSize(l.width, l.height))
1479		}
1480	}
1481	cmds = append(cmds, l.render())
1482	return tea.Batch(cmds...)
1483}
1484
1485// SetSize implements List.
1486func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1487	oldWidth := l.width
1488	l.width = width
1489	l.height = height
1490	if oldWidth != width {
1491		// Get current selected item ID to preserve selection
1492		var selectedID string
1493		if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
1494			if item, ok := l.items.Get(l.selectedIndex); ok {
1495				selectedID = item.ID()
1496			}
1497		}
1498		cmd := l.reset(selectedID)
1499		return cmd
1500	}
1501	return nil
1502}
1503
1504// UpdateItem implements List.
1505func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1506	var cmds []tea.Cmd
1507	if inx, ok := l.indexMap.Get(id); ok {
1508		// Update the item
1509		l.items.Set(inx, item)
1510
1511		// Clear cache for this item
1512		l.viewCache.Del(id)
1513
1514		// Mark positions as dirty for recalculation
1515		l.shouldCalculateItemPositions = true
1516
1517		// Re-render with updated positions
1518		cmd := l.renderWithScrollToSelection(false)
1519		if cmd != nil {
1520			cmds = append(cmds, cmd)
1521		}
1522
1523		cmds = append(cmds, item.Init())
1524		if l.width > 0 && l.height > 0 {
1525			cmds = append(cmds, item.SetSize(l.width, l.height))
1526		}
1527	}
1528	return tea.Sequence(cmds...)
1529}
1530
1531func (l *list[T]) hasSelection() bool {
1532	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1533}
1534
1535// StartSelection implements List.
1536func (l *list[T]) StartSelection(col, line int) {
1537	l.selectionStartCol = col
1538	l.selectionStartLine = line
1539	l.selectionEndCol = col
1540	l.selectionEndLine = line
1541	l.selectionActive = true
1542}
1543
1544// EndSelection implements List.
1545func (l *list[T]) EndSelection(col, line int) {
1546	if !l.selectionActive {
1547		return
1548	}
1549	l.selectionEndCol = col
1550	l.selectionEndLine = line
1551}
1552
1553func (l *list[T]) SelectionStop() {
1554	l.selectionActive = false
1555}
1556
1557func (l *list[T]) SelectionClear() {
1558	l.selectionStartCol = -1
1559	l.selectionStartLine = -1
1560	l.selectionEndCol = -1
1561	l.selectionEndLine = -1
1562	l.selectionActive = false
1563}
1564
1565func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1566	lines := strings.Split(l.rendered, "\n")
1567	for i, l := range lines {
1568		lines[i] = ansi.Strip(l)
1569	}
1570
1571	if l.direction == DirectionBackward && len(lines) > l.height {
1572		line = ((len(lines) - 1) - l.height) + line + 1
1573	}
1574
1575	if l.offset > 0 {
1576		if l.direction == DirectionBackward {
1577			line -= l.offset
1578		} else {
1579			line += l.offset
1580		}
1581	}
1582
1583	if line < 0 || line >= len(lines) {
1584		return 0, 0
1585	}
1586
1587	currentLine := lines[line]
1588	gr := uniseg.NewGraphemes(currentLine)
1589	startCol = -1
1590	upTo := col
1591	for gr.Next() {
1592		if gr.IsWordBoundary() && upTo > 0 {
1593			startCol = col - upTo + 1
1594		} else if gr.IsWordBoundary() && upTo < 0 {
1595			endCol = col - upTo + 1
1596			break
1597		}
1598		if upTo == 0 && gr.Str() == " " {
1599			return 0, 0
1600		}
1601		upTo -= 1
1602	}
1603	if startCol == -1 {
1604		return 0, 0
1605	}
1606	return
1607}
1608
1609func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1610	lines := strings.Split(l.rendered, "\n")
1611	for i, l := range lines {
1612		lines[i] = ansi.Strip(l)
1613		for _, icon := range styles.SelectionIgnoreIcons {
1614			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1615		}
1616	}
1617	if l.direction == DirectionBackward && len(lines) > l.height {
1618		line = (len(lines) - 1) - l.height + line + 1
1619	}
1620
1621	if l.offset > 0 {
1622		if l.direction == DirectionBackward {
1623			line -= l.offset
1624		} else {
1625			line += l.offset
1626		}
1627	}
1628
1629	// Ensure line is within bounds
1630	if line < 0 || line >= len(lines) {
1631		return 0, 0, false
1632	}
1633
1634	if strings.TrimSpace(lines[line]) == "" {
1635		return 0, 0, false
1636	}
1637
1638	// Find start of paragraph (search backwards for empty line or start of text)
1639	startLine = line
1640	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1641		startLine--
1642	}
1643
1644	// Find end of paragraph (search forwards for empty line or end of text)
1645	endLine = line
1646	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1647		endLine++
1648	}
1649
1650	// revert the line numbers if we are in backward direction
1651	if l.direction == DirectionBackward && len(lines) > l.height {
1652		startLine = startLine - (len(lines) - 1) + l.height - 1
1653		endLine = endLine - (len(lines) - 1) + l.height - 1
1654	}
1655	if l.offset > 0 {
1656		if l.direction == DirectionBackward {
1657			startLine += l.offset
1658			endLine += l.offset
1659		} else {
1660			startLine -= l.offset
1661			endLine -= l.offset
1662		}
1663	}
1664	return startLine, endLine, true
1665}
1666
1667// SelectWord selects the word at the given position.
1668func (l *list[T]) SelectWord(col, line int) {
1669	startCol, endCol := l.findWordBoundaries(col, line)
1670	l.selectionStartCol = startCol
1671	l.selectionStartLine = line
1672	l.selectionEndCol = endCol
1673	l.selectionEndLine = line
1674	l.selectionActive = false // Not actively selecting, just selected
1675}
1676
1677// SelectParagraph selects the paragraph at the given position.
1678func (l *list[T]) SelectParagraph(col, line int) {
1679	startLine, endLine, found := l.findParagraphBoundaries(line)
1680	if !found {
1681		return
1682	}
1683	l.selectionStartCol = 0
1684	l.selectionStartLine = startLine
1685	l.selectionEndCol = l.width - 1
1686	l.selectionEndLine = endLine
1687	l.selectionActive = false // Not actively selecting, just selected
1688}
1689
1690// HasSelection returns whether there is an active selection.
1691func (l *list[T]) HasSelection() bool {
1692	return l.hasSelection()
1693}
1694
1695// GetSelectedText returns the currently selected text.
1696func (l *list[T]) GetSelectedText(paddingLeft int) string {
1697	if !l.hasSelection() {
1698		return ""
1699	}
1700
1701	return l.selectionView(l.View(), true)
1702}